Files
goclaw/internal/tools/memory_interceptor_test.go
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

422 lines
12 KiB
Go

package tools
import (
"context"
"fmt"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// mockMemoryStore is a minimal in-memory implementation of store.MemoryStore
// for unit testing the MemoryInterceptor.
type mockMemoryStore struct {
docs map[string]string // key: "agentID|userID|path"
}
func newMockMemoryStore() *mockMemoryStore {
return &mockMemoryStore{docs: make(map[string]string)}
}
func docKey(agentID, userID, path string) string {
return agentID + "|" + userID + "|" + path
}
func (m *mockMemoryStore) GetDocument(_ context.Context, agentID, userID, path string) (string, error) {
if v, ok := m.docs[docKey(agentID, userID, path)]; ok {
return v, nil
}
return "", fmt.Errorf("not found")
}
func (m *mockMemoryStore) PutDocument(_ context.Context, agentID, userID, path, content string) error {
m.docs[docKey(agentID, userID, path)] = content
return nil
}
func (m *mockMemoryStore) DeleteDocument(_ context.Context, agentID, userID, path string) error {
delete(m.docs, docKey(agentID, userID, path))
return nil
}
func (m *mockMemoryStore) ListDocuments(_ context.Context, agentID, userID string) ([]store.DocumentInfo, error) {
var out []store.DocumentInfo
prefix := agentID + "|" + userID + "|"
for k := range m.docs {
if after, ok := strings.CutPrefix(k, prefix); ok {
path := after
out = append(out, store.DocumentInfo{Path: path})
}
}
return out, nil
}
// Unused interface methods — satisfy store.MemoryStore.
func (m *mockMemoryStore) ListAllDocumentsGlobal(_ context.Context) ([]store.DocumentInfo, error) {
return nil, nil
}
func (m *mockMemoryStore) ListAllDocuments(_ context.Context, _ string) ([]store.DocumentInfo, error) {
return nil, nil
}
func (m *mockMemoryStore) GetDocumentDetail(_ context.Context, _, _, _ string) (*store.DocumentDetail, error) {
return nil, nil
}
func (m *mockMemoryStore) ListChunks(_ context.Context, _, _, _ string) ([]store.ChunkInfo, error) {
return nil, nil
}
func (m *mockMemoryStore) Search(_ context.Context, _ string, _, _ string, _ store.MemorySearchOptions) ([]store.MemorySearchResult, error) {
return nil, nil
}
func (m *mockMemoryStore) IndexDocument(_ context.Context, _, _, _ string) error { return nil }
func (m *mockMemoryStore) IndexAll(_ context.Context, _, _ string) error { return nil }
func (m *mockMemoryStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {}
func (m *mockMemoryStore) Close() error { return nil }
// --- Test helpers ---
func memCtx(agentID uuid.UUID, userID, leaderID string) context.Context {
ctx := context.Background()
ctx = store.WithAgentID(ctx, agentID)
ctx = store.WithUserID(ctx, userID)
if leaderID != "" {
ctx = WithLeaderAgentID(ctx, leaderID)
}
return ctx
}
// --- ReadFile tests ---
func TestReadFile_NoLeader_OwnMemory(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
agentID := uuid.New()
ms.docs[docKey(agentID.String(), "user1", "MEMORY.md")] = "my notes"
ctx := memCtx(agentID, "user1", "")
content, handled, err := mi.ReadFile(ctx, "MEMORY.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatal("expected handled=true")
}
if content != "my notes" {
t.Errorf("expected 'my notes', got %q", content)
}
}
func TestReadFile_LeaderFallback(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
memberID := uuid.New()
leaderID := uuid.New()
// Leader has memory, member does not.
ms.docs[docKey(leaderID.String(), "user1", "MEMORY.md")] = "leader notes"
ctx := memCtx(memberID, "user1", leaderID.String())
content, handled, err := mi.ReadFile(ctx, "MEMORY.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatal("expected handled=true")
}
if content != "leader notes" {
t.Errorf("expected 'leader notes', got %q", content)
}
}
func TestReadFile_LeaderFallback_SharedScope(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
memberID := uuid.New()
leaderID := uuid.New()
// Leader has shared (global) memory only.
ms.docs[docKey(leaderID.String(), "", "MEMORY.md")] = "leader shared"
ctx := memCtx(memberID, "user1", leaderID.String())
content, handled, err := mi.ReadFile(ctx, "MEMORY.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatal("expected handled=true")
}
if content != "leader shared" {
t.Errorf("expected 'leader shared', got %q", content)
}
}
func TestReadFile_LeaderIsSelf(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
agentID := uuid.New()
ms.docs[docKey(agentID.String(), "user1", "MEMORY.md")] = "own notes"
// Leader is the same agent — should read own memory, no fallback.
ctx := memCtx(agentID, "user1", agentID.String())
content, handled, err := mi.ReadFile(ctx, "MEMORY.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatal("expected handled=true")
}
if content != "own notes" {
t.Errorf("expected 'own notes', got %q", content)
}
}
func TestReadFile_NonMemoryPath(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
ctx := memCtx(uuid.New(), "user1", "")
_, handled, err := mi.ReadFile(ctx, "README.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if handled {
t.Error("expected handled=false for non-memory path")
}
}
func TestReadFile_MemberNoMemory_NoLeader_Empty(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
ctx := memCtx(uuid.New(), "user1", "")
content, handled, err := mi.ReadFile(ctx, "MEMORY.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatal("expected handled=true for memory path")
}
if content != "" {
t.Errorf("expected empty content, got %q", content)
}
}
// --- WriteFile tests ---
func TestWriteFile_NoLeader_AllowWrite(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
agentID := uuid.New()
ctx := memCtx(agentID, "user1", "")
result, err := mi.WriteFile(ctx, "MEMORY.md", "new content", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Handled {
t.Fatal("expected handled=true")
}
// Verify content was written.
got, _ := ms.GetDocument(ctx, agentID.String(), "user1", "MEMORY.md")
if got != "new content" {
t.Errorf("expected 'new content', got %q", got)
}
}
func TestWriteFile_LeaderPresent_BlockWrite(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
memberID := uuid.New()
leaderID := uuid.New()
ctx := memCtx(memberID, "user1", leaderID.String())
result, err := mi.WriteFile(ctx, "MEMORY.md", "attempt", false)
if err == nil {
t.Fatal("expected error for blocked write")
}
if !result.Handled {
t.Fatal("expected handled=true")
}
if !strings.Contains(err.Error(), "read-only") {
t.Errorf("expected read-only error, got: %v", err)
}
}
func TestWriteFile_LeaderIsSelf_AllowWrite(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
agentID := uuid.New()
// Leader is the same agent — should allow write.
ctx := memCtx(agentID, "user1", agentID.String())
result, err := mi.WriteFile(ctx, "MEMORY.md", "leader writes", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Handled {
t.Fatal("expected handled=true")
}
got, _ := ms.GetDocument(ctx, agentID.String(), "user1", "MEMORY.md")
if got != "leader writes" {
t.Errorf("expected 'leader writes', got %q", got)
}
}
// --- MemoryGetTool leader fallback tests ---
func TestMemoryGet_LeaderFallback(t *testing.T) {
ms := newMockMemoryStore()
tool := NewMemoryGetTool()
tool.SetMemoryStore(ms)
memberID := uuid.New()
leaderID := uuid.New()
ms.docs[docKey(leaderID.String(), "user1", "MEMORY.md")] = "leader get content"
ctx := memCtx(memberID, "user1", leaderID.String())
result := tool.Execute(ctx, map[string]any{"path": "MEMORY.md"})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "leader get content") {
t.Errorf("expected leader content in result, got: %s", result.ForLLM)
}
}
func TestMemoryGet_BlockedByNoLeader(t *testing.T) {
ms := newMockMemoryStore()
tool := NewMemoryGetTool()
tool.SetMemoryStore(ms)
memberID := uuid.New()
// No leader, no own memory → error.
ctx := memCtx(memberID, "user1", "")
result := tool.Execute(ctx, map[string]any{"path": "MEMORY.md"})
if !result.IsError {
t.Fatal("expected error for missing memory")
}
}
// --- MemorySearchTool leader fallback tests ---
func TestMemorySearch_LeaderFallback(t *testing.T) {
ms := newMockMemoryStore()
tool := NewMemorySearchTool()
tool.SetMemoryStore(ms)
memberID := uuid.New()
leaderID := uuid.New()
// mockMemoryStore.Search returns nil — just verify no crash and correct agent IDs used.
ctx := memCtx(memberID, "user1", leaderID.String())
result := tool.Execute(ctx, map[string]any{"query": "test"})
// With mock returning nil results for both, should get "No memory results found".
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "No memory results found") {
t.Errorf("expected no results message, got: %s", result.ForLLM)
}
}
// --- ListFiles tests ---
func TestListFiles_MergeLeaderDocs(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
memberID := uuid.New()
leaderID := uuid.New()
// Leader has docs, member has none.
ms.docs[docKey(leaderID.String(), "user1", "MEMORY.md")] = "leader mem"
ms.docs[docKey(leaderID.String(), "user1", "memory/notes.md")] = "leader notes"
ctx := memCtx(memberID, "user1", leaderID.String())
listing, handled, err := mi.ListFiles(ctx, "memory")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatal("expected handled=true")
}
if !strings.Contains(listing, "MEMORY.md") {
t.Errorf("expected MEMORY.md in listing, got: %s", listing)
}
if !strings.Contains(listing, "memory/notes.md") {
t.Errorf("expected memory/notes.md in listing, got: %s", listing)
}
}
func TestListFiles_LeaderGlobalScopeFallback(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
memberID := uuid.New()
leaderID := uuid.New()
// Leader has only global-scope docs (userID="").
ms.docs[docKey(leaderID.String(), "", "MEMORY.md")] = "leader global"
ctx := memCtx(memberID, "user1", leaderID.String())
listing, handled, err := mi.ListFiles(ctx, "memory")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatal("expected handled=true")
}
if !strings.Contains(listing, "MEMORY.md") {
t.Errorf("expected MEMORY.md from leader's global scope, got: %s", listing)
}
}
func TestReadFile_LeaderFallback_MemorySubpath(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
memberID := uuid.New()
leaderID := uuid.New()
// Leader has a memory subpath file.
ms.docs[docKey(leaderID.String(), "user1", "memory/notes.md")] = "leader subpath"
ctx := memCtx(memberID, "user1", leaderID.String())
content, handled, err := mi.ReadFile(ctx, "memory/notes.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatal("expected handled=true")
}
if content != "leader subpath" {
t.Errorf("expected 'leader subpath', got %q", content)
}
}
func TestListFiles_LeaderIsSelf_NoDuplication(t *testing.T) {
ms := newMockMemoryStore()
mi := NewMemoryInterceptor(ms, "/workspace")
agentID := uuid.New()
ms.docs[docKey(agentID.String(), "user1", "MEMORY.md")] = "own mem"
ctx := memCtx(agentID, "user1", agentID.String())
listing, handled, err := mi.ListFiles(ctx, "memory")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatal("expected handled=true")
}
// Should appear exactly once.
count := strings.Count(listing, "MEMORY.md")
if count != 1 {
t.Errorf("expected MEMORY.md once, got %d times in: %s", count, listing)
}
}