Files
zzet--gortex/internal/mcp/tools_planning_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

198 lines
7.1 KiB
Go

package mcp
import (
"encoding/json"
"strings"
"testing"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
)
func TestPlanTurn_EmptyTask_Errors(t *testing.T) {
srv, _ := setupTestServer(t)
result := callTool(t, srv, "plan_turn", map[string]any{})
assert.True(t, result.IsError, "expected error when task is missing")
}
func TestPlanTurn_AlwaysRecommendsSmartContextFirst(t *testing.T) {
srv, _ := setupTestServer(t)
result := callTool(t, srv, "plan_turn", map[string]any{
"task": "refactor the helper function",
})
require.False(t, result.IsError)
var resp struct {
RecommendedCalls []recommendedCall `json:"recommended_calls"`
}
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))
require.Greater(t, len(resp.RecommendedCalls), 0)
assert.Equal(t, "smart_context", resp.RecommendedCalls[0].Tool,
"smart_context must be the first recommendation — the cheapest and broadest opening move")
assert.NotEmpty(t, resp.RecommendedCalls[0].Why)
}
func TestPlanTurn_RespectsMaxCalls(t *testing.T) {
srv, _ := setupTestServer(t)
result := callTool(t, srv, "plan_turn", map[string]any{
"task": "add rate limit to helper",
"max_calls": 2,
})
require.False(t, result.IsError)
var resp struct {
RecommendedCalls []recommendedCall `json:"recommended_calls"`
}
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))
assert.LessOrEqual(t, len(resp.RecommendedCalls), 2)
}
func TestPlanTurn_IncludesCandidates(t *testing.T) {
srv, _ := setupTestServer(t)
// The fixture defines "helper" and "main"; keyword "helper" must hit
// at least one candidate.
result := callTool(t, srv, "plan_turn", map[string]any{
"task": "modify helper behavior",
})
require.False(t, result.IsError)
var resp struct {
TopCandidates []string `json:"top_candidates"`
CandidateCount int `json:"candidate_count"`
RecommendedCalls []recommendedCall `json:"recommended_calls"`
}
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))
assert.Greater(t, len(resp.TopCandidates), 0, "expected at least one BM25 candidate for 'helper'")
assert.Equal(t, len(resp.TopCandidates), resp.CandidateCount)
// With candidates present, we should see a get_editing_context call
// for the top candidate's file.
var sawEditingContext bool
for _, rec := range resp.RecommendedCalls {
if rec.Tool == "get_editing_context" {
sawEditingContext = true
_, hasPath := rec.Args["path"]
assert.True(t, hasPath, "get_editing_context recommendation must include path arg")
}
}
assert.True(t, sawEditingContext, "expected get_editing_context recommendation when candidates exist")
}
func TestPlanTurn_NoCandidates_StillEmitsFallbacks(t *testing.T) {
srv, _ := setupTestServer(t)
result := callTool(t, srv, "plan_turn", map[string]any{
"task": "xyzzynothingmatchesthis",
})
require.False(t, result.IsError)
var resp struct {
TopCandidates []string `json:"top_candidates"`
RecommendedCalls []recommendedCall `json:"recommended_calls"`
}
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))
// No candidates — but plan_turn must still suggest openings so the
// agent doesn't hit a dead end.
assert.GreaterOrEqual(t, len(resp.RecommendedCalls), 2)
assert.Equal(t, "smart_context", resp.RecommendedCalls[0].Tool)
// search_symbols fallback should appear somewhere in the recommendations.
var sawSearch bool
for _, rec := range resp.RecommendedCalls {
if rec.Tool == "search_symbols" {
sawSearch = true
}
}
assert.True(t, sawSearch, "expected search_symbols fallback when no BM25 candidates")
}
// TestPlanTurn_ExactNameMatchLeadsCandidates pins the regression where
// a task naming a real graph symbol (e.g. "find every caller of
// renderToolsSearchResult") used to surface unrelated BM25 results
// (ts_lex from tree-sitter parser tables) because engine.SearchSymbols
// runs without the MCP search_symbols rerank pipeline. The fix
// prepends FindNodesByName hits for identifier-shape keywords so the
// user's actual target leads the candidate list.
func TestPlanTurn_ExactNameMatchLeadsCandidates(t *testing.T) {
srv, _ := setupTestServer(t)
// setupTestServer fixture already provides a `helper` function;
// add a clearly-named identifier the keyword extractor will
// classify as identifier-shape (camelCase) and the graph's name
// index will find directly.
srv.graph.AddNode(&graph.Node{
ID: "fake.go::renderToolsSearchResult",
Name: "renderToolsSearchResult",
Kind: graph.KindFunction,
FilePath: "fake.go",
})
result := callTool(t, srv, "plan_turn", map[string]any{
"task": "find every caller of renderToolsSearchResult",
})
require.False(t, result.IsError)
var resp struct {
TopCandidates []string `json:"top_candidates"`
}
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))
require.NotEmpty(t, resp.TopCandidates)
require.Equal(t, "fake.go::renderToolsSearchResult", resp.TopCandidates[0],
"exact-name match must lead the candidate list; got %v", resp.TopCandidates)
}
// TestExtractKeywords_PrioritisesIdentifierShape pins the regression
// where `find every caller of renderToolsSearchResult` produced
// recommendations centred on unrelated `findLocked` / `findDeclaration`
// methods because the BM25 budget filled on "find" before the actual
// identifier got a chance. After the fix, identifier-shape tokens
// surface first and English verbs ("find", "caller") are stopped.
func TestExtractKeywords_PrioritisesIdentifierShape(t *testing.T) {
got := extractKeywords("find every caller of renderToolsSearchResult")
require.NotEmpty(t, got)
// Identifier-shape token must be first so the BM25 budget anchors
// on what the user actually typed.
require.Equal(t, "renderToolsSearchResult", got[0],
"identifier-shape keyword must lead, got %v", got)
// Verb-shape "find" / "every" / "caller" must be stop-listed.
for _, kw := range got {
require.NotEqual(t, "find", strings.ToLower(kw))
require.NotEqual(t, "every", strings.ToLower(kw))
require.NotEqual(t, "caller", strings.ToLower(kw))
}
}
// TestExtractKeywords_PlainWordsStillSurfaceWhenNoIdentifier covers
// the fallback: when the task has no identifier-shape token, plain
// english keywords still come through (e.g. "trace error logger").
func TestExtractKeywords_PlainWordsStillSurfaceWhenNoIdentifier(t *testing.T) {
got := extractKeywords("trace the error logger")
require.Contains(t, got, "trace")
require.Contains(t, got, "error")
require.Contains(t, got, "logger")
}
func TestIsCallableKind(t *testing.T) {
callable := []graph.NodeKind{
graph.KindFunction, graph.KindMethod, graph.KindType, graph.KindInterface,
}
for _, k := range callable {
assert.True(t, isCallableKind(k), "kind %s should be callable", k)
}
nonCallable := []graph.NodeKind{
graph.KindFile, graph.KindImport, graph.KindVariable, graph.KindPackage,
}
for _, k := range nonCallable {
assert.False(t, isCallableKind(k), "kind %s must not be callable", k)
}
}