package mcp import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/persistence" ) // --------------------------------------------------------------------------- // memoryManager — CRUD + filter unit tests // --------------------------------------------------------------------------- func TestMemoryManager_SaveQueryDelete(t *testing.T) { mm := newMemoryManager("", "") id1, err := mm.Save(persistence.MemoryEntry{ Title: "Bar invariant", Body: "Bar must hold the lock before mutating cache", SymbolIDs: []string{"pkg/foo.go::Bar"}, Tags: []string{"invariant"}, Kind: "invariant", }) require.NoError(t, err) require.NotEmpty(t, id1) id2, err := mm.Save(persistence.MemoryEntry{ Body: "TODO: revisit timeout in worker", FilePaths: []string{"pkg/foo.go"}, Tags: []string{"gotcha"}, Kind: "gotcha", }) require.NoError(t, err) all := mm.Query(MemoryQueryFilter{}) require.Len(t, all, 2) bySymbol := mm.Query(MemoryQueryFilter{SymbolID: "pkg/foo.go::Bar"}) require.Len(t, bySymbol, 1) assert.Equal(t, id1, bySymbol[0].ID) byFile := mm.Query(MemoryQueryFilter{FilePath: "pkg/foo.go"}) require.Len(t, byFile, 1) assert.Equal(t, id2, byFile[0].ID) byTag := mm.Query(MemoryQueryFilter{Tag: "INVARIANT"}) require.Len(t, byTag, 1) assert.Equal(t, id1, byTag[0].ID) byKind := mm.Query(MemoryQueryFilter{Kind: "gotcha"}) require.Len(t, byKind, 1) assert.Equal(t, id2, byKind[0].ID) byText := mm.Query(MemoryQueryFilter{TextSearch: "TIMEOUT"}) require.Len(t, byText, 1) assert.Equal(t, id2, byText[0].ID) // Title also matched by TextSearch. byTitle := mm.Query(MemoryQueryFilter{TextSearch: "invariant"}) require.NotEmpty(t, byTitle) require.NoError(t, mm.Delete(id1)) assert.Equal(t, 1, mm.Count()) require.NoError(t, mm.Delete(id1), "deleting twice is a noop") } func TestMemoryManager_Update(t *testing.T) { mm := newMemoryManager("", "") id, err := mm.Save(persistence.MemoryEntry{ Body: "draft body", Tags: []string{"draft"}, }) require.NoError(t, err) newBody := "final body referring to Bar" pinned := true importance := 5 updated, err := mm.Update(id, MemoryPatch{ Body: &newBody, Tags: []string{"final"}, Pinned: &pinned, Importance: &importance, AddLinks: []string{"pkg/x.go::Bar"}, }) require.NoError(t, err) assert.Equal(t, newBody, updated.Body) assert.Equal(t, []string{"final"}, updated.Tags) assert.True(t, updated.Pinned) assert.Equal(t, 5, updated.Importance) assert.Contains(t, updated.AutoLinks, "pkg/x.go::Bar") got, ok := mm.Get(id) require.True(t, ok) assert.Equal(t, updated.Body, got.Body) } func TestMemoryManager_UpdateMissingID(t *testing.T) { mm := newMemoryManager("", "") _, err := mm.Update("nope", MemoryPatch{}) require.Error(t, err) assert.Contains(t, err.Error(), "not found") } func TestMemoryManager_SortPinnedThenImportance(t *testing.T) { mm := newMemoryManager("", "") // importance=2, not pinned, newest _, _ = mm.Save(persistence.MemoryEntry{Body: "low-imp newest", Importance: 2}) // importance=5, pinned, oldest _, _ = mm.Save(persistence.MemoryEntry{Body: "pinned high-imp", Importance: 5, Pinned: true}) // importance=4, not pinned, middle _, _ = mm.Save(persistence.MemoryEntry{Body: "mid-imp", Importance: 4}) out := mm.Query(MemoryQueryFilter{}) require.Len(t, out, 3) assert.True(t, out[0].Pinned, "pinned first") assert.Equal(t, 4, out[1].Importance, "then importance=4") assert.Equal(t, 2, out[2].Importance, "then importance=2") } func TestMemoryManager_HidesSuperseded(t *testing.T) { mm := newMemoryManager("", "") oldID, _ := mm.Save(persistence.MemoryEntry{Body: "old"}) newID, _ := mm.Save(persistence.MemoryEntry{Body: "new"}) supBy := newID _, err := mm.Update(oldID, MemoryPatch{SupersededBy: &supBy}) require.NoError(t, err) out := mm.Query(MemoryQueryFilter{}) require.Len(t, out, 1, "superseded entry hidden by default") assert.Equal(t, newID, out[0].ID) out2 := mm.Query(MemoryQueryFilter{IncludeSuperseded: true}) require.Len(t, out2, 2) } func TestMemoryManager_MarkAccessed(t *testing.T) { mm := newMemoryManager("", "") id, _ := mm.Save(persistence.MemoryEntry{Body: "x"}) mm.MarkAccessed([]string{id}) got, _ := mm.Get(id) assert.EqualValues(t, 1, got.AccessCount) assert.False(t, got.LastAccessed.IsZero()) } func TestMemoryManager_DefaultsApplied(t *testing.T) { mm := newMemoryManager("", "") id, _ := mm.Save(persistence.MemoryEntry{Body: "minimal"}) got, _ := mm.Get(id) assert.Equal(t, float32(1.0), got.Confidence) assert.Equal(t, 3, got.Importance) assert.Equal(t, "reference", got.Kind) assert.Equal(t, "manual", got.Source) } func TestMemoryManager_PersistenceRoundTrip(t *testing.T) { tmp := t.TempDir() repoPath := filepath.Join(tmp, "fake-repo") require.NoError(t, os.MkdirAll(repoPath, 0o755)) cacheDir := filepath.Join(tmp, "cache") mm := newMemoryManager(cacheDir, repoPath) _, err := mm.Save(persistence.MemoryEntry{ Body: "remember this", SymbolIDs: []string{"pkg/x.go::Foo"}, Tags: []string{"invariant"}, Pinned: true, }) require.NoError(t, err) mm2 := newMemoryManager(cacheDir, repoPath) out := mm2.Query(MemoryQueryFilter{}) require.Len(t, out, 1) assert.Equal(t, "remember this", out[0].Body) assert.True(t, out[0].Pinned) } // --------------------------------------------------------------------------- // Surface — ranking behaviour // --------------------------------------------------------------------------- func TestSurface_RanksSymbolAnchorAbovePinned(t *testing.T) { mm := newMemoryManager("", "") // Pinned but no anchor match. _, _ = mm.Save(persistence.MemoryEntry{ Body: "pinned unrelated", Pinned: true, Importance: 3, }) // Symbol anchor match — should rank above pinned. anchorID, _ := mm.Save(persistence.MemoryEntry{ Body: "anchor match", SymbolIDs: []string{"pkg/foo.go::Bar"}, Importance: 3, }) res := mm.Surface(SurfaceOptions{ SymbolIDs: []string{"pkg/foo.go::Bar"}, Limit: 10, }, nil) require.Equal(t, 2, res.Total) assert.Equal(t, anchorID, res.Memories[0].ID, "anchor match should rank above pinned-without-anchor") } func TestSurface_KeywordHits(t *testing.T) { mm := newMemoryManager("", "") id, _ := mm.Save(persistence.MemoryEntry{Body: "the JWT validator rejects expired tokens"}) _, _ = mm.Save(persistence.MemoryEntry{Body: "unrelated note about caching"}) res := mm.Surface(SurfaceOptions{ Task: "fix jwt validator regression", Limit: 10, }, nil) require.NotEmpty(t, res.Memories) assert.Equal(t, id, res.Memories[0].ID) } func TestSurface_IgnoresSupersededByDefault(t *testing.T) { mm := newMemoryManager("", "") oldID, _ := mm.Save(persistence.MemoryEntry{ Body: "old", SymbolIDs: []string{"pkg/x.go::Y"}, }) newID, _ := mm.Save(persistence.MemoryEntry{ Body: "new", SymbolIDs: []string{"pkg/x.go::Y"}, }) supBy := newID _, _ = mm.Update(oldID, MemoryPatch{SupersededBy: &supBy}) res := mm.Surface(SurfaceOptions{ SymbolIDs: []string{"pkg/x.go::Y"}, Limit: 10, }, nil) require.Equal(t, 1, res.Total) assert.Equal(t, newID, res.Memories[0].ID) res2 := mm.Surface(SurfaceOptions{ SymbolIDs: []string{"pkg/x.go::Y"}, Limit: 10, IncludeSuperseded: true, }, nil) require.Equal(t, 2, res2.Total) } func TestSurface_FilePromotionFromSymbol(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "pkg/foo.go::Bar", Name: "Bar", FilePath: "pkg/foo.go", Kind: graph.KindFunction}) mm := newMemoryManager("", "") id, _ := mm.Save(persistence.MemoryEntry{ Body: "file-anchored memory", FilePaths: []string{"pkg/foo.go"}, }) res := mm.Surface(SurfaceOptions{ SymbolIDs: []string{"pkg/foo.go::Bar"}, Limit: 10, }, func(symID string) *graph.Node { return g.GetNode(symID) }) require.Equal(t, 1, res.Total, "containing file of the anchor symbol should match file-anchored memory") assert.Equal(t, id, res.Memories[0].ID) } func TestSurface_RequiresAnchorMatchWhenAnchorsProvided(t *testing.T) { mm := newMemoryManager("", "") // Not pinned, no anchor match. _, _ = mm.Save(persistence.MemoryEntry{Body: "unrelated"}) res := mm.Surface(SurfaceOptions{ SymbolIDs: []string{"pkg/foo.go::Bar"}, Limit: 10, }, nil) assert.Equal(t, 0, res.Total, "no-match non-pinned memory must be dropped when anchors provided") } func TestSurface_NoAnchorsReturnsEverything(t *testing.T) { mm := newMemoryManager("", "") _, _ = mm.Save(persistence.MemoryEntry{Body: "a"}) _, _ = mm.Save(persistence.MemoryEntry{Body: "b"}) res := mm.Surface(SurfaceOptions{Limit: 10}, nil) assert.Equal(t, 2, res.Total) } func TestSurface_ExcerptCap(t *testing.T) { mm := newMemoryManager("", "") body := strings.Repeat("A", 1000) _, _ = mm.Save(persistence.MemoryEntry{Body: body}) res := mm.Surface(SurfaceOptions{Limit: 10, ExcerptCap: 100}, nil) require.NotEmpty(t, res.Memories) assert.LessOrEqual(t, len([]byte(res.Memories[0].Body)), 100+len("…")+3) assert.Contains(t, res.Memories[0].Body, "…") } func TestSurface_MarkAccessedIncrements(t *testing.T) { mm := newMemoryManager("", "") id, _ := mm.Save(persistence.MemoryEntry{Body: "x"}) _ = mm.Surface(SurfaceOptions{Limit: 10, MarkAccessed: true}, nil) got, _ := mm.Get(id) assert.EqualValues(t, 1, got.AccessCount) } // --------------------------------------------------------------------------- // Handler tests — end-to-end against an embedded Server // --------------------------------------------------------------------------- func newMemoryTestServer(t *testing.T) *Server { t.Helper() g := graph.New() g.AddNode(&graph.Node{ID: "pkg/foo.go::Bar", Name: "Bar", Kind: graph.KindFunction, FilePath: "pkg/foo.go"}) g.AddNode(&graph.Node{ID: "pkg/foo.go::Baz", Name: "Baz", Kind: graph.KindMethod, FilePath: "pkg/foo.go"}) s := &Server{ graph: g, session: newSessionState(), tokenStats: &tokenStats{}, symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)}, sessions: newSessionMap(), toolScopes: newScopeRegistry(), } s.memories = newMemoryManager("", "") return s } func callMemHandler(t *testing.T, h func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error), args map[string]any) *mcp.CallToolResult { t.Helper() req := mcp.CallToolRequest{} req.Params.Arguments = args res, err := h(context.Background(), req) require.NoError(t, err) require.NotNil(t, res) return res } func unmarshalMemResult(t *testing.T, res *mcp.CallToolResult) map[string]any { t.Helper() require.False(t, res.IsError, "handler returned an error result: %+v", res.Content) require.NotEmpty(t, res.Content) tc, ok := res.Content[0].(mcp.TextContent) require.True(t, ok, "expected TextContent, got %T", res.Content[0]) var m map[string]any require.NoError(t, json.Unmarshal([]byte(tc.Text), &m)) return m } func TestHandleStoreMemory_CreateAndAutoLink(t *testing.T) { s := newMemoryTestServer(t) res := callMemHandler(t, s.handleStoreMemory, map[string]any{ "title": "Bar invariant", "body": "Bar and Baz must run under the same lock", "symbol_ids": "pkg/foo.go::Bar", "tags": "invariant,decision", "kind": "invariant", "importance": 5, }) out := unmarshalMemResult(t, res) require.NotEmpty(t, out["id"]) assert.Equal(t, "Bar invariant", out["title"]) assert.Equal(t, "invariant", out["kind"]) assert.EqualValues(t, 5, out["importance"].(float64)) symbols, _ := out["symbol_ids"].([]any) require.NotEmpty(t, symbols) links, _ := out["links"].([]any) idSet := map[string]bool{} for _, l := range links { idSet[l.(string)] = true } assert.True(t, idSet["pkg/foo.go::Baz"], "auto-linker should pick up Baz") } func TestHandleStoreMemory_RejectEmpty(t *testing.T) { s := newMemoryTestServer(t) req := mcp.CallToolRequest{} req.Params.Arguments = map[string]any{} res, err := s.handleStoreMemory(context.Background(), req) require.NoError(t, err) require.NotNil(t, res) assert.True(t, res.IsError, "empty store_memory should return error result") } func TestHandleStoreMemory_Update(t *testing.T) { s := newMemoryTestServer(t) res := callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "draft", }) out := unmarshalMemResult(t, res) id := out["id"].(string) res2 := callMemHandler(t, s.handleStoreMemory, map[string]any{ "id": id, "body": "final body referring to Bar", "tags": "final", "pinned": true, "importance": 5, }) out2 := unmarshalMemResult(t, res2) assert.Equal(t, id, out2["id"]) assert.Equal(t, "final body referring to Bar", out2["body"]) assert.Equal(t, true, out2["pinned"]) } func TestHandleStoreMemory_SupersedesOlder(t *testing.T) { s := newMemoryTestServer(t) oldRes := callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "old wisdom", "symbol_ids": "pkg/foo.go::Bar", }) oldID := unmarshalMemResult(t, oldRes)["id"].(string) newRes := callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "new wisdom", "symbol_ids": "pkg/foo.go::Bar", "supersedes": oldID, }) newID := unmarshalMemResult(t, newRes)["id"].(string) old, ok := s.memories.Get(oldID) require.True(t, ok) assert.Equal(t, newID, old.SupersededBy) // query_memories must hide the old one by default. qres := callMemHandler(t, s.handleQueryMemories, map[string]any{}) q := unmarshalMemResult(t, qres) total := int(q["total"].(float64)) assert.Equal(t, 1, total) } func TestHandleQueryMemories_FilterByKindAndTag(t *testing.T) { s := newMemoryTestServer(t) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "alpha", "symbol_ids": "pkg/foo.go::Bar", "tags": "invariant", "kind": "invariant", }) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "beta", "symbol_ids": "pkg/foo.go::Baz", "tags": "gotcha", "kind": "gotcha", }) res := callMemHandler(t, s.handleQueryMemories, map[string]any{"kind": "invariant"}) out := unmarshalMemResult(t, res) assert.Equal(t, 1, int(out["total"].(float64))) res2 := callMemHandler(t, s.handleQueryMemories, map[string]any{"tag": "gotcha"}) out2 := unmarshalMemResult(t, res2) assert.Equal(t, 1, int(out2["total"].(float64))) } func TestHandleQueryMemories_TextAndSince(t *testing.T) { s := newMemoryTestServer(t) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{"body": "TIMEOUT bug in worker"}) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{"body": "unrelated memory"}) res := callMemHandler(t, s.handleQueryMemories, map[string]any{"text": "timeout"}) out := unmarshalMemResult(t, res) assert.Equal(t, 1, int(out["total"].(float64))) future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) resFuture := callMemHandler(t, s.handleQueryMemories, map[string]any{"since": future}) outFuture := unmarshalMemResult(t, resFuture) assert.Equal(t, 0, int(outFuture["total"].(float64))) } func TestHandleQueryMemories_BadSince(t *testing.T) { s := newMemoryTestServer(t) req := mcp.CallToolRequest{} req.Params.Arguments = map[string]any{"since": "not-a-timestamp"} res, err := s.handleQueryMemories(context.Background(), req) require.NoError(t, err) require.NotNil(t, res) assert.True(t, res.IsError) } func TestHandleSurfaceMemories_RanksAnchorMatch(t *testing.T) { s := newMemoryTestServer(t) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "pinned unrelated", "pinned": true, }) anchorRes := callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "anchor", "symbol_ids": "pkg/foo.go::Bar", }) anchorID := unmarshalMemResult(t, anchorRes)["id"].(string) res := callMemHandler(t, s.handleSurfaceMemories, map[string]any{ "symbol_ids": "pkg/foo.go::Bar", "mark_accessed": false, }) out := unmarshalMemResult(t, res) mems, _ := out["memories"].([]any) require.NotEmpty(t, mems) first := mems[0].(map[string]any) assert.Equal(t, anchorID, first["id"]) reasons, _ := first["match_reasons"].([]any) assert.NotEmpty(t, reasons) } func TestHandleSurfaceMemories_TaskKeywordSearch(t *testing.T) { s := newMemoryTestServer(t) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "the JWT validator handles expired tokens", }) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "completely unrelated note", }) res := callMemHandler(t, s.handleSurfaceMemories, map[string]any{ "task": "fix the jwt validator", "mark_accessed": false, }) out := unmarshalMemResult(t, res) mems, _ := out["memories"].([]any) require.NotEmpty(t, mems) body := mems[0].(map[string]any)["body"].(string) assert.Contains(t, body, "JWT") } func TestHandleSurfaceMemories_MarkAccessedDefaultsTrue(t *testing.T) { s := newMemoryTestServer(t) storeRes := callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": "anchored", "symbol_ids": "pkg/foo.go::Bar", }) id := unmarshalMemResult(t, storeRes)["id"].(string) _ = callMemHandler(t, s.handleSurfaceMemories, map[string]any{ "symbol_ids": "pkg/foo.go::Bar", // mark_accessed not set — default true. }) got, _ := s.memories.Get(id) assert.EqualValues(t, 1, got.AccessCount) } // Registration smoke test — protects against accidental removal of // the registerMemoriesTools call from NewServer. func TestRegisterMemoriesTools_Wired(t *testing.T) { s := newMemoryTestServer(t) require.NotNil(t, s.memories) // The handlers themselves are exercised by the tests above; this // stub confirms the manager pointer survives construction. } // check_onboarding_performed — readiness probe for the gortex-onboarding // skill. Performed iff every essential kind clears min_per_kind. func TestCheckOnboardingPerformed_EmptyStore(t *testing.T) { s := newMemoryTestServer(t) res := callMemHandler(t, s.handleCheckOnboardingPerformed, map[string]any{}) out := unmarshalMemResult(t, res) assert.Equal(t, false, out["performed"]) assert.EqualValues(t, 0, out["total_memories"].(float64)) missing, _ := out["missing_kinds"].([]any) assert.Len(t, missing, len(defaultEssentialKinds), "every default kind is missing in an empty store") } func TestCheckOnboardingPerformed_AllKindsSatisfied(t *testing.T) { s := newMemoryTestServer(t) for _, kind := range defaultEssentialKinds { _ = callMemHandler(t, s.handleStoreMemory, map[string]any{ "body": kind + " memory", "kind": kind, }) } res := callMemHandler(t, s.handleCheckOnboardingPerformed, map[string]any{}) out := unmarshalMemResult(t, res) assert.Equal(t, true, out["performed"]) missing, _ := out["missing_kinds"].([]any) assert.Empty(t, missing, "no kinds should be missing when each has a memory") counts := out["counts_by_kind"].(map[string]any) for _, k := range defaultEssentialKinds { assert.EqualValues(t, 1, counts[k].(float64), "kind %s count", k) } } func TestCheckOnboardingPerformed_OneKindMissing(t *testing.T) { s := newMemoryTestServer(t) // Only invariant + decision; convention deliberately absent. _ = callMemHandler(t, s.handleStoreMemory, map[string]any{"body": "x", "kind": "invariant"}) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{"body": "y", "kind": "decision"}) res := callMemHandler(t, s.handleCheckOnboardingPerformed, map[string]any{}) out := unmarshalMemResult(t, res) assert.Equal(t, false, out["performed"]) missing, _ := out["missing_kinds"].([]any) require.Len(t, missing, 1) assert.Equal(t, "convention", missing[0].(string)) } func TestCheckOnboardingPerformed_CustomKindsAndThresholds(t *testing.T) { s := newMemoryTestServer(t) // Two gotchas, one reference. _ = callMemHandler(t, s.handleStoreMemory, map[string]any{"body": "g1", "kind": "gotcha"}) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{"body": "g2", "kind": "gotcha"}) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{"body": "r1", "kind": "reference"}) res := callMemHandler(t, s.handleCheckOnboardingPerformed, map[string]any{ "essential_kinds": "gotcha,reference", "min_per_kind": 2, }) out := unmarshalMemResult(t, res) // Gotcha clears 2; reference is at 1 (< min 2). assert.Equal(t, false, out["performed"]) missing, _ := out["missing_kinds"].([]any) require.Len(t, missing, 1) assert.Equal(t, "reference", missing[0].(string)) } func TestCheckOnboardingPerformed_MinTotalGate(t *testing.T) { s := newMemoryTestServer(t) // Satisfy all essential kinds, but min_total=10 demands more. for _, k := range defaultEssentialKinds { _ = callMemHandler(t, s.handleStoreMemory, map[string]any{"body": k, "kind": k}) } res := callMemHandler(t, s.handleCheckOnboardingPerformed, map[string]any{"min_total": 10}) out := unmarshalMemResult(t, res) assert.Equal(t, false, out["performed"], "min_total=10 not reached with 3 memories") res2 := callMemHandler(t, s.handleCheckOnboardingPerformed, map[string]any{"min_total": 3}) out2 := unmarshalMemResult(t, res2) assert.Equal(t, true, out2["performed"], "min_total=3 satisfied by 3 memories") } func TestCheckOnboardingPerformed_EmptyEssentialKinds(t *testing.T) { s := newMemoryTestServer(t) _ = callMemHandler(t, s.handleStoreMemory, map[string]any{"body": "anything"}) res := callMemHandler(t, s.handleCheckOnboardingPerformed, map[string]any{ "essential_kinds": "", }) out := unmarshalMemResult(t, res) // No essential kinds → no missing entries; performed gated only by min_total (default 0). assert.Equal(t, true, out["performed"]) missing, _ := out["missing_kinds"].([]any) assert.Empty(t, missing) }