mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-10 04:19:48 +00:00
- 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
468 lines
11 KiB
Go
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)
|
|
}
|
|
}
|