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
198 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|