Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

533 lines
17 KiB
Go

package mcp
import (
"context"
"encoding/json"
"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"
)
// ---------------------------------------------------------------------------
// notesManager — unit tests for the persistence + filter layer
// ---------------------------------------------------------------------------
func TestNotesManager_SaveQueryDelete(t *testing.T) {
nm := newNotesManager("", "")
id1, err := nm.Save(persistence.NoteEntry{
SessionID: "s1",
Body: "init the cache for pkg/foo.go::Bar",
SymbolID: "pkg/foo.go::Bar",
Tags: []string{"decision"},
})
require.NoError(t, err)
require.NotEmpty(t, id1)
id2, err := nm.Save(persistence.NoteEntry{
SessionID: "s1",
Body: "TODO: revisit timeout",
FilePath: "pkg/foo.go",
Tags: []string{"todo"},
})
require.NoError(t, err)
all := nm.Query(NoteQueryFilter{SessionID: "s1"})
require.Len(t, all, 2)
// Newest-first ordering.
assert.Equal(t, id2, all[0].ID)
bySymbol := nm.Query(NoteQueryFilter{SymbolID: "pkg/foo.go::Bar"})
require.Len(t, bySymbol, 1)
assert.Equal(t, id1, bySymbol[0].ID)
byFile := nm.Query(NoteQueryFilter{FilePath: "pkg/foo.go"})
require.Len(t, byFile, 1)
assert.Equal(t, id2, byFile[0].ID)
byTag := nm.Query(NoteQueryFilter{Tag: "DECISION"})
require.Len(t, byTag, 1)
assert.Equal(t, id1, byTag[0].ID)
byText := nm.Query(NoteQueryFilter{TextSearch: "TIMEOUT"})
require.Len(t, byText, 1)
assert.Equal(t, id2, byText[0].ID)
require.NoError(t, nm.Delete(id1))
assert.Equal(t, 1, nm.Count())
require.NoError(t, nm.Delete(id1), "deleting twice is a noop")
}
func TestNotesManager_Update(t *testing.T) {
nm := newNotesManager("", "")
id, err := nm.Save(persistence.NoteEntry{
SessionID: "s1",
Body: "first body",
Tags: []string{"draft"},
})
require.NoError(t, err)
newBody := "second body"
pinned := true
updated, err := nm.Update(id, &newBody, []string{"final"}, &pinned, []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.Contains(t, updated.AutoLinks, "pkg/x.go::Bar")
// Re-fetch and confirm persisted state matches.
got, ok := nm.Get(id)
require.True(t, ok)
assert.Equal(t, updated.Body, got.Body)
}
func TestNotesManager_UpdateMissingID(t *testing.T) {
nm := newNotesManager("", "")
_, err := nm.Update("nope", nil, nil, nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestNotesManager_PersistenceRoundTrip(t *testing.T) {
cache := t.TempDir()
repo := "/tmp/notes-test-repo"
nm1 := newNotesManager(cache, repo)
id, err := nm1.Save(persistence.NoteEntry{SessionID: "s1", Body: "first"})
require.NoError(t, err)
// New manager pointed at the same cache — simulates server restart.
nm2 := newNotesManager(cache, repo)
assert.True(t, nm2.HasData())
got, ok := nm2.Get(id)
require.True(t, ok)
assert.Equal(t, "first", got.Body)
}
func TestNotesManager_Distill(t *testing.T) {
nm := newNotesManager("", "")
g := graph.New()
g.AddNode(&graph.Node{
ID: "pkg/foo.go::Bar",
Name: "Bar",
Kind: graph.KindFunction,
FilePath: "pkg/foo.go",
})
for range 3 {
_, err := nm.Save(persistence.NoteEntry{
SessionID: "s1",
Body: "note about Bar",
SymbolID: "pkg/foo.go::Bar",
Tags: []string{"decision"},
})
require.NoError(t, err)
}
_, err := nm.Save(persistence.NoteEntry{
SessionID: "s1",
Body: "side note",
Pinned: true,
Tags: []string{"note"},
})
require.NoError(t, err)
res := nm.DistillSession("s1", "", "", defaultDistillOptions(), g.GetNode)
assert.Equal(t, 4, res.NoteCount)
require.NotEmpty(t, res.TopSymbols)
assert.Equal(t, "pkg/foo.go::Bar", res.TopSymbols[0].ID)
assert.Equal(t, "Bar", res.TopSymbols[0].Name)
assert.Equal(t, 3, res.TopSymbols[0].Count)
// Pinned should be surfaced.
require.NotEmpty(t, res.PinnedNotes)
// Decision excerpt present.
require.NotEmpty(t, res.Decisions)
// Summary non-empty.
assert.NotEmpty(t, res.Summary)
}
func TestNotesManager_DistillEmpty(t *testing.T) {
nm := newNotesManager("", "")
res := nm.DistillSession("missing", "", "", defaultDistillOptions(), nil)
assert.Equal(t, 0, res.NoteCount)
assert.Empty(t, res.Summary)
}
func TestNotesManager_QueryLimit(t *testing.T) {
nm := newNotesManager("", "")
for range 5 {
_, _ = nm.Save(persistence.NoteEntry{SessionID: "s1", Body: "n"})
}
out := nm.Query(NoteQueryFilter{SessionID: "s1", Limit: 2})
assert.Len(t, out, 2)
}
func TestNotesManager_QuerySince(t *testing.T) {
nm := newNotesManager("", "")
id1, _ := nm.Save(persistence.NoteEntry{SessionID: "s1", Body: "old"})
time.Sleep(5 * time.Millisecond)
cutoff := time.Now().UTC()
time.Sleep(5 * time.Millisecond)
id2, _ := nm.Save(persistence.NoteEntry{SessionID: "s1", Body: "new"})
out := nm.Query(NoteQueryFilter{Since: cutoff})
require.Len(t, out, 1)
assert.Equal(t, id2, out[0].ID)
assert.NotEqual(t, id1, out[0].ID)
}
// ---------------------------------------------------------------------------
// Auto-linking — body→symbol extractor
// ---------------------------------------------------------------------------
func TestAutoLink_ResolvesByExactID(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "pkg/foo.go::Bar", Name: "Bar", FilePath: "pkg/foo.go"})
g.AddNode(&graph.Node{ID: "pkg/other.go::Quux", Name: "Quux", FilePath: "pkg/other.go"})
body := "Looked at pkg/foo.go::Bar — it depends on Quux."
links := autoLinkBody(body, g, "", defaultAutoLinkOptions())
assert.Contains(t, links, "pkg/foo.go::Bar")
assert.Contains(t, links, "pkg/other.go::Quux")
}
func TestAutoLink_DropsAmbiguousNames(t *testing.T) {
g := graph.New()
// Four nodes named "Foo" — exceeds the precision cap (3).
for i := range 4 {
g.AddNode(&graph.Node{ID: "pkg/x.go::Foo" + string(rune('A'+i)), Name: "Foo", FilePath: "pkg/x.go"})
}
links := autoLinkBody("mentions Foo somewhere", g, "", defaultAutoLinkOptions())
for _, l := range links {
assert.False(t, strings.HasPrefix(l, "pkg/x.go::Foo"), "ambiguous Foo should be skipped, got: %s", l)
}
}
func TestAutoLink_SkipsStopwords(t *testing.T) {
g := graph.New()
// "the" is in the stop-word list — even if a node existed, it
// must not be auto-linked.
g.AddNode(&graph.Node{ID: "pkg/x.go::the", Name: "the", FilePath: "pkg/x.go"})
links := autoLinkBody("this is the thing", g, "", defaultAutoLinkOptions())
assert.NotContains(t, links, "pkg/x.go::the")
}
func TestAutoLink_HonorsWorkspaceScope(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "pkg/a.go::Bar",
Name: "Bar",
FilePath: "pkg/a.go",
WorkspaceID: "ws-a",
})
g.AddNode(&graph.Node{
ID: "pkg/b.go::Baz",
Name: "Baz",
FilePath: "pkg/b.go",
WorkspaceID: "ws-b",
})
links := autoLinkBody("see Bar and Baz", g, "ws-a", defaultAutoLinkOptions())
assert.Contains(t, links, "pkg/a.go::Bar")
assert.NotContains(t, links, "pkg/b.go::Baz", "ws-b symbol must not leak into ws-a auto-link result")
}
// TestAutoLink_DoesNotMatchBareEnglishWords pins the regression: a
// note body containing the word "memory" used to auto-link to
// `daemon_status_tui.go::repoItem.memory` (a field whose Name is
// literally "memory"), and "MCP" auto-linked to `Config.MCP`. The
// auto-linker now requires a code-shape signal on each token
// (uppercase, underscore, or dot) and a single unambiguous graph
// match, so plain English words and short caps acronyms with
// multiple matches no longer pollute the link set.
func TestAutoLink_DoesNotMatchBareEnglishWords(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "pkg/tui.go::repoItem.memory", Name: "memory", FilePath: "pkg/tui.go"})
g.AddNode(&graph.Node{ID: "pkg/config.go::Config.MCP", Name: "MCP", FilePath: "pkg/config.go"})
g.AddNode(&graph.Node{ID: "pkg/cli.go::otherMCP", Name: "MCP", FilePath: "pkg/cli.go"})
body := "Validation memory: MCP integration tests exercising store_memory"
links := autoLinkBody(body, g, "", defaultAutoLinkOptions())
// bare "memory" / "MCP" with multiple-name-match must not link.
require.NotContains(t, links, "pkg/tui.go::repoItem.memory",
"plain English `memory` must not pull in a field of the same name")
require.NotContains(t, links, "pkg/config.go::Config.MCP",
"short caps `MCP` with multiple matches is ambiguous; must not auto-link")
require.NotContains(t, links, "pkg/cli.go::otherMCP",
"short caps `MCP` with multiple matches must not auto-link")
// A signal-bearing token like store_memory still works when the
// graph has a uniquely-named node for it.
g.AddNode(&graph.Node{ID: "pkg/mem.go::store_memory", Name: "store_memory", FilePath: "pkg/mem.go"})
links = autoLinkBody(body, g, "", defaultAutoLinkOptions())
require.Contains(t, links, "pkg/mem.go::store_memory",
"snake_case tokens carry the code signal and must still link when unambiguous")
}
func TestAutoLink_CapsAtMaxLinks(t *testing.T) {
g := graph.New()
body := strings.Builder{}
for i := range 30 {
name := "Sym" + string(rune('A'+i))
g.AddNode(&graph.Node{ID: "pkg/x.go::" + name, Name: name, FilePath: "pkg/x.go"})
body.WriteString(name + " ")
}
opts := defaultAutoLinkOptions()
opts.MaxLinks = 5
links := autoLinkBody(body.String(), g, "", opts)
assert.LessOrEqual(t, len(links), 5)
}
// ---------------------------------------------------------------------------
// Handler tests — end-to-end against an embedded Server
// ---------------------------------------------------------------------------
func newTestServer(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.notes = newNotesManager("", "")
return s
}
func callHandler(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 unmarshalResult(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 TestHandleSaveNote_CreateAndAutoLink(t *testing.T) {
s := newTestServer(t)
res := callHandler(t, s.handleSaveNote, map[string]any{
"body": "look at pkg/foo.go::Bar and update Baz",
"symbol_id": "pkg/foo.go::Bar",
"tags": "decision, follow-up",
})
out := unmarshalResult(t, res)
require.NotEmpty(t, out["id"])
links, _ := out["links"].([]any)
require.NotEmpty(t, links)
idSet := map[string]bool{}
for _, l := range links {
idSet[l.(string)] = true
}
assert.True(t, idSet["pkg/foo.go::Bar"], "primary symbol must be linked")
assert.True(t, idSet["pkg/foo.go::Baz"], "auto-linker should pick up Baz from body")
tags, _ := out["tags"].([]any)
assert.ElementsMatch(t, []any{"decision", "follow-up"}, tags)
}
func TestHandleSaveNote_RejectEmpty(t *testing.T) {
s := newTestServer(t)
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{}
res, err := s.handleSaveNote(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, res)
assert.True(t, res.IsError, "empty save should return error result")
}
func TestHandleSaveNote_NoAutolink(t *testing.T) {
s := newTestServer(t)
res := callHandler(t, s.handleSaveNote, map[string]any{
"body": "Bar is mentioned but should not auto-link",
"no_autolink": true,
})
out := unmarshalResult(t, res)
_, hasLinks := out["links"]
assert.False(t, hasLinks, "no_autolink should produce no links field")
}
func TestHandleSaveNote_Update(t *testing.T) {
s := newTestServer(t)
// Create.
res := callHandler(t, s.handleSaveNote, map[string]any{
"body": "draft",
})
out := unmarshalResult(t, res)
id := out["id"].(string)
// Update.
res2 := callHandler(t, s.handleSaveNote, map[string]any{
"id": id,
"body": "final body referring to Bar",
"tags": "final",
"pinned": true,
})
out2 := unmarshalResult(t, res2)
assert.Equal(t, id, out2["id"])
assert.Equal(t, "final body referring to Bar", out2["body"])
assert.Equal(t, true, out2["pinned"])
links, _ := out2["links"].([]any)
require.NotEmpty(t, links)
}
func TestHandleQueryNotes_FilterBySymbolAndTag(t *testing.T) {
s := newTestServer(t)
_ = callHandler(t, s.handleSaveNote, map[string]any{
"body": "alpha", "symbol_id": "pkg/foo.go::Bar", "tags": "decision",
})
_ = callHandler(t, s.handleSaveNote, map[string]any{
"body": "beta", "symbol_id": "pkg/foo.go::Baz", "tags": "todo",
})
res := callHandler(t, s.handleQueryNotes, map[string]any{
"symbol_id": "pkg/foo.go::Bar",
"session_id": "all",
})
out := unmarshalResult(t, res)
total := int(out["total"].(float64))
assert.Equal(t, 1, total)
res2 := callHandler(t, s.handleQueryNotes, map[string]any{
"tag": "todo",
"session_id": "all",
})
out2 := unmarshalResult(t, res2)
total2 := int(out2["total"].(float64))
assert.Equal(t, 1, total2)
}
func TestHandleQueryNotes_TextFilter(t *testing.T) {
s := newTestServer(t)
_ = callHandler(t, s.handleSaveNote, map[string]any{"body": "timeout bug in worker"})
_ = callHandler(t, s.handleSaveNote, map[string]any{"body": "unrelated note"})
res := callHandler(t, s.handleQueryNotes, map[string]any{
"text": "TIMEOUT",
"session_id": "all",
})
out := unmarshalResult(t, res)
total := int(out["total"].(float64))
assert.Equal(t, 1, total)
}
func TestHandleDistillSession_FullCycle(t *testing.T) {
s := newTestServer(t)
for range 3 {
_ = callHandler(t, s.handleSaveNote, map[string]any{
"body": "decision about Bar", "symbol_id": "pkg/foo.go::Bar", "tags": "decision",
})
}
_ = callHandler(t, s.handleSaveNote, map[string]any{
"body": "pin this", "pinned": true,
})
res := callHandler(t, s.handleDistillSession, map[string]any{
"session_id": "all",
})
out := unmarshalResult(t, res)
noteCount := int(out["note_count"].(float64))
assert.Equal(t, 4, noteCount)
topSyms, _ := out["top_symbols"].([]any)
require.NotEmpty(t, topSyms)
first, _ := topSyms[0].(map[string]any)
assert.Equal(t, "pkg/foo.go::Bar", first["id"])
pinned, _ := out["pinned_notes"].([]any)
assert.NotEmpty(t, pinned)
decisions, _ := out["decisions"].([]any)
assert.NotEmpty(t, decisions)
summary, _ := out["summary"].(string)
assert.Contains(t, summary, "Top symbols")
}
// TestNotes_RegisteredOnNewServer asserts the three session-memory
// tools land in the registered tool set after NewServer + InitNotes
// — protects against accidental removal of the registerNotesTools
// call.
func TestNotes_RegisteredOnNewServer(t *testing.T) {
srv, _ := setupTestServer(t)
srv.InitNotes("", "")
// Round-trip a save → query → distill through the wired handlers
// to confirm the entire family works end-to-end on a real Server
// built by NewServer.
saveRes := callHandler(t, srv.handleSaveNote, map[string]any{
"body": "decision: use fastpath",
"tags": "decision",
})
out := unmarshalResult(t, saveRes)
require.NotEmpty(t, out["id"])
queryRes := callHandler(t, srv.handleQueryNotes, map[string]any{
"tag": "decision",
"session_id": "all",
})
queryOut := unmarshalResult(t, queryRes)
assert.Equal(t, 1.0, queryOut["total"])
distillRes := callHandler(t, srv.handleDistillSession, map[string]any{
"session_id": "all",
})
distillOut := unmarshalResult(t, distillRes)
assert.Equal(t, 1.0, distillOut["note_count"])
}
func TestNotes_HandlersWithoutInit(t *testing.T) {
// Server with no notes manager — every handler must surface a
// clear error rather than panic.
s := &Server{
graph: graph.New(),
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{"body": "x"}
res, err := s.handleSaveNote(context.Background(), req)
require.NoError(t, err)
assert.True(t, res.IsError)
res2, err := s.handleQueryNotes(context.Background(), req)
require.NoError(t, err)
assert.True(t, res2.IsError)
res3, err := s.handleDistillSession(context.Background(), req)
require.NoError(t, err)
assert.True(t, res3.IsError)
}