zzet--gortex
项目文件夹
您已经派生过 zzet--gortex
296 行
11 KiB
Go
296 行
11 KiB
Go
|
|
package mcp
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|||
|
|
"github.com/stretchr/testify/assert"
|
|||
|
|
"github.com/stretchr/testify/require"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// TestDefaultFormatForClient verifies the GCX-capable client allowlist.
|
|||
|
|
// The list is the contract: clients in it get gcx by default; everything
|
|||
|
|
// else falls back to JSON. Drift in this list silently changes the wire
|
|||
|
|
// format every shipping client receives, so the test pins it explicitly.
|
|||
|
|
func TestDefaultFormatForClient(t *testing.T) {
|
|||
|
|
cases := []struct {
|
|||
|
|
client string
|
|||
|
|
want string
|
|||
|
|
}{
|
|||
|
|
// GCX-capable: every client whose plugin/CLI ships a GCX1 decoder.
|
|||
|
|
{"claude-code", "gcx"},
|
|||
|
|
{"Claude-Code", "gcx"}, // case-insensitive
|
|||
|
|
{" claude-code ", "gcx"}, // trimmed
|
|||
|
|
{"cursor", "gcx"},
|
|||
|
|
{"vscode", "gcx"},
|
|||
|
|
{"zed", "gcx"},
|
|||
|
|
{"aider", "gcx"},
|
|||
|
|
{"kilocode", "gcx"},
|
|||
|
|
{"opencode", "gcx"},
|
|||
|
|
{"openclaw", "gcx"},
|
|||
|
|
{"codex", "gcx"},
|
|||
|
|
{"omp-coding-agent", "gcx"},
|
|||
|
|
|
|||
|
|
// Unknown / unset → JSON fallback.
|
|||
|
|
{"", ""},
|
|||
|
|
{"some-other-client", ""},
|
|||
|
|
{"unknown", ""},
|
|||
|
|
}
|
|||
|
|
for _, tc := range cases {
|
|||
|
|
assert.Equal(t, tc.want, defaultFormatForClient(tc.client),
|
|||
|
|
"defaultFormatForClient(%q)", tc.client)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestResolveSessionFormat_NoSession returns "" for a bare context — no
|
|||
|
|
// session means no client identity, which means no default format.
|
|||
|
|
func TestResolveSessionFormat_NoSession(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
assert.Equal(t, "", srv.resolveSessionFormat(context.Background()))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestResolveSessionFormat_KnownClient verifies the full per-session
|
|||
|
|
// path: NoteSessionClient stores the client name, and resolveSessionFormat
|
|||
|
|
// reads it back through sessionFor + defaultFormatForClient.
|
|||
|
|
func TestResolveSessionFormat_KnownClient(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
srv.NoteSessionClient("session_X", "claude-code", "1.0.42")
|
|||
|
|
ctx := WithSessionID(context.Background(), "session_X")
|
|||
|
|
assert.Equal(t, "gcx", srv.resolveSessionFormat(ctx))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestResolveSessionFormat_UnknownClient(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
srv.NoteSessionClient("session_X", "some-bespoke-client", "0.1")
|
|||
|
|
ctx := WithSessionID(context.Background(), "session_X")
|
|||
|
|
assert.Equal(t, "", srv.resolveSessionFormat(ctx),
|
|||
|
|
"unknown client must fall back to JSON (empty string)")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestNoteSessionClient_NilSafe ensures NoteSessionClient never panics
|
|||
|
|
// when called on a nil *Server or with empty inputs — both are normal
|
|||
|
|
// during boot races / embedded-mode tests.
|
|||
|
|
func TestNoteSessionClient_NilSafe(t *testing.T) {
|
|||
|
|
var srv *Server
|
|||
|
|
srv.NoteSessionClient("sess", "claude-code", "1.0")
|
|||
|
|
|
|||
|
|
srv2, _ := setupTestServer(t)
|
|||
|
|
srv2.NoteSessionClient("", "claude-code", "1.0") // empty session id → no-op
|
|||
|
|
srv2.NoteSessionClient("sess", "", "1.0") // empty client → no-op
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestNoteSessionClient_IsolatedPerSession verifies two sessions get
|
|||
|
|
// independent client-name state. This is the core invariant that lets
|
|||
|
|
// the daemon serve multiple proxies through one shared *Server.
|
|||
|
|
func TestNoteSessionClient_IsolatedPerSession(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
|
|||
|
|
srv.NoteSessionClient("sess_A", "claude-code", "1.0")
|
|||
|
|
srv.NoteSessionClient("sess_B", "some-bespoke-client", "0.1")
|
|||
|
|
|
|||
|
|
ctxA := WithSessionID(context.Background(), "sess_A")
|
|||
|
|
ctxB := WithSessionID(context.Background(), "sess_B")
|
|||
|
|
assert.Equal(t, "gcx", srv.resolveSessionFormat(ctxA))
|
|||
|
|
assert.Equal(t, "", srv.resolveSessionFormat(ctxB))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestIsGCX_ExplicitFormatWins verifies that an explicit `format` arg
|
|||
|
|
// overrides the per-session default in either direction.
|
|||
|
|
func TestIsGCX_ExplicitFormatWins(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
srv.NoteSessionClient("sess_A", "claude-code", "1.0") // session default = gcx
|
|||
|
|
ctx := WithSessionID(context.Background(), "sess_A")
|
|||
|
|
|
|||
|
|
// Explicit "json" must override the session default.
|
|||
|
|
req := mcp.CallToolRequest{}
|
|||
|
|
req.Params.Arguments = map[string]any{"format": "json"}
|
|||
|
|
assert.False(t, srv.isGCX(ctx, req),
|
|||
|
|
"explicit format=json must defeat session-default gcx")
|
|||
|
|
|
|||
|
|
// Explicit "gcx" stays gcx.
|
|||
|
|
req2 := mcp.CallToolRequest{}
|
|||
|
|
req2.Params.Arguments = map[string]any{"format": "gcx"}
|
|||
|
|
assert.True(t, srv.isGCX(ctx, req2))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestIsGCX_SessionDefaultApplies verifies that a session whose client
|
|||
|
|
// is GCX-capable picks gcx when the request omits `format`.
|
|||
|
|
func TestIsGCX_SessionDefaultApplies(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
srv.NoteSessionClient("sess_A", "claude-code", "1.0")
|
|||
|
|
ctx := WithSessionID(context.Background(), "sess_A")
|
|||
|
|
|
|||
|
|
req := mcp.CallToolRequest{}
|
|||
|
|
req.Params.Arguments = map[string]any{} // no format
|
|||
|
|
assert.True(t, srv.isGCX(ctx, req),
|
|||
|
|
"claude-code session with no explicit format must default to gcx")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestIsGCX_NoSession_NoFormat returns false — the legacy default is
|
|||
|
|
// JSON, and absent both an explicit format and a known client we must
|
|||
|
|
// preserve that.
|
|||
|
|
func TestIsGCX_NoSession_NoFormat(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
req := mcp.CallToolRequest{}
|
|||
|
|
req.Params.Arguments = map[string]any{}
|
|||
|
|
assert.False(t, srv.isGCX(context.Background(), req))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestIsTOON_ExplicitFormatWins verifies that an explicit `format=toon`
|
|||
|
|
// trips isTOON regardless of session default.
|
|||
|
|
func TestIsTOON_ExplicitFormatWins(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
srv.NoteSessionClient("sess_A", "claude-code", "1.0") // gcx by default
|
|||
|
|
ctx := WithSessionID(context.Background(), "sess_A")
|
|||
|
|
|
|||
|
|
req := mcp.CallToolRequest{}
|
|||
|
|
req.Params.Arguments = map[string]any{"format": "toon"}
|
|||
|
|
assert.True(t, srv.isTOON(ctx, req))
|
|||
|
|
assert.False(t, srv.isGCX(ctx, req),
|
|||
|
|
"format=toon must not also trigger gcx")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestRespondJSONOrTOON_RoutesByFormat pins the helper that 14
|
|||
|
|
// list-shaped tools share. With explicit format=toon the payload comes
|
|||
|
|
// back as TOON-marshalled text; with no format and an unknown client
|
|||
|
|
// it falls back to JSON. This is the single decision point most tools
|
|||
|
|
// route through, so a regression here would silently flip every
|
|||
|
|
// downstream consumer to the wrong format.
|
|||
|
|
func TestRespondJSONOrTOON_RoutesByFormat(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
payload := map[string]any{"x": 1, "y": "two"}
|
|||
|
|
|
|||
|
|
// format=toon → TOON text result.
|
|||
|
|
reqTOON := mcp.CallToolRequest{}
|
|||
|
|
reqTOON.Params.Arguments = map[string]any{"format": "toon"}
|
|||
|
|
res, err := srv.respondJSONOrTOON(context.Background(), reqTOON, payload)
|
|||
|
|
assert.NoError(t, err)
|
|||
|
|
assert.False(t, res.IsError)
|
|||
|
|
tc, ok := res.Content[0].(mcp.TextContent)
|
|||
|
|
assert.True(t, ok, "expected TextContent for TOON result")
|
|||
|
|
// TOON encodes scalar map values with `key: value` lines; an empty
|
|||
|
|
// or JSON-shaped payload would not contain that exact prefix.
|
|||
|
|
assert.Contains(t, tc.Text, "x: 1")
|
|||
|
|
assert.NotContains(t, tc.Text, "{")
|
|||
|
|
|
|||
|
|
// no format, unknown client → JSON fallback.
|
|||
|
|
reqJSON := mcp.CallToolRequest{}
|
|||
|
|
reqJSON.Params.Arguments = map[string]any{}
|
|||
|
|
res, err = srv.respondJSONOrTOON(context.Background(), reqJSON, payload)
|
|||
|
|
assert.NoError(t, err)
|
|||
|
|
tc, ok = res.Content[0].(mcp.TextContent)
|
|||
|
|
assert.True(t, ok)
|
|||
|
|
assert.Contains(t, tc.Text, "{") // JSON object braces
|
|||
|
|
|
|||
|
|
// format=json overrides session default → JSON.
|
|||
|
|
srv.NoteSessionClient("sess_T", "claude-code", "1.0") // session default would be gcx, not toon
|
|||
|
|
ctx := WithSessionID(context.Background(), "sess_T")
|
|||
|
|
reqExplicitJSON := mcp.CallToolRequest{}
|
|||
|
|
reqExplicitJSON.Params.Arguments = map[string]any{"format": "json"}
|
|||
|
|
res, err = srv.respondJSONOrTOON(ctx, reqExplicitJSON, payload)
|
|||
|
|
assert.NoError(t, err)
|
|||
|
|
tc, ok = res.Content[0].(mcp.TextContent)
|
|||
|
|
assert.True(t, ok)
|
|||
|
|
assert.Contains(t, tc.Text, "{")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestRespondJSONOrTOON_MaxTokensTrimAndDecorate is the end-to-end
|
|||
|
|
// pin for the new max_tokens parameter: a payload that exceeds the
|
|||
|
|
// derived byte cap must come back trimmed AND carry the
|
|||
|
|
// _truncated_by_tokens marker so the agent can see the tokens
|
|||
|
|
// budget was the constraint that fired. Pairs with the unit tests
|
|||
|
|
// in budget_test.go — this one rides the actual respondJSONOrTOON
|
|||
|
|
// path that get_file_summary / find_usages / get_call_chain /
|
|||
|
|
// get_editing_context / prefetch_context all share.
|
|||
|
|
func TestRespondJSONOrTOON_MaxTokensTrimAndDecorate(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
|
|||
|
|
// Build a payload large enough that a tight max_tokens cap must
|
|||
|
|
// trim. 200 rows × ~80 bytes per row ≈ 16 KB raw payload.
|
|||
|
|
rows := make([]any, 200)
|
|||
|
|
for i := range rows {
|
|||
|
|
rows[i] = map[string]any{
|
|||
|
|
"id": "row-" + strings.Repeat("x", 40),
|
|||
|
|
"line": i,
|
|||
|
|
"meta": strings.Repeat("padding-", 10),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
payload := map[string]any{
|
|||
|
|
"results": rows,
|
|||
|
|
"total": 200,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// max_tokens=200 ≈ 700 bytes — clearly too small for the raw
|
|||
|
|
// payload, so trim should fire.
|
|||
|
|
req := mcp.CallToolRequest{}
|
|||
|
|
req.Params.Arguments = map[string]any{
|
|||
|
|
"format": "json",
|
|||
|
|
"max_tokens": float64(200),
|
|||
|
|
}
|
|||
|
|
res, err := srv.respondJSONOrTOON(context.Background(), req, payload)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.False(t, res.IsError)
|
|||
|
|
|
|||
|
|
tc, ok := res.Content[0].(mcp.TextContent)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
|
|||
|
|
// The response must carry both the generic truncation flag AND
|
|||
|
|
// the tokens-specific marker, plus the original max_tokens value
|
|||
|
|
// for caller diagnostics.
|
|||
|
|
assert.Contains(t, tc.Text, "_truncated_by_budget")
|
|||
|
|
assert.Contains(t, tc.Text, "_truncated_by_tokens")
|
|||
|
|
assert.Contains(t, tc.Text, "_max_tokens")
|
|||
|
|
|
|||
|
|
// Same payload with a generous max_tokens cap → no trim, no markers.
|
|||
|
|
req.Params.Arguments = map[string]any{
|
|||
|
|
"format": "json",
|
|||
|
|
"max_tokens": float64(100000),
|
|||
|
|
}
|
|||
|
|
res, err = srv.respondJSONOrTOON(context.Background(), req, payload)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
tc, ok = res.Content[0].(mcp.TextContent)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
assert.NotContains(t, tc.Text, "_truncated_by_budget")
|
|||
|
|
assert.NotContains(t, tc.Text, "_truncated_by_tokens")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestGCXResponseWithBudget_MaxTokensTrimAndDecorate covers the GCX
|
|||
|
|
// path's max_tokens behaviour: a GCX payload exceeding the
|
|||
|
|
// token-derived byte cap gets row-trimmed by trimGCXBytes and then
|
|||
|
|
// stamped with a `# max_tokens=N truncated_by_tokens=true` comment by
|
|||
|
|
// decorateTokenBudgetGCX. The header stays intact so decoders still
|
|||
|
|
// see a valid GCX1 envelope.
|
|||
|
|
func TestGCXResponseWithBudget_MaxTokensTrimAndDecorate(t *testing.T) {
|
|||
|
|
srv, _ := setupTestServer(t)
|
|||
|
|
|
|||
|
|
// Build a synthetic GCX payload that is comfortably over a
|
|||
|
|
// 200-token (~700 byte) cap.
|
|||
|
|
var sb strings.Builder
|
|||
|
|
sb.WriteString("GCX1 tool=test fields=id,kind,name\n")
|
|||
|
|
for i := 0; i < 50; i++ {
|
|||
|
|
sb.WriteString("internal/foo.go::Bar")
|
|||
|
|
sb.WriteString(strings.Repeat("x", 20))
|
|||
|
|
sb.WriteString("\tfunction\tBar\n")
|
|||
|
|
}
|
|||
|
|
payload := []byte(sb.String())
|
|||
|
|
|
|||
|
|
req := mcp.CallToolRequest{}
|
|||
|
|
req.Params.Arguments = map[string]any{
|
|||
|
|
"format": "gcx",
|
|||
|
|
"max_tokens": float64(200),
|
|||
|
|
}
|
|||
|
|
res, err := srv.gcxResponseWithBudget(req)(payload, nil)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.False(t, res.IsError)
|
|||
|
|
|
|||
|
|
tc, ok := res.Content[0].(mcp.TextContent)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
assert.True(t, strings.HasPrefix(tc.Text, "GCX1 tool=test"))
|
|||
|
|
// Both the byte-level and token-level markers must appear.
|
|||
|
|
assert.Contains(t, tc.Text, "# truncated_by_budget=true")
|
|||
|
|
assert.Contains(t, tc.Text, "max_tokens=200")
|
|||
|
|
assert.Contains(t, tc.Text, "truncated_by_tokens=true")
|
|||
|
|
}
|