Files
viettranx 21b6c454ca feat: merge pipeline, per-user credentials, unified picker, group contacts
- Enable merge UI for linking channel contacts to tenant_users
- Contact → tenant_user resolution with cached lookup (60s TTL)
- MCP per-user credentials via user-keyed connection pool
- Secure CLI per-user credentials with AES-256-GCM encryption
- Unified UserPickerCombobox searching contacts + tenant_users
- Group contact collection with chat title in all channels
- Group permission inheritance via wildcard user_id="*"
- Fix heartbeat using wrong userID in group chats
- Filter internal senders from contact collection
- Add contact_type column (user/group) to channel_contacts
- SQLite schema v2 migration for desktop edition
2026-03-29 22:33:17 +07:00

468 lines
11 KiB
Go

package sessions
import (
"context"
"os"
"sync"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// --- SessionKey ---
func TestSessionKey(t *testing.T) {
got := SessionKey("agent-123", "telegram:direct:chat456")
want := "agent:agent-123:telegram:direct:chat456"
if got != want {
t.Fatalf("SessionKey = %q, want %q", got, want)
}
}
// --- GetOrCreate idempotency ---
func TestGetOrCreate_Idempotent(t *testing.T) {
m := NewManager("")
ctx := context.Background()
s1 := m.GetOrCreate(ctx, "agent:a1:scope1")
s2 := m.GetOrCreate(ctx, "agent:a1:scope1")
if s1 != s2 {
t.Fatal("GetOrCreate should return same session pointer for same key")
}
}
func TestGetOrCreate_DifferentKeys(t *testing.T) {
m := NewManager("")
ctx := context.Background()
s1 := m.GetOrCreate(ctx, "agent:a1:scope1")
s2 := m.GetOrCreate(ctx, "agent:a2:scope2")
if s1 == s2 {
t.Fatal("different keys should return different sessions")
}
}
// --- AddMessage ---
func TestAddMessage_CreatesSession(t *testing.T) {
m := NewManager("")
ctx := context.Background()
m.AddMessage(ctx, "agent:a1:s1", providers.Message{Role: "user", Content: "hello"})
history := m.GetHistory(ctx, "agent:a1:s1")
if len(history) != 1 {
t.Fatalf("expected 1 message, got %d", len(history))
}
if history[0].Content != "hello" {
t.Fatalf("content mismatch: got %q", history[0].Content)
}
}
func TestAddMessage_AppendsToExisting(t *testing.T) {
m := NewManager("")
ctx := context.Background()
m.AddMessage(ctx, "key1", providers.Message{Role: "user", Content: "msg1"})
m.AddMessage(ctx, "key1", providers.Message{Role: "assistant", Content: "msg2"})
m.AddMessage(ctx, "key1", providers.Message{Role: "user", Content: "msg3"})
history := m.GetHistory(ctx, "key1")
if len(history) != 3 {
t.Fatalf("expected 3 messages, got %d", len(history))
}
}
// --- Concurrent AddMessage: messages must not be lost ---
func TestAddMessage_ConcurrentSafety(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:concurrent:test"
const n = 100
var wg sync.WaitGroup
wg.Add(n)
for i := range n {
go func(i int) {
defer wg.Done()
m.AddMessage(ctx, key, providers.Message{Role: "user", Content: "msg"})
}(i)
}
wg.Wait()
history := m.GetHistory(ctx, key)
if len(history) != n {
t.Fatalf("expected %d messages (no loss), got %d", n, len(history))
}
}
// --- GetHistory returns defensive copy ---
func TestGetHistory_DefensiveCopy(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.AddMessage(ctx, key, providers.Message{Role: "user", Content: "original"})
history := m.GetHistory(ctx, key)
history[0].Content = "mutated" // mutate the copy
// Original should be unchanged
original := m.GetHistory(ctx, key)
if original[0].Content != "original" {
t.Fatal("GetHistory should return a copy, not a reference to internal slice")
}
}
func TestGetHistory_NonExistentSession(t *testing.T) {
m := NewManager("")
history := m.GetHistory(context.Background(), "nonexistent")
if history != nil {
t.Fatalf("expected nil for non-existent session, got %v", history)
}
}
// --- SetLabel / SetSummary on missing session: silent no-op ---
func TestSetLabel_MissingSession_SilentNoOp(t *testing.T) {
m := NewManager("")
// Should not panic
m.SetLabel(context.Background(), "nonexistent", "label")
}
func TestSetSummary_MissingSession_SilentNoOp(t *testing.T) {
m := NewManager("")
m.SetSummary(context.Background(), "nonexistent", "summary")
}
// --- Metadata accumulation ---
func TestAccumulateTokens(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.GetOrCreate(ctx, key)
m.AccumulateTokens(ctx, key, 100, 50)
m.AccumulateTokens(ctx, key, 200, 80)
m.mu.RLock()
s := m.sessions[key]
m.mu.RUnlock()
if s.InputTokens != 300 {
t.Fatalf("input tokens: got %d, want 300", s.InputTokens)
}
if s.OutputTokens != 130 {
t.Fatalf("output tokens: got %d, want 130", s.OutputTokens)
}
}
// --- CompactionCount tracking ---
func TestCompactionCount(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.GetOrCreate(ctx, key)
if m.GetCompactionCount(ctx, key) != 0 {
t.Fatal("initial compaction count should be 0")
}
m.IncrementCompaction(ctx, key)
m.IncrementCompaction(ctx, key)
if m.GetCompactionCount(ctx, key) != 2 {
t.Fatalf("compaction count: got %d, want 2", m.GetCompactionCount(ctx, key))
}
// MemoryFlushCompactionCount tracks separately
if m.GetMemoryFlushCompactionCount(ctx, key) != 0 {
t.Fatal("memory flush compaction should start at 0")
}
m.SetMemoryFlushDone(ctx, key)
if m.GetMemoryFlushCompactionCount(ctx, key) != 2 {
t.Fatalf("memory flush should match current compaction count: got %d", m.GetMemoryFlushCompactionCount(ctx, key))
}
}
func TestGetMemoryFlushCompactionCount_NonExistent(t *testing.T) {
m := NewManager("")
got := m.GetMemoryFlushCompactionCount(context.Background(), "nonexistent")
if got != -1 {
t.Fatalf("expected -1 for non-existent session, got %d", got)
}
}
// --- TruncateHistory ---
func TestTruncateHistory(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
for range 10 {
m.AddMessage(ctx, key, providers.Message{Role: "user", Content: "msg"})
}
m.TruncateHistory(ctx, key, 3)
history := m.GetHistory(ctx, key)
if len(history) != 3 {
t.Fatalf("expected 3 messages after truncate, got %d", len(history))
}
}
func TestTruncateHistory_KeepZero(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.AddMessage(ctx, key, providers.Message{Role: "user", Content: "msg"})
m.TruncateHistory(ctx, key, 0)
history := m.GetHistory(ctx, key)
if len(history) != 0 {
t.Fatalf("expected 0 messages, got %d", len(history))
}
}
// --- Reset ---
func TestReset_ClearsHistoryAndSummary(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.AddMessage(ctx, key, providers.Message{Role: "user", Content: "msg"})
m.SetSummary(ctx, key, "summary text")
m.Reset(ctx, key)
history := m.GetHistory(ctx, key)
if len(history) != 0 {
t.Fatalf("expected empty history after reset, got %d messages", len(history))
}
if m.GetSummary(ctx, key) != "" {
t.Fatal("expected empty summary after reset")
}
}
// --- Delete ---
func TestDelete_RemovesSession(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.GetOrCreate(ctx, key)
if err := m.Delete(ctx, key); err != nil {
t.Fatalf("delete error: %v", err)
}
history := m.GetHistory(ctx, key)
if history != nil {
t.Fatal("expected nil history after delete")
}
}
// --- List ---
func TestList_FiltersByAgentID(t *testing.T) {
m := NewManager("")
ctx := context.Background()
m.GetOrCreate(ctx, "agent:a1:scope1")
m.GetOrCreate(ctx, "agent:a1:scope2")
m.GetOrCreate(ctx, "agent:a2:scope1")
all := m.List(ctx, "")
if len(all) != 3 {
t.Fatalf("expected 3 sessions, got %d", len(all))
}
filtered := m.List(ctx, "a1")
if len(filtered) != 2 {
t.Fatalf("expected 2 sessions for agent a1, got %d", len(filtered))
}
}
// --- ContextWindow ---
func TestContextWindow_SetAndGet(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.GetOrCreate(ctx, key)
if m.GetContextWindow(ctx, key) != 0 {
t.Fatal("initial context window should be 0")
}
m.SetContextWindow(ctx, key, 200000)
if m.GetContextWindow(ctx, key) != 200000 {
t.Fatalf("context window mismatch: got %d", m.GetContextWindow(ctx, key))
}
}
// --- Persistence: Save and load roundtrip ---
func TestSave_LoadAll_Roundtrip(t *testing.T) {
dir := t.TempDir()
// Create manager and save a session
m1 := NewManager(dir)
ctx := context.Background()
key := "agent:a1:scope1"
m1.AddMessage(ctx, key, providers.Message{Role: "user", Content: "hello"})
m1.AddMessage(ctx, key, providers.Message{Role: "assistant", Content: "hi"})
m1.SetLabel(ctx, key, "test-label")
m1.AccumulateTokens(ctx, key, 100, 50)
if err := m1.Save(ctx, key); err != nil {
t.Fatalf("save error: %v", err)
}
// Create new manager from same directory → should load the session
m2 := NewManager(dir)
history := m2.GetHistory(ctx, key)
if len(history) != 2 {
t.Fatalf("expected 2 messages after load, got %d", len(history))
}
if history[0].Content != "hello" || history[1].Content != "hi" {
t.Fatal("message content mismatch after load")
}
m2.mu.RLock()
s := m2.sessions[key]
m2.mu.RUnlock()
if s.Label != "test-label" {
t.Fatalf("label mismatch: got %q", s.Label)
}
if s.InputTokens != 100 {
t.Fatalf("input tokens mismatch: got %d", s.InputTokens)
}
}
func TestDelete_RemovesFile(t *testing.T) {
dir := t.TempDir()
m := NewManager(dir)
ctx := context.Background()
key := "agent:a1:scope1"
m.AddMessage(ctx, key, providers.Message{Role: "user", Content: "msg"})
_ = m.Save(ctx, key)
// File should exist
files, _ := os.ReadDir(dir)
if len(files) == 0 {
t.Fatal("expected session file to exist")
}
_ = m.Delete(ctx, key)
// File should be gone
files, _ = os.ReadDir(dir)
jsonCount := 0
for _, f := range files {
if !f.IsDir() {
jsonCount++
}
}
if jsonCount != 0 {
t.Fatalf("expected no session files after delete, got %d", jsonCount)
}
}
// --- UpdateMetadata ---
func TestUpdateMetadata_PartialUpdate(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.GetOrCreate(ctx, key)
m.UpdateMetadata(ctx, key, "claude-3", "anthropic", "telegram")
m.mu.RLock()
s := m.sessions[key]
m.mu.RUnlock()
if s.Model != "claude-3" || s.Provider != "anthropic" || s.Channel != "telegram" {
t.Fatalf("metadata mismatch: model=%q provider=%q channel=%q", s.Model, s.Provider, s.Channel)
}
// Partial update: only model, rest unchanged
m.UpdateMetadata(ctx, key, "gpt-4", "", "")
m.mu.RLock()
s = m.sessions[key]
m.mu.RUnlock()
if s.Model != "gpt-4" {
t.Fatalf("model should update: got %q", s.Model)
}
if s.Provider != "anthropic" {
t.Fatalf("provider should stay unchanged: got %q", s.Provider)
}
}
// --- SetSpawnInfo ---
func TestSetSpawnInfo(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.GetOrCreate(ctx, key)
m.SetSpawnInfo(ctx, key, "parent-run-123", 2)
m.mu.RLock()
s := m.sessions[key]
m.mu.RUnlock()
if s.SpawnedBy != "parent-run-123" {
t.Fatalf("spawnedBy: got %q", s.SpawnedBy)
}
if s.SpawnDepth != 2 {
t.Fatalf("spawnDepth: got %d", s.SpawnDepth)
}
}
// --- LastPromptTokens ---
func TestLastPromptTokens(t *testing.T) {
m := NewManager("")
ctx := context.Background()
key := "agent:a1:s1"
m.GetOrCreate(ctx, key)
tokens, msgCount := m.GetLastPromptTokens(ctx, key)
if tokens != 0 || msgCount != 0 {
t.Fatal("initial values should be 0")
}
m.SetLastPromptTokens(ctx, key, 5000, 42)
tokens, msgCount = m.GetLastPromptTokens(ctx, key)
if tokens != 5000 || msgCount != 42 {
t.Fatalf("got tokens=%d, msgCount=%d", tokens, msgCount)
}
}
// --- sanitizeFilename ---
func TestSanitizeFilename(t *testing.T) {
got := sanitizeFilename("agent:a1:telegram:direct:123")
want := "agent_a1_telegram_direct_123"
if got != want {
t.Fatalf("sanitizeFilename = %q, want %q", got, want)
}
}