package mcp import ( "context" "encoding/json" "testing" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "github.com/zzet/gortex/internal/graph" ) // --------------------------------------------------------------------------- // warmupStateFromSnapshot — phase → real-percentage derivation // --------------------------------------------------------------------------- // TestWarmupStateFromSnapshot_NilSnapshot — no readiness phase has been // published (single-process server, or daemon pre-first-phase): the // state is not-known, so the fast path stays a pure pass-through. func TestWarmupStateFromSnapshot_NilSnapshot(t *testing.T) { w := warmupStateFromSnapshot(nil) assert.False(t, w.known, "nil snapshot must yield a not-known state") assert.False(t, w.warming(), "not-known is never warming") assert.Equal(t, 0, w.percent) } // TestWarmupStateFromSnapshot_PhaseProgress — every known warmup phase // maps to its real, monotonically non-decreasing completion // percentage, and mid-warmup phases report warming() == true. func TestWarmupStateFromSnapshot_PhaseProgress(t *testing.T) { // The pipeline order — percent must be non-decreasing along it. order := []string{ "snapshot_loaded", "parallel_parse", "parallel_parse_done", "resolve", "resolve_done", "deferred_passes_all", "deferred_passes_all_done", "global_resolve", "global_resolve_done", "end_batch", "end_batch_done", "watcher_started", } prev := 0 for _, phase := range order { w := warmupStateFromSnapshot(map[string]any{"phase": phase, "ready": false}) require.True(t, w.known, "phase %q must be known", phase) assert.True(t, w.warming(), "phase %q is mid-warmup so warming() must be true", phase) assert.Greater(t, w.percent, 0, "phase %q must have a real percent", phase) assert.Less(t, w.percent, 100, "mid-warmup phase %q must be < 100%%", phase) assert.GreaterOrEqual(t, w.percent, prev, "percent must not regress along the pipeline at phase %q", phase) prev = w.percent } } // TestWarmupStateFromSnapshot_Ready — the terminal `ready` phase // reports 100% and clears the warming flag. func TestWarmupStateFromSnapshot_Ready(t *testing.T) { w := warmupStateFromSnapshot(map[string]any{"phase": "ready", "ready": true}) assert.True(t, w.known) assert.True(t, w.ready) assert.False(t, w.warming(), "a ready daemon is not warming") assert.Equal(t, 100, w.percent) } // TestWarmupStateFromSnapshot_UnknownPhase — a forward-compat phase // string this build does not recognise still yields a real (non-zero) // percent rather than a placeholder, and is treated as mid-warmup. func TestWarmupStateFromSnapshot_UnknownPhase(t *testing.T) { w := warmupStateFromSnapshot(map[string]any{"phase": "some_future_phase", "ready": false}) assert.True(t, w.known) assert.True(t, w.warming()) assert.Equal(t, 1, w.percent, "unknown mid-warmup phase falls back to a non-zero floor") // ready flag wins even with an unrecognised phase string. wr := warmupStateFromSnapshot(map[string]any{"phase": "some_future_phase", "ready": true}) assert.False(t, wr.warming()) assert.Equal(t, 100, wr.percent) } // --------------------------------------------------------------------------- // checkWarmupFastPath — graph-tool vs exempt-tool gating // --------------------------------------------------------------------------- // warmingServer builds a minimal Server whose readiness broadcaster // already published the given phase, so warmupSnapshot reflects it. func warmingServer(t *testing.T, phase string, ready bool) *Server { t.Helper() s := &Server{readinessBroadcaster: newReadinessBroadcaster(&fakeSpecificSender{}, zap.NewNop())} s.readinessBroadcaster.publish(map[string]any{"phase": phase, "ready": ready}) return s } // TestCheckWarmupFastPath_GraphToolDuringWarmup — a graph-querying // tool invoked mid-warmup is flagged for decoration with a real // percent + phase envelope. func TestCheckWarmupFastPath_GraphToolDuringWarmup(t *testing.T) { s := warmingServer(t, "parallel_parse", false) env, warming := s.checkWarmupFastPath("search_symbols") require.True(t, warming, "graph tool during warmup must be flagged") assert.True(t, env.Warming) assert.Equal(t, "parallel_parse", env.Phase) assert.Equal(t, warmupPhaseProgress["parallel_parse"], env.Percent) assert.True(t, env.Retriable) assert.True(t, env.PartialResults, "decoration envelope marks the result partial") assert.NotEmpty(t, env.Message) } // TestCheckWarmupFastPath_GraphToolWhenReady — once the daemon is // ready, the same tool is a transparent pass-through (no envelope). func TestCheckWarmupFastPath_GraphToolWhenReady(t *testing.T) { s := warmingServer(t, "ready", true) _, warming := s.checkWarmupFastPath("search_symbols") assert.False(t, warming, "a ready daemon must not flag graph tools") } // TestCheckWarmupFastPath_NoPhasePublished — with no readiness phase // ever published (single-process server), the fast path is inert. func TestCheckWarmupFastPath_NoPhasePublished(t *testing.T) { s := &Server{readinessBroadcaster: newReadinessBroadcaster(&fakeSpecificSender{}, zap.NewNop())} _, warming := s.checkWarmupFastPath("find_usages") assert.False(t, warming, "no published phase means not-warming") // And a Server with no broadcaster at all is equally inert. bare := &Server{} _, warming = bare.checkWarmupFastPath("find_usages") assert.False(t, warming) } // TestCheckWarmupFastPath_ExemptToolUnaffected — graph-independent // tools (subscription, session-control, discovery/indexing) are never // flagged, even mid-warmup. func TestCheckWarmupFastPath_ExemptToolUnaffected(t *testing.T) { s := warmingServer(t, "global_resolve", false) for _, tool := range []string{ "subscribe_workspace_readiness", "unsubscribe_workspace_readiness", "set_planning_mode", "tools_search", "index_repository", "track_repository", } { _, warming := s.checkWarmupFastPath(tool) assert.False(t, warming, "exempt tool %q must never be flagged during warmup", tool) } } // --------------------------------------------------------------------------- // decorateResultWithWarming — the three result shapes // --------------------------------------------------------------------------- func warmingEnv() warmupEnvelope { return newWarmupEnvelope(warmupStateFromSnapshot( map[string]any{"phase": "parallel_parse", "ready": false}), true) } // decodeWarmingResult extracts the JSON object from a decorated tool // result and returns its `warming` sub-object. func decodeWarmingResult(t *testing.T, res *mcp.CallToolResult) (map[string]any, map[string]any) { t.Helper() require.NotNil(t, res) require.NotEmpty(t, res.Content) tc, ok := res.Content[0].(mcp.TextContent) require.True(t, ok, "expected TextContent, got %T", res.Content[0]) var obj map[string]any require.NoError(t, json.Unmarshal([]byte(tc.Text), &obj)) warming, ok := obj["warming"].(map[string]any) require.True(t, ok, "decorated result must carry a `warming` object, got: %s", tc.Text) return obj, warming } // TestDecorateResultWithWarming_JSONObject — a JSON-object handler // result keeps every original key and gains the `warming` block. func TestDecorateResultWithWarming_JSONObject(t *testing.T) { orig, err := mcp.NewToolResultJSON(map[string]any{ "results": []string{"Bar", "Baz"}, "total": 2, }) require.NoError(t, err) decorated := decorateResultWithWarming(orig, warmingEnv()) obj, warming := decodeWarmingResult(t, decorated) // Original partial data is preserved. assert.Equal(t, float64(2), obj["total"]) assert.Len(t, obj["results"], 2) // Warming block is real and marked partial. assert.Equal(t, true, warming["warming"]) assert.Equal(t, "parallel_parse", warming["phase"]) assert.Equal(t, float64(warmupPhaseProgress["parallel_parse"]), warming["percent"]) assert.Equal(t, true, warming["partial_results"]) assert.Equal(t, true, warming["retriable"]) assert.NotEmpty(t, warming["message"]) } // TestDecorateResultWithWarming_JSONArray — an array payload is // wrapped under a stable `results` key so the partial answer survives // verbatim alongside the warming flag. func TestDecorateResultWithWarming_JSONArray(t *testing.T) { orig, err := mcp.NewToolResultJSON([]string{"a", "b", "c"}) require.NoError(t, err) decorated := decorateResultWithWarming(orig, warmingEnv()) obj, warming := decodeWarmingResult(t, decorated) results, ok := obj["results"].([]any) require.True(t, ok, "array payload must be preserved under `results`") assert.Len(t, results, 3) assert.Equal(t, true, warming["warming"]) assert.Equal(t, true, warming["partial_results"]) } // TestDecorateResultWithWarming_EmptyResult — a handler that produced // nothing usable mid-warmup is replaced with a standalone warming-only // envelope so the caller still gets flag + percent + phase + message. func TestDecorateResultWithWarming_EmptyResult(t *testing.T) { empty := &mcp.CallToolResult{} decorated := decorateResultWithWarming(empty, warmingEnv()) _, warming := decodeWarmingResult(t, decorated) assert.Equal(t, true, warming["warming"]) assert.Equal(t, "parallel_parse", warming["phase"]) assert.Equal(t, float64(warmupPhaseProgress["parallel_parse"]), warming["percent"]) // No partial data was available — the flag says so. assert.Equal(t, false, warming["partial_results"]) assert.Equal(t, true, warming["retriable"]) assert.NotEmpty(t, warming["message"]) } // TestDecorateResultWithWarming_PreservesErrorFlag — an error result // raised mid-warmup keeps IsError but still carries the retry signal. func TestDecorateResultWithWarming_PreservesErrorFlag(t *testing.T) { errRes := mcp.NewToolResultError("graph not ready") require.True(t, errRes.IsError) decorated := decorateResultWithWarming(errRes, warmingEnv()) assert.True(t, decorated.IsError, "error flag must survive decoration") _, warming := decodeWarmingResult(t, decorated) assert.Equal(t, true, warming["warming"]) assert.Equal(t, true, warming["retriable"]) } // TestDecorateResultWithWarming_DoesNotClobberExistingKey — a handler // that already set its own `warming` key keeps it untouched. func TestDecorateResultWithWarming_DoesNotClobberExistingKey(t *testing.T) { orig, err := mcp.NewToolResultJSON(map[string]any{"warming": "handler-set"}) require.NoError(t, err) decorated := decorateResultWithWarming(orig, warmingEnv()) require.NotEmpty(t, decorated.Content) tc := decorated.Content[0].(mcp.TextContent) var obj map[string]any require.NoError(t, json.Unmarshal([]byte(tc.Text), &obj)) assert.Equal(t, "handler-set", obj["warming"], "decoration must not overwrite a handler's own key") } // --------------------------------------------------------------------------- // End-to-end — the fast path through wrapToolHandler // --------------------------------------------------------------------------- // fastPathTestServer builds a Server (struct literal, same shape as // notes_test.go's newTestServer) wired with a readiness broadcaster so // wrapToolHandler's warmup hook is live. func fastPathTestServer(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"}) s := &Server{ graph: g, session: newSessionState(), tokenStats: &tokenStats{}, symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)}, sessions: newSessionMap(), toolScopes: newScopeRegistry(), } s.readinessBroadcaster = newReadinessBroadcaster(&fakeSpecificSender{}, zap.NewNop()) return s } // echoHandler is a stand-in graph-querying tool: it returns a JSON // object of best-effort "partial" data, exactly what a real tool would // return against a half-built graph. func echoHandler(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { return mcp.NewToolResultJSON(map[string]any{"results": []string{"Bar"}, "total": 1}) } func callWrapped(t *testing.T, s *Server, h mcpserver.ToolHandlerFunc, toolName string) *mcp.CallToolResult { t.Helper() wrapped := s.wrapToolHandler(h) req := mcp.CallToolRequest{} req.Params.Name = toolName req.Params.Arguments = map[string]any{} res, err := wrapped(context.Background(), req) require.NoError(t, err) require.NotNil(t, res) return res } // TestWrapToolHandler_GraphToolDuringWarmup — a graph-querying tool // dispatched while the daemon warms up returns its best-effort partial // data AND a real warming envelope. func TestWrapToolHandler_GraphToolDuringWarmup(t *testing.T) { s := fastPathTestServer(t) s.readinessBroadcaster.publish(map[string]any{"phase": "deferred_passes_all", "ready": false}) res := callWrapped(t, s, echoHandler, "search_symbols") obj, warming := decodeWarmingResult(t, res) // Best-effort partial answer survived. assert.Equal(t, float64(1), obj["total"]) assert.Len(t, obj["results"], 1) // Warming envelope carries a real percent + phase. assert.Equal(t, true, warming["warming"]) assert.Equal(t, "deferred_passes_all", warming["phase"]) assert.Equal(t, float64(warmupPhaseProgress["deferred_passes_all"]), warming["percent"]) assert.Equal(t, true, warming["partial_results"]) } // TestWrapToolHandler_GraphToolWhenReady — once warmup completes, the // same tool returns its plain result with no warming block. func TestWrapToolHandler_GraphToolWhenReady(t *testing.T) { s := fastPathTestServer(t) s.readinessBroadcaster.publish(map[string]any{"phase": "ready", "ready": true}) res := callWrapped(t, s, echoHandler, "search_symbols") require.NotEmpty(t, res.Content) tc := res.Content[0].(mcp.TextContent) var obj map[string]any require.NoError(t, json.Unmarshal([]byte(tc.Text), &obj)) assert.Equal(t, float64(1), obj["total"]) _, hasWarming := obj["warming"] assert.False(t, hasWarming, "a ready daemon must not decorate results") } // TestWrapToolHandler_GraphToolNoWarmupPipeline — with no readiness // phase ever published (single-process server), graph tools pass // through cleanly. func TestWrapToolHandler_GraphToolNoWarmupPipeline(t *testing.T) { s := fastPathTestServer(t) // broadcaster wired but nothing published res := callWrapped(t, s, echoHandler, "search_symbols") require.NotEmpty(t, res.Content) tc := res.Content[0].(mcp.TextContent) var obj map[string]any require.NoError(t, json.Unmarshal([]byte(tc.Text), &obj)) _, hasWarming := obj["warming"] assert.False(t, hasWarming, "no warmup pipeline means no decoration") } // TestWrapToolHandler_ExemptToolUnaffectedDuringWarmup — a // graph-independent tool dispatched mid-warmup is NOT decorated; its // result passes through byte-for-byte. func TestWrapToolHandler_ExemptToolUnaffectedDuringWarmup(t *testing.T) { s := fastPathTestServer(t) s.readinessBroadcaster.publish(map[string]any{"phase": "parallel_parse", "ready": false}) res := callWrapped(t, s, echoHandler, "subscribe_workspace_readiness") require.NotEmpty(t, res.Content) tc := res.Content[0].(mcp.TextContent) var obj map[string]any require.NoError(t, json.Unmarshal([]byte(tc.Text), &obj)) assert.Equal(t, float64(1), obj["total"]) _, hasWarming := obj["warming"] assert.False(t, hasWarming, "exempt tool must not be decorated even during warmup") } // TestWrapToolHandler_HandlerErrorNotDecorated — a handler that // returns a transport error (non-nil err) is left untouched: the fast // path only decorates a returned result, never a Go error. func TestWrapToolHandler_HandlerErrorNotDecorated(t *testing.T) { s := fastPathTestServer(t) s.readinessBroadcaster.publish(map[string]any{"phase": "parallel_parse", "ready": false}) wantErr := assert.AnError failing := func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { return nil, wantErr } wrapped := s.wrapToolHandler(failing) req := mcp.CallToolRequest{} req.Params.Name = "search_symbols" req.Params.Arguments = map[string]any{} res, err := wrapped(context.Background(), req) require.ErrorIs(t, err, wantErr, "transport error must propagate unchanged") assert.Nil(t, res) }