Files
zzet--gortex/internal/mcp/warmup_fastpath_test.go
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

393 lines
16 KiB
Go

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)
}