package tools import ( "context" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/cache" "github.com/nextlevelbuilder/goclaw/internal/store" ) // ---- minimal AgentStore stub for interceptor tests ---- type stubAgentStore struct { agentFiles []store.AgentContextFileData userFiles []store.UserContextFileData agentCallsN atomic.Int32 // counts GetAgentContextFiles calls setAgentCallN atomic.Int32 setUserCallN atomic.Int32 } func (s *stubAgentStore) GetAgentContextFiles(_ context.Context, _ uuid.UUID) ([]store.AgentContextFileData, error) { s.agentCallsN.Add(1) return s.agentFiles, nil } func (s *stubAgentStore) SetAgentContextFile(_ context.Context, _ uuid.UUID, _, _ string) error { s.setAgentCallN.Add(1) return nil } func (s *stubAgentStore) GetUserContextFiles(_ context.Context, _ uuid.UUID, _ string) ([]store.UserContextFileData, error) { return s.userFiles, nil } func (s *stubAgentStore) SetUserContextFile(_ context.Context, _ uuid.UUID, _, _, _ string) error { s.setUserCallN.Add(1) return nil } func (s *stubAgentStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _, _ string) error { return nil } // Remaining interface methods — not exercised in these tests. func (s *stubAgentStore) Create(_ context.Context, _ *store.AgentData) error { return nil } func (s *stubAgentStore) GetByKey(_ context.Context, _ string) (*store.AgentData, error) { return nil, nil } func (s *stubAgentStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil } func (s *stubAgentStore) GetDefault(_ context.Context) (*store.AgentData, error) { return nil, nil } func (s *stubAgentStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil } func (s *stubAgentStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } func (s *stubAgentStore) List(_ context.Context, _ string) ([]store.AgentData, error) { return nil, nil } func (s *stubAgentStore) ShareAgent(_ context.Context, _ uuid.UUID, _, _, _ string) error { return nil } func (s *stubAgentStore) RevokeShare(_ context.Context, _ uuid.UUID, _ string) error { return nil } func (s *stubAgentStore) ListShares(_ context.Context, _ uuid.UUID) ([]store.AgentShareData, error) { return nil, nil } func (s *stubAgentStore) CanAccess(_ context.Context, _ uuid.UUID, _ string) (bool, string, error) { return true, "admin", nil } func (s *stubAgentStore) ListAccessible(_ context.Context, _ string) ([]store.AgentData, error) { return nil, nil } func (s *stubAgentStore) GetUserOverride(_ context.Context, _ uuid.UUID, _ string) (*store.UserAgentOverrideData, error) { return nil, nil } func (s *stubAgentStore) SetUserOverride(_ context.Context, _ *store.UserAgentOverrideData) error { return nil } func (s *stubAgentStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _, _ string) (bool, string, error) { return false, "", nil } func (s *stubAgentStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) { return false, nil } func (s *stubAgentStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error { return nil } func (s *stubAgentStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error { return nil } func (s *stubAgentStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) { return nil, nil } func (s *stubAgentStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) { return nil, nil } func (s *stubAgentStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) { return nil, nil } func (s *stubAgentStore) UpdateUserProfileMetadata(_ context.Context, _ uuid.UUID, _ string, _ map[string]string) error { return nil } func (s *stubAgentStore) EnsureUserProfile(_ context.Context, _ uuid.UUID, _ string) error { return nil } // ---- Tests ---- // TestInterceptor_CacheHit verifies that a second read does NOT call GetAgentContextFiles again. func TestInterceptor_CacheHit(t *testing.T) { agentID := uuid.New() as := &stubAgentStore{ agentFiles: []store.AgentContextFileData{ {AgentID: agentID, FileName: "SOUL.md", Content: "you are helpful"}, }, } intc := NewContextFileInterceptor(as, "", cache.NewInMemoryCache[[]store.AgentContextFileData](), cache.NewInMemoryCache[[]store.AgentContextFileData](), ) ctx := store.WithAgentID(context.Background(), agentID) // First read — cache miss → goes to store content1, handled1, err := intc.readAgentFile(ctx, agentID, "SOUL.md") if err != nil || !handled1 || content1 != "you are helpful" { t.Fatalf("first read: want ('you are helpful', true, nil), got (%q, %v, %v)", content1, handled1, err) } if n := as.agentCallsN.Load(); n != 1 { t.Fatalf("expected 1 store call, got %d", n) } // Second read — cache hit → should NOT call store again content2, _, _ := intc.readAgentFile(ctx, agentID, "SOUL.md") if content2 != "you are helpful" { t.Errorf("second read: expected cached content, got %q", content2) } if n := as.agentCallsN.Load(); n != 1 { t.Errorf("cache hit should not call store again, got %d calls", n) } } // TestInterceptor_InvalidateAgent_ClearsCache verifies that after InvalidateAgent, // the next read fetches fresh content from the store (not cached stale content). func TestInterceptor_InvalidateAgent_ClearsCache(t *testing.T) { agentID := uuid.New() as := &stubAgentStore{ agentFiles: []store.AgentContextFileData{ {AgentID: agentID, FileName: "SOUL.md", Content: "old content"}, }, } intc := NewContextFileInterceptor(as, "", cache.NewInMemoryCache[[]store.AgentContextFileData](), cache.NewInMemoryCache[[]store.AgentContextFileData](), ) ctx := store.WithAgentID(context.Background(), agentID) // Warm up cache with old content intc.readAgentFile(ctx, agentID, "SOUL.md") if n := as.agentCallsN.Load(); n != 1 { t.Fatalf("expected 1 store call after warm-up, got %d", n) } // Simulate wizard writing new content to the store as.agentFiles = []store.AgentContextFileData{ {AgentID: agentID, FileName: "SOUL.md", Content: "new wizard content"}, } // WITHOUT invalidation: stale cache is still served content, _, _ := intc.readAgentFile(ctx, agentID, "SOUL.md") if content != "old content" { t.Errorf("expected stale cached content before invalidation, got %q", content) } if n := as.agentCallsN.Load(); n != 1 { t.Errorf("expected no extra store call (cache hit), got %d", n) } // Invalidate — this is what agents.files.set must call intc.InvalidateAgent(agentID) // AFTER invalidation: must fetch from store and return fresh content content, _, err := intc.readAgentFile(ctx, agentID, "SOUL.md") if err != nil { t.Fatalf("read after invalidation: unexpected error: %v", err) } if content != "new wizard content" { t.Errorf("after invalidation expected fresh content, got %q", content) } if n := as.agentCallsN.Load(); n != 2 { t.Errorf("expected 2 store calls total (1 warm-up + 1 post-invalidate), got %d", n) } } // TestInterceptor_InvalidateAgent_ClearsUserCache verifies that InvalidateAgent // also evicts per-user cache entries for that agent. func TestInterceptor_InvalidateAgent_ClearsUserCache(t *testing.T) { agentID := uuid.New() userID := "user-42" as := &stubAgentStore{ userFiles: []store.UserContextFileData{ {AgentID: agentID, UserID: userID, FileName: "USER.md", Content: "old user content"}, }, } intc := NewContextFileInterceptor(as, "", cache.NewInMemoryCache[[]store.AgentContextFileData](), cache.NewInMemoryCache[[]store.AgentContextFileData](), ) // Warm user cache intc.readUserFile(context.Background(), agentID, userID, "USER.md") // Update store content as.userFiles = []store.UserContextFileData{ {AgentID: agentID, UserID: userID, FileName: "USER.md", Content: "new user content"}, } // Verify cache is served before invalidation content, _, _ := intc.readUserFile(context.Background(), agentID, userID, "USER.md") if content != "old user content" { t.Errorf("expected stale user cache before invalidation, got %q", content) } // Invalidate the agent — must also clear user cache intc.InvalidateAgent(agentID) // Now fresh content should come through content, _, err := intc.readUserFile(context.Background(), agentID, userID, "USER.md") if err != nil { t.Fatalf("unexpected error after user cache invalidation: %v", err) } if content != "new user content" { t.Errorf("after InvalidateAgent expected fresh user content, got %q", content) } } // TestInterceptor_InvalidateAgent_DoesNotAffectOtherAgents verifies that // invalidating one agent's cache does not evict another agent's entries. func TestInterceptor_InvalidateAgent_DoesNotAffectOtherAgents(t *testing.T) { agentA := uuid.New() agentB := uuid.New() as := &stubAgentStore{} as.agentFiles = []store.AgentContextFileData{ {AgentID: agentA, FileName: "SOUL.md", Content: "agent A soul"}, } intc := NewContextFileInterceptor(as, "", cache.NewInMemoryCache[[]store.AgentContextFileData](), cache.NewInMemoryCache[[]store.AgentContextFileData](), ) ctx := context.Background() // Warm agent A cache intc.readAgentFile(ctx, agentA, "SOUL.md") callsAfterWarmup := as.agentCallsN.Load() // Invalidate agent B — must not touch agent A's cache intc.InvalidateAgent(agentB) // Agent A should still be served from cache (no extra store call) intc.readAgentFile(ctx, agentA, "SOUL.md") if n := as.agentCallsN.Load(); n != callsAfterWarmup { t.Errorf("invalidating agent B should not evict agent A's cache: got %d store calls", n) } } // TestInterceptor_TTLExpiry verifies that entries older than TTL are re-fetched. func TestInterceptor_TTLExpiry(t *testing.T) { agentID := uuid.New() as := &stubAgentStore{ agentFiles: []store.AgentContextFileData{ {AgentID: agentID, FileName: "SOUL.md", Content: "soul content"}, }, } intc := NewContextFileInterceptor(as, "", cache.NewInMemoryCache[[]store.AgentContextFileData](), cache.NewInMemoryCache[[]store.AgentContextFileData](), ) intc.ttl = 10 * time.Millisecond // very short TTL for testing ctx := context.Background() intc.readAgentFile(ctx, agentID, "SOUL.md") if n := as.agentCallsN.Load(); n != 1 { t.Fatalf("expected 1 store call, got %d", n) } // Wait for TTL to expire time.Sleep(20 * time.Millisecond) // Should fetch from store again after TTL intc.readAgentFile(ctx, agentID, "SOUL.md") if n := as.agentCallsN.Load(); n != 2 { t.Errorf("after TTL expiry expected 2 store calls, got %d", n) } }