From fb10666dbc0e9d36ae33d6039c8468f27109c345 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 24 Jul 2026 00:29:15 +0200 Subject: [PATCH 1/5] fix(localization): replay terminal evidence deterministically --- internal/hooks/localization_terminal.go | 15 +- internal/hooks/localization_terminal_test.go | 30 +++ .../explore_divergent_default_owner_test.go | 5 +- internal/mcp/localization_digest.go | 143 +++++++--- internal/mcp/localization_digest_test.go | 255 +++++++++++++----- internal/mcp/localization_recovery_test.go | 12 +- .../mcp/localization_refinement_route_test.go | 16 +- internal/mcp/localization_terminal.go | 30 +-- internal/mcp/localization_terminal_test.go | 76 ++++-- internal/mcp/tools_explore.go | 17 +- internal/mcp/tools_explore_test.go | 4 +- 11 files changed, 447 insertions(+), 156 deletions(-) diff --git a/internal/hooks/localization_terminal.go b/internal/hooks/localization_terminal.go index 5bee332f..68662d05 100644 --- a/internal/hooks/localization_terminal.go +++ b/internal/hooks/localization_terminal.go @@ -98,6 +98,7 @@ type localizationTerminalCompletion struct { State string `json:"state"` Scope string `json:"scope"` RequiredAction string `json:"required_action"` + FinalResponse string `json:"final_response,omitempty"` AllowedToolCalls *int `json:"allowed_tool_calls"` ContractVersion int `json:"contract_version"` Enforceable bool `json:"enforceable"` @@ -240,14 +241,15 @@ func sameLocalizationTerminalContract(left, right localizationTerminalContract) return false } lc, rc := left.Completion, right.Completion + if lc.State != rc.State || lc.Scope != rc.Scope || lc.RequiredAction != rc.RequiredAction || + lc.FinalResponse != rc.FinalResponse || lc.ContractVersion != rc.ContractVersion || + lc.Enforceable != rc.Enforceable { + return false + } if lc.AllowedToolCalls == nil || rc.AllowedToolCalls == nil { - return lc.AllowedToolCalls == nil && rc.AllowedToolCalls == nil && - lc.State == rc.State && lc.Scope == rc.Scope && lc.RequiredAction == rc.RequiredAction && - lc.ContractVersion == rc.ContractVersion && lc.Enforceable == rc.Enforceable + return lc.AllowedToolCalls == nil && rc.AllowedToolCalls == nil } - return lc.State == rc.State && lc.Scope == rc.Scope && lc.RequiredAction == rc.RequiredAction && - *lc.AllowedToolCalls == *rc.AllowedToolCalls && lc.ContractVersion == rc.ContractVersion && - lc.Enforceable == rc.Enforceable + return *lc.AllowedToolCalls == *rc.AllowedToolCalls } func unwrapJSONString(raw json.RawMessage) (json.RawMessage, bool) { @@ -274,6 +276,7 @@ func enforceableLocalizationTerminalContract(contract localizationTerminalContra completion.State == "answer_ready" && completion.Scope == "localization" && completion.RequiredAction == "respond" && + strings.TrimSpace(completion.FinalResponse) != "" && completion.AllowedToolCalls != nil && *completion.AllowedToolCalls == 0 && completion.ContractVersion >= localizationTerminalContractV2 && completion.Enforceable diff --git a/internal/hooks/localization_terminal_test.go b/internal/hooks/localization_terminal_test.go index 16ac4db2..e597b373 100644 --- a/internal/hooks/localization_terminal_test.go +++ b/internal/hooks/localization_terminal_test.go @@ -42,6 +42,7 @@ func TestObserveLocalizationTerminalRequiresExactEnforceableV2Contract(t *testin mutate func(map[string]any) }{ {name: "v1", mutate: func(root map[string]any) { completionMap(root)["contract_version"] = 1 }}, + {name: "missing final response", mutate: func(root map[string]any) { delete(completionMap(root), "final_response") }}, {name: "advisory", mutate: func(root map[string]any) { completionMap(root)["enforceable"] = false }}, {name: "needs refinement", mutate: func(root map[string]any) { completionMap(root)["state"] = "needs_refinement" }}, {name: "wrong scope", mutate: func(root map[string]any) { completionMap(root)["scope"] = "diagnosis" }}, @@ -117,6 +118,18 @@ func TestObserveLocalizationTerminalRequiresMatchingAuthoritativeMeta(t *testing return response }, }, + { + name: "final response mismatch", + response: func(t *testing.T) map[string]any { + response := terminalToolResponse(t, terminalContractMap(), true, false) + meta := response["_meta"].(map[string]any) + envelope := meta[localizationHostMetaKey].(map[string]any) + mismatched := cloneMap(t, terminalContractMap()) + completionMap(mismatched)["final_response"] = "different response" + envelope["contract"] = mismatched + return response + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -131,6 +144,22 @@ func TestObserveLocalizationTerminalRequiresMatchingAuthoritativeMeta(t *testing } } +func TestPostToolUseObservesMatchingFinalResponseContract(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, "terminal-final-response", "prompt", t.TempDir()) + snapshotTestLocalizationTool(t, identity, gortexMCPToolPrefix+"read", "tool") + response := terminalToolResponse(t, terminalContractMap(), true, false) + parsed, ok := exactLocalizationTerminalContract(mustJSON(t, response)) + if !ok || parsed.Completion.FinalResponse != completionMap(terminalContractMap())["final_response"] { + t.Fatalf("new-shape terminal response did not parse exactly: %#v", parsed) + } + post := localizationPostToolPayload(t, gortexMCPToolPrefix+"read", "tool", identity, response) + output := captureHookStdout(t, func() { runPostToolUse(post) }) + if !strings.Contains(output, localizationTerminalContext) { + t.Fatalf("PostToolUse output %q does not contain terminal context", output) + } +} + func TestLocalizationTerminalHookFlowDeniesThenPromptRotatesTurn(t *testing.T) { configureLocalizationTerminalTestHome(t) sessionID := "terminal-flow" @@ -762,6 +791,7 @@ func terminalContractMap() map[string]any { "state": "answer_ready", "scope": "localization", "required_action": "respond", + "final_response": "FILES:\n#1 repo/source.go\n\nSYMBOLS:\n#1 repo/source.go::Target\n\nEVIDENCE:\n#1 repo/source.go:1 — repo/source.go::Target", "allowed_tool_calls": 0, "contract_version": localizationTerminalContractV2, "enforceable": true, diff --git a/internal/mcp/explore_divergent_default_owner_test.go b/internal/mcp/explore_divergent_default_owner_test.go index 8f4e030f..dc6c9da7 100644 --- a/internal/mcp/explore_divergent_default_owner_test.go +++ b/internal/mcp/explore_divergent_default_owner_test.go @@ -146,9 +146,10 @@ func TestDivergentDefaultOwnerPromotesFromStoreProjectionWithoutEviction(t *test require.True(t, ok) var envelope localizationExploreEnvelope require.NoError(t, json.Unmarshal([]byte(body), &envelope)) - require.GreaterOrEqual(t, len(envelope.Files), 2) + require.GreaterOrEqual(t, len(envelope.Files), 3) require.Equal(t, fixture.childCtor.FilePath, envelope.Files[0]) - require.Equal(t, fixture.write.FilePath, envelope.Files[1]) + require.Equal(t, fixture.childType.FilePath, envelope.Files[1]) + require.Equal(t, fixture.write.FilePath, envelope.Files[2]) require.GreaterOrEqual(t, len(envelope.Symbols), 3) require.Equal(t, []string{fixture.childCtor.ID, fixture.childType.ID, fixture.write.ID}, envelope.Symbols[:3]) } diff --git a/internal/mcp/localization_digest.go b/internal/mcp/localization_digest.go index c0e2375e..e30ce90b 100644 --- a/internal/mcp/localization_digest.go +++ b/internal/mcp/localization_digest.go @@ -2,6 +2,8 @@ package mcp import ( "encoding/json" + "fmt" + "strings" mcpgo "github.com/mark3labs/mcp-go/mcp" ) @@ -9,10 +11,9 @@ import ( // Terminal evidence retention. // // The localize handler builds a byte-budgeted evidence envelope once and -// retains a compact projection for host-side fallback and diagnostics. A -// post-terminal tool call does not replay that projection: the original -// localization response already supplied it, and repeating it consumed turns -// and tokens while encouraging further navigation. +// retains a compact projection for host-side fallback and deterministic replay. +// A post-terminal navigation call returns the same successful ready-to-emit +// answer instead of an error that would invite another recovery loop. const ( // localizationDigestMaxBytes bounds retained session state independently of @@ -23,6 +24,9 @@ const ( // Five keeps the promoted structural/literal candidates reserved by the // envelope builder while bounding repeat-turn cost. localizationReplayEvidenceLimit = 5 + // localizationFinalResponseMaxBytes bounds the ready-to-emit answer that + // accompanies the retained digest on terminal responses and replays. + localizationFinalResponseMaxBytes = 4096 // This canonical envelope is deliberately carried in MCP _meta. Adapting // hosts may render its ordered evidence deterministically without exposing // retained rows to model-visible text or structuredContent. @@ -35,6 +39,10 @@ type localizationEvidenceDigest struct { Files []string `json:"files,omitempty"` Symbols []string `json:"symbols,omitempty"` Evidence []localizationDigestRow `json:"evidence,omitempty"` + + // finalResponse is derived from Evidence and excluded from digest JSON so + // the retained-state byte cap does not count the same identities twice. + finalResponse string } type localizationDigestRow struct { @@ -86,8 +94,9 @@ func newLocalizationEvidenceDigest(envelope localizationExploreEnvelope) *locali } for { rebuildLocalizationDigestSkeleton(digest) + digest.finalResponse = renderLocalizationFinalResponse(digest.Evidence) encoded, err := json.Marshal(digest) - if err == nil && len(encoded) <= localizationDigestMaxBytes { + if err == nil && len(encoded) <= localizationDigestMaxBytes && len(digest.finalResponse) <= localizationFinalResponseMaxBytes { return digest } if len(digest.Evidence) == 0 { @@ -97,11 +106,12 @@ func newLocalizationEvidenceDigest(envelope localizationExploreEnvelope) *locali if shedLocalizationDigestRowOptionalFields(&digest.Evidence[last]) { continue } + // The identity and file are the irreducible row. If even one pathological + // row cannot fit, prefer a bounded empty answer over retaining oversized + // session state or emitting a truncated, misleading identity. if last == 0 { - // ID and file are the irreducible replay contract. They are bounded by - // filesystem and symbol extraction limits in production, so retain the - // mandatory row rather than returning an empty terminal replay. - return digest + digest.Evidence = nil + continue } digest.Evidence = digest.Evidence[:last] } @@ -135,22 +145,86 @@ func shedLocalizationDigestRowOptionalFields(row *localizationDigestRow) bool { func rebuildLocalizationDigestSkeleton(digest *localizationEvidenceDigest) { digest.Files = digest.Files[:0] digest.Symbols = digest.Symbols[:0] - seenFiles := make(map[string]struct{}, len(digest.Evidence)) - seenSymbols := make(map[string]struct{}, len(digest.Evidence)) - for _, row := range digest.Evidence { - if _, exists := seenFiles[row.File]; !exists { - seenFiles[row.File] = struct{}{} - digest.Files = append(digest.Files, row.File) - } - if _, exists := seenSymbols[row.ID]; !exists { - seenSymbols[row.ID] = struct{}{} - digest.Symbols = append(digest.Symbols, row.ID) + for index := range digest.Evidence { + row := &digest.Evidence[index] + row.Rank = index + 1 + // Keep these arrays positional, including repeated files. A consumer can + // now pair FILES #N, SYMBOLS #N, and EVIDENCE #N without guessing. + digest.Files = append(digest.Files, row.File) + digest.Symbols = append(digest.Symbols, row.ID) + } +} + +func localizationFinalResponseField(value string) string { + return strings.Join(strings.Fields(value), " ") +} + +func renderLocalizationFinalResponse(rows []localizationDigestRow) string { + if len(rows) == 0 { + return "FILES:\n(none)\n\nSYMBOLS:\n(none)\n\nEVIDENCE:\nNo bounded localization evidence was found." + } + var response strings.Builder + response.WriteString("FILES:\n") + for index, row := range rows { + fmt.Fprintf(&response, "#%d %s\n", index+1, localizationFinalResponseField(row.File)) + } + response.WriteString("\nSYMBOLS:\n") + for index, row := range rows { + fmt.Fprintf(&response, "#%d %s\n", index+1, localizationFinalResponseField(row.ID)) + } + response.WriteString("\nEVIDENCE:\n") + for index, row := range rows { + file := localizationFinalResponseField(row.File) + id := localizationFinalResponseField(row.ID) + if row.Line > 0 { + fmt.Fprintf(&response, "#%d %s:%d — %s\n", index+1, file, row.Line, id) + continue } + fmt.Fprintf(&response, "#%d %s — %s\n", index+1, file, id) } + return response.String() } -const localizationAnswerReadyNotice = "Localization is complete. Do not call another tool. " + - "Answer the user now in your own words using the evidence already returned." +const localizationAnswerReadyDirective = "You already hold the localization answer — respond now using this evidence; do not call another tool." + +func localizationCompletionWithDigest(completion localizationCompletion, digest *localizationEvidenceDigest) localizationCompletion { + if digest == nil { + digest = completion.digest + } + completion.digest = digest + if completion.State != localizationStateAnswerReady { + completion.FinalResponse = "" + return completion + } + if digest != nil && digest.finalResponse != "" { + completion.FinalResponse = digest.finalResponse + } else if completion.FinalResponse == "" { + completion.FinalResponse = renderLocalizationFinalResponse(nil) + } + return completion +} + +func localizationTerminalStructuredContent(payload any, contract localizationTerminalContract) map[string]any { + var structured map[string]any + switch existing := payload.(type) { + case map[string]any: + structured = make(map[string]any, len(existing)+4) + for key, value := range existing { + structured[key] = value + } + case nil: + structured = make(map[string]any, 4) + default: + structured = map[string]any{"payload": existing} + } + structured["completion"] = contract.Completion + structured["terminal"] = contract.Terminal + if contract.Terminal && contract.Completion.FinalResponse != "" { + structured["final_response"] = contract.Completion.FinalResponse + structured["directive"] = localizationAnswerReadyDirective + } + return structured +} // localizationHostEnvelope stores each retained row exactly once. Hosts render // the ordered rows with fallback_format; no prewritten answer or duplicate row @@ -169,6 +243,14 @@ func attachLocalizationHostEnvelope(result *mcpgo.CallToolResult, completion loc if result == nil { return result } + completion = localizationCompletionWithDigest(completion, digest) + contract := localizationContractFor(completion) + // Preserve preterminal tool payloads byte-for-byte. Only answer_ready adds + // the terminal host projection; refinement and recovery retain their + // existing structuredContent shape and visible completion envelope. + if completion.State == localizationStateAnswerReady { + result.StructuredContent = localizationTerminalStructuredContent(result.StructuredContent, contract) + } if result.Meta == nil { result.Meta = &mcpgo.Meta{} } @@ -179,22 +261,17 @@ func attachLocalizationHostEnvelope(result *mcpgo.CallToolResult, completion loc Version: 1, FallbackFormat: "{file}:{line} — {id} ({signature})", Evidence: digest, - Contract: localizationContractFor(completion), + Contract: contract, } return result } -// localizationAnswerReadyResult is deliberately successful, answer-neutral, -// and constant-size. An error invites tool-recovery loops; replaying retained -// evidence invites more analysis. The completion contract is sufficient for -// hosts, while the imperative text reliably steers non-adapted models to a -// final answer without supplying a prewritten one. +// localizationAnswerReadyResult is the successful, deterministic evidence +// replay. Hooked hosts may stop before dispatch; every other host receives the +// same ready-to-emit answer and directive on every post-terminal navigation. func localizationAnswerReadyResult(completion localizationCompletion) *mcpgo.CallToolResult { - result := mcpgo.NewToolResultText(localizationAnswerReadyNotice) - contract := localizationContractFor(completion) - result.StructuredContent = map[string]any{ - "completion": contract.Completion, - "terminal": contract.Terminal, - } + completion = localizationCompletionWithDigest(completion, completion.digest) + visible := completion.FinalResponse + "\n\n" + localizationAnswerReadyDirective + result := mcpgo.NewToolResultText(visible) return attachLocalizationHostEnvelope(result, completion, completion.digest) } diff --git a/internal/mcp/localization_digest_test.go b/internal/mcp/localization_digest_test.go index 433873be..5fef624f 100644 --- a/internal/mcp/localization_digest_test.go +++ b/internal/mcp/localization_digest_test.go @@ -66,19 +66,56 @@ func requireLocalizationTerminalError(t *testing.T, result *mcpgo.CallToolResult return contract } -func TestPostTerminalNavigationReturnsCompactTypedError(t *testing.T) { +func requireLocalizationTerminalReplay(t *testing.T, result *mcpgo.CallToolResult, _, _ string) localizationTerminalContract { + t.Helper() + if result == nil || result.IsError { + t.Fatalf("terminal replay = %#v, want successful MCP result", result) + } + text, ok := singleTextContent(result) + if !ok || !strings.Contains(text, localizationAnswerReadyDirective) { + t.Fatalf("terminal replay content = %#v", result.Content) + } + structured, ok := result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("terminal replay structured content = %#v", result.StructuredContent) + } + encoded, err := json.Marshal(structured) + if err != nil { + t.Fatalf("marshal terminal replay: %v", err) + } + var contract localizationTerminalContract + if err := json.Unmarshal(encoded, &contract); err != nil { + t.Fatalf("decode terminal replay contract: %v", err) + } + if !contract.Terminal || contract.Completion.State != localizationStateAnswerReady || + contract.Completion.ContractVersion != localizationTerminalContractV2 || + contract.Completion.AllowedToolCalls != 0 || contract.Completion.FinalResponse == "" { + t.Fatalf("terminal replay contract = %#v", contract) + } + if structured["final_response"] != contract.Completion.FinalResponse || + structured["directive"] != localizationAnswerReadyDirective { + t.Fatalf("terminal replay stable keys = %#v", structured) + } + if text != contract.Completion.FinalResponse+"\n\n"+localizationAnswerReadyDirective { + t.Fatalf("terminal replay text diverged from final_response: %q", text) + } + return contract +} + +func TestPostTerminalNavigationReturnsByteIdenticalSuccessfulReplay(t *testing.T) { state := &localizationTerminalState{} completion := newLocalizationCompletion(true, "") completion.digest = testEvidenceDigest() state.armForTask(completion, "find the storage load implementations") + var canonical []byte for _, facade := range []string{"explore", "search", "read", "relations", "trace", "analyze"} { for repeat := 0; repeat < 3; repeat++ { result, reserved := state.authorize(facade, "any_operation", nil) if reserved { t.Fatalf("%s repeat %d reserved a handler call", facade, repeat) } - contract := requireLocalizationTerminalError(t, result, facade, "any_operation") + contract := requireLocalizationTerminalReplay(t, result, facade, "any_operation") if contract.Completion.Enforceable { t.Fatalf("%s repeat %d unexpectedly upgraded advisory evidence", facade, repeat) } @@ -86,11 +123,13 @@ func TestPostTerminalNavigationReturnsCompactTypedError(t *testing.T) { if err != nil { t.Fatalf("marshal %s result: %v", facade, err) } - if len(visible) > 512 { - t.Fatalf("%s visible terminal result = %d bytes, want <= 512", facade, len(visible)) + if !strings.Contains(string(visible), "repo/storage/disk.go") { + t.Fatalf("%s replay omitted retained evidence: %s", facade, visible) } - if strings.Contains(string(visible), "repo/storage") { - t.Fatalf("%s visible terminal result replayed retained evidence: %s", facade, visible) + if canonical == nil { + canonical = append([]byte(nil), visible...) + } else if !reflect.DeepEqual(visible, canonical) { + t.Fatalf("%s repeat %d replay changed by route", facade, repeat) } } } @@ -111,13 +150,19 @@ func TestRepeatLocalizeAgainstTerminalContractReturnsCompactStop(t *testing.T) { if token != 0 { t.Fatal("repeat localize must not reserve the handler slot") } - requireLocalizationTerminalError(t, blocked, "explore", "localize") + requireLocalizationTerminalReplay(t, blocked, "explore", "localize") } func TestRefinementPromotionRetainsDigestForReplay(t *testing.T) { state := &localizationTerminalState{} candidate := "repo/storage/disk.go::DiskStorage.Load" - state.armRefinementForTask("find the storage load implementations", candidate, []string{candidate}, testEvidenceDigest()) + state.armRefinementRoutesForTask( + "find the storage load implementations", + candidate, + []string{candidate}, + map[string]localizationRefinementRoute{candidate: {enforceable: true}}, + testEvidenceDigest(), + ) args := map[string]any{"target": map[string]any{"symbol": candidate}} if blocked, reserved := state.authorize("read", "source", args); blocked != nil || !reserved { @@ -129,10 +174,10 @@ func TestRefinementPromotionRetainsDigestForReplay(t *testing.T) { if reserved { t.Fatal("post-promotion navigation reserved a handler") } - requireLocalizationTerminalError(t, terminal, "search", "symbols") - encoded, err := json.Marshal(terminal) - if err != nil || strings.Contains(string(encoded), "repo/storage/cloud.go") { - t.Fatalf("promotion must stop without replaying the retained digest: %s (%v)", encoded, err) + contract := requireLocalizationTerminalReplay(t, terminal, "search", "symbols") + if !strings.Contains(contract.Completion.FinalResponse, "#1 repo/storage/disk.go") || + !strings.Contains(contract.Completion.FinalResponse, "#2 repo/storage/cloud.go") { + t.Fatalf("promotion lost retained aligned evidence: %q", contract.Completion.FinalResponse) } } @@ -163,7 +208,7 @@ func TestRefinementAllowsOneAlternateRankedCandidateRead(t *testing.T) { if result, reserved := state.authorize("read", "source", args); reserved { t.Fatal("second read reserved a handler") } else { - requireLocalizationTerminalError(t, result, "read", "source") + requireLocalizationTerminalReplay(t, result, "read", "source") } } @@ -183,7 +228,7 @@ func TestDigestLifecycleAndLegacyFallback(t *testing.T) { withoutDigest := &localizationTerminalState{} withoutDigest.armForTask(newLocalizationCompletion(true, ""), "task without digest") blocked, _ := withoutDigest.authorize("search", "symbols", nil) - requireLocalizationTerminalError(t, blocked, "search", "symbols") + requireLocalizationTerminalReplay(t, blocked, "search", "symbols") } func TestDigestByteCapShedsEvidenceTail(t *testing.T) { @@ -209,11 +254,16 @@ func TestDigestByteCapShedsEvidenceTail(t *testing.T) { if len(digest.Evidence) == 0 || len(digest.Evidence) >= localizationReplayEvidenceLimit { t.Fatalf("byte cap retained %d rows, want a non-empty shed prefix", len(digest.Evidence)) } - if !reflect.DeepEqual(digest.Files, []string{"repo/big/file.go"}) { - t.Fatalf("files were not rebuilt from retained evidence: %#v", digest.Files) + if len(digest.Files) != len(digest.Evidence) || len(digest.Symbols) != len(digest.Evidence) { + t.Fatalf("files=%d symbols=%d evidence=%d, want positional rows", len(digest.Files), len(digest.Symbols), len(digest.Evidence)) } - if len(digest.Symbols) != len(digest.Evidence) { - t.Fatalf("symbols=%d evidence=%d, want one supported symbol per row", len(digest.Symbols), len(digest.Evidence)) + for index, file := range digest.Files { + if file != "repo/big/file.go" || digest.Evidence[index].Rank != index+1 { + t.Fatalf("unaligned compacted row %d: file=%q evidence=%#v", index, file, digest.Evidence[index]) + } + } + if len(digest.finalResponse) > localizationFinalResponseMaxBytes { + t.Fatalf("final_response = %d bytes, want <= %d", len(digest.finalResponse), localizationFinalResponseMaxBytes) } } @@ -251,6 +301,34 @@ func TestDigestByteCapRetainsSingleMandatoryRowAfterSheddingOptionalFields(t *te } } +func TestDigestPathologicalIdentityFallsBackToBoundedEmptyEvidence(t *testing.T) { + oversized := strings.Repeat("x", localizationDigestMaxBytes+1) + digest := newLocalizationEvidenceDigest(localizationExploreEnvelope{Evidence: []localizationEvidence{{ + Rank: 1, ID: oversized, File: oversized, Line: 1, + }}}) + encoded, err := json.Marshal(digest) + if err != nil { + t.Fatalf("marshal pathological digest: %v", err) + } + if len(encoded) > localizationDigestMaxBytes { + t.Fatalf("pathological digest = %d bytes, want <= %d", len(encoded), localizationDigestMaxBytes) + } + if len(digest.Evidence) != 0 || len(digest.Files) != 0 || len(digest.Symbols) != 0 { + t.Fatalf("oversized irreducible row survived fallback: %#v", digest) + } + want := renderLocalizationFinalResponse(nil) + if digest.finalResponse != want { + t.Fatalf("pathological fallback final_response = %q, want %q", digest.finalResponse, want) + } + completion := newLocalizationCompletion(true, "") + completion.digest = digest + result := localizationAnswerReadyResult(completion) + contract := requireLocalizationTerminalReplay(t, result, "search", "symbols") + if contract.Completion.FinalResponse != want { + t.Fatalf("pathological replay final_response = %q, want %q", contract.Completion.FinalResponse, want) + } +} + func TestPostTerminalReadsAreIntercepted(t *testing.T) { state := &localizationTerminalState{} completion := newLocalizationCompletion(true, "") @@ -263,7 +341,7 @@ func TestPostTerminalReadsAreIntercepted(t *testing.T) { if reserved { t.Fatal("post-terminal read reserved a handler") } - requireLocalizationTerminalError(t, blocked, "read", "source") + requireLocalizationTerminalReplay(t, blocked, "read", "source") } func TestLocalizationDigestKeepsOnlyConcreteBoundedEvidence(t *testing.T) { @@ -305,41 +383,47 @@ func TestLocalizationDigestKeepsOnlyConcreteBoundedEvidence(t *testing.T) { } } -func TestLocalizationAnswerReadyResultIsTinyNeutralAndStructured(t *testing.T) { +func TestLocalizationFinalResponseKeepsDuplicateFilesPositionallyAligned(t *testing.T) { + digest := newLocalizationEvidenceDigest(localizationExploreEnvelope{Evidence: []localizationEvidence{ + {Rank: 7, ID: "repo/pkg/shared.go::First", File: "pkg/shared.go", Line: 11, Source: "first body"}, + {Rank: 9, ID: "repo/pkg/shared.go::Second", File: "pkg/shared.go", Line: 27, Source: "second body"}, + {Rank: 12, ID: "repo/pkg/other.go::Third", File: "pkg/other.go", Line: 3, Source: "third body"}, + }}) + wantFiles := []string{"pkg/shared.go", "pkg/shared.go", "pkg/other.go"} + wantSymbols := []string{"repo/pkg/shared.go::First", "repo/pkg/shared.go::Second", "repo/pkg/other.go::Third"} + if !reflect.DeepEqual(digest.Files, wantFiles) || !reflect.DeepEqual(digest.Symbols, wantSymbols) { + t.Fatalf("positional digest = files %#v symbols %#v", digest.Files, digest.Symbols) + } + for index, row := range digest.Evidence { + marker := fmt.Sprintf("#%d %s", index+1, row.ID) + if row.Rank != index+1 || !strings.Contains(digest.finalResponse, marker) { + t.Fatalf("row %d not aligned in final_response:\n%s", index, digest.finalResponse) + } + } + encoded, err := json.Marshal(digest) + if err != nil || strings.Contains(string(encoded), "body") || strings.Contains(digest.finalResponse, "body") { + t.Fatalf("retained digest leaked source: %s (%v)", encoded, err) + } +} + +func TestLocalizationAnswerReadyResultReplaysAlignedStableContract(t *testing.T) { completion := newLocalizationCompletion(true, "") completion.digest = testEvidenceDigest() result := localizationAnswerReadyResult(completion) - if result == nil || result.IsError || len(result.Content) != 1 { - t.Fatalf("terminal result = %#v, want one successful text block", result) - } - visible, ok := singleTextContent(result) - if !ok || visible != localizationAnswerReadyNotice { - t.Fatalf("terminal result text = %q", visible) - } - for _, forbidden := range []string{"verbatim", "final_response", `"directive"`, "FILES:", "SYMBOLS:", "pkg/"} { - if strings.Contains(visible, forbidden) { - t.Fatalf("terminal result contains %q: %s", forbidden, visible) + contract := requireLocalizationTerminalReplay(t, result, "", "") + wantRows := []string{ + "FILES:\n#1 repo/storage/disk.go\n#2 repo/storage/cloud.go", + "SYMBOLS:\n#1 repo/storage/disk.go::DiskStorage.Load\n#2 repo/storage/cloud.go::CloudStorage.Load", + "EVIDENCE:\n#1 repo/storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load\n#2 repo/storage/cloud.go:17 — repo/storage/cloud.go::CloudStorage.Load", + } + for _, want := range wantRows { + if !strings.Contains(contract.Completion.FinalResponse, want) { + t.Fatalf("final_response omitted aligned rows %q:\n%s", want, contract.Completion.FinalResponse) } } - structured, ok := result.StructuredContent.(map[string]any) - if !ok || structured["terminal"] != true || structured["completion"] == nil { - t.Fatalf("structured terminal contract = %#v", result.StructuredContent) - } - if _, exists := structured["evidence_digest"]; exists { - t.Fatalf("terminal contract replayed evidence: %#v", structured) - } - visibleEncoded, err := json.Marshal(struct { - Content []mcpgo.Content `json:"content"` - Structured any `json:"structuredContent"` - }{Content: result.Content, Structured: result.StructuredContent}) - if err != nil { - t.Fatalf("marshal terminal result: %v", err) - } - if len(visibleEncoded) > 512 { - t.Fatalf("visible terminal result = %d bytes, want <= 512", len(visibleEncoded)) - } - if strings.Contains(string(visibleEncoded), "repo/storage") { - t.Fatalf("retained evidence escaped into visible terminal result: %s", visibleEncoded) + visible, _ := singleTextContent(result) + if strings.Contains(strings.ToLower(visible), "source") || len(contract.Completion.FinalResponse) > localizationFinalResponseMaxBytes { + t.Fatalf("terminal replay leaked source or exceeded cap: %q", visible) } if result.Meta == nil || result.Meta.AdditionalFields == nil { t.Fatal("terminal result omitted host-only metadata") @@ -348,11 +432,38 @@ func TestLocalizationAnswerReadyResultIsTinyNeutralAndStructured(t *testing.T) { if !ok || host.Evidence == nil || host.FallbackFormat == "" || host.Evidence.Evidence[0].File != "repo/storage/disk.go" { t.Fatalf("host-only fallback envelope = %#v", result.Meta.AdditionalFields[localizationHostMetaKey]) } - if host.Contract.Terminal != localizationContractFor(completion).Terminal || - host.Contract.Completion.State != completion.State || - host.Contract.Completion.ContractVersion != localizationTerminalContractV2 || - host.Contract.Completion.Enforceable != completion.Enforceable { - t.Fatalf("host contract = %#v, want %#v", host.Contract, localizationContractFor(completion)) + if host.Contract.Completion.FinalResponse != contract.Completion.FinalResponse { + t.Fatalf("host and visible final_response disagree: host=%q visible=%q", host.Contract.Completion.FinalResponse, contract.Completion.FinalResponse) + } +} + +func TestAttachLocalizationHostEnvelopePreservesPreterminalStructuredContent(t *testing.T) { + original := map[string]any{ + "payload": map[string]any{"value": "kept"}, + "completion": map[string]any{"legacy": true}, + "terminal": "unchanged", + } + before, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal original structured content: %v", err) + } + result := mcpgo.NewToolResultText("preterminal payload") + result.StructuredContent = original + completion := newLocalizationRecoveryCompletion() + attached := attachLocalizationHostEnvelope(result, completion, testEvidenceDigest()) + after, err := json.Marshal(attached.StructuredContent) + if err != nil { + t.Fatalf("marshal attached structured content: %v", err) + } + if !reflect.DeepEqual(attached.StructuredContent, original) || !reflect.DeepEqual(after, before) { + t.Fatalf("preterminal structured content changed: before=%s after=%s shape=%#v", before, after, attached.StructuredContent) + } + host, ok := attached.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok { + t.Fatalf("preterminal host envelope missing: %#v", attached.Meta) + } + if host.Contract.Terminal || host.Contract.Completion.State != localizationStateNeedsRecovery || host.Contract.Completion.FinalResponse != "" { + t.Fatalf("preterminal host contract was terminally enriched: %#v", host.Contract) } } @@ -526,6 +637,21 @@ func TestPermittedRefinementReadInvokesHandlerAndPreservesPayload(t *testing.T) if result.Meta == nil || result.Meta.AdditionalFields["handler"] != "kept" { t.Fatalf("handler metadata was lost: %#v", result.Meta) } + // This completed read is the answer_ready PostToolUse event observed by a + // host. Capture its exact terminal payload before deliberately issuing one + // more navigation call below. + initialEncoded, err := json.Marshal(result.StructuredContent) + if err != nil { + t.Fatalf("marshal initial terminal contract: %v", err) + } + var initialContract localizationTerminalContract + if err := json.Unmarshal(initialEncoded, &initialContract); err != nil { + t.Fatalf("decode initial terminal contract: %v", err) + } + initialHost, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok || !initialContract.Terminal || initialContract.Completion.FinalResponse == "" { + t.Fatalf("answer_ready PostToolUse envelope = contract %#v host %#v", initialContract, result.Meta) + } wire, err := json.Marshal(result) if err != nil { t.Fatalf("marshal MCP result: %v", err) @@ -553,7 +679,16 @@ func TestPermittedRefinementReadInvokesHandlerAndPreservesPayload(t *testing.T) if searchCalls != 0 { t.Fatalf("search handler calls after answer_ready = %d, want 0", searchCalls) } - requireLocalizationTerminalError(t, terminal, "search", "symbols") + replayContract := requireLocalizationTerminalReplay(t, terminal, "search", "symbols") + replayHost, ok := terminal.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok { + t.Fatalf("post-terminal replay host envelope missing: %#v", terminal.Meta) + } + if replayContract.Completion.FinalResponse != initialContract.Completion.FinalResponse || + replayHost.Contract.Completion.FinalResponse != initialHost.Contract.Completion.FinalResponse || + !reflect.DeepEqual(replayHost.Evidence, initialHost.Evidence) { + t.Fatalf("host-ignored terminal replay diverged: initial=%#v/%#v replay=%#v/%#v", initialContract, initialHost, replayContract, replayHost) + } } func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { @@ -606,7 +741,7 @@ func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { if err != nil { t.Fatalf("repeat %d result = (%+v, %v)", repeat, result, err) } - requireLocalizationTerminalError(t, result, "search", "symbols") + requireLocalizationTerminalReplay(t, result, "search", "symbols") } if handlerCalls != 0 { t.Fatalf("legacy handler invoked %d times after answer_ready", handlerCalls) @@ -622,7 +757,7 @@ func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { if err != nil { t.Fatalf("post-terminal read = (%+v, %v)", readResult, err) } - requireLocalizationTerminalError(t, readResult, "read", "source") + requireLocalizationTerminalReplay(t, readResult, "read", "source") if handlerCalls != 0 { t.Fatalf("read handler invoked %d times after answer_ready", handlerCalls) } @@ -634,7 +769,7 @@ func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { if err != nil { t.Fatalf("malformed post-terminal request = (%+v, %v)", result, err) } - requireLocalizationTerminalError(t, result, "search", "not_an_operation") + requireLocalizationTerminalReplay(t, result, "search", "not_an_operation") changeRequest := mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ Name: "change", @@ -644,7 +779,7 @@ func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { if err != nil || changeResult == nil || changeResult.IsError || changeCalls != 1 { t.Fatalf("post-terminal change.detect = (%+v, %v), calls=%d", changeResult, err, changeCalls) } - if text, _ := singleTextContent(changeResult); text == localizationAnswerReadyNotice { + if text, _ := singleTextContent(changeResult); strings.Contains(text, localizationAnswerReadyDirective) { t.Fatal("post-terminal change.detect was incorrectly intercepted") } @@ -664,7 +799,7 @@ func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { if called.Error != nil || called.Result == nil || called.Result.IsError { t.Fatalf("terminal capabilities response = error %#v result %#v", called.Error, called.Result) } - if text, _ := singleTextContent(called.Result); text == localizationAnswerReadyNotice { + if text, _ := singleTextContent(called.Result); strings.Contains(text, localizationAnswerReadyDirective) { t.Fatalf("capabilities was incorrectly intercepted: %q", text) } } diff --git a/internal/mcp/localization_recovery_test.go b/internal/mcp/localization_recovery_test.go index 6a36f85d..0a5f6cdc 100644 --- a/internal/mcp/localization_recovery_test.go +++ b/internal/mcp/localization_recovery_test.go @@ -57,7 +57,7 @@ func TestWeakReadAllowsOneBoundedSearchRecoveryThenTerminates(t *testing.T) { if err != nil { t.Fatalf("post-recovery call returned transport error: %v", err) } - requireLocalizationTerminalError(t, extra, "search", "text") + requireLocalizationTerminalReplay(t, extra, "search", "text") if searchCalls != 1 { t.Fatalf("post-recovery search reached handler: calls=%d", searchCalls) } @@ -204,7 +204,7 @@ func TestRecoveryFailureRestoresOnceAndTerminalizesSameResponse(t *testing.T) { if err != nil { t.Fatalf("post-exhaustion search returned transport error: %v", err) } - requireLocalizationTerminalError(t, third, "search", "symbols") + requireLocalizationTerminalReplay(t, third, "search", "symbols") if calls != 2 { t.Fatalf("recovery allowance restored more than once: calls=%d", calls) } @@ -233,7 +233,7 @@ func TestEnforceableAnswerReadyLocksBeforeHandler(t *testing.T) { if err != nil { t.Fatalf("strong terminal search returned transport error: %v", err) } - requireLocalizationTerminalError(t, result, "search", "text") + requireLocalizationTerminalReplay(t, result, "search", "text") if calls != 0 { t.Fatalf("enforceable answer_ready reached handler: calls=%d", calls) } @@ -258,7 +258,7 @@ func TestUnsupportedRecoveryAttemptTerminatesBeforeSchemaDispatch(t *testing.T) if err != nil { t.Fatalf("post-rejection recovery returned transport error: %v", err) } - requireLocalizationTerminalError(t, valid, "search", "text") + requireLocalizationTerminalReplay(t, valid, "search", "text") } func TestSchemaInvalidAllowedRecoveryTerminatesBeforeHandler(t *testing.T) { @@ -294,7 +294,7 @@ func TestSchemaInvalidAllowedRecoveryTerminatesBeforeHandler(t *testing.T) { if err != nil { t.Fatalf("post-invalid recovery returned transport error: %v", err) } - requireLocalizationTerminalError(t, valid, "search", "text") + requireLocalizationTerminalReplay(t, valid, "search", "text") if calls != 0 { t.Fatalf("recovery allowance survived invalid schema: calls=%d", calls) } @@ -348,7 +348,7 @@ func TestStaleRecoveryCannotConsumeNewTaskState(t *testing.T) { if blocked, reserved := state.authorize("search", "text", map[string]any{"query": "new anchor"}); reserved { t.Fatal("new strong task reserved a recovery call") } else { - requireLocalizationTerminalError(t, blocked, "search", "text") + requireLocalizationTerminalReplay(t, blocked, "search", "text") } } diff --git a/internal/mcp/localization_refinement_route_test.go b/internal/mcp/localization_refinement_route_test.go index f2c2de96..6173e7d1 100644 --- a/internal/mcp/localization_refinement_route_test.go +++ b/internal/mcp/localization_refinement_route_test.go @@ -26,7 +26,7 @@ func TestRefinementRouteConcreteReadCompletesInOneCall(t *testing.T) { if reserved { t.Fatal("read after concrete completion reserved a handler") } - requireLocalizationTerminalError(t, result, "read", "source") + requireLocalizationTerminalReplay(t, result, "read", "source") } func TestRefinementRouteUsesActuallySelectedAlternateGenericCandidate(t *testing.T) { @@ -58,7 +58,7 @@ func TestRefinementRouteUsesActuallySelectedAlternateGenericCandidate(t *testing if reserved { t.Fatal("third read reserved a handler") } - requireLocalizationTerminalError(t, result, "read", "source") + requireLocalizationTerminalReplay(t, result, "read", "source") } func TestRefinementRouteGenericReadFailureRestoresFirstAllowance(t *testing.T) { @@ -193,7 +193,7 @@ func TestWeakPreferredReadOffersOnePrecomputedCorrection(t *testing.T) { if result, reserved := state.authorize("read", "source", refinementSourceArgs(third)); reserved { t.Fatal("third read reserved a handler") } else { - requireLocalizationTerminalError(t, result, "read", "source") + requireLocalizationTerminalReplay(t, result, "read", "source") } } @@ -224,7 +224,7 @@ func TestStrongPreferredReadRemainsOneReadTerminal(t *testing.T) { if result, reserved := state.authorize("read", "source", refinementSourceArgs(alternate)); reserved { t.Fatal("strong route opened a corrective read") } else { - requireLocalizationTerminalError(t, result, "read", "source") + requireLocalizationTerminalReplay(t, result, "read", "source") } } @@ -279,7 +279,7 @@ func TestWeakPreferredReadExecutesGenericCorrectionRoute(t *testing.T) { if result, reserved := state.authorize("read", "source", refinementSourceArgs(generic)); reserved { t.Fatal("fourth route read reserved a handler") } else { - requireLocalizationTerminalError(t, result, "read", "source") + requireLocalizationTerminalReplay(t, result, "read", "source") } } @@ -342,7 +342,7 @@ func TestGenericCorrectionSharesOneRetryAcrossRouteHops(t *testing.T) { if result, reserved := state.authorize("read", "source", refinementSourceArgs(implementation)); reserved { t.Fatal("implementation hop received a second route-level retry") } else { - requireLocalizationTerminalError(t, result, "read", "source") + requireLocalizationTerminalReplay(t, result, "read", "source") } } @@ -375,7 +375,7 @@ func TestInitialRefinementFailureRestoresOnlyOnce(t *testing.T) { if result, reserved := state.authorize("read", "source", refinementSourceArgs(preferred)); reserved { t.Fatal("initial refinement failure restored more than once") } else { - requireLocalizationTerminalError(t, result, "read", "source") + requireLocalizationTerminalReplay(t, result, "read", "source") } } @@ -424,7 +424,7 @@ func TestWeakCorrectionFailureRestoresOnlyOnce(t *testing.T) { if result, reserved := state.authorize("read", "source", refinementSourceArgs(alternate)); reserved { t.Fatal("correction was restored more than once") } else { - requireLocalizationTerminalError(t, result, "read", "source") + requireLocalizationTerminalReplay(t, result, "read", "source") } } diff --git a/internal/mcp/localization_terminal.go b/internal/mcp/localization_terminal.go index fdcba07a..f3d03c5e 100644 --- a/internal/mcp/localization_terminal.go +++ b/internal/mcp/localization_terminal.go @@ -32,6 +32,7 @@ type localizationCompletion struct { Scope string `json:"scope"` RequiredAction string `json:"required_action"` Instruction string `json:"instruction,omitempty"` + FinalResponse string `json:"final_response,omitempty"` AllowedToolCalls int `json:"allowed_tool_calls"` ContractVersion int `json:"contract_version"` Enforceable bool `json:"enforceable"` @@ -92,6 +93,10 @@ func localizationContractFor(completion localizationCompletion) localizationTerm if completion.State != localizationStateAnswerReady { completion.Enforceable = false } + // Session-only evidence remains on localizationTerminalState and in the + // authenticated host envelope. The wire completion carries only its bounded + // final_response, never a live digest pointer. + completion.digest = nil return localizationTerminalContract{ Completion: completion, Terminal: completion.State == localizationStateAnswerReady, @@ -442,7 +447,7 @@ func (s *localizationTerminalState) completionLocked() localizationCompletion { completion.Enforceable = s.enforceableOnAnswerReady } completion.digest = s.digest - return completion + return localizationCompletionWithDigest(completion, s.digest) } // interceptAnswerReady is the cheap pre-validation gate used by facade @@ -940,23 +945,12 @@ func (s *localizationTerminalState) finishReservedReadToken(token uint64, succes return s.completionLocked() } -// localizationTerminalResult is the compact, typed suppression returned only -// after a successful localization response established answer_ready. It never -// replays evidence and is non-retriable by default. -func localizationTerminalResult(completion localizationCompletion, facade, operation string) *mcpgo.CallToolResult { - data := map[string]any{"contract": localizationContractFor(completion)} - if facade != "" { - data["facade"] = facade - } - if operation != "" { - data["operation"] = operation - } - return newStructuredErrorResult(StructuredError{ - ErrorCode: ErrCodeLocalizationTerminal, - Message: "localization is terminal for this user request; respond using the evidence already returned", - Retriable: false, - Data: data, - }, true) +// localizationTerminalResult is the route-neutral successful replay returned +// after a localization response established answer_ready. The facade and +// operation are intentionally ignored so every later navigation call receives +// byte-identical evidence and a ready-to-emit answer. +func localizationTerminalResult(completion localizationCompletion, _, _ string) *mcpgo.CallToolResult { + return localizationAnswerReadyResult(completion) } func cloneLocalizationRefinementRoutes(routes map[string]localizationRefinementRoute) map[string]localizationRefinementRoute { diff --git a/internal/mcp/localization_terminal_test.go b/internal/mcp/localization_terminal_test.go index 222e02fb..3d17bf7c 100644 --- a/internal/mcp/localization_terminal_test.go +++ b/internal/mcp/localization_terminal_test.go @@ -43,7 +43,7 @@ func TestHandleFacadeRejectsLocalizationBypassesWithoutClearingState(t *testing. t.Fatalf("handleFacade() transport error = %v", err) } operation, _ := request.args["operation"].(string) - requireLocalizationTerminalError(t, result, "explore", normalizeFacadeOperation(operation)) + requireLocalizationTerminalReplay(t, result, "explore", normalizeFacadeOperation(operation)) if blocked := terminal.block("search", "symbols", nil); blocked == nil { t.Fatal("invalid localization request cleared terminal state") } @@ -219,12 +219,41 @@ func TestLocalizationCompletionEnvelope(t *testing.T) { } } +func TestLocalizationEnvelopeRowsStayPositionallyAlignedForDuplicateFiles(t *testing.T) { + targets := []exploreTarget{ + {node: &graph.Node{ID: "repo/pkg/shared.go::First", Name: "First", Kind: graph.KindFunction, FilePath: "pkg/shared.go", StartLine: 10}}, + {node: &graph.Node{ID: "repo/pkg/shared.go::Second", Name: "Second", Kind: graph.KindFunction, FilePath: "pkg/shared.go", StartLine: 20}}, + {node: &graph.Node{ID: "repo/pkg/other.go::Third", Name: "Third", Kind: graph.KindFunction, FilePath: "pkg/other.go", StartLine: 30}}, + } + result := newLocalizationExploreResult(newLocalizationCompletion(true, ""), targets, 1600) + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("expected one text result: %#v", result) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatalf("decode aligned envelope: %v", err) + } + if len(envelope.Files) != 3 || len(envelope.Symbols) != 3 || len(envelope.Evidence) != 3 { + t.Fatalf("files=%d symbols=%d evidence=%d, want three positional rows", len(envelope.Files), len(envelope.Symbols), len(envelope.Evidence)) + } + for index := range envelope.Evidence { + row := envelope.Evidence[index] + if envelope.Files[index] != row.File || envelope.Symbols[index] != row.ID || row.Rank != index+1 { + t.Fatalf("row %d is misaligned: file=%q symbol=%q evidence=%#v", index+1, envelope.Files[index], envelope.Symbols[index], row) + } + } + if envelope.Files[0] != "pkg/shared.go" || envelope.Files[1] != "pkg/shared.go" { + t.Fatalf("duplicate source path was deduplicated: %#v", envelope.Files) + } +} + func TestLocalizationTerminalStateInterceptsOnlyNavigation(t *testing.T) { state := newLocalizationTerminalState() state.arm(newLocalizationCompletion(true, "")) for _, facade := range []string{"explore", "search", "read", "relations", "trace", "analyze"} { blocked := state.block(facade, "anything", nil) - requireLocalizationTerminalError(t, blocked, facade, "anything") + requireLocalizationTerminalReplay(t, blocked, facade, "anything") } for _, facade := range []string{"change", "edit", "refactor", "workspace", "session", "recall", "remember", "capabilities"} { if blocked := state.block(facade, "anything", nil); blocked != nil { @@ -291,7 +320,7 @@ func TestLocalizationRefinementAllowsExactlyOneCandidateRead(t *testing.T) { if blocked, reserved := state.authorize("read", "source", read); reserved { t.Fatal("second successful refinement read reserved a handler") } else { - requireLocalizationTerminalError(t, blocked, "read", "source") + requireLocalizationTerminalReplay(t, blocked, "read", "source") } } @@ -362,7 +391,7 @@ func TestHandleFacadeRefinementReadReturnsAnswerReadyCompletion(t *testing.T) { if err != nil { t.Fatalf("terminal analyze returned transport error: %v", err) } - requireLocalizationTerminalError(t, blockedAnalyze, "analyze", "why") + requireLocalizationTerminalReplay(t, blockedAnalyze, "analyze", "why") if analyzeCalls != 0 { t.Fatalf("terminal analyze reached its legacy handler %d time(s)", analyzeCalls) } @@ -375,12 +404,21 @@ func TestLocalizationTerminalStateIsPerSession(t *testing.T) { } ctxA := WithSessionID(context.Background(), "a") ctxB := WithSessionID(context.Background(), "b") - server.localizationFor(ctxA).arm(newLocalizationCompletion(true, "")) - if server.localizationFor(ctxA).block("search", "symbols", nil) == nil { - t.Fatal("armed session should be blocked") + completionA := newLocalizationCompletion(true, "") + completionA.digest = newLocalizationEvidenceDigest(localizationExploreEnvelope{Evidence: []localizationEvidence{{ + ID: "repo/a.go::A", File: "repo/a.go", Line: 1, + }}}) + server.localizationFor(ctxA).arm(completionA) + replayA := server.localizationFor(ctxA).block("search", "symbols", nil) + if replayA == nil || replayA.IsError { + t.Fatalf("armed session replay = %#v", replayA) + } + textA, _ := singleTextContent(replayA) + if !strings.Contains(textA, "repo/a.go::A") { + t.Fatalf("session A lost its evidence: %q", textA) } if blocked := server.localizationFor(ctxB).block("search", "symbols", nil); blocked != nil { - t.Fatalf("separate session inherited terminality: %#v", blocked) + t.Fatalf("separate session inherited terminality or evidence: %#v", blocked) } if blocked := server.localizationFor(context.Background()).block("search", "symbols", nil); blocked != nil { t.Fatalf("embedded default inherited daemon session state: %#v", blocked) @@ -413,7 +451,7 @@ func TestHandleFacadeTaskCannotEscapeTerminalState(t *testing.T) { if err != nil { t.Fatalf("ordinary task escaped terminal state: result=%#v err=%v", result, err) } - requireLocalizationTerminalError(t, result, "explore", "task") + requireLocalizationTerminalReplay(t, result, "explore", "task") } if called { t.Fatal("ordinary explore(task) dispatched after localization completed") @@ -464,7 +502,9 @@ func TestHandleFacadeExplicitNewUserTaskCrossesTerminalBoundary(t *testing.T) { server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} ctx := WithSessionID(context.Background(), "explicit-task-boundary") terminal := server.localizationFor(ctx) - terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Foo") + prior := newLocalizationCompletion(true, "") + prior.digest = testEvidenceDigest() + terminal.armForTask(prior, "Locate Foo") req := mcpgo.CallToolRequest{} req.Params.Name = "explore" @@ -485,10 +525,16 @@ func TestHandleFacadeExplicitNewUserTaskCrossesTerminalBoundary(t *testing.T) { } terminal.mu.Lock() fingerprint := terminal.taskFingerprint + state := terminal.state + digest := terminal.digest + completion := terminal.completionLocked() terminal.mu.Unlock() if fingerprint != "Diagnose Bar" { t.Fatalf("successful diagnostic task committed fingerprint %q", fingerprint) } + if state != localizationStateInactive || digest != nil || completion.digest != nil || completion.FinalResponse != "" { + t.Fatalf("new user task leaked prior terminal replay: state=%q digest=%#v completion=%#v", state, digest, completion) + } } func TestHandleFacadeExactReadCommitsOnlyOnSuccess(t *testing.T) { @@ -533,7 +579,7 @@ func TestHandleFacadeExactReadCommitsOnlyOnSuccess(t *testing.T) { if err != nil || calls != 3 { t.Fatalf("post-recovery exact read = result=%#v err=%v calls=%d", fourth, err, calls) } - requireLocalizationTerminalError(t, fourth, "read", "source") + requireLocalizationTerminalReplay(t, fourth, "read", "source") } func TestHandleFacadeExhaustedCorrectionFailureCarriesTerminalCompletion(t *testing.T) { @@ -603,7 +649,7 @@ func TestHandleFacadeExhaustedCorrectionFailureCarriesTerminalCompletion(t *test if blocked, err := read(alternate); err != nil { t.Fatalf("post-terminal read returned transport error: %v", err) } else { - requireLocalizationTerminalError(t, blocked, "read", "source") + requireLocalizationTerminalReplay(t, blocked, "read", "source") } } @@ -725,7 +771,7 @@ func TestHandleFacadeExplicitNewUserTaskCommitsOnSuccess(t *testing.T) { if err != nil || calls != 1 { t.Fatalf("later localize without boundary escaped: result=%#v err=%v calls=%d", blocked, err, calls) } - requireLocalizationTerminalError(t, blocked, "explore", "localize") + requireLocalizationTerminalReplay(t, blocked, "explore", "localize") } func TestHandleFacadeNewUserTaskPanicRollsBack(t *testing.T) { @@ -887,7 +933,7 @@ func TestHandleFacadeExactReadPanicRestoresReservation(t *testing.T) { if err != nil { t.Fatalf("fourth exact read = (%v, %v), want terminal block", fourth, err) } - requireLocalizationTerminalError(t, fourth, "read", "source") + requireLocalizationTerminalReplay(t, fourth, "read", "source") if calls != 3 { t.Fatalf("legacy source calls = %d, want 3", calls) } @@ -917,7 +963,7 @@ func TestHandleFacadeLocalizeBlocksParaphrasesWithoutBoundary(t *testing.T) { if err != nil { t.Fatalf("localize(%q) bypassed active contract: result=%#v err=%v", task, result, err) } - requireLocalizationTerminalError(t, result, "explore", "localize") + requireLocalizationTerminalReplay(t, result, "explore", "localize") } if calls != 0 { t.Fatalf("blocked localize calls dispatched %d legacy request(s)", calls) diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index 7bac62bf..db083843 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -2976,7 +2976,6 @@ func buildLocalizationExploreResultForTaskFinalized( } } - seenFiles := make(map[string]struct{}) acceptedTargets := make([]exploreTarget, 0, len(targets)) for _, target := range targets { if target.node == nil { @@ -3001,9 +3000,9 @@ func buildLocalizationExploreResultForTaskFinalized( candidate.Files = append([]string(nil), envelope.Files...) candidate.Symbols = append([]string(nil), envelope.Symbols...) candidate.Evidence = append([]localizationEvidence(nil), envelope.Evidence...) - if _, seen := seenFiles[path]; !seen { - candidate.Files = append(candidate.Files, path) - } + // Files, Symbols, and Evidence are one positional projection. Repeated + // files are intentional when multiple ranked symbols share a source file. + candidate.Files = append(candidate.Files, path) candidate.Symbols = append(candidate.Symbols, n.ID) candidate.Evidence = append(candidate.Evidence, evidence) mandatory := len(envelope.Evidence) < mandatoryCount @@ -3021,7 +3020,6 @@ func buildLocalizationExploreResultForTaskFinalized( candidate.Evidence[len(candidate.Evidence)-1] = evidence } envelope = candidate - seenFiles[path] = struct{}{} acceptedTargets = append(acceptedTargets, target) } @@ -3079,11 +3077,18 @@ func buildLocalizationExploreResultForTaskFinalized( contract = localizationContractFor(envelope.Completion) envelope.Completion = contract.Completion envelope.Terminal = contract.Terminal + digest := newLocalizationEvidenceDigest(envelope) + // The serialized completion, returned state, structuredContent, and host + // metadata must carry the same final_response. Build the digest first, then + // enrich the one completion value before any wire representation is made. + envelope.Completion = localizationCompletionWithDigest(envelope.Completion, digest) + contract = localizationContractFor(envelope.Completion) + envelope.Completion = contract.Completion + envelope.Terminal = contract.Terminal body, err := json.Marshal(envelope) if err != nil { return mcp.NewToolResultError("encode localization result: " + err.Error()), nil, nil, envelope.Completion } - digest := newLocalizationEvidenceDigest(envelope) result := attachLocalizationHostEnvelope(mcp.NewToolResultText(string(body)), envelope.Completion, digest) return result, append([]string(nil), envelope.Symbols...), digest, envelope.Completion } diff --git a/internal/mcp/tools_explore_test.go b/internal/mcp/tools_explore_test.go index 0f7fc349..7844acbd 100644 --- a/internal/mcp/tools_explore_test.go +++ b/internal/mcp/tools_explore_test.go @@ -1042,8 +1042,8 @@ func TestLocalizationEvidenceReservesExactBeforePromotedNeighbor(t *testing.T) { if len(envelope.Evidence) != 2 || envelope.Evidence[1].ID != exact.ID { t.Fatalf("exact evidence missing: %+v", envelope.Evidence) } - if len(envelope.Files) != 1 || envelope.Files[0] != "walk.go" { - t.Fatalf("files = %v, want exact target file retained with primary", envelope.Files) + if len(envelope.Files) != 2 || envelope.Files[0] != "walk.go" || envelope.Files[1] != "walk.go" { + t.Fatalf("files = %v, want one positional file per primary/exact row", envelope.Files) } } From 402245949fd4aab317cca19ffb8926be36efb505 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 24 Jul 2026 01:53:35 +0200 Subject: [PATCH 2/5] fix(localization): trust current terminal evidence --- internal/hooks/codex_test.go | 5 +- internal/hooks/hook_tier_test.go | 2 +- internal/hooks/localization_terminal.go | 17 +- internal/hooks/localization_terminal_test.go | 86 +++++- internal/hooks/sessionstart.go | 7 +- internal/hooks/sessionstart_test.go | 54 ++++ internal/mcp/facade_tools.go | 148 +++++++++- .../mcp/localization_current_capture_test.go | 268 ++++++++++++++++++ internal/mcp/localization_digest.go | 65 +++++ internal/mcp/localization_terminal.go | 48 ++++ internal/mcp/tools_coding.go | 4 + internal/mcp/tools_core.go | 2 + internal/mcp/tools_search_text.go | 1 + 13 files changed, 687 insertions(+), 20 deletions(-) create mode 100644 internal/mcp/localization_current_capture_test.go diff --git a/internal/hooks/codex_test.go b/internal/hooks/codex_test.go index d378e84e..da82a6bf 100644 --- a/internal/hooks/codex_test.go +++ b/internal/hooks/codex_test.go @@ -43,8 +43,9 @@ func TestRunCodexSessionStartUsesManagedOrientationHook(t *testing.T) { if hso == nil || hso.HookEventName != "SessionStart" { t.Fatalf("invalid SessionStart hook output: %s", out) } - if !strings.Contains(hso.AdditionalContext, "Call `explore` first") { - t.Fatalf("mandatory compact-tool orientation missing: %q", hso.AdditionalContext) + if !strings.Contains(hso.AdditionalContext, "choose by requested output") || + !strings.Contains(hso.AdditionalContext, "preserve the user's exact technical identifiers") { + t.Fatalf("mandatory localization routing orientation missing: %q", hso.AdditionalContext) } } diff --git a/internal/hooks/hook_tier_test.go b/internal/hooks/hook_tier_test.go index 385dc18e..2bfcf4e5 100644 --- a/internal/hooks/hook_tier_test.go +++ b/internal/hooks/hook_tier_test.go @@ -40,7 +40,7 @@ func TestSessionStart_LeanTier_TrackedCwd(t *testing.T) { if !strings.Contains(briefing, "enforcement active") { t.Errorf("lean briefing lost the enforcement signal:\n%s", briefing) } - if !strings.Contains(briefing, "Rule:") || !strings.Contains(briefing, "`explore`") || !strings.Contains(briefing, "`search`") { + if !strings.Contains(briefing, "Rule:") || !strings.Contains(briefing, "explore(operation:\"localize\")") || !strings.Contains(briefing, "`search`") { t.Errorf("lean briefing lost the rule preamble cues:\n%s", briefing) } // The standard-tier status prose must be gone. diff --git a/internal/hooks/localization_terminal.go b/internal/hooks/localization_terminal.go index 68662d05..16108692 100644 --- a/internal/hooks/localization_terminal.go +++ b/internal/hooks/localization_terminal.go @@ -23,7 +23,7 @@ const ( localizationTerminalAgentHardCap = 64 localizationTerminalJanitorDeletes = 32 - localizationTerminalContext = "Gortex localization is complete. Respond to the user now; do not call another tool in this turn." + localizationTerminalContext = "[Gortex] Localization is answer-ready. Respond now using the current Gortex tool result and completion.final_response. Do not call native tools or any other tool in this turn." localizationTerminalDenyReason = "[Gortex] Localization is complete. Respond to the user now; no further tool calls are allowed in this turn." gortexPluginMCPToolPrefix = "mcp__plugin_gortex_gortex__" localizationHostMetaKey = "gortex/localization" @@ -142,10 +142,10 @@ func observeLocalizationTerminal(data []byte) (localizationTerminalHookInput, bo return localizationTerminalHookInput{}, false } contract, ok := exactLocalizationTerminalContract(input.ToolResponse) - if !ok || !enforceableLocalizationTerminalContract(contract) { + if !ok || !answerReadyLocalizationTerminalContract(contract) { return localizationTerminalHookInput{}, false } - if !markLocalizationTerminal(identity, contract.Completion.ContractVersion) { + if enforceableLocalizationTerminalContract(contract) && !markLocalizationTerminal(identity, contract.Completion.ContractVersion) { return localizationTerminalHookInput{}, false } return input, true @@ -200,7 +200,7 @@ func localizationHostContract(meta map[string]json.RawMessage) (localizationTerm if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Version != localizationTerminalHostMetaVersion { return localizationTerminalContract{}, false } - if !enforceableLocalizationTerminalContract(envelope.Contract) { + if !answerReadyLocalizationTerminalContract(envelope.Contract) { return localizationTerminalContract{}, false } return envelope.Contract, true @@ -270,7 +270,7 @@ func unwrapJSONString(raw json.RawMessage) (json.RawMessage, bool) { return raw, true } -func enforceableLocalizationTerminalContract(contract localizationTerminalContract) bool { +func answerReadyLocalizationTerminalContract(contract localizationTerminalContract) bool { completion := contract.Completion return contract.Terminal && completion.State == "answer_ready" && @@ -278,8 +278,11 @@ func enforceableLocalizationTerminalContract(contract localizationTerminalContra completion.RequiredAction == "respond" && strings.TrimSpace(completion.FinalResponse) != "" && completion.AllowedToolCalls != nil && *completion.AllowedToolCalls == 0 && - completion.ContractVersion >= localizationTerminalContractV2 && - completion.Enforceable + completion.ContractVersion >= localizationTerminalContractV2 +} + +func enforceableLocalizationTerminalContract(contract localizationTerminalContract) bool { + return answerReadyLocalizationTerminalContract(contract) && contract.Completion.Enforceable } func localizationNavigationTool(tool string) bool { diff --git a/internal/hooks/localization_terminal_test.go b/internal/hooks/localization_terminal_test.go index e597b373..87229139 100644 --- a/internal/hooks/localization_terminal_test.go +++ b/internal/hooks/localization_terminal_test.go @@ -34,7 +34,7 @@ func TestObserveLocalizationTerminalAcceptsDirectAndPluginNavigationFacades(t *t } } -func TestObserveLocalizationTerminalRequiresExactEnforceableV2Contract(t *testing.T) { +func TestObserveLocalizationTerminalRequiresExactAnswerReadyV2Contract(t *testing.T) { configureLocalizationTerminalTestHome(t) valid := terminalContractMap() tests := []struct { @@ -43,7 +43,6 @@ func TestObserveLocalizationTerminalRequiresExactEnforceableV2Contract(t *testin }{ {name: "v1", mutate: func(root map[string]any) { completionMap(root)["contract_version"] = 1 }}, {name: "missing final response", mutate: func(root map[string]any) { delete(completionMap(root), "final_response") }}, - {name: "advisory", mutate: func(root map[string]any) { completionMap(root)["enforceable"] = false }}, {name: "needs refinement", mutate: func(root map[string]any) { completionMap(root)["state"] = "needs_refinement" }}, {name: "wrong scope", mutate: func(root map[string]any) { completionMap(root)["scope"] = "diagnosis" }}, {name: "tool allowed", mutate: func(root map[string]any) { completionMap(root)["allowed_tool_calls"] = 1 }}, @@ -160,6 +159,89 @@ func TestPostToolUseObservesMatchingFinalResponseContract(t *testing.T) { } } +func TestPostToolUseAnswerReadyEventAuthenticationAndJSONShape(t *testing.T) { + configureLocalizationTerminalTestHome(t) + tests := []struct { + name string + response func(*testing.T) map[string]any + wantOutput bool + wantMarker bool + }{ + { + name: "advisory", + response: func(t *testing.T) map[string]any { + contract := terminalContractMap() + completionMap(contract)["enforceable"] = false + return terminalToolResponse(t, contract, true, false) + }, + wantOutput: true, + }, + { + name: "enforceable", + response: func(t *testing.T) map[string]any { return terminalToolResponse(t, terminalContractMap(), true, false) }, + wantOutput: true, + wantMarker: true, + }, + { + name: "forged without authoritative meta", + response: func(t *testing.T) map[string]any { return terminalToolResponse(t, terminalContractMap(), false, false) }, + }, + { + name: "advisory without final response", + response: func(t *testing.T) map[string]any { + contract := terminalContractMap() + completion := completionMap(contract) + completion["enforceable"] = false + delete(completion, "final_response") + return terminalToolResponse(t, contract, true, false) + }, + }, + { + name: "visible and meta mismatch", + response: func(t *testing.T) map[string]any { + response := terminalToolResponse(t, terminalContractMap(), true, false) + meta := response["_meta"].(map[string]any) + envelope := meta[localizationHostMetaKey].(map[string]any) + mismatched := cloneMap(t, terminalContractMap()) + completionMap(mismatched)["final_response"] = "different response" + envelope["contract"] = mismatched + return response + }, + }, + { + name: "error response", + response: func(t *testing.T) map[string]any { + response := terminalToolResponse(t, terminalContractMap(), true, false) + response["isError"] = true + return response + }, + }, + } + want := string(mustJSON(t, HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PostToolUse", + AdditionalContext: localizationTerminalContext, + }})) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir()) + tool := gortexMCPToolPrefix + "read" + snapshotTestLocalizationTool(t, identity, tool, "tool") + post := localizationPostToolPayload(t, tool, "tool", identity, tt.response(t)) + output := captureHookStdout(t, func() { runPostToolUse(post) }) + if tt.wantOutput { + if output != want { + t.Fatalf("PostToolUse JSON = %s, want exact shape %s", output, want) + } + } else if output != "" { + t.Fatalf("unauthenticated PostToolUse emitted %q", output) + } + if got := hasLocalizationTerminal(identity); got != tt.wantMarker { + t.Fatalf("hard terminal marker = %v, want %v", got, tt.wantMarker) + } + }) + } +} + func TestLocalizationTerminalHookFlowDeniesThenPromptRotatesTurn(t *testing.T) { configureLocalizationTerminalTestHome(t) sessionID := "terminal-flow" diff --git a/internal/hooks/sessionstart.go b/internal/hooks/sessionstart.go index 60b352a3..b0772496 100644 --- a/internal/hooks/sessionstart.go +++ b/internal/hooks/sessionstart.go @@ -293,9 +293,10 @@ func hasPathPrefix(path, prefix string) bool { // — this is just enough that an agent in the very first turn knows // to reach for graph tools first. func rulePreamble() string { - return "**Rule:** Call `explore` first for code discovery, diagnosis, relationship analysis, or implementation. " + - "For an explicitly named file that the user asks you to read, review, or summarize, call `read(operation:\"file\", target:{file:\"\"})` directly; do not start localization. " + - "Use `explore(operation:\"localize\")` when the file, symbol, or supporting evidence must be discovered. " + + return "**Rule:** For an explicitly named file that the user asks you to read, review, or summarize, call `read(operation:\"file\", target:{file:\"\"})` directly; do not start localization. " + + "Otherwise choose by requested output: when the requested output is files, symbols, or supporting evidence, call `explore(operation:\"localize\")`; it returns terminal evidence. " + + "In the localize task, preserve the user's exact technical identifiers, paths, literals, error text, and observed symptoms; do not replace them with a causal hypothesis. " + + "Call `explore(operation:\"task\")` only when work will actually continue beyond localization into diagnosis, relationship analysis, or implementation. " + "After starting localization, obey `completion.required_action` and make no further tool calls after `answer_ready`. " + "Inspect indexed code with `search`, `read`, `relations`, or `trace`. " + "Use native read, search, or edit tools only when Gortex performance or integration is bad. " + diff --git a/internal/hooks/sessionstart_test.go b/internal/hooks/sessionstart_test.go index 02cd61b1..c9d9180a 100644 --- a/internal/hooks/sessionstart_test.go +++ b/internal/hooks/sessionstart_test.go @@ -24,6 +24,60 @@ func TestRunSessionStart_RejectsWrongEvent(t *testing.T) { } } +func TestRulePreambleRoutesByOutcomeAndPreservesExactIdentifiers(t *testing.T) { + briefing := rulePreamble() + for _, required := range []string{ + "For an explicitly named file", + "choose by requested output", + "requested output is files, symbols, or supporting evidence", + "preserve the user's exact technical identifiers, paths, literals, error text, and observed symptoms", + "only when work will actually continue beyond localization into diagnosis, relationship analysis, or implementation", + "make no further tool calls after `answer_ready`", + } { + if !strings.Contains(briefing, required) { + t.Fatalf("rule preamble missing %q: %s", required, briefing) + } + } + for _, forced := range []string{ + "including a request framed as diagnosis or a why question", + "Call `explore` first for code discovery, diagnosis", + } { + if strings.Contains(briefing, forced) { + t.Fatalf("rule preamble contains forced-localize wording %q: %s", forced, briefing) + } + } +} + +func TestRunSessionStartEmitsNeutralRoutingAndIdentifierGuidance(t *testing.T) { + configureLocalizationTerminalTestHome(t) + withFakeStatus(t, func() (*daemon.StatusResponse, error) { return nil, errDaemonUnreachable }) + payload := mustJSON(t, map[string]any{ + "hook_event_name": "SessionStart", + "session_id": "routing-event", + "cwd": t.TempDir(), + }) + output := captureHookStdout(t, func() { runSessionStart(payload) }) + var decoded HookOutput + if err := json.Unmarshal([]byte(output), &decoded); err != nil { + t.Fatalf("decode SessionStart output: %v; output=%q", err, output) + } + if decoded.Decision != "" || decoded.Reason != "" || decoded.SystemMessage != "" || decoded.HookSpecificOutput == nil { + t.Fatalf("unexpected SessionStart output shape: %#v", decoded) + } + if decoded.HookSpecificOutput.HookEventName != "SessionStart" { + t.Fatalf("hook event = %q, want SessionStart", decoded.HookSpecificOutput.HookEventName) + } + context := decoded.HookSpecificOutput.AdditionalContext + for _, required := range []string{"choose by requested output", "preserve the user's exact technical identifiers", "after `answer_ready`"} { + if !strings.Contains(context, required) { + t.Fatalf("SessionStart event missing %q: %s", required, context) + } + } + if strings.Contains(context, "including a request framed as diagnosis or a why question") { + t.Fatalf("SessionStart event contains forced-localize diagnosis wording: %s", context) + } +} + func TestRunSessionStart_DaemonDown(t *testing.T) { withFakeStatus(t, func() (*daemon.StatusResponse, error) { return nil, errDaemonUnreachable diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go index 41f7d226..ac7c89dd 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -9,6 +9,7 @@ import ( "slices" "sort" "strings" + "sync" "time" mcpgo "github.com/mark3labs/mcp-go/mcp" @@ -526,7 +527,7 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call localizationReadSucceeded := false defer func() { if localizationReadReservation != 0 { - terminal.finishReservedReadToken(localizationReadReservation, localizationReadSucceeded) + terminal.finishReservedReadTokenWithDigest(localizationReadReservation, localizationReadSucceeded, nil, false) } if localizeReservation != 0 && !localizeFinished { // Errors and panics roll back to the previous completion contract. @@ -540,16 +541,26 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call return blocked, nil } localizationReadReservation = reservation + if reservation != 0 { + ctx = withLocalizationPermittedEvidenceCapture(ctx, reservation) + } } result, err := s.invokeFacadeSpec(ctx, req, spec) succeeded := err == nil && result != nil && !result.IsError if localizationReadReservation != 0 { localizationReadSucceeded = succeeded + var currentEvidence []localizationDigestRow + evidenceRecorded := false + if succeeded { + currentEvidence, evidenceRecorded = localizationEvidenceForPermittedCall(ctx, facade, operation, localizationReadReservation) + } // Finalize before decorating so a preclassified route or bounded recovery - // can expose its next state in this same response. Every failed allowance - // is converted to a tool error carrying either its one restored retry or - // terminal answer_ready; a Go handler error must not hide the contract. - completion := terminal.finishReservedReadToken(localizationReadReservation, succeeded) + // can expose its next state in this same response. Typed identities are + // merged under the terminal-state lock only when this call transitions to + // answer_ready. Every failed allowance is converted to a tool error carrying + // either its one restored retry or terminal answer_ready; a Go handler error + // must not hide the contract. + completion := terminal.finishReservedReadTokenWithDigest(localizationReadReservation, succeeded, currentEvidence, evidenceRecorded) localizationReadReservation = 0 if succeeded { result = decorateLocalizationReadResult(result, completion) @@ -564,6 +575,133 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call return result, err } +type localizationPermittedEvidenceCaptureKey struct{} + +type localizationPermittedEvidenceCapture struct { + mu sync.Mutex + token uint64 + recorded bool + rows []localizationDigestRow +} + +func withLocalizationPermittedEvidenceCapture(ctx context.Context, token uint64) context.Context { + if token == 0 { + return ctx + } + return context.WithValue(ctx, localizationPermittedEvidenceCaptureKey{}, &localizationPermittedEvidenceCapture{token: token}) +} + +func captureLocalizationRows(ctx context.Context, rows []localizationDigestRow) { + capture, _ := ctx.Value(localizationPermittedEvidenceCaptureKey{}).(*localizationPermittedEvidenceCapture) + if capture == nil { + return + } + capture.mu.Lock() + capture.rows = cloneLocalizationDigestRows(rows) + capture.recorded = true + capture.mu.Unlock() +} + +// captureLocalizationSearchSymbols records the exact typed, scoped page that +// search_symbols is about to render. The request-private context value keeps a +// concurrent or stale search from supplying identities to another reservation. +func captureLocalizationSearchSymbols(ctx context.Context, nodes []*graph.Node) { + rows := make([]localizationDigestRow, 0, len(nodes)) + for _, node := range nodes { + if row, ok := localizationDigestRowFromNode(node, "permitted_search_symbols"); ok { + rows = append(rows, row) + } + } + captureLocalizationRows(ctx, rows) +} + +// captureLocalizationSearchText promotes only graph-backed symbol identities +// from the typed search.text page. File-only hits have no SymbolID, and the +// rendered MCP Content is never parsed as evidence. +func (s *Server) captureLocalizationSearchText(ctx context.Context, matches []enrichedTextMatch) { + capture, _ := ctx.Value(localizationPermittedEvidenceCaptureKey{}).(*localizationPermittedEvidenceCapture) + if capture == nil { + return + } + rows := make([]localizationDigestRow, 0, len(matches)) + if s.graph != nil { + for _, match := range matches { + id := strings.TrimSpace(match.SymbolID) + if id == "" { + continue + } + node := s.graph.GetNode(id) + if node == nil || !s.nodeInSessionScope(ctx, node) { + continue + } + if row, ok := localizationDigestRowFromNode(node, "permitted_search_text"); ok { + rows = append(rows, row) + } + } + } + captureLocalizationRows(ctx, rows) +} + +// captureLocalizationReadSource records the validated graph node whose source +// was successfully read. It never reconstructs identity from serialized output. +func captureLocalizationReadSource(ctx context.Context, node *graph.Node) { + rows := make([]localizationDigestRow, 0, 1) + if row, ok := localizationDigestRowFromNode(node, "permitted_read_source"); ok { + rows = append(rows, row) + } + captureLocalizationRows(ctx, rows) +} + +func localizationCapturedEvidence(ctx context.Context, token uint64) ([]localizationDigestRow, bool) { + capture, _ := ctx.Value(localizationPermittedEvidenceCaptureKey{}).(*localizationPermittedEvidenceCapture) + if capture == nil || token == 0 || capture.token != token { + return nil, false + } + capture.mu.Lock() + defer capture.mu.Unlock() + if !capture.recorded { + return nil, false + } + return cloneLocalizationDigestRows(capture.rows), true +} + +func localizationDigestRowFromNode(node *graph.Node, provenance string) (localizationDigestRow, bool) { + if node == nil { + return localizationDigestRow{}, false + } + id := strings.TrimSpace(node.ID) + file := strings.TrimSpace(nodeDisplayPath(node)) + if id == "" || file == "" { + return localizationDigestRow{}, false + } + signature := "" + if node.Meta != nil { + signature, _ = node.Meta["signature"].(string) + } + return localizationDigestRow{ + ID: id, + Name: node.Name, + QualName: node.QualName, + Kind: string(node.Kind), + File: file, + Line: node.StartLine, + Signature: signature, + Provenance: provenance, + }, true +} + +func localizationEvidenceForPermittedCall(ctx context.Context, facade, operation string, token uint64) ([]localizationDigestRow, bool) { + if token == 0 { + return nil, false + } + switch facade + "." + operation { + case "search.symbols", "search.text", "read.source": + return localizationCapturedEvidence(ctx, token) + default: + return nil, false + } +} + func decorateExhaustedLocalizationReadFailure( result *mcpgo.CallToolResult, err error, diff --git a/internal/mcp/localization_current_capture_test.go b/internal/mcp/localization_current_capture_test.go new file mode 100644 index 00000000..e0dd1d51 --- /dev/null +++ b/internal/mcp/localization_current_capture_test.go @@ -0,0 +1,268 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func captureTestRow(id, file string) localizationDigestRow { + return localizationDigestRow{ + ID: id, + Name: id, + Kind: "method", + File: file, + Line: 17, + Provenance: "capture_test", + } +} + +func TestMergeLocalizationEvidenceDigestIsCurrentFirstAlignedAndBounded(t *testing.T) { + retained := mergeLocalizationEvidenceDigest([]localizationDigestRow{ + captureTestRow("repo/storage/base.go::Storage.Load", "repo/storage/base.go"), + captureTestRow("repo/storage/cloud.go::CloudStorage.Load", "repo/storage/cloud.go"), + captureTestRow("repo/storage/cache.go::Cache.Load", "repo/storage/cache.go"), + captureTestRow("repo/storage/archive.go::Archive.Load", "repo/storage/archive.go"), + }, nil) + current := []localizationDigestRow{ + captureTestRow("repo/storage/disk.go::DiskStorage.Load", "repo/storage/disk.go"), + captureTestRow("repo/storage/base.go::Storage.Load", "repo/storage/base.go"), + captureTestRow("repo/storage/mirror.go::Mirror.Load", "repo/storage/mirror.go"), + } + merged := mergeLocalizationEvidenceDigest(current, retained) + if merged == nil { + t.Fatal("merge returned nil digest") + } + if got, want := len(merged.Evidence), localizationReplayEvidenceLimit; got != want { + t.Fatalf("evidence count = %d, want %d", got, want) + } + wantIDs := []string{ + "repo/storage/disk.go::DiskStorage.Load", + "repo/storage/base.go::Storage.Load", + "repo/storage/mirror.go::Mirror.Load", + "repo/storage/cloud.go::CloudStorage.Load", + "repo/storage/cache.go::Cache.Load", + } + for index, want := range wantIDs { + row := merged.Evidence[index] + if row.ID != want || merged.Symbols[index] != want { + t.Fatalf("position %d IDs = row %q symbols %q, want %q", index+1, row.ID, merged.Symbols[index], want) + } + if merged.Files[index] != row.File { + t.Fatalf("position %d file = %q, evidence file %q", index+1, merged.Files[index], row.File) + } + if row.Rank != index+1 { + t.Fatalf("position %d rank = %d", index+1, row.Rank) + } + } + encoded, err := json.Marshal(merged) + if err != nil { + t.Fatal(err) + } + if len(encoded) > localizationDigestMaxBytes { + t.Fatalf("encoded digest = %d bytes, cap %d", len(encoded), localizationDigestMaxBytes) + } + if len(merged.finalResponse) > localizationFinalResponseMaxBytes { + t.Fatalf("final response = %d bytes, cap %d", len(merged.finalResponse), localizationFinalResponseMaxBytes) + } +} + +func TestLocalizationTypedCaptureIsTokenPrivateAndDefensivelyCopied(t *testing.T) { + ctx := withLocalizationPermittedEvidenceCapture(context.Background(), 41) + input := []localizationDigestRow{captureTestRow("repo/storage/disk.go::DiskStorage.Load", "repo/storage/disk.go")} + captureLocalizationRows(ctx, input) + input[0].ID = "mutated" + + rows, recorded := localizationCapturedEvidence(ctx, 41) + if !recorded || len(rows) != 1 { + t.Fatalf("capture = %#v, recorded %v", rows, recorded) + } + if rows[0].ID != "repo/storage/disk.go::DiskStorage.Load" { + t.Fatalf("captured ID = %q", rows[0].ID) + } + rows[0].ID = "mutated-copy" + again, recorded := localizationCapturedEvidence(ctx, 41) + if !recorded || again[0].ID != "repo/storage/disk.go::DiskStorage.Load" { + t.Fatalf("capture was not defensively copied: %#v", again) + } + if _, recorded := localizationCapturedEvidence(ctx, 42); recorded { + t.Fatal("mismatched reservation token read captured evidence") + } + if _, recorded := localizationCapturedEvidence(context.Background(), 41); recorded { + t.Fatal("unreserved context exposed captured evidence") + } + zero := withLocalizationPermittedEvidenceCapture(context.Background(), 0) + captureLocalizationRows(zero, []localizationDigestRow{captureTestRow("repo/storage/cloud.go::CloudStorage.Load", "repo/storage/cloud.go")}) + if _, recorded := localizationCapturedEvidence(zero, 1); recorded { + t.Fatal("zero reservation installed a capture sink") + } +} + +func TestLocalizationReadSourceCaptureUsesValidatedTypedNode(t *testing.T) { + ctx := withLocalizationPermittedEvidenceCapture(context.Background(), 7) + node := &graph.Node{ + ID: "repo/storage/disk.go::DiskStorage.Load", + Name: "Load", + QualName: "DiskStorage.Load", + Kind: graph.KindMethod, + FilePath: "repo/storage/disk.go", + StartLine: 23, + Meta: map[string]any{"signature": "func (d *DiskStorage) Load() error"}, + } + captureLocalizationReadSource(ctx, node) + rows, recorded := localizationEvidenceForPermittedCall(ctx, "read", "source", 7) + if !recorded || len(rows) != 1 { + t.Fatalf("typed source capture = %#v, recorded %v", rows, recorded) + } + if rows[0].ID != node.ID || rows[0].Line != node.StartLine || rows[0].Provenance != "permitted_read_source" { + t.Fatalf("typed source row = %#v", rows[0]) + } + if _, recorded := localizationEvidenceForPermittedCall(ctx, "read", "file", 7); recorded { + t.Fatal("read.file consumed a reserved evidence sink") + } +} + +func TestLocalizationCaptureContextsRemainIsolatedConcurrently(t *testing.T) { + const workers = 32 + var wg sync.WaitGroup + errors := make(chan string, workers) + for index := 1; index <= workers; index++ { + index := index + wg.Add(1) + go func() { + defer wg.Done() + token := uint64(index) + id := fmt.Sprintf("repo/storage/disk_%d.go::DiskStorage.Load", index) + ctx := withLocalizationPermittedEvidenceCapture(context.Background(), token) + captureLocalizationRows(ctx, []localizationDigestRow{captureTestRow(id, fmt.Sprintf("repo/storage/disk_%d.go", index))}) + rows, recorded := localizationCapturedEvidence(ctx, token) + if !recorded || len(rows) != 1 || rows[0].ID != id { + errors <- fmt.Sprintf("token %d capture = %#v recorded=%v", token, rows, recorded) + } + if _, recorded := localizationCapturedEvidence(ctx, token+workers); recorded { + errors <- fmt.Sprintf("token %d accepted mismatched token", token) + } + }() + } + wg.Wait() + close(errors) + for err := range errors { + t.Error(err) + } +} + +func TestFinishReservedReadWithDigestMergesOnlyOnAnswerReady(t *testing.T) { + retained := mergeLocalizationEvidenceDigest([]localizationDigestRow{ + captureTestRow("repo/storage/base.go::Storage.Load", "repo/storage/base.go"), + }, nil) + state := &localizationTerminalState{ + state: localizationStateExactReadInFlight, + exactSymbol: "repo/storage/disk.go::DiskStorage.Load", + generation: 3, + readReservationToken: 11, + readReservationGen: 3, + digest: retained, + } + exact := []localizationDigestRow{captureTestRow("repo/storage/disk.go::DiskStorage.Load", "repo/storage/disk.go")} + completion := state.finishReservedReadTokenWithDigest(11, true, exact, true) + if completion.State != localizationStateNeedsRecovery { + t.Fatalf("exact completion state = %q", completion.State) + } + if len(state.digest.Evidence) != 1 || state.digest.Evidence[0].ID != "repo/storage/base.go::Storage.Load" { + t.Fatalf("preterminal digest was changed: %#v", state.digest) + } + + state.state = localizationStateRecoveryInFlight + state.readReservationToken = 12 + state.readReservationGen = state.generation + state.recoveryRetriesRemaining = 1 + recovery := []localizationDigestRow{captureTestRow("repo/storage/cloud.go::CloudStorage.Load", "repo/storage/cloud.go")} + completion = state.finishReservedReadTokenWithDigest(12, true, recovery, true) + if completion.State != localizationStateAnswerReady { + t.Fatalf("recovery completion state = %q", completion.State) + } + if len(state.digest.Evidence) != 2 || state.digest.Evidence[0].ID != recovery[0].ID || state.digest.Evidence[1].ID != retained.Evidence[0].ID { + t.Fatalf("terminal digest is not current-first: %#v", state.digest) + } + if completion.digest != state.digest { + t.Fatal("terminal completion did not publish merged digest") + } +} + +func TestFinishReservedReadWithDigestRetriesRecordedZeroThenClearsStaleDigest(t *testing.T) { + retained := mergeLocalizationEvidenceDigest([]localizationDigestRow{ + captureTestRow("repo/storage/base.go::Storage.Load", "repo/storage/base.go"), + }, nil) + state := &localizationTerminalState{ + state: localizationStateRecoveryInFlight, + generation: 5, + readReservationToken: 21, + readReservationGen: 5, + recoveryRetriesRemaining: 1, + digest: retained, + } + completion := state.finishReservedReadTokenWithDigest(21, true, nil, true) + if completion.State != localizationStateNeedsRecovery || completion.AllowedToolCalls != 1 { + t.Fatalf("first zero-result completion = %#v", completion) + } + if state.digest == nil { + t.Fatal("bounded retry discarded digest before exhaustion") + } + + state.state = localizationStateRecoveryInFlight + state.readReservationToken = 22 + state.readReservationGen = state.generation + completion = state.finishReservedReadTokenWithDigest(22, true, nil, true) + if completion.State != localizationStateAnswerReady { + t.Fatalf("exhausted zero-result state = %q", completion.State) + } + if state.digest != nil || completion.digest != nil { + t.Fatalf("exhaustion replayed stale digest: state=%#v completion=%#v", state.digest, completion.digest) + } +} + +func TestFinishReservedReadWithDigestRejectsStaleTokenAndGeneration(t *testing.T) { + retained := mergeLocalizationEvidenceDigest([]localizationDigestRow{ + captureTestRow("repo/storage/base.go::Storage.Load", "repo/storage/base.go"), + }, nil) + state := &localizationTerminalState{ + state: localizationStateRecoveryInFlight, + generation: 9, + readReservationToken: 31, + readReservationGen: 9, + digest: retained, + } + current := []localizationDigestRow{captureTestRow("repo/storage/disk.go::DiskStorage.Load", "repo/storage/disk.go")} + completion := state.finishReservedReadTokenWithDigest(30, true, current, true) + if completion.State != localizationStateInactive || state.state != localizationStateRecoveryInFlight { + t.Fatalf("stale token changed state: completion=%#v state=%q", completion, state.state) + } + if state.digest.Evidence[0].ID != retained.Evidence[0].ID { + t.Fatal("stale token changed digest") + } + + state.readReservationGen = 8 + completion = state.finishReservedReadTokenWithDigest(31, true, current, true) + if completion.State != localizationStateInactive || state.state != localizationStateRecoveryInFlight { + t.Fatalf("stale generation changed state: completion=%#v state=%q", completion, state.state) + } +} + +func TestLocalizationRecoveryDoesNotAuthorizeReadFile(t *testing.T) { + if localizationRecoveryOperationAllowed("read", "file") { + t.Fatal("read.file was added to the recovery authorization set") + } + state := &localizationTerminalState{ + state: localizationStateNeedsRecovery, + generation: 2, + recoveryRetriesRemaining: 1, + } + blocked, token := state.authorizeWithToken("read", "file", map[string]any{"path": "repo/storage/disk.go"}) + if token != 0 || blocked == nil { + t.Fatalf("read.file authorization = token %d result %#v", token, blocked) + } +} diff --git a/internal/mcp/localization_digest.go b/internal/mcp/localization_digest.go index e30ce90b..df373be0 100644 --- a/internal/mcp/localization_digest.go +++ b/internal/mcp/localization_digest.go @@ -117,6 +117,71 @@ func newLocalizationEvidenceDigest(envelope localizationExploreEnvelope) *locali } } +func cloneLocalizationDigestRows(rows []localizationDigestRow) []localizationDigestRow { + if len(rows) == 0 { + return nil + } + cloned := make([]localizationDigestRow, len(rows)) + for index, row := range rows { + cloned[index] = row + cloned[index].Callers = append([]string(nil), row.Callers...) + cloned[index].Callees = append([]string(nil), row.Callees...) + } + return cloned +} + +// mergeLocalizationEvidenceDigest puts evidence returned by the terminalizing +// permitted call first, then fills the bounded tail from the retained localize +// digest. Files, Symbols, and Evidence are rebuilt from the same rows so their +// ordinal positions can never diverge. +func mergeLocalizationEvidenceDigest(current []localizationDigestRow, retained *localizationEvidenceDigest) *localizationEvidenceDigest { + digest := &localizationEvidenceDigest{} + seen := make(map[string]struct{}, localizationReplayEvidenceLimit) + appendRows := func(rows []localizationDigestRow) { + for _, row := range rows { + if len(digest.Evidence) >= localizationReplayEvidenceLimit { + return + } + row.ID = strings.TrimSpace(row.ID) + row.File = strings.TrimSpace(row.File) + if row.ID == "" || row.File == "" { + continue + } + if _, exists := seen[row.ID]; exists { + continue + } + seen[row.ID] = struct{}{} + row.Callers = append([]string(nil), row.Callers...) + row.Callees = append([]string(nil), row.Callees...) + digest.Evidence = append(digest.Evidence, row) + } + } + appendRows(current) + if retained != nil { + appendRows(retained.Evidence) + } + for { + rebuildLocalizationDigestSkeleton(digest) + digest.finalResponse = renderLocalizationFinalResponse(digest.Evidence) + encoded, err := json.Marshal(digest) + if err == nil && len(encoded) <= localizationDigestMaxBytes && len(digest.finalResponse) <= localizationFinalResponseMaxBytes { + return digest + } + if len(digest.Evidence) == 0 { + return digest + } + last := len(digest.Evidence) - 1 + if shedLocalizationDigestRowOptionalFields(&digest.Evidence[last]) { + continue + } + if last == 0 { + digest.Evidence = nil + continue + } + digest.Evidence = digest.Evidence[:last] + } +} + func shedLocalizationDigestRowOptionalFields(row *localizationDigestRow) bool { if row == nil { return false diff --git a/internal/mcp/localization_terminal.go b/internal/mcp/localization_terminal.go index f3d03c5e..3a2c22ec 100644 --- a/internal/mcp/localization_terminal.go +++ b/internal/mcp/localization_terminal.go @@ -816,6 +816,29 @@ func (s *localizationTerminalState) finishReservedRead(success bool) localizatio } func (s *localizationTerminalState) finishReservedReadToken(token uint64, success bool) localizationCompletion { + return s.finishReservedReadTokenInternal(token, success, nil, false, false) +} + +// finishReservedReadTokenWithDigest is the production finalizer for a reserved +// localization read. Handler-captured evidence is trusted only after the token +// and generation match, and is published only when this transition actually +// reaches answer_ready. +func (s *localizationTerminalState) finishReservedReadTokenWithDigest( + token uint64, + success bool, + currentEvidence []localizationDigestRow, + evidenceRecorded bool, +) localizationCompletion { + return s.finishReservedReadTokenInternal(token, success, currentEvidence, evidenceRecorded, true) +} + +func (s *localizationTerminalState) finishReservedReadTokenInternal( + token uint64, + success bool, + currentEvidence []localizationDigestRow, + evidenceRecorded bool, + captureRequired bool, +) (completion localizationCompletion) { if s == nil { return newLocalizationOpenCompletion() } @@ -826,6 +849,31 @@ func (s *localizationTerminalState) finishReservedReadToken(token uint64, succes } s.readReservationToken = 0 s.readReservationGen = 0 + + wireSuccess := success + capturedResult := wireSuccess && evidenceRecorded && len(currentEvidence) > 0 + zeroResult := wireSuccess && evidenceRecorded && len(currentEvidence) == 0 + if captureRequired && zeroResult { + // An explicitly recorded empty typed page is a bounded zero-result, not + // evidence that can safely terminalize the session. A synthetic handler + // with no capture record keeps the legacy transition contract. + success = false + } + defer func() { + if !captureRequired || s.state != localizationStateAnswerReady { + return + } + switch { + case capturedResult: + s.digest = mergeLocalizationEvidenceDigest(currentEvidence, s.digest) + case !wireSuccess || zeroResult: + // Exhaustion without current evidence must never replay the stale + // localization candidates that triggered recovery. + s.digest = nil + } + completion = s.completionLocked() + }() + switch s.state { case localizationStateRecoveryInFlight: if success { diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 60a258ca..faecfc74 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -978,6 +978,10 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ result["omissions"] = omissions } + // Capture the validated typed owner before any output encoding branch. The + // dispatcher consumes it only when this reserved call itself succeeds. + captureLocalizationReadSource(ctx, node) + // ETag conditional fetch. etag := computeETag(result) if ifNoneMatch := req.GetString("if_none_match", ""); ifNoneMatch != "" && ifNoneMatch == etag { diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index 6c661d4c..dd266835 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -1828,6 +1828,8 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques // Decorate the page with absolute file paths so every output format // below surfaces an openable path alongside the repo-relative one. page = s.withAbsPaths(page) + // Capture the final ranked, scoped page once, before JSON/TOON/GCX encoding. + captureLocalizationSearchSymbols(ctx, page) nextCursor := "" if end < total { nextCursor = encodeCursor(end) diff --git a/internal/mcp/tools_search_text.go b/internal/mcp/tools_search_text.go index 33e3632a..68ebb4c6 100644 --- a/internal/mcp/tools_search_text.go +++ b/internal/mcp/tools_search_text.go @@ -109,6 +109,7 @@ func (s *Server) handleSearchText(ctx context.Context, req mcp.CallToolRequest) } enriched := s.enrichTextMatches(matches) + s.captureLocalizationSearchText(ctx, enriched) resp := map[string]any{ "query": query, "matches": enriched, From fd6b63b818f73289494fc771882076c70f8d5f3f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 24 Jul 2026 03:21:20 +0200 Subject: [PATCH 3/5] fix(localization): authenticate terminal evidence --- internal/hooks/explicit_file_policy_test.go | 2 +- internal/hooks/hook_tier_test.go | 2 +- internal/hooks/localization_terminal.go | 97 +++++- .../localization_terminal_receipt_test.go | 308 +++++++++++++++++ internal/hooks/localization_terminal_test.go | 3 +- internal/hooks/posttooluse.go | 4 +- internal/hooks/pretooluse.go | 31 +- internal/hooks/sessionstart.go | 6 +- internal/localizationauth/receipt.go | 257 ++++++++++++++ internal/localizationauth/receipt_test.go | 167 +++++++++ internal/mcp/facade_tools.go | 108 +++++- .../mcp/localization_current_capture_test.go | 12 +- internal/mcp/localization_digest.go | 21 +- internal/mcp/localization_digest_test.go | 13 +- internal/mcp/localization_terminal.go | 139 +++++++- .../localization_terminal_evidence_v8_test.go | 320 ++++++++++++++++++ internal/mcp/tools_explore.go | 155 ++++++++- 17 files changed, 1572 insertions(+), 73 deletions(-) create mode 100644 internal/hooks/localization_terminal_receipt_test.go create mode 100644 internal/localizationauth/receipt.go create mode 100644 internal/localizationauth/receipt_test.go create mode 100644 internal/mcp/localization_terminal_evidence_v8_test.go diff --git a/internal/hooks/explicit_file_policy_test.go b/internal/hooks/explicit_file_policy_test.go index 70a396d4..c8796bc0 100644 --- a/internal/hooks/explicit_file_policy_test.go +++ b/internal/hooks/explicit_file_policy_test.go @@ -8,7 +8,7 @@ import ( func TestRulePreambleRoutesExplicitFileReadsDirectly(t *testing.T) { got := rulePreamble() direct := `read(operation:"file", target:{file:""})` - localize := `explore(operation:"localize")` + localize := "`mcp__gortex__explore` (never a bare `explore`) with `operation:\"localize\"`" if !strings.Contains(got, "explicitly named file") || !strings.Contains(got, direct) { t.Fatalf("rule preamble must route explicit file reads directly; got %q", got) diff --git a/internal/hooks/hook_tier_test.go b/internal/hooks/hook_tier_test.go index 2bfcf4e5..8829cf72 100644 --- a/internal/hooks/hook_tier_test.go +++ b/internal/hooks/hook_tier_test.go @@ -40,7 +40,7 @@ func TestSessionStart_LeanTier_TrackedCwd(t *testing.T) { if !strings.Contains(briefing, "enforcement active") { t.Errorf("lean briefing lost the enforcement signal:\n%s", briefing) } - if !strings.Contains(briefing, "Rule:") || !strings.Contains(briefing, "explore(operation:\"localize\")") || !strings.Contains(briefing, "`search`") { + if !strings.Contains(briefing, "Rule:") || !strings.Contains(briefing, "`mcp__gortex__explore`") || strings.Contains(briefing, "call `explore`") || !strings.Contains(briefing, "`search`") { t.Errorf("lean briefing lost the rule preamble cues:\n%s", briefing) } // The standard-tier status prose must be gone. diff --git a/internal/hooks/localization_terminal.go b/internal/hooks/localization_terminal.go index 16108692..b88330ec 100644 --- a/internal/hooks/localization_terminal.go +++ b/internal/hooks/localization_terminal.go @@ -11,6 +11,8 @@ import ( "sort" "strings" "time" + + "github.com/zzet/gortex/internal/localizationauth" ) const ( @@ -72,6 +74,7 @@ type localizationToolSnapshot struct { Version int `json:"version"` ToolUseID string `json:"tool_use_id"` ToolName string `json:"tool_name"` + AuthToken string `json:"auth_token,omitempty"` Identity localizationTerminalIdentity `json:"identity"` CreatedUnixNano int64 `json:"created_unix_nano"` } @@ -84,14 +87,15 @@ type localizationTerminalMarker struct { } type localizationTerminalHookInput struct { - HookEventName string `json:"hook_event_name"` - ToolName string `json:"tool_name"` - ToolUseID string `json:"tool_use_id"` - SessionID string `json:"session_id"` - PromptID string `json:"prompt_id"` - AgentID string `json:"agent_id"` - CWD string `json:"cwd"` - ToolResponse json.RawMessage `json:"tool_response"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + SessionID string `json:"session_id"` + PromptID string `json:"prompt_id"` + AgentID string `json:"agent_id"` + CWD string `json:"cwd"` + ToolResponse json.RawMessage `json:"tool_response"` + TerminalReceipt localizationauth.Receipt `json:"-"` } type localizationTerminalCompletion struct { @@ -137,10 +141,28 @@ func observeLocalizationTerminal(data []byte) (localizationTerminalHookInput, bo if !localizationNavigationTool(input.ToolName) { return localizationTerminalHookInput{}, false } - identity, ok := consumeLocalizationToolSnapshot(input) + identity, authToken, ok := consumeLocalizationToolSnapshot(input) if !ok { return localizationTerminalHookInput{}, false } + + // Claude's real PostToolUse wire strips MCP _meta and structuredContent. + // The one-shot receipt was published by the MCP server immediately before + // returning answer_ready and is correlated through the authenticated + // PreToolUse snapshot. Visible tool JSON is never an authority on this path. + if authToken != "" { + if receipt, authenticated := localizationauth.Consume(authToken); authenticated { + if receipt.Enforceable && !markLocalizationTerminal(identity, receipt.ContractVersion) { + return localizationTerminalHookInput{}, false + } + input.TerminalReceipt = receipt + return input, true + } + } + + // Retain compatibility with hosts that preserve the authoritative _meta + // envelope. It still requires byte-equivalent visible and server metadata; + // an arbitrary visible contract alone can never arm terminal state. contract, ok := exactLocalizationTerminalContract(input.ToolResponse) if !ok || !answerReadyLocalizationTerminalContract(contract) { return localizationTerminalHookInput{}, false @@ -148,6 +170,11 @@ func observeLocalizationTerminal(data []byte) (localizationTerminalHookInput, bo if enforceableLocalizationTerminalContract(contract) && !markLocalizationTerminal(identity, contract.Completion.ContractVersion) { return localizationTerminalHookInput{}, false } + input.TerminalReceipt = localizationauth.Receipt{ + FinalResponse: contract.Completion.FinalResponse, + ContractVersion: contract.Completion.ContractVersion, + Enforceable: contract.Completion.Enforceable, + } return input, true } @@ -306,6 +333,19 @@ func preToolUsePolicyTool(tool string) bool { return ok } +func localizationAuthUpdatedInput(input map[string]any, authToken string) map[string]any { + updated := make(map[string]any, len(input)+1) + for key, value := range input { + updated[key] = value + } + updated[localizationauth.ArgumentKey] = authToken + return updated +} + +func localizationTerminalAdditionalContext(receipt localizationauth.Receipt) string { + return localizationTerminalContext + "\n\n" + receipt.FinalResponse +} + func localizationTerminalBaseFor(sessionID, agentID, cwd string) (localizationTerminalBase, bool) { base := localizationTerminalBase{ SessionID: strings.TrimSpace(sessionID), @@ -436,27 +476,40 @@ func currentLocalizationTurn(sessionID, promptID, agentID, cwd string) (localiza } func snapshotLocalizationToolUse(input HookInput, identity localizationTerminalIdentity) bool { + _, ok := snapshotLocalizationToolUseWithAuth(input, identity) + return ok +} + +func snapshotLocalizationToolUseWithAuth(input HookInput, identity localizationTerminalIdentity) (string, bool) { if !localizationNavigationTool(input.ToolName) || strings.TrimSpace(input.ToolUseID) == "" { - return false + return "", false } base, ok := localizationTerminalBaseFor(input.SessionID, input.AgentID, input.CWD) if !ok { - return false + return "", false + } + authToken, ok := localizationauth.NewToken() + if !ok { + return "", false } snapshot := localizationToolSnapshot{ Version: localizationTerminalMarkerVersion, ToolUseID: strings.TrimSpace(input.ToolUseID), ToolName: input.ToolName, + AuthToken: authToken, Identity: identity, CreatedUnixNano: time.Now().UnixNano(), } - return writeBoundedLocalizationState(localizationSnapshotPath(base, input.ToolUseID), snapshot) + if !writeBoundedLocalizationState(localizationSnapshotPath(base, input.ToolUseID), snapshot) { + return "", false + } + return authToken, true } -func consumeLocalizationToolSnapshot(input localizationTerminalHookInput) (localizationTerminalIdentity, bool) { +func consumeLocalizationToolSnapshot(input localizationTerminalHookInput) (localizationTerminalIdentity, string, bool) { base, ok := localizationTerminalBaseFor(input.SessionID, input.AgentID, input.CWD) if !ok || strings.TrimSpace(input.ToolUseID) == "" { - return localizationTerminalIdentity{}, false + return localizationTerminalIdentity{}, "", false } path := localizationSnapshotPath(base, input.ToolUseID) var snapshot localizationToolSnapshot @@ -464,13 +517,21 @@ func consumeLocalizationToolSnapshot(input localizationTerminalHookInput) (local snapshot.Version != localizationTerminalMarkerVersion || snapshot.ToolUseID != strings.TrimSpace(input.ToolUseID) || snapshot.ToolName != input.ToolName || snapshot.Identity.SessionID != base.SessionID || snapshot.Identity.AgentID != base.AgentID || snapshot.Identity.CWD != base.CWD { - return localizationTerminalIdentity{}, false + return localizationTerminalIdentity{}, "", false } - _ = os.Remove(path) if promptID := strings.TrimSpace(input.PromptID); promptID != "" && promptID != snapshot.Identity.PromptID { - return localizationTerminalIdentity{}, false + return localizationTerminalIdentity{}, "", false + } + claimToken, ok := localizationauth.NewToken() + if !ok { + return localizationTerminalIdentity{}, "", false + } + claimPath := path + ".consume-" + claimToken + if err := os.Rename(path, claimPath); err != nil { + return localizationTerminalIdentity{}, "", false } - return snapshot.Identity, true + defer os.Remove(claimPath) //nolint:errcheck // one-shot cleanup is best effort + return snapshot.Identity, snapshot.AuthToken, true } func markLocalizationTerminal(identity localizationTerminalIdentity, contractVersion int) bool { diff --git a/internal/hooks/localization_terminal_receipt_test.go b/internal/hooks/localization_terminal_receipt_test.go new file mode 100644 index 00000000..bef52bf0 --- /dev/null +++ b/internal/hooks/localization_terminal_receipt_test.go @@ -0,0 +1,308 @@ +package hooks + +import ( + "encoding/json" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/zzet/gortex/internal/localizationauth" +) + +func TestLocalizationReceiptSurvivesStrippedClaudeWire(t *testing.T) { + for _, tt := range []struct { + name string + tool string + enforceable bool + wireError bool + }{ + {name: "direct advisory", tool: gortexMCPToolPrefix + "explore"}, + {name: "plugin enforceable", tool: gortexPluginMCPToolPrefix + "search", enforceable: true}, + {name: "authenticated terminal error", tool: gortexMCPToolPrefix + "read", enforceable: true, wireError: true}, + } { + t.Run(tt.name, func(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir()) + original := map[string]any{ + "operation": "localize", + "target": map[string]any{"file": "literal.go"}, + } + token := captureTerminalAuthToken(t, identity, tt.tool, "tool-use", original) + if _, exists := original[localizationauth.ArgumentKey]; exists { + t.Fatal("PreToolUse mutated the decoded source input") + } + + finalResponse := "FILES:\n#1 literal.go\n\nSYMBOLS:\n#1 literal.go::Exact\n\nEVIDENCE:\n#1 literal.go:7 — exact bytes\n" + if !localizationauth.Publish(token, localizationauth.Receipt{ + FinalResponse: finalResponse, + ContractVersion: 2, + Enforceable: tt.enforceable, + }) { + t.Fatal("server receipt publish failed") + } + response := map[string]any{ + "content": []any{map[string]any{"type": "text", "text": "host-visible response with metadata stripped"}}, + } + if tt.wireError { + response["isError"] = true + } + post := localizationPostToolPayload(t, tt.tool, "tool-use", identity, response) + output := captureHookStdout(t, func() { runPostToolUse(post) }) + + var decoded HookOutput + if err := json.Unmarshal([]byte(output), &decoded); err != nil { + t.Fatalf("PostToolUse output is not valid JSON: %v\n%s", err, output) + } + if decoded.Decision != "" || decoded.SystemMessage != "" || decoded.HookSpecificOutput == nil { + t.Fatalf("incompatible PostToolUse envelope: %#v", decoded) + } + gotContext := decoded.HookSpecificOutput.AdditionalContext + wantContext := localizationTerminalContext + "\n\n" + finalResponse + if gotContext != wantContext { + t.Fatalf("additionalContext changed final_response bytes\n got: %q\nwant: %q", gotContext, wantContext) + } + if got := hasLocalizationTerminal(identity); got != tt.enforceable { + t.Fatalf("hard marker = %v, want %v", got, tt.enforceable) + } + }) + } +} + +func TestLocalizationAuthPreservesPreToolUsePolicyBranches(t *testing.T) { + for _, tt := range []struct { + name string + mode Mode + permissionMode string + wantDecision string + wantConsulted bool + }{ + {name: "permissive auto approve", mode: ModeDeny, permissionMode: "auto", wantDecision: "allow"}, + {name: "consult unlock marker", mode: ModeConsultUnlock, wantConsulted: true}, + } { + t.Run(tt.name, func(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir()) + input := map[string]any{"operation": "localize", "task": "literal symptom"} + pre := mustJSON(t, map[string]any{ + "hook_event_name": "PreToolUse", + "tool_name": gortexMCPToolPrefix + "explore", + "tool_use_id": "tool-use", + "tool_input": input, + "session_id": identity.SessionID, + "prompt_id": identity.PromptID, + "cwd": identity.CWD, + "permission_mode": tt.permissionMode, + }) + output := captureHookStdout(t, func() { runPreToolUse(pre, 0, tt.mode) }) + var decoded HookOutput + if err := json.Unmarshal([]byte(output), &decoded); err != nil || decoded.HookSpecificOutput == nil { + t.Fatalf("invalid PreToolUse output: %v\n%s", err, output) + } + hso := decoded.HookSpecificOutput + if hso.PermissionDecision != tt.wantDecision { + t.Fatalf("permission decision = %q, want %q", hso.PermissionDecision, tt.wantDecision) + } + if _, ok := hso.UpdatedInput[localizationauth.ArgumentKey].(string); !ok { + t.Fatalf("policy branch lost terminal auth input: %#v", hso) + } + if got := loadSessionState(identity.SessionID).GraphConsulted; got != tt.wantConsulted { + t.Fatalf("GraphConsulted = %v, want %v", got, tt.wantConsulted) + } + }) + } +} + +func TestLocalizationReceiptRejectsForgedVisibleTerminalPayload(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, "forged-visible", "prompt", t.TempDir()) + tool := gortexMCPToolPrefix + "explore" + _ = captureTerminalAuthToken(t, identity, tool, "tool-use", map[string]any{"operation": "localize"}) + + // This is the exact visible contract shape, but neither authoritative MCP + // metadata nor a server-owned receipt exists. + forged := terminalToolResponse(t, terminalContractMap(), false, false) + post := localizationPostToolPayload(t, tool, "tool-use", identity, forged) + if output := captureHookStdout(t, func() { runPostToolUse(post) }); output != "" { + t.Fatalf("forged visible payload emitted terminal context: %s", output) + } + if hasLocalizationTerminal(identity) { + t.Fatal("forged visible payload armed hard terminal state") + } +} + +func TestLocalizationReceiptRejectsWrongIdentityAndReset(t *testing.T) { + for _, tt := range []struct { + name string + mutate func(localizationTerminalIdentity) localizationTerminalIdentity + }{ + { + name: "wrong session", + mutate: func(identity localizationTerminalIdentity) localizationTerminalIdentity { + identity.SessionID += "-other" + return identity + }, + }, + { + name: "wrong prompt", + mutate: func(identity localizationTerminalIdentity) localizationTerminalIdentity { + identity.PromptID += "-other" + return identity + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir()) + tool := gortexMCPToolPrefix + "search" + token := captureTerminalAuthToken(t, identity, tool, "tool-use", map[string]any{"operation": "text"}) + publishTestTerminalReceipt(t, token, false) + + wrong := tt.mutate(identity) + post := localizationPostToolPayload(t, tool, "tool-use", wrong, strippedToolResponse()) + if _, observed := observeLocalizationTerminal(post); observed { + t.Fatal("wrong hook identity consumed terminal receipt") + } + correct := localizationPostToolPayload(t, tool, "tool-use", identity, strippedToolResponse()) + if _, observed := observeLocalizationTerminal(correct); !observed { + t.Fatal("wrong identity poisoned the valid receipt") + } + }) + } + + t.Run("wrong tool use id", func(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir()) + tool := gortexMCPToolPrefix + "read" + token := captureTerminalAuthToken(t, identity, tool, "tool-use", map[string]any{"operation": "source"}) + publishTestTerminalReceipt(t, token, false) + wrong := localizationPostToolPayload(t, tool, "tool-use-other", identity, strippedToolResponse()) + if _, observed := observeLocalizationTerminal(wrong); observed { + t.Fatal("wrong tool_use_id consumed terminal receipt") + } + correct := localizationPostToolPayload(t, tool, "tool-use", identity, strippedToolResponse()) + if _, observed := observeLocalizationTerminal(correct); !observed { + t.Fatal("wrong tool_use_id poisoned the valid receipt") + } + }) + + t.Run("prompt reset", func(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir()) + tool := gortexMCPToolPrefix + "analyze" + token := captureTerminalAuthToken(t, identity, tool, "tool-use", map[string]any{"kind": "contracts"}) + publishTestTerminalReceipt(t, token, true) + reset := mustJSON(t, map[string]any{ + "hook_event_name": "UserPromptSubmit", + "session_id": identity.SessionID, + "prompt_id": "next-prompt", + "cwd": identity.CWD, + }) + _ = clearLocalizationTerminalFromHook(reset) + old := localizationPostToolPayload(t, tool, "tool-use", identity, strippedToolResponse()) + if _, observed := observeLocalizationTerminal(old); observed { + t.Fatal("pre-reset receipt armed the next turn") + } + }) +} + +func TestLocalizationReceiptConcurrentPostHasSingleWinner(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, "concurrent-receipt", "prompt", t.TempDir()) + tool := gortexMCPToolPrefix + "relations" + token := captureTerminalAuthToken(t, identity, tool, "tool-use", map[string]any{"operation": "usages"}) + publishTestTerminalReceipt(t, token, false) + post := localizationPostToolPayload(t, tool, "tool-use", identity, strippedToolResponse()) + + var winners atomic.Int32 + var wg sync.WaitGroup + for range 32 { + wg.Add(1) + go func() { + defer wg.Done() + if _, observed := observeLocalizationTerminal(post); observed { + winners.Add(1) + } + }() + } + wg.Wait() + if got := winners.Load(); got != 1 { + t.Fatalf("authenticated PostToolUse winners = %d, want 1", got) + } +} + +func TestSessionStartNamesMountedExploreAndFaithfulSymptomTask(t *testing.T) { + briefing := rulePreamble() + for _, required := range []string{ + "`mcp__gortex__explore` (never a bare `explore`)", + "faithful symptom-only restatement", + "exact technical identifiers, paths, literals, error text, and observed symptoms", + "add no invented causal hypothesis", + } { + if !strings.Contains(briefing, required) { + t.Fatalf("SessionStart rule is missing %q:\n%s", required, briefing) + } + } + if strings.Contains(briefing, "call `explore(operation") { + t.Fatalf("SessionStart still suggests a bare explore call:\n%s", briefing) + } +} + +func captureTerminalAuthToken( + t *testing.T, + identity localizationTerminalIdentity, + tool, toolUseID string, + input map[string]any, +) string { + t.Helper() + pre := preToolPayload(t, tool, toolUseID, identity, input) + output := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeDeny) }) + var decoded HookOutput + if err := json.Unmarshal([]byte(output), &decoded); err != nil { + t.Fatalf("PreToolUse auth output is not valid JSON: %v\n%s", err, output) + } + if decoded.Decision != "" || decoded.SystemMessage != "" || decoded.HookSpecificOutput == nil { + t.Fatalf("incompatible PreToolUse auth envelope: %#v", decoded) + } + hso := decoded.HookSpecificOutput + if hso.HookEventName != "PreToolUse" || hso.PermissionDecision != "" || hso.AdditionalContext != "" { + t.Fatalf("auth injection changed hook policy: %#v", hso) + } + raw, ok := hso.UpdatedInput[localizationauth.ArgumentKey] + if !ok { + t.Fatalf("PreToolUse did not inject %s: %#v", localizationauth.ArgumentKey, hso.UpdatedInput) + } + token, ok := raw.(string) + if !ok || token == "" { + t.Fatalf("invalid auth token %#v", raw) + } + for key, want := range input { + if got := hso.UpdatedInput[key]; !equalJSONValue(got, want) { + t.Fatalf("UpdatedInput[%q] = %#v, want %#v", key, got, want) + } + } + return token +} + +func publishTestTerminalReceipt(t *testing.T, token string, enforceable bool) { + t.Helper() + if !localizationauth.Publish(token, localizationauth.Receipt{ + FinalResponse: "FILES:\n#1 exact.go\n\nSYMBOLS:\n#1 exact.go::Call\n\nEVIDENCE:\n#1 exact.go:1 — exact.go::Call", + ContractVersion: 2, + Enforceable: enforceable, + }) { + t.Fatal("Publish failed") + } +} + +func strippedToolResponse() map[string]any { + return map[string]any{ + "content": []any{map[string]any{"type": "text", "text": "stripped response"}}, + } +} + +func equalJSONValue(left, right any) bool { + leftJSON, leftErr := json.Marshal(left) + rightJSON, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON) +} diff --git a/internal/hooks/localization_terminal_test.go b/internal/hooks/localization_terminal_test.go index 87229139..b238f9f5 100644 --- a/internal/hooks/localization_terminal_test.go +++ b/internal/hooks/localization_terminal_test.go @@ -217,9 +217,10 @@ func TestPostToolUseAnswerReadyEventAuthenticationAndJSONShape(t *testing.T) { }, }, } + finalResponse := completionMap(terminalContractMap())["final_response"].(string) want := string(mustJSON(t, HookOutput{HookSpecificOutput: &HookSpecificOutput{ HookEventName: "PostToolUse", - AdditionalContext: localizationTerminalContext, + AdditionalContext: localizationTerminalContext + "\n\n" + finalResponse, }})) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/hooks/posttooluse.go b/internal/hooks/posttooluse.go index 409891c3..05d86c6a 100644 --- a/internal/hooks/posttooluse.go +++ b/internal/hooks/posttooluse.go @@ -63,10 +63,10 @@ func runPostToolUse(data []byte) { logHookEffectiveness("PostToolUse", emitted, daemonReachableFn(), 0, time.Since(started)) }() - if _, observed := observeLocalizationTerminal(data); observed { + if terminal, observed := observeLocalizationTerminal(data); observed { terminalObserved = true emitted = true - emitPostToolContext(localizationTerminalContext, false) + emitPostToolContext(localizationTerminalAdditionalContext(terminal.TerminalReceipt), false) return } diff --git a/internal/hooks/pretooluse.go b/internal/hooks/pretooluse.go index 365447d2..93433f3f 100644 --- a/internal/hooks/pretooluse.go +++ b/internal/hooks/pretooluse.go @@ -113,11 +113,16 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { localizationTerminalTelemetry("denied", true, started) return } + localizationAuthToken := "" if terminalTurnReady { - // Correlate the current turn with this exact tool invocation. PostToolUse - // consumes the snapshot, so a delayed result from a previous turn cannot - // arm the new turn's marker. - _ = snapshotLocalizationToolUse(input, terminalIdentity) + // Correlate the current turn with this exact tool invocation. The nonce is + // injected into the MCP request, then the server publishes a one-shot + // answer_ready receipt under it immediately before returning. PostToolUse + // consumes both the snapshot and receipt, so stripped response metadata, + // delayed events, and visible JSON cannot arm terminal state. + if authToken, snapshotReady := snapshotLocalizationToolUseWithAuth(input, terminalIdentity); snapshotReady { + localizationAuthToken = authToken + } } // The installed matcher is deliberately broad so terminal state can stop @@ -154,6 +159,9 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { if adv := gortexReadNudge(input.ToolName, input.ToolInput); adv != "" { hso.AdditionalContext = adv } + if localizationAuthToken != "" { + hso.UpdatedInput = localizationAuthUpdatedInput(input.ToolInput, localizationAuthToken) + } emitted = hso.AdditionalContext != "" || hso.PermissionDecisionReason != "" emitPreToolUse(HookOutput{HookSpecificOutput: hso}) return @@ -165,12 +173,24 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { // call itself is a no-op pass-through — nothing to enrich. if mode == ModeConsultUnlock && isGortexMCP { markGraphConsulted(input.SessionID) + if localizationAuthToken != "" { + emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + UpdatedInput: localizationAuthUpdatedInput(input.ToolInput, localizationAuthToken), + }}) + } return } result := applyMode(input, isGortexMCP, mode, enrich(input, gortexPort)) if result.context == "" && !result.deny { + if localizationAuthToken != "" { + emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + UpdatedInput: localizationAuthUpdatedInput(input.ToolInput, localizationAuthToken), + }}) + } return } @@ -179,6 +199,9 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { HookEventName: "PreToolUse", }, } + if localizationAuthToken != "" { + output.HookSpecificOutput.UpdatedInput = localizationAuthUpdatedInput(input.ToolInput, localizationAuthToken) + } if result.deny { output.HookSpecificOutput.PermissionDecision = "deny" diff --git a/internal/hooks/sessionstart.go b/internal/hooks/sessionstart.go index b0772496..4e53db72 100644 --- a/internal/hooks/sessionstart.go +++ b/internal/hooks/sessionstart.go @@ -294,9 +294,9 @@ func hasPathPrefix(path, prefix string) bool { // to reach for graph tools first. func rulePreamble() string { return "**Rule:** For an explicitly named file that the user asks you to read, review, or summarize, call `read(operation:\"file\", target:{file:\"\"})` directly; do not start localization. " + - "Otherwise choose by requested output: when the requested output is files, symbols, or supporting evidence, call `explore(operation:\"localize\")`; it returns terminal evidence. " + - "In the localize task, preserve the user's exact technical identifiers, paths, literals, error text, and observed symptoms; do not replace them with a causal hypothesis. " + - "Call `explore(operation:\"task\")` only when work will actually continue beyond localization into diagnosis, relationship analysis, or implementation. " + + "Otherwise choose by requested output: when the requested output is files, symbols, or supporting evidence, call the mounted tool `mcp__gortex__explore` (never a bare `explore`) with `operation:\"localize\"`; it returns terminal evidence. " + + "Its localize task must be a faithful symptom-only restatement: preserve the user's exact technical identifiers, paths, literals, error text, and observed symptoms, and add no invented causal hypothesis. " + + "Use `mcp__gortex__explore` with `operation:\"task\"` only when work will actually continue beyond localization into diagnosis, relationship analysis, or implementation. " + "After starting localization, obey `completion.required_action` and make no further tool calls after `answer_ready`. " + "Inspect indexed code with `search`, `read`, `relations`, or `trace`. " + "Use native read, search, or edit tools only when Gortex performance or integration is bad. " + diff --git a/internal/localizationauth/receipt.go b/internal/localizationauth/receipt.go new file mode 100644 index 00000000..5e6c980c --- /dev/null +++ b/internal/localizationauth/receipt.go @@ -0,0 +1,257 @@ +// Package localizationauth carries one-shot terminal receipts between the +// Gortex MCP server and short-lived Claude Code hook processes. +package localizationauth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/zzet/gortex/internal/platform" +) + +const ( + // ArgumentKey is injected by the authenticated PreToolUse hook. Facade + // dispatch must remove it before validating or forwarding user arguments. + ArgumentKey = "_gortex_terminal_auth" + + receiptVersion = 1 + contractVersionV2 = 2 + tokenBytes = 32 + tokenHexLength = tokenBytes * 2 + receiptTTL = 24 * time.Hour + maxFinalResponseSize = 64 << 10 + maxReceiptFiles = 256 + hookSessionDirEnvVar = "GORTEX_HOOK_SESSION_DIR" +) + +// Receipt is written only by the MCP response path after it has constructed an +// answer_ready contract. PostToolUse treats this server-owned record, rather +// than its visible tool_response, as the terminal authority. +type Receipt struct { + FinalResponse string `json:"final_response"` + ContractVersion int `json:"contract_version"` + Enforceable bool `json:"enforceable"` +} + +type receiptEnvelope struct { + Version int `json:"version"` + TokenDigest string `json:"token_digest"` + Receipt Receipt `json:"receipt"` + CreatedUnixNano int64 `json:"created_unix_nano"` +} + +// NewToken returns a cryptographically random, single-call correlation token. +func NewToken() (string, bool) { + var token [tokenBytes]byte + if _, err := rand.Read(token[:]); err != nil { + return "", false + } + return hex.EncodeToString(token[:]), true +} + +// TakeArgument removes and returns the hook-owned token from a facade argument +// map. Callers must strip the reserved field before schema validation or +// forwarding; a model-supplied malformed value becomes an empty token. +func TakeArgument(arguments map[string]any) string { + if arguments == nil { + return "" + } + raw, exists := arguments[ArgumentKey] + delete(arguments, ArgumentKey) + if !exists { + return "" + } + token, ok := raw.(string) + if !ok || !validToken(token) { + return "" + } + return strings.TrimSpace(token) +} + +// Publish atomically records an answer_ready result immediately before the MCP +// handler returns it. Invalid or oversized records fail open and are not stored. +func Publish(token string, receipt Receipt) bool { + path, digest, ok := receiptPath(token) + if !ok || !validReceipt(receipt) { + return false + } + envelope := receiptEnvelope{ + Version: receiptVersion, + TokenDigest: digest, + Receipt: receipt, + CreatedUnixNano: time.Now().UnixNano(), + } + data, err := json.Marshal(envelope) + if err != nil { + return false + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return false + } + tmp, err := os.CreateTemp(dir, ".terminal-receipt-*") + if err != nil { + return false + } + tmpPath := tmp.Name() + committed := false + defer func() { + _ = tmp.Close() + if !committed { + _ = os.Remove(tmpPath) + } + }() + if err := tmp.Chmod(0o600); err != nil { + return false + } + if _, err := tmp.Write(data); err != nil { + return false + } + if err := tmp.Sync(); err != nil { + return false + } + if err := tmp.Close(); err != nil { + return false + } + if err := os.Rename(tmpPath, path); err != nil { + return false + } + committed = true + trimReceipts(dir, path) + return true +} + +// Consume atomically claims and removes one receipt. Concurrent or repeated +// consumers cannot observe the same terminal result. +func Consume(token string) (Receipt, bool) { + path, digest, ok := receiptPath(token) + if !ok { + return Receipt{}, false + } + claimToken, ok := NewToken() + if !ok { + return Receipt{}, false + } + claimPath := path + ".consume-" + claimToken + ".json" + if err := os.Rename(path, claimPath); err != nil { + return Receipt{}, false + } + defer os.Remove(claimPath) //nolint:errcheck // one-shot cleanup is best effort + + data, err := os.ReadFile(claimPath) + if err != nil { + return Receipt{}, false + } + var envelope receiptEnvelope + if err := json.Unmarshal(data, &envelope); err != nil || + envelope.Version != receiptVersion || envelope.TokenDigest != digest || + !fresh(envelope.CreatedUnixNano) || !validReceipt(envelope.Receipt) { + return Receipt{}, false + } + return envelope.Receipt, true +} + +// Discard removes a pending receipt without exposing its contents. +func Discard(token string) { + path, _, ok := receiptPath(token) + if ok { + _ = os.Remove(path) + } +} + +func validReceipt(receipt Receipt) bool { + return receipt.FinalResponse != "" && len(receipt.FinalResponse) <= maxFinalResponseSize && + receipt.ContractVersion >= contractVersionV2 +} + +func validToken(token string) bool { + token = strings.TrimSpace(token) + if len(token) != tokenHexLength || token != strings.ToLower(token) { + return false + } + _, err := hex.DecodeString(token) + return err == nil +} + +func receiptPath(token string) (string, string, bool) { + token = strings.TrimSpace(token) + if !validToken(token) { + return "", "", false + } + root := receiptRoot() + if root == "" { + return "", "", false + } + digestBytes := sha256.Sum256([]byte(token)) + digest := hex.EncodeToString(digestBytes[:]) + return filepath.Join(root, digest+".json"), digest, true +} + +func receiptRoot() string { + base := strings.TrimSpace(os.Getenv(hookSessionDirEnvVar)) + if base == "" { + base = filepath.Join(platform.OSCacheDir(), "sessions") + } + if base == "" { + return "" + } + return filepath.Join(base, "localization-terminal-v3", "receipts") +} + +func fresh(createdUnixNano int64) bool { + if createdUnixNano <= 0 { + return false + } + age := time.Since(time.Unix(0, createdUnixNano)) + return age >= 0 && age <= receiptTTL +} + +func trimReceipts(dir, preserve string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + type receiptFile struct { + path string + modTime time.Time + } + files := make([]receiptFile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + path := filepath.Join(dir, entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + if time.Since(info.ModTime()) > receiptTTL && path != preserve { + _ = os.Remove(path) + continue + } + files = append(files, receiptFile{path: path, modTime: info.ModTime()}) + } + if len(files) <= maxReceiptFiles { + return + } + sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) }) + remaining := len(files) - maxReceiptFiles + for _, file := range files { + if remaining == 0 { + break + } + if file.path == preserve { + continue + } + if os.Remove(file.path) == nil { + remaining-- + } + } +} diff --git a/internal/localizationauth/receipt_test.go b/internal/localizationauth/receipt_test.go new file mode 100644 index 00000000..b933829c --- /dev/null +++ b/internal/localizationauth/receipt_test.go @@ -0,0 +1,167 @@ +package localizationauth + +import ( + "encoding/json" + "os" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestTakeArgumentStripsReservedField(t *testing.T) { + token, ok := NewToken() + if !ok { + t.Fatal("NewToken failed") + } + arguments := map[string]any{"operation": "localize", ArgumentKey: token} + if got := TakeArgument(arguments); got != token { + t.Fatalf("TakeArgument = %q, want %q", got, token) + } + if _, exists := arguments[ArgumentKey]; exists { + t.Fatal("reserved auth argument was not stripped") + } + if arguments["operation"] != "localize" { + t.Fatal("ordinary arguments were mutated") + } + arguments[ArgumentKey] = "forged" + if got := TakeArgument(arguments); got != "" { + t.Fatalf("malformed token accepted: %q", got) + } + if _, exists := arguments[ArgumentKey]; exists { + t.Fatal("malformed reserved argument was not stripped") + } +} + +func TestReceiptRoundTripPreservesFinalResponseVerbatim(t *testing.T) { + t.Setenv(hookSessionDirEnvVar, t.TempDir()) + token, ok := NewToken() + if !ok { + t.Fatal("NewToken failed") + } + want := Receipt{ + FinalResponse: "FILES\n#1 exact.go\n\nSYMBOLS\n#1 Exact.Call\n\nEVIDENCE\n#1 exact.go:9 — unchanged\n", + ContractVersion: 2, + Enforceable: true, + } + if !Publish(token, want) { + t.Fatal("Publish failed") + } + got, ok := Consume(token) + if !ok { + t.Fatal("Consume failed") + } + if got != want { + t.Fatalf("receipt mismatch\n got: %#v\nwant: %#v", got, want) + } + if _, ok := Consume(token); ok { + t.Fatal("receipt was consumed more than once") + } +} + +func TestReceiptRejectsUntrustedOrInvalidRecords(t *testing.T) { + t.Setenv(hookSessionDirEnvVar, t.TempDir()) + valid, ok := NewToken() + if !ok { + t.Fatal("NewToken failed") + } + for name, token := range map[string]string{ + "empty": "", + "too_short": "abcd", + "uppercase": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "non_hex": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + } { + t.Run(name, func(t *testing.T) { + if Publish(token, Receipt{FinalResponse: "answer", ContractVersion: 2}) { + t.Fatal("accepted invalid token") + } + }) + } + if Publish(valid, Receipt{FinalResponse: "", ContractVersion: 2}) { + t.Fatal("accepted empty final response") + } + if Publish(valid, Receipt{FinalResponse: "answer", ContractVersion: 1}) { + t.Fatal("accepted legacy contract") + } + if _, ok := Consume(valid); ok { + t.Fatal("invalid publish left a consumable receipt") + } +} + +func TestReceiptConcurrentConsumeHasSingleWinner(t *testing.T) { + t.Setenv(hookSessionDirEnvVar, t.TempDir()) + token, ok := NewToken() + if !ok { + t.Fatal("NewToken failed") + } + if !Publish(token, Receipt{FinalResponse: "answer", ContractVersion: 2}) { + t.Fatal("Publish failed") + } + + var winners atomic.Int32 + var wg sync.WaitGroup + for range 32 { + wg.Add(1) + go func() { + defer wg.Done() + if receipt, ok := Consume(token); ok && receipt.FinalResponse == "answer" { + winners.Add(1) + } + }() + } + wg.Wait() + if got := winners.Load(); got != 1 { + t.Fatalf("consume winners = %d, want 1", got) + } +} + +func TestReceiptStorageIsBounded(t *testing.T) { + t.Setenv(hookSessionDirEnvVar, t.TempDir()) + for range maxReceiptFiles + 24 { + token, ok := NewToken() + if !ok { + t.Fatal("NewToken failed") + } + if !Publish(token, Receipt{FinalResponse: "answer", ContractVersion: 2}) { + t.Fatal("Publish failed") + } + } + entries, err := os.ReadDir(receiptRoot()) + if err != nil { + t.Fatal(err) + } + if got := len(entries); got > maxReceiptFiles { + t.Fatalf("receipt files = %d, hard cap = %d", got, maxReceiptFiles) + } +} + +func TestReceiptRejectsStaleEnvelope(t *testing.T) { + t.Setenv(hookSessionDirEnvVar, t.TempDir()) + token, ok := NewToken() + if !ok { + t.Fatal("NewToken failed") + } + if !Publish(token, Receipt{FinalResponse: "answer", ContractVersion: 2}) { + t.Fatal("Publish failed") + } + path, digest, ok := receiptPath(token) + if !ok { + t.Fatal("receiptPath failed") + } + stale := receiptEnvelope{ + Version: receiptVersion, + TokenDigest: digest, + Receipt: Receipt{FinalResponse: "answer", ContractVersion: 2}, + CreatedUnixNano: time.Now().Add(-receiptTTL - time.Minute).UnixNano(), + } + data, err := json.Marshal(stale) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if _, ok := Consume(token); ok { + t.Fatal("accepted stale receipt") + } +} diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go index ac7c89dd..cc81322f 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -15,6 +15,7 @@ import ( mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/localizationauth" "github.com/zzet/gortex/internal/telemetry" ) @@ -418,6 +419,8 @@ func parseLocalizationNewUserBoundary(facade, operation string, arguments map[st func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { started := time.Now() + input, _ := req.Params.Arguments.(map[string]any) + localizationAuthToken := localizationauth.TakeArgument(input) operation := normalizeFacadeOperation(req.GetString("operation", "")) if facade == "analyze" { operation = requestedAnalyzeKind(req.GetArguments()) @@ -452,6 +455,7 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call blocked, recoveryGeneration = terminal.interceptAnswerReady(facade, operation, req.GetArguments()) if blocked != nil { s.recordFacadeTelemetry(facade, operation, facadeOutcomeBlocked, time.Since(started)) + publishLocalizationAuthReceipt(localizationAuthToken, blocked) return blocked, nil } } @@ -497,12 +501,12 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call s.recordFacadeTelemetry(facade, "unknown", facadeOutcomeInvalidOperation, time.Since(started)) return result, nil } - input, _ := req.Params.Arguments.(map[string]any) if invalid := validateFacadeInput(spec, input); invalid != nil { if completion, consumed := terminal.consumeInvalidRecovery(facade, operation, recoveryGeneration); consumed { invalid, _ = decorateExhaustedLocalizationReadFailure(invalid, nil, completion, spec) } s.recordFacadeTelemetry(facade, operation, facadeOutcomeInvalidArgument, time.Since(started)) + publishLocalizationAuthReceipt(localizationAuthToken, invalid) return invalid, nil } // Every localize call and an explicit new-user task call starts a @@ -517,6 +521,7 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call localizeReservation, blocked = terminal.beginLocalize(req.GetString("task", ""), newUserTask) if blocked != nil { s.recordFacadeTelemetry(facade, operation, facadeOutcomeBlocked, time.Since(started)) + publishLocalizationAuthReceipt(localizationAuthToken, blocked) return blocked, nil } if !freshLocalizeFlow { @@ -538,6 +543,7 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call blocked, reservation := terminal.authorizeWithToken(facade, operation, req.GetArguments()) if blocked != nil { s.recordFacadeTelemetry(facade, operation, facadeOutcomeBlocked, time.Since(started)) + publishLocalizationAuthReceipt(localizationAuthToken, blocked) return blocked, nil } localizationReadReservation = reservation @@ -572,9 +578,26 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call terminal.finishLocalize(localizeReservation, succeeded) localizeFinished = true } + publishLocalizationAuthReceipt(localizationAuthToken, result) return result, err } +func publishLocalizationAuthReceipt(token string, result *mcpgo.CallToolResult) { + if token == "" || result == nil || result.Meta == nil || result.Meta.AdditionalFields == nil { + return + } + host, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok || !host.Contract.Terminal || host.Contract.Completion.State != localizationStateAnswerReady { + return + } + completion := host.Contract.Completion + localizationauth.Publish(token, localizationauth.Receipt{ + FinalResponse: completion.FinalResponse, + ContractVersion: completion.ContractVersion, + Enforceable: completion.Enforceable, + }) +} + type localizationPermittedEvidenceCaptureKey struct{} type localizationPermittedEvidenceCapture struct { @@ -615,33 +638,86 @@ func captureLocalizationSearchSymbols(ctx context.Context, nodes []*graph.Node) captureLocalizationRows(ctx, rows) } -// captureLocalizationSearchText promotes only graph-backed symbol identities -// from the typed search.text page. File-only hits have no SymbolID, and the -// rendered MCP Content is never parsed as evidence. +// captureLocalizationSearchText promotes only graph-backed identities from the +// typed search.text page. A file-level literal may have no enclosing SymbolID; +// in that case the matching path and line are resolved back to the narrowest +// in-scope graph declaration, or finally to the graph's file node. Rendered MCP +// Content is never parsed as evidence. func (s *Server) captureLocalizationSearchText(ctx context.Context, matches []enrichedTextMatch) { capture, _ := ctx.Value(localizationPermittedEvidenceCaptureKey{}).(*localizationPermittedEvidenceCapture) if capture == nil { return } rows := make([]localizationDigestRow, 0, len(matches)) - if s.graph != nil { - for _, match := range matches { - id := strings.TrimSpace(match.SymbolID) - if id == "" { - continue - } - node := s.graph.GetNode(id) - if node == nil || !s.nodeInSessionScope(ctx, node) { - continue - } - if row, ok := localizationDigestRowFromNode(node, "permitted_search_text"); ok { - rows = append(rows, row) + for _, match := range matches { + node, provenance := s.localizationTextMatchNode(ctx, match) + if row, ok := localizationDigestRowFromNode(node, provenance); ok { + if match.Line > 0 { + row.Line = match.Line } + rows = append(rows, row) } } captureLocalizationRows(ctx, rows) } +func (s *Server) localizationTextMatchNode(ctx context.Context, match enrichedTextMatch) (*graph.Node, string) { + if s == nil || s.graph == nil { + return nil, "" + } + if id := strings.TrimSpace(match.SymbolID); id != "" { + if node := s.graph.GetNode(id); node != nil && s.nodeInSessionScope(ctx, node) { + return node, "permitted_search_text" + } + return nil, "" + } + path := strings.TrimSpace(match.Path) + if path == "" { + return nil, "" + } + var owner *graph.Node + var fileNode *graph.Node + ownerSpan := int(^uint(0) >> 1) + for _, node := range s.graph.GetFileNodes(path) { + if node == nil || !s.nodeInSessionScope(ctx, node) { + continue + } + if node.Kind == graph.KindFile { + if fileNode == nil || node.ID < fileNode.ID { + fileNode = node + } + continue + } + if match.Line <= 0 || node.StartLine <= 0 || node.StartLine > match.Line || !exploreLocalizableKind(node.Kind) { + continue + } + end := node.EndLine + if end <= 0 { + end = node.StartLine + } + if end < match.Line { + continue + } + span := end - node.StartLine + if owner == nil || span < ownerSpan || (span == ownerSpan && node.ID < owner.ID) { + owner = node + ownerSpan = span + } + } + if owner != nil { + return owner, "permitted_search_text_owner" + } + if fileNode == nil { + if node := s.graph.GetNode(path); node != nil && node.Kind == graph.KindFile && s.nodeInSessionScope(ctx, node) { + fileNode = node + } + } + if fileNode != nil { + return fileNode, "permitted_search_text_file" + } + return nil, "" +} + // captureLocalizationReadSource records the validated graph node whose source // was successfully read. It never reconstructs identity from serialized output. func captureLocalizationReadSource(ctx context.Context, node *graph.Node) { diff --git a/internal/mcp/localization_current_capture_test.go b/internal/mcp/localization_current_capture_test.go index e0dd1d51..e8f09bb5 100644 --- a/internal/mcp/localization_current_capture_test.go +++ b/internal/mcp/localization_current_capture_test.go @@ -37,7 +37,7 @@ func TestMergeLocalizationEvidenceDigestIsCurrentFirstAlignedAndBounded(t *testi if merged == nil { t.Fatal("merge returned nil digest") } - if got, want := len(merged.Evidence), localizationReplayEvidenceLimit; got != want { + if got, want := len(merged.Evidence), 6; got != want { t.Fatalf("evidence count = %d, want %d", got, want) } wantIDs := []string{ @@ -46,6 +46,7 @@ func TestMergeLocalizationEvidenceDigestIsCurrentFirstAlignedAndBounded(t *testi "repo/storage/mirror.go::Mirror.Load", "repo/storage/cloud.go::CloudStorage.Load", "repo/storage/cache.go::Cache.Load", + "repo/storage/archive.go::Archive.Load", } for index, want := range wantIDs { row := merged.Evidence[index] @@ -193,7 +194,7 @@ func TestFinishReservedReadWithDigestMergesOnlyOnAnswerReady(t *testing.T) { } } -func TestFinishReservedReadWithDigestRetriesRecordedZeroThenClearsStaleDigest(t *testing.T) { +func TestFinishReservedReadWithDigestRetriesRecordedZeroThenPreservesCurrentTaskDigest(t *testing.T) { retained := mergeLocalizationEvidenceDigest([]localizationDigestRow{ captureTestRow("repo/storage/base.go::Storage.Load", "repo/storage/base.go"), }, nil) @@ -220,8 +221,11 @@ func TestFinishReservedReadWithDigestRetriesRecordedZeroThenClearsStaleDigest(t if completion.State != localizationStateAnswerReady { t.Fatalf("exhausted zero-result state = %q", completion.State) } - if state.digest != nil || completion.digest != nil { - t.Fatalf("exhaustion replayed stale digest: state=%#v completion=%#v", state.digest, completion.digest) + if state.digest != retained || completion.digest != retained { + t.Fatalf("same-task exhaustion discarded retained evidence: state=%#v completion=%#v", state.digest, completion.digest) + } + if got := completion.digest.Evidence[0].ID; got != "repo/storage/base.go::Storage.Load" { + t.Fatalf("same-task exhaustion changed retained identity: %q", got) } } diff --git a/internal/mcp/localization_digest.go b/internal/mcp/localization_digest.go index df373be0..5687bf54 100644 --- a/internal/mcp/localization_digest.go +++ b/internal/mcp/localization_digest.go @@ -19,11 +19,10 @@ const ( // localizationDigestMaxBytes bounds retained session state independently of // the original envelope budget. localizationDigestMaxBytes = 4096 - // localizationReplayEvidenceLimit prevents a broad localization envelope - // from becoming an exhaustive, implicitly endorsed answer during replay. - // Five keeps the promoted structural/literal candidates reserved by the - // envelope builder while bounding repeat-turn cost. - localizationReplayEvidenceLimit = 5 + // localizationReplayEvidenceLimit preserves the five strongest direct rows + // plus at most one graph-validated direct relationship for each. The retained + // byte cap remains authoritative when long identities cannot all fit. + localizationReplayEvidenceLimit = 10 // localizationFinalResponseMaxBytes bounds the ready-to-emit answer that // accompanies the retained digest on terminal responses and replays. localizationFinalResponseMaxBytes = 4096 @@ -226,7 +225,7 @@ func localizationFinalResponseField(value string) string { func renderLocalizationFinalResponse(rows []localizationDigestRow) string { if len(rows) == 0 { - return "FILES:\n(none)\n\nSYMBOLS:\n(none)\n\nEVIDENCE:\nNo bounded localization evidence was found." + return "FILES:\n(none)\n\nSYMBOLS:\n(none)\n\nEVIDENCE:\nNo bounded localization evidence was found.\n\n" + localizationAnswerReadyDirective } var response strings.Builder response.WriteString("FILES:\n") @@ -247,6 +246,8 @@ func renderLocalizationFinalResponse(rows []localizationDigestRow) string { } fmt.Fprintf(&response, "#%d %s — %s\n", index+1, file, id) } + response.WriteString("\n") + response.WriteString(localizationAnswerReadyDirective) return response.String() } @@ -336,7 +337,13 @@ func attachLocalizationHostEnvelope(result *mcpgo.CallToolResult, completion loc // same ready-to-emit answer and directive on every post-terminal navigation. func localizationAnswerReadyResult(completion localizationCompletion) *mcpgo.CallToolResult { completion = localizationCompletionWithDigest(completion, completion.digest) - visible := completion.FinalResponse + "\n\n" + localizationAnswerReadyDirective + visible := completion.FinalResponse + // Older retained completions may predate the in-response convergence cue. + // Preserve their successful replay shape without duplicating the directive + // for newly rendered terminal evidence. + if !strings.HasSuffix(visible, localizationAnswerReadyDirective) { + visible += "\n\n" + localizationAnswerReadyDirective + } result := mcpgo.NewToolResultText(visible) return attachLocalizationHostEnvelope(result, completion, completion.digest) } diff --git a/internal/mcp/localization_digest_test.go b/internal/mcp/localization_digest_test.go index 5fef624f..42e04055 100644 --- a/internal/mcp/localization_digest_test.go +++ b/internal/mcp/localization_digest_test.go @@ -96,9 +96,12 @@ func requireLocalizationTerminalReplay(t *testing.T, result *mcpgo.CallToolResul structured["directive"] != localizationAnswerReadyDirective { t.Fatalf("terminal replay stable keys = %#v", structured) } - if text != contract.Completion.FinalResponse+"\n\n"+localizationAnswerReadyDirective { + if text != contract.Completion.FinalResponse { t.Fatalf("terminal replay text diverged from final_response: %q", text) } + if !strings.HasSuffix(contract.Completion.FinalResponse, localizationAnswerReadyDirective) { + t.Fatalf("terminal convergence directive is outside final_response: %q", contract.Completion.FinalResponse) + } return contract } @@ -360,11 +363,11 @@ func TestLocalizationDigestKeepsOnlyConcreteBoundedEvidence(t *testing.T) { } digest := newLocalizationEvidenceDigest(envelope) - if len(digest.Evidence) != localizationReplayEvidenceLimit { - t.Fatalf("retained evidence = %d, want %d", len(digest.Evidence), localizationReplayEvidenceLimit) + if len(digest.Evidence) != 6 { + t.Fatalf("retained evidence = %d, want 6", len(digest.Evidence)) } - wantFiles := []string{"pkg/a.go", "pkg/b.go", "pkg/c.go", "pkg/d.go", "pkg/e.go"} - wantSymbols := []string{"repo/pkg/a.go::A", "repo/pkg/b.go::B", "repo/pkg/c.go::C", "repo/pkg/d.go::D", "repo/pkg/e.go::E"} + wantFiles := []string{"pkg/a.go", "pkg/b.go", "pkg/c.go", "pkg/d.go", "pkg/e.go", "pkg/f.go"} + wantSymbols := []string{"repo/pkg/a.go::A", "repo/pkg/b.go::B", "repo/pkg/c.go::C", "repo/pkg/d.go::D", "repo/pkg/e.go::E", "repo/pkg/f.go::F"} if !reflect.DeepEqual(digest.Files, wantFiles) { t.Fatalf("digest files = %#v, want %#v", digest.Files, wantFiles) } diff --git a/internal/mcp/localization_terminal.go b/internal/mcp/localization_terminal.go index 3a2c22ec..05c2b361 100644 --- a/internal/mcp/localization_terminal.go +++ b/internal/mcp/localization_terminal.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "sync" + "unicode" mcpgo "github.com/mark3labs/mcp-go/mcp" ) @@ -519,6 +520,12 @@ func (s *localizationTerminalState) localizationRecoveryAllowsLocked(facade, ope return true } query, _ := arguments["query"].(string) + if operation == "symbols" && localizationRecoveryConcreteIdentifier(query) { + // A symbol-shaped recovery query is validated by the scoped graph search + // result captured for this reservation. An empty typed page restores the + // allowance; search.text remains anchored to the original task. + return true + } return localizationRecoveryQueryAligned(s.taskFingerprint, query) } @@ -550,6 +557,111 @@ func localizationRecoveryQueryAligned(task, query string) bool { return localizationRecoverySpecificAnchor(query) } +// localizationRecoveryConcreteIdentifier admits one exact-looking symbol query +// even when the identifier was inferred from preceding graph evidence rather +// than copied from the task. Authorization alone is not success: the scoped, +// typed search page must contain a graph node before the allowance is consumed. +func localizationRecoveryConcreteIdentifier(query string) bool { + identifier := strings.Trim(strings.TrimSpace(query), "`'\"") + runes := []rune(identifier) + if len(runes) < 3 || !unicode.IsLetter(runes[0]) || + (!unicode.IsLetter(runes[len(runes)-1]) && !unicode.IsDigit(runes[len(runes)-1])) { + return false + } + hasLower := false + hasUpper := false + hasQualifier := false + hasCaseBoundary := false + previousLowerOrDigit := false + for _, r := range runes { + switch { + case unicode.IsLetter(r): + if unicode.IsLower(r) { + hasLower = true + } else if unicode.IsUpper(r) { + hasUpper = true + if previousLowerOrDigit { + hasCaseBoundary = true + } + } + previousLowerOrDigit = unicode.IsLower(r) + case unicode.IsDigit(r): + previousLowerOrDigit = true + case r == '_' || r == '$' || r == '.' || r == ':': + hasQualifier = true + previousLowerOrDigit = false + default: + return false + } + } + return hasQualifier || hasCaseBoundary || (unicode.IsUpper(runes[0]) && hasUpper && hasLower) +} + +// localizationReservedReadEvidenceAligned recognizes a successful reserved read +// as sufficient only when its typed graph identity agrees with a concrete task +// anchor. Two independent semantic terms are also accepted; a single generic +// overlap keeps the bounded recovery path open. +func localizationReservedReadEvidenceAligned(task, requested string, rows []localizationDigestRow) bool { + if strings.TrimSpace(task) == "" || len(rows) == 0 { + return false + } + if localizationTaskCitesConcreteEvidence(task, requested) { + return true + } + taskTerms := exploreTerminalTerms(task) + for _, row := range rows { + values := []string{row.ID, row.Name, row.QualName, row.File, row.Signature} + for _, value := range values { + if localizationTaskCitesConcreteEvidence(task, value) { + return true + } + } + overlap := 0 + for term := range exploreTerminalTerms(strings.Join(values, " ")) { + if _, aligned := taskTerms[term]; aligned { + overlap++ + } + } + if overlap >= 2 { + return true + } + } + return false +} + +func localizationTaskCitesConcreteEvidence(task, value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + lowerTask := strings.ToLower(task) + lowerValue := strings.ToLower(value) + if strings.ContainsAny(value, "/\\.:") && strings.Contains(lowerTask, lowerValue) { + return true + } + if localizationRecoveryConcreteIdentifier(value) && strings.Contains(task, value) { + return true + } + name := value + if cut := strings.LastIndexAny(name, "/\\.:"); cut >= 0 && cut+1 < len(name) { + name = name[cut+1:] + } + name = strings.TrimSpace(name) + if name == "" { + return false + } + if exploreQueryHasCallAnchor(task, name) { + return true + } + lowerName := strings.ToLower(name) + for _, quote := range []string{"`", "'", "\""} { + if strings.Contains(lowerTask, quote+lowerName+quote) { + return true + } + } + return false +} + // localizationRecoverySpecificAnchor admits compact path-like literals that // are sufficiently concrete to bound a recovery search on their own. This // covers metadata paths such as `".jj/"` whose semantic class (VCS state) may @@ -601,7 +713,7 @@ func localizationRecoveryMisalignedResult(completion localizationCompletion, fac } func localizationRecoveryRejectedResult(completion localizationCompletion, facade, operation string) *mcpgo.CallToolResult { - return newStructuredErrorResult(StructuredError{ + result := newStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeLocalizationTerminal, Message: "the one bounded localization recovery call must be search.text or search.symbols with a task-aligned query, or read.source with a concrete symbol; localization is now terminal", Retriable: false, @@ -612,6 +724,7 @@ func localizationRecoveryRejectedResult(completion localizationCompletion, facad "allowed_operations": append([]string(nil), localizationRecoveryOperations...), }, }, true) + return attachLocalizationHostEnvelope(result, completion, completion.digest) } // beginLocalize reserves the only localization handler slot for this session. @@ -853,6 +966,10 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( wireSuccess := success capturedResult := wireSuccess && evidenceRecorded && len(currentEvidence) > 0 zeroResult := wireSuccess && evidenceRecorded && len(currentEvidence) == 0 + var mergedDigest *localizationEvidenceDigest + if capturedResult { + mergedDigest = mergeLocalizationEvidenceDigest(currentEvidence, s.digest) + } if captureRequired && zeroResult { // An explicitly recorded empty typed page is a bounded zero-result, not // evidence that can safely terminalize the session. A synthetic handler @@ -863,14 +980,12 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( if !captureRequired || s.state != localizationStateAnswerReady { return } - switch { - case capturedResult: - s.digest = mergeLocalizationEvidenceDigest(currentEvidence, s.digest) - case !wireSuccess || zeroResult: - // Exhaustion without current evidence must never replay the stale - // localization candidates that triggered recovery. - s.digest = nil + if capturedResult { + s.digest = mergedDigest } + // A recorded empty page or handler failure carries no new evidence, but + // the retained digest still belongs to this token's generation and task. + // Stale or cross-task finishers were rejected before reaching this defer. completion = s.completionLocked() }() @@ -892,6 +1007,8 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( implementationSymbol := s.inFlightImplementationSymbol routeEnforceable := s.inFlightEnforceable wasCorrection := s.exactReadIsCorrection + confidentRead := capturedResult && mergedDigest != nil && len(mergedDigest.Evidence) > 0 && + localizationReservedReadEvidenceAligned(s.taskFingerprint, s.exactSymbol, currentEvidence) s.inFlightImplementationSymbol = "" s.inFlightEnforceable = false s.inFlightCorrectionSymbol = "" @@ -911,7 +1028,7 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( s.exactReadIsCorrection = false s.exactReadRoute = localizationRefinementRoute{} s.correctionRetriesRemaining = 0 - if !wasCorrection && !s.enforceableOnAnswerReady { + if !wasCorrection && !s.enforceableOnAnswerReady && !confidentRead { s.state = localizationStateNeedsRecovery s.recoveryRetriesRemaining = 1 return s.completionLocked() @@ -935,6 +1052,8 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( } s.state = localizationStateNeedsExactRead case localizationStateRefineInFlight: + confidentRead := capturedResult && mergedDigest != nil && len(mergedDigest.Evidence) > 0 && + localizationReservedReadEvidenceAligned(s.taskFingerprint, s.refinementSymbol, currentEvidence) if success { implementationSymbol := s.inFlightImplementationSymbol enforceable := s.inFlightEnforceable @@ -966,7 +1085,7 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( s.correctionRetriesRemaining = 1 return s.completionLocked() } - if !enforceable { + if !enforceable && !confidentRead { s.state = localizationStateNeedsRecovery s.recoveryRetriesRemaining = 1 return s.completionLocked() diff --git a/internal/mcp/localization_terminal_evidence_v8_test.go b/internal/mcp/localization_terminal_evidence_v8_test.go new file mode 100644 index 00000000..7e8b0628 --- /dev/null +++ b/internal/mcp/localization_terminal_evidence_v8_test.go @@ -0,0 +1,320 @@ +package mcp + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/localizationauth" +) + +func localizationV8Node(id, name, file string) *graph.Node { + return &graph.Node{ + ID: id, + Name: name, + QualName: name, + Kind: graph.KindMethod, + FilePath: file, + StartLine: 11, + EndLine: 19, + Meta: map[string]any{"signature": "func " + name + "()"}, + } +} + +func TestLocalizationDirectRelationsInterleaveDeterministicallyWithinExistingCap(t *testing.T) { + targets := make([]exploreTarget, 0, localizationReplayEvidenceLimit) + for index := 0; index < localizationReplayEvidenceLimit; index++ { + name := "DirectTarget" + string(rune('A'+index)) + node := localizationV8Node("repo/direct_"+string(rune('a'+index))+".go::"+name, name, "repo/direct_"+string(rune('a'+index))+".go") + targets = append(targets, exploreTarget{node: node}) + } + callee := localizationV8Node("repo/policy.go::PolicyEngine.ApplyRule", "ApplyRule", "repo/policy.go") + unrelatedCaller := localizationV8Node("repo/bootstrap.go::Bootstrap.Start", "Start", "repo/bootstrap.go") + targets[0].callees = []*graph.Node{callee} + targets[0].callers = []*graph.Node{unrelatedCaller} + unrelatedCallee := localizationV8Node("repo/alloc.go::Allocator.Grow", "Grow", "repo/alloc.go") + caller := localizationV8Node("repo/defaults.go::Registry.BuildDefaultRegistry", "BuildDefaultRegistry", "repo/defaults.go") + targets[1].callees = []*graph.Node{unrelatedCallee} + targets[1].callers = []*graph.Node{caller} + + task := "trace ApplyRule and BuildDefaultRegistry behavior" + got := interleaveLocalizationDirectRelations(task, "", targets) + again := interleaveLocalizationDirectRelations(task, "", targets) + if !reflect.DeepEqual(got, again) { + t.Fatal("relationship interleaving is not deterministic") + } + if len(got) != len(targets) || len(got) != localizationReplayEvidenceLimit { + t.Fatalf("interleaved rows = %d, want existing cap %d", len(got), len(targets)) + } + wantHead := []string{targets[0].node.ID, callee.ID, targets[1].node.ID, caller.ID, targets[2].node.ID} + for index, want := range wantHead { + if got[index].node.ID != want { + t.Fatalf("position %d = %q, want %q", index+1, got[index].node.ID, want) + } + } + if got[1].localizationRelation != "direct_callee" || got[3].localizationRelation != "direct_caller" { + t.Fatalf("relationship provenance = %q/%q", got[1].localizationRelation, got[3].localizationRelation) + } + + completion := newLocalizationCompletion(true, "") + result, _, digest, packed := buildLocalizationExploreResultForTaskFinalized(completion, task, targets, exploreMaxBudgetTokens) + if result == nil || result.IsError || digest == nil || packed.State == localizationStateInactive { + t.Fatalf("packed relationship result = (%#v, %#v, %#v)", result, digest, packed) + } + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("packed result content = %#v", result.Content) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatalf("decode packed relationship envelope: %v", err) + } + if len(envelope.Evidence) > localizationReplayEvidenceLimit || len(envelope.Files) != len(envelope.Evidence) || len(envelope.Symbols) != len(envelope.Evidence) { + t.Fatalf("unaligned packed rows: files=%d symbols=%d evidence=%d", len(envelope.Files), len(envelope.Symbols), len(envelope.Evidence)) + } + relationRows := 0 + for index, row := range envelope.Evidence { + if row.Rank != index+1 || envelope.Files[index] != row.File || envelope.Symbols[index] != row.ID { + t.Fatalf("unaligned row %d: %#v", index+1, row) + } + if row.Provenance == "direct_caller" || row.Provenance == "direct_callee" { + relationRows++ + } + } + if relationRows == 0 { + t.Fatal("packed envelope lost every direct relationship row") + } + encoded, err := json.Marshal(digest) + if err != nil || len(encoded) > localizationDigestMaxBytes { + t.Fatalf("digest size = %d, err=%v", len(encoded), err) + } + ready := newLocalizationCompletion(true, "") + ready = localizationCompletionWithDigest(ready, digest) + firstReplay, _ := json.Marshal(localizationAnswerReadyResult(ready)) + secondReplay, _ := json.Marshal(localizationAnswerReadyResult(ready)) + if !reflect.DeepEqual(firstReplay, secondReplay) { + t.Fatal("relationship replay is not byte-identical") + } + if !strings.HasSuffix(ready.FinalResponse, localizationAnswerReadyDirective) { + t.Fatalf("directive is not inside final_response: %q", ready.FinalResponse) + } +} + +func TestRecoveryAllowsInferredConcreteIdentifierOnlyForScopedSymbolEvidence(t *testing.T) { + retained := mergeLocalizationEvidenceDigest([]localizationDigestRow{ + captureTestRow("repo/registry.go::Registry.Configure", "repo/registry.go"), + }, nil) + state := newLocalizationTerminalState() + completion := newLocalizationRecoveryCompletion() + completion.digest = retained + state.armForTask(completion, "investigate registry configuration behavior") + args := map[string]any{"query": "DerivedPolicy.ApplyRule"} + + blocked, firstToken := state.authorizeWithToken("search", "symbols", args) + if blocked != nil || firstToken == 0 { + t.Fatalf("inferred exact identifier authorization = (%#v, %d)", blocked, firstToken) + } + zero := state.finishReservedReadTokenWithDigest(firstToken, true, nil, true) + if zero.State != localizationStateNeedsRecovery || zero.AllowedToolCalls != 1 || zero.digest != retained { + t.Fatalf("empty typed page consumed recovery: %#v", zero) + } + + blocked, secondToken := state.authorizeWithToken("search", "symbols", args) + if blocked != nil || secondToken == 0 || secondToken == firstToken { + t.Fatalf("restored exact identifier authorization = (%#v, %d)", blocked, secondToken) + } + captureCtx := withLocalizationPermittedEvidenceCapture(context.Background(), secondToken) + node := localizationV8Node("repo/policy.go::DerivedPolicy.ApplyRule", "ApplyRule", "repo/policy.go") + captureLocalizationSearchSymbols(captureCtx, []*graph.Node{node}) + rows, recorded := localizationEvidenceForPermittedCall(captureCtx, "search", "symbols", secondToken) + ready := state.finishReservedReadTokenWithDigest(secondToken, true, rows, recorded) + if ready.State != localizationStateAnswerReady || ready.digest == nil || ready.digest.Evidence[0].ID != node.ID { + t.Fatalf("nonempty typed symbol page did not terminalize current-first: %#v", ready) + } + + textState := newLocalizationTerminalState() + textState.armForTask(newLocalizationRecoveryCompletion(), "investigate registry configuration behavior") + if corrective, token := textState.authorizeWithToken("search", "text", args); corrective == nil || token != 0 { + t.Fatalf("inferred identifier loosened search.text: (%#v, %d)", corrective, token) + } + plainState := newLocalizationTerminalState() + plainState.armForTask(newLocalizationRecoveryCompletion(), "investigate registry configuration behavior") + if corrective, token := plainState.authorizeWithToken("search", "symbols", map[string]any{"query": "neighbor"}); corrective == nil || token != 0 { + t.Fatalf("plain unrelated word became an exact identifier: (%#v, %d)", corrective, token) + } +} + +func TestTypedReservedReadStopsOnlyWhenEvidenceAlignsWithConcreteTaskAnchor(t *testing.T) { + makeState := func(task, symbol string, refinement bool) (*localizationTerminalState, uint64) { + state := newLocalizationTerminalState() + retained := mergeLocalizationEvidenceDigest([]localizationDigestRow{ + captureTestRow("repo/router.go::Router.Route", "repo/router.go"), + }, nil) + if refinement { + state.armRefinementForTask(task, symbol, []string{symbol}, retained) + } else { + completion := newLocalizationExactReadCompletion(symbol, false) + completion.digest = retained + state.armForTask(completion, task) + } + blocked, token := state.authorizeWithToken("read", "source", map[string]any{ + "target": map[string]any{"symbol": symbol}, + }) + if blocked != nil || token == 0 { + t.Fatalf("reserved read authorization = (%#v, %d)", blocked, token) + } + return state, token + } + + for _, refinement := range []bool{false, true} { + name := "exact" + if refinement { + name = "refinement" + } + t.Run(name+" aligned", func(t *testing.T) { + symbol := "repo/policy.go::PolicyRouter.ApplyRule" + state, token := makeState("trace PolicyRouter.ApplyRule decisions", symbol, refinement) + row := captureTestRow(symbol, "repo/policy.go") + row.Name = "ApplyRule" + row.QualName = "PolicyRouter.ApplyRule" + ready := state.finishReservedReadTokenWithDigest(token, true, []localizationDigestRow{row}, true) + if ready.State != localizationStateAnswerReady || ready.Enforceable || ready.digest == nil || len(ready.digest.Evidence) == 0 { + t.Fatalf("aligned typed read did not converge advisory answer_ready: %#v", ready) + } + }) + t.Run(name+" low alignment", func(t *testing.T) { + symbol := "repo/cache.go::Cache.Flush" + state, token := makeState("investigate payment timeout policy", symbol, refinement) + row := captureTestRow(symbol, "repo/cache.go") + row.Name = "Flush" + row.QualName = "Cache.Flush" + open := state.finishReservedReadTokenWithDigest(token, true, []localizationDigestRow{row}, true) + if open.State != localizationStateNeedsRecovery || open.AllowedToolCalls != 1 { + t.Fatalf("low-alignment typed read skipped recovery: %#v", open) + } + }) + } +} + +func TestSearchTextCaptureResolvesInScopeOwnerThenFileEvidence(t *testing.T) { + ownerGraph := graph.New() + file := &graph.Node{ID: "repo/config.go", Name: "config.go", Kind: graph.KindFile, FilePath: "repo/config.go", StartLine: 1, EndLine: 60} + owner := &graph.Node{ID: "repo/config.go::FormatterRegistry", Name: "FormatterRegistry", QualName: "FormatterRegistry", Kind: graph.KindType, FilePath: "repo/config.go", StartLine: 10, EndLine: 40} + ownerGraph.AddNode(file) + ownerGraph.AddNode(owner) + ownerServer := &Server{graph: ownerGraph} + ctx := withLocalizationPermittedEvidenceCapture(context.Background(), 71) + ownerServer.captureLocalizationSearchText(ctx, []enrichedTextMatch{{Path: "repo/config.go", Line: 24, Text: "register marker"}}) + rows, recorded := localizationEvidenceForPermittedCall(ctx, "search", "text", 71) + if !recorded || len(rows) != 1 || rows[0].ID != owner.ID || rows[0].Line != 24 || rows[0].Provenance != "permitted_search_text_owner" { + t.Fatalf("file-level text owner capture = %#v, recorded=%v", rows, recorded) + } + + fileGraph := graph.New() + fileOnly := &graph.Node{ID: "repo/root.go", Name: "root.go", Kind: graph.KindFile, FilePath: "repo/root.go", StartLine: 1, EndLine: 20} + fileGraph.AddNode(fileOnly) + fileServer := &Server{graph: fileGraph} + fileCtx := withLocalizationPermittedEvidenceCapture(context.Background(), 72) + fileServer.captureLocalizationSearchText(fileCtx, []enrichedTextMatch{{Path: "repo/root.go", Line: 3, Text: "package marker"}}) + rows, recorded = localizationEvidenceForPermittedCall(fileCtx, "search", "text", 72) + if !recorded || len(rows) != 1 || rows[0].ID != fileOnly.ID || rows[0].File != fileOnly.FilePath || rows[0].Line != 3 || rows[0].Provenance != "permitted_search_text_file" { + t.Fatalf("file text evidence capture = %#v, recorded=%v", rows, recorded) + } + + missingCtx := withLocalizationPermittedEvidenceCapture(context.Background(), 73) + fileServer.captureLocalizationSearchText(missingCtx, []enrichedTextMatch{{Path: "repo/missing.go", Line: 8, Text: "unattributed marker"}}) + if rows, recorded = localizationEvidenceForPermittedCall(missingCtx, "search", "text", 73); !recorded || len(rows) != 0 { + t.Fatalf("unvalidated text path became evidence: %#v, recorded=%v", rows, recorded) + } +} + +func TestFacadeAuthArgumentIsStrippedAndPublishesTypedTerminalReceipts(t *testing.T) { + t.Setenv("GORTEX_HOOK_SESSION_DIR", t.TempDir()) + registry := newFacadeRegistry() + var server *Server + handlerSawAuth := false + registry.capture(mcpgo.NewTool("explore", mcpgo.WithString("task", mcpgo.Required())), func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if arguments, ok := req.Params.Arguments.(map[string]any); ok { + _, handlerSawAuth = arguments[localizationauth.ArgumentKey] + } + completion := newLocalizationCompletion(true, "") + completion.digest = testEvidenceDigest() + server.localizationFor(ctx).armForTask(completion, req.GetString("task", "")) + return localizationAnswerReadyResult(completion), nil + }) + server = &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + + newToken := func(t *testing.T) string { + t.Helper() + token, ok := localizationauth.NewToken() + if !ok { + t.Fatal("could not create localization auth token") + } + return token + } + requireReceipt := func(t *testing.T, token string, result *mcpgo.CallToolResult) localizationauth.Receipt { + t.Helper() + if result == nil || result.Meta == nil { + t.Fatalf("terminal result has no typed metadata: %#v", result) + } + host, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok { + t.Fatalf("typed host envelope = %T", result.Meta.AdditionalFields[localizationHostMetaKey]) + } + receipt, ok := localizationauth.Consume(token) + if !ok { + t.Fatal("terminal receipt was not published") + } + if receipt.FinalResponse != host.Contract.Completion.FinalResponse || receipt.ContractVersion != host.Contract.Completion.ContractVersion || receipt.Enforceable != host.Contract.Completion.Enforceable { + t.Fatalf("receipt %#v != typed host completion %#v", receipt, host.Contract.Completion) + } + return receipt + } + + ctx := WithSessionID(context.Background(), "auth_direct_answer_ready") + directToken := newToken(t) + directArgs := map[string]any{ + "operation": "localize", + "task": "locate registry configuration", + localizationauth.ArgumentKey: directToken, + } + direct, err := server.handleFacade(ctx, "explore", mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{Name: "explore", Arguments: directArgs}}) + if err != nil || direct == nil || direct.IsError { + t.Fatalf("direct answer_ready = (%#v, %v)", direct, err) + } + if handlerSawAuth { + t.Fatal("reserved auth argument reached the legacy handler") + } + if _, exists := directArgs[localizationauth.ArgumentKey]; exists { + t.Fatal("reserved auth argument survived facade entry") + } + directReceipt := requireReceipt(t, directToken, direct) + + replayToken := newToken(t) + replayArgs := map[string]any{"operation": "symbols", "query": "AnyIdentifier", localizationauth.ArgumentKey: replayToken} + replay, err := server.handleFacade(ctx, "search", mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{Name: "search", Arguments: replayArgs}}) + if err != nil || replay == nil || replay.IsError { + t.Fatalf("authenticated replay = (%#v, %v)", replay, err) + } + replayReceipt := requireReceipt(t, replayToken, replay) + if replayReceipt.FinalResponse != directReceipt.FinalResponse { + t.Fatal("authenticated replay receipt diverged from direct answer_ready") + } + + errorCtx := WithSessionID(context.Background(), "auth_terminal_error") + errorCompletion := newLocalizationRecoveryCompletion() + errorCompletion.digest = testEvidenceDigest() + server.localizationFor(errorCtx).armForTask(errorCompletion, "locate registry configuration") + errorToken := newToken(t) + errorArgs := map[string]any{"operation": "unsupported_operation", "query": "Registry.Configure", localizationauth.ArgumentKey: errorToken} + terminalError, err := server.handleFacade(errorCtx, "search", mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{Name: "search", Arguments: errorArgs}}) + if err != nil || terminalError == nil || !terminalError.IsError { + t.Fatalf("authenticated terminal error = (%#v, %v)", terminalError, err) + } + requireReceipt(t, errorToken, terminalError) +} diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index db083843..e4fd6390 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -95,6 +95,7 @@ type exploreTarget struct { sourceLiteralAligned bool // source-literal callee that instantiates the task's value; strongest literal owner typedAnchorProjection bool // bounded field-owner-call proof promoted from a task-aligned typed field foldedOwner bool // synthetic owner inserted by concept member folding + localizationRelation string // direct_caller/direct_callee row promoted only into the bounded terminal projection } type exploreCausalNeighbor struct { @@ -2778,6 +2779,153 @@ func localizationEvidenceTargetsFromDraft(task, exactID string, targets []explor return selected } +const ( + localizationDirectRelationSourceLimit = 5 + localizationDirectEvidenceReserve = 3 +) + +// interleaveLocalizationDirectRelations promotes one already-hydrated direct +// caller or callee beside each high-ranked evidence row. The relationship nodes +// came from the same scoped graph walks as the parent target; this helper never +// performs a new lookup or reconstructs an identity from rendered output. +func interleaveLocalizationDirectRelations(task, requiredID string, targets []exploreTarget) []exploreTarget { + if len(targets) == 0 { + return targets + } + limit := min(len(targets), localizationReplayEvidenceLimit) + type relationCandidate struct { + node *graph.Node + direction string + overlap int + longest int + production bool + callable bool + order int + } + + direct := make(map[string]exploreTarget, len(targets)) + protected := make(map[string]struct{}, localizationDirectEvidenceReserve+6) + orderedPrefix := 0 + hasDivergentDefault := false + for index, target := range targets { + if target.node == nil || target.node.ID == "" { + continue + } + direct[target.node.ID] = target + if index < localizationDirectEvidenceReserve || target.node.ID == requiredID || + target.divergentDefaultOwner || target.divergentDefaultType || target.conceptImplementation || + target.exactContent || target.sourceLiteral || target.typedAnchorProjection { + protected[target.node.ID] = struct{}{} + } + if target.node.ID == requiredID || target.divergentDefaultOwner || target.divergentDefaultType || + target.exactContent || target.sourceLiteral || target.typedAnchorProjection { + orderedPrefix = index + 1 + } + if target.divergentDefaultOwner || target.divergentDefaultType { + hasDivergentDefault = true + } + } + // Divergent-default evidence is an ordered causal triple: promoted owner, + // owning type, then the original consumer. Relationships may enrich the + // projection only after that prefix, never between its proof rows. + if hasDivergentDefault && orderedPrefix < len(targets) { + orderedPrefix++ + } + terms := exploreTerminalTerms(shapeExploreQuery(task)) + selected := make([]exploreTarget, 0, limit) + seen := make(map[string]struct{}, limit) + appendTarget := func(target exploreTarget) bool { + if len(selected) >= limit || target.node == nil || target.node.ID == "" || nodeDisplayPath(target.node) == "" { + return false + } + if _, exists := seen[target.node.ID]; exists { + return false + } + seen[target.node.ID] = struct{}{} + selected = append(selected, target) + return true + } + chooseRelation := func(target exploreTarget) (exploreTarget, bool) { + var best relationCandidate + found := false + order := 0 + consider := func(nodes []*graph.Node, direction string) { + for _, node := range nodes { + candidateOrder := order + order++ + if node == nil || node.ID == "" || node.ID == target.node.ID || nodeDisplayPath(node) == "" || !exploreLocalizableKind(node.Kind) { + continue + } + if _, exists := seen[node.ID]; exists { + continue + } + overlap, longest := exploreDraftTermOverlap(terms, node) + candidate := relationCandidate{ + node: node, + direction: direction, + overlap: overlap, + longest: longest, + production: !exploreDraftIsTestNode(node), + callable: node.Kind == graph.KindFunction || node.Kind == graph.KindMethod, + order: candidateOrder, + } + better := !found || candidate.overlap > best.overlap || + (candidate.overlap == best.overlap && candidate.production && !best.production) || + (candidate.overlap == best.overlap && candidate.production == best.production && candidate.callable && !best.callable) || + (candidate.overlap == best.overlap && candidate.production == best.production && candidate.callable == best.callable && candidate.longest > best.longest) || + (candidate.overlap == best.overlap && candidate.production == best.production && candidate.callable == best.callable && candidate.longest == best.longest && candidate.order < best.order) + if better { + best = candidate + found = true + } + } + } + // Callees win a complete tie because a wrapper-to-implementation hop is + // the most common missing terminal relation. A task-aligned caller still + // wins through the overlap comparison above. + consider(target.callees, "direct_callee") + consider(target.callers, "direct_caller") + if !found { + return exploreTarget{}, false + } + relation, exists := direct[best.node.ID] + if !exists { + relation = exploreTarget{node: best.node, localizationRelation: best.direction} + } + return relation, true + } + + protectedRemaining := func() int { + remaining := 0 + for id := range protected { + if _, exists := seen[id]; !exists { + remaining++ + } + } + return remaining + } + for index, target := range targets { + targetProtected := false + if target.node != nil { + _, targetProtected = protected[target.node.ID] + } + if targetProtected || len(selected)+protectedRemaining() < limit { + appendTarget(target) + } + if len(selected) >= limit { + break + } + if index+1 < orderedPrefix || index >= localizationDirectRelationSourceLimit || + target.node == nil || len(selected)+protectedRemaining() >= limit { + continue + } + if relation, ok := chooseRelation(target); ok { + appendTarget(relation) + } + } + return selected +} + func newLocalizationExploreResult(completion localizationCompletion, targets []exploreTarget, budget int) *mcp.CallToolResult { return newLocalizationExploreResultForTask(completion, "", targets, budget) } @@ -2910,6 +3058,7 @@ func buildLocalizationExploreResultForTaskFinalized( if refinementFirst { targets = prioritizeLocalizationEvidenceTarget(requiredSymbol, targets) } + targets = interleaveLocalizationDirectRelations(task, requiredSymbol, targets) contract := localizationContractFor(completion) envelope := localizationExploreEnvelope{ Completion: contract.Completion, @@ -2984,6 +3133,10 @@ func buildLocalizationExploreResultForTaskFinalized( n := target.node path := nodeDisplayPath(n) retrieval := n.RetrievalMetadata() + provenance := localizationTargetProvenance(completion, target) + if target.localizationRelation != "" { + provenance = target.localizationRelation + } evidence := localizationEvidence{ Rank: len(envelope.Evidence) + 1, ID: n.ID, Name: compactLocalizationField(n.Name, localizationMaxNameRunes), @@ -2993,7 +3146,7 @@ func buildLocalizationExploreResultForTaskFinalized( Signature: compactLocalizationField(retrieval.Signature, localizationMaxSignatureRunes), Callers: boundedLocalizationNeighborIDs(target.callers, localizationMaxNeighborIDs), Callees: boundedLocalizationNeighborIDs(target.callees, localizationMaxNeighborIDs), - Provenance: localizationTargetProvenance(completion, target), + Provenance: provenance, } candidate := envelope From c2ef77be62f465b1ed4edd075a5b036c0a29b05f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 24 Jul 2026 15:44:43 +0200 Subject: [PATCH 4/5] fix(localization): retain trusted terminal evidence --- internal/hooks/codex_test.go | 3 +- internal/hooks/localization_terminal.go | 187 ++++- .../localization_terminal_receipt_test.go | 581 ++++++++++++++- internal/hooks/localization_terminal_test.go | 49 +- internal/hooks/posttask.go | 3 + internal/hooks/posttooluse.go | 11 +- internal/hooks/pretooluse.go | 47 +- internal/hooks/sessionstart.go | 8 +- internal/hooks/sessionstart_test.go | 40 +- internal/hooks/userpromptsubmit.go | 126 ++++ internal/hooks/userpromptsubmit_test.go | 61 ++ .../mcp/explore_typed_anchor_projection.go | 23 + internal/mcp/localization_digest.go | 631 +++++++++++++++-- internal/mcp/localization_digest_test.go | 666 +++++++++++++++++- internal/mcp/localization_recovery_test.go | 338 ++++++++- internal/mcp/localization_terminal.go | 543 +++++++++++++- .../localization_terminal_evidence_v8_test.go | 192 +++++ internal/mcp/localization_terminal_test.go | 9 + internal/mcp/tools_explore.go | 449 +++++++++++- internal/mcp/tools_explore_protected_test.go | 241 +++++++ internal/mcp/tools_explore_test.go | 91 +++ 21 files changed, 4137 insertions(+), 162 deletions(-) diff --git a/internal/hooks/codex_test.go b/internal/hooks/codex_test.go index da82a6bf..0ef9285c 100644 --- a/internal/hooks/codex_test.go +++ b/internal/hooks/codex_test.go @@ -44,7 +44,8 @@ func TestRunCodexSessionStartUsesManagedOrientationHook(t *testing.T) { t.Fatalf("invalid SessionStart hook output: %s", out) } if !strings.Contains(hso.AdditionalContext, "choose by requested output") || - !strings.Contains(hso.AdditionalContext, "preserve the user's exact technical identifiers") { + !strings.Contains(hso.AdditionalContext, "localize task may be concise") || + !strings.Contains(hso.AdditionalContext, "faithfully preserve the issue title") { t.Fatalf("mandatory localization routing orientation missing: %q", hso.AdditionalContext) } } diff --git a/internal/hooks/localization_terminal.go b/internal/hooks/localization_terminal.go index b88330ec..8edc0634 100644 --- a/internal/hooks/localization_terminal.go +++ b/internal/hooks/localization_terminal.go @@ -25,8 +25,9 @@ const ( localizationTerminalAgentHardCap = 64 localizationTerminalJanitorDeletes = 32 - localizationTerminalContext = "[Gortex] Localization is answer-ready. Respond now using the current Gortex tool result and completion.final_response. Do not call native tools or any other tool in this turn." - localizationTerminalDenyReason = "[Gortex] Localization is complete. Respond to the user now; no further tool calls are allowed in this turn." + localizationTerminalContext = "[Gortex] Localization for this task is complete. Respond now using completion.final_response; do not call another tool." + localizationTerminalDenyReason = "[Gortex] Localization for this task is complete, so this tool call is blocked. Respond now using the retained evidence; do not call another tool." + localizationAdvisoryDenyReason = "[Gortex] Localization for this task is complete, so this additional Gortex navigation call was not run. Respond now using the retained evidence; do not call another tool." gortexPluginMCPToolPrefix = "mcp__plugin_gortex_gortex__" localizationHostMetaKey = "gortex/localization" ) @@ -65,9 +66,10 @@ type localizationTerminalIdentity struct { } type localizationTurnState struct { - Version int `json:"version"` - Identity localizationTerminalIdentity `json:"identity"` - CreatedUnixNano int64 `json:"created_unix_nano"` + Version int `json:"version"` + Identity localizationTerminalIdentity `json:"identity"` + ProblemStatement string `json:"problem_statement,omitempty"` + CreatedUnixNano int64 `json:"created_unix_nano"` } type localizationToolSnapshot struct { @@ -84,18 +86,22 @@ type localizationTerminalMarker struct { ContractVersion int `json:"contract_version"` Identity localizationTerminalIdentity `json:"identity"` ObservedUnixNano int64 `json:"observed_unix_nano"` + Advisory bool `json:"advisory,omitempty"` } type localizationTerminalHookInput struct { - HookEventName string `json:"hook_event_name"` - ToolName string `json:"tool_name"` - ToolUseID string `json:"tool_use_id"` - SessionID string `json:"session_id"` - PromptID string `json:"prompt_id"` - AgentID string `json:"agent_id"` - CWD string `json:"cwd"` - ToolResponse json.RawMessage `json:"tool_response"` - TerminalReceipt localizationauth.Receipt `json:"-"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + ToolInput map[string]any `json:"tool_input"` + SessionID string `json:"session_id"` + PromptID string `json:"prompt_id"` + AgentID string `json:"agent_id"` + CWD string `json:"cwd"` + Prompt string `json:"prompt"` + ToolResponse json.RawMessage `json:"tool_response"` + TerminalReceipt localizationauth.Receipt `json:"-"` + TerminalIdentity localizationTerminalIdentity `json:"-"` } type localizationTerminalCompletion struct { @@ -142,9 +148,13 @@ func observeLocalizationTerminal(data []byte) (localizationTerminalHookInput, bo return localizationTerminalHookInput{}, false } identity, authToken, ok := consumeLocalizationToolSnapshot(input) - if !ok { + if !ok || !localizationTerminalIdentityCurrent(identity) { return localizationTerminalHookInput{}, false } + input.TerminalIdentity = identity + if localizationProblemTaskRequest(input.ToolName, input.ToolInput) { + _ = clearLocalizationProblemStatement(identity) + } // Claude's real PostToolUse wire strips MCP _meta and structuredContent. // The one-shot receipt was published by the MCP server immediately before @@ -152,7 +162,7 @@ func observeLocalizationTerminal(data []byte) (localizationTerminalHookInput, bo // PreToolUse snapshot. Visible tool JSON is never an authority on this path. if authToken != "" { if receipt, authenticated := localizationauth.Consume(authToken); authenticated { - if receipt.Enforceable && !markLocalizationTerminal(identity, receipt.ContractVersion) { + if !markLocalizationTerminalReceipt(identity, receipt.ContractVersion, receipt.Enforceable) { return localizationTerminalHookInput{}, false } input.TerminalReceipt = receipt @@ -167,7 +177,7 @@ func observeLocalizationTerminal(data []byte) (localizationTerminalHookInput, bo if !ok || !answerReadyLocalizationTerminalContract(contract) { return localizationTerminalHookInput{}, false } - if enforceableLocalizationTerminalContract(contract) && !markLocalizationTerminal(identity, contract.Completion.ContractVersion) { + if !markLocalizationTerminalReceipt(identity, contract.Completion.ContractVersion, contract.Completion.Enforceable) { return localizationTerminalHookInput{}, false } input.TerminalReceipt = localizationauth.Receipt{ @@ -308,10 +318,6 @@ func answerReadyLocalizationTerminalContract(contract localizationTerminalContra completion.ContractVersion >= localizationTerminalContractV2 } -func enforceableLocalizationTerminalContract(contract localizationTerminalContract) bool { - return answerReadyLocalizationTerminalContract(contract) && contract.Completion.Enforceable -} - func localizationNavigationTool(tool string) bool { operation := shortGortexToolName(tool) if operation == tool { @@ -333,15 +339,48 @@ func preToolUsePolicyTool(tool string) bool { return ok } -func localizationAuthUpdatedInput(input map[string]any, authToken string) map[string]any { - updated := make(map[string]any, len(input)+1) - for key, value := range input { +func localizationPreToolUpdatedInput(input HookInput, authToken, problemStatement string) map[string]any { + rewriteTask := localizationProblemTaskRewrite(input, problemStatement) + if authToken == "" && !rewriteTask { + return nil + } + updated := make(map[string]any, len(input.ToolInput)+1) + for key, value := range input.ToolInput { updated[key] = value } - updated[localizationauth.ArgumentKey] = authToken + if authToken != "" { + updated[localizationauth.ArgumentKey] = authToken + } + if rewriteTask { + updated["task"] = problemStatement + } return updated } +func localizationProblemTaskRewrite(input HookInput, problemStatement string) bool { + if problemStatement == "" || !localizationProblemTaskRequest(input.ToolName, input.ToolInput) { + return false + } + modelTask, _ := input.ToolInput["task"].(string) + return len(strings.TrimSpace(modelTask))*2 < len(problemStatement) +} + +func localizationProblemTaskRequest(toolName string, toolInput map[string]any) bool { + if !isGortexMCPToolName(toolName) || shortGortexToolName(toolName) != "explore" { + return false + } + operation, _ := toolInput["operation"].(string) + if operation != "localize" { + return false + } + options, ok := toolInput["options"].(map[string]any) + if !ok { + return false + } + newUserTask, _ := options["new_user_task"].(bool) + return newUserTask +} + func localizationTerminalAdditionalContext(receipt localizationauth.Receipt) string { return localizationTerminalContext + "\n\n" + receipt.FinalResponse } @@ -427,7 +466,19 @@ func localizationTerminalMarkerPath(identity localizationTerminalIdentity) strin return filepath.Join(agentDir, "markers", digest+".json") } +func localizationTerminalAdvisoryMarkerPath(identity localizationTerminalIdentity) string { + path := localizationTerminalMarkerPath(identity) + if path == "" { + return "" + } + return strings.TrimSuffix(path, ".json") + ".advisory.json" +} + func beginLocalizationTurn(sessionID, promptID, agentID, cwd string) (localizationTerminalIdentity, bool) { + return beginLocalizationTurnWithProblem(sessionID, promptID, agentID, cwd, "") +} + +func beginLocalizationTurnWithProblem(sessionID, promptID, agentID, cwd, problemStatement string) (localizationTerminalIdentity, bool) { base, ok := localizationTerminalBaseFor(sessionID, agentID, cwd) if !ok { return localizationTerminalIdentity{}, false @@ -444,9 +495,10 @@ func beginLocalizationTurn(sessionID, promptID, agentID, cwd string) (localizati TurnToken: hex.EncodeToString(tokenBytes[:]), } state := localizationTurnState{ - Version: localizationTerminalMarkerVersion, - Identity: identity, - CreatedUnixNano: time.Now().UnixNano(), + Version: localizationTerminalMarkerVersion, + Identity: identity, + ProblemStatement: problemStatement, + CreatedUnixNano: time.Now().UnixNano(), } if !writeLocalizationState(localizationTurnPath(base), state) { return localizationTerminalIdentity{}, false @@ -456,23 +508,49 @@ func beginLocalizationTurn(sessionID, promptID, agentID, cwd string) (localizati } func currentLocalizationTurn(sessionID, promptID, agentID, cwd string) (localizationTerminalIdentity, bool) { - base, ok := localizationTerminalBaseFor(sessionID, agentID, cwd) + state, ok := currentLocalizationTurnState(sessionID, promptID, agentID, cwd) if !ok { return localizationTerminalIdentity{}, false } + return state.Identity, true +} + +func currentLocalizationTurnState(sessionID, promptID, agentID, cwd string) (localizationTurnState, bool) { + base, ok := localizationTerminalBaseFor(sessionID, agentID, cwd) + if !ok { + return localizationTurnState{}, false + } var state localizationTurnState path := localizationTurnPath(base) if !readLocalizationState(path, &state) || !freshLocalizationTimestamp(path, state.CreatedUnixNano) || state.Version != localizationTerminalMarkerVersion || state.Identity.SessionID != base.SessionID || state.Identity.AgentID != base.AgentID || state.Identity.CWD != base.CWD || strings.TrimSpace(state.Identity.TurnToken) == "" { - return localizationTerminalIdentity{}, false + return localizationTurnState{}, false } promptID = strings.TrimSpace(promptID) if promptID != "" && promptID != state.Identity.PromptID { - return localizationTerminalIdentity{}, false + return localizationTurnState{}, false } - return state.Identity, true + return state, true +} + +func clearLocalizationProblemStatement(identity localizationTerminalIdentity) bool { + state, ok := currentLocalizationTurnState(identity.SessionID, identity.PromptID, identity.AgentID, identity.CWD) + if !ok || state.Identity != identity || state.ProblemStatement == "" { + return false + } + state.ProblemStatement = "" + base, ok := localizationTerminalBaseFor(identity.SessionID, identity.AgentID, identity.CWD) + return ok && writeLocalizationState(localizationTurnPath(base), state) +} + +func clearLocalizationProblemStatementForTurn(sessionID, promptID, agentID, cwd string) bool { + state, ok := currentLocalizationTurnState(sessionID, promptID, agentID, cwd) + if !ok { + return false + } + return clearLocalizationProblemStatement(state.Identity) } func snapshotLocalizationToolUse(input HookInput, identity localizationTerminalIdentity) bool { @@ -534,7 +612,20 @@ func consumeLocalizationToolSnapshot(input localizationTerminalHookInput) (local return snapshot.Identity, snapshot.AuthToken, true } +func localizationTerminalIdentityCurrent(identity localizationTerminalIdentity) bool { + state, ok := currentLocalizationTurnState(identity.SessionID, identity.PromptID, identity.AgentID, identity.CWD) + return ok && state.Identity == identity +} + func markLocalizationTerminal(identity localizationTerminalIdentity, contractVersion int) bool { + return markLocalizationTerminalWithStrength(identity, contractVersion, false) +} + +func markLocalizationTerminalReceipt(identity localizationTerminalIdentity, contractVersion int, enforceable bool) bool { + return markLocalizationTerminalWithStrength(identity, contractVersion, !enforceable) +} + +func markLocalizationTerminalWithStrength(identity localizationTerminalIdentity, contractVersion int, advisory bool) bool { if identity.SessionID == "" || identity.CWD == "" || identity.TurnToken == "" || contractVersion < localizationTerminalContractV2 { return false @@ -544,19 +635,38 @@ func markLocalizationTerminal(identity localizationTerminalIdentity, contractVer ContractVersion: contractVersion, Identity: identity, ObservedUnixNano: time.Now().UnixNano(), + Advisory: advisory, + } + path := localizationTerminalMarkerPath(identity) + if advisory { + path = localizationTerminalAdvisoryMarkerPath(identity) } - return writeBoundedLocalizationState(localizationTerminalMarkerPath(identity), marker) + return writeBoundedLocalizationState(path, marker) } -func hasLocalizationTerminal(identity localizationTerminalIdentity) bool { +func localizationTerminalMarkerFor(identity localizationTerminalIdentity) (localizationTerminalMarker, bool) { + if marker, ok := readLocalizationTerminalMarker(localizationTerminalMarkerPath(identity), identity); ok && !marker.Advisory { + return marker, true + } + if marker, ok := readLocalizationTerminalMarker(localizationTerminalAdvisoryMarkerPath(identity), identity); ok && marker.Advisory { + return marker, true + } + return localizationTerminalMarker{}, false +} + +func readLocalizationTerminalMarker(path string, identity localizationTerminalIdentity) (localizationTerminalMarker, bool) { var marker localizationTerminalMarker - path := localizationTerminalMarkerPath(identity) if !readLocalizationState(path, &marker) || !freshLocalizationTimestamp(path, marker.ObservedUnixNano) || marker.Version != localizationTerminalMarkerVersion || marker.ContractVersion < localizationTerminalContractV2 || marker.Identity != identity { - return false + return localizationTerminalMarker{}, false } - return true + return marker, true +} + +func hasLocalizationTerminal(identity localizationTerminalIdentity) bool { + marker, ok := localizationTerminalMarkerFor(identity) + return ok && !marker.Advisory } func writeLocalizationState(path string, value any) bool { @@ -778,7 +888,8 @@ func clearLocalizationTerminalFromHook(data []byte) bool { return false } removed, _ := discardLocalizationStateTree(localizationAgentStateDir(base)) - _, _ = beginLocalizationTurn(input.SessionID, input.PromptID, input.AgentID, input.CWD) + problemStatement, _ := framedLocalizationProblemStatement(input.Prompt) + _, _ = beginLocalizationTurnWithProblem(input.SessionID, input.PromptID, input.AgentID, input.CWD, problemStatement) return removed case "SessionStart": path := localizationAgentStateDir(base) diff --git a/internal/hooks/localization_terminal_receipt_test.go b/internal/hooks/localization_terminal_receipt_test.go index bef52bf0..798b533d 100644 --- a/internal/hooks/localization_terminal_receipt_test.go +++ b/internal/hooks/localization_terminal_receipt_test.go @@ -2,6 +2,7 @@ package hooks import ( "encoding/json" + "os" "strings" "sync" "sync/atomic" @@ -62,13 +63,201 @@ func TestLocalizationReceiptSurvivesStrippedClaudeWire(t *testing.T) { if gotContext != wantContext { t.Fatalf("additionalContext changed final_response bytes\n got: %q\nwant: %q", gotContext, wantContext) } + for _, required := range []string{ + "Localization for this task is complete", + "completion.final_response", + "do not call another tool", + } { + if !strings.Contains(gotContext, required) { + t.Fatalf("additionalContext %q does not contain %q", gotContext, required) + } + } + if got := hasLocalizationTerminal(identity); got != tt.enforceable { + t.Fatalf("hard marker = %v, want %v", got, tt.enforceable) + } + }) + } +} + +func TestLocalizationReceiptMarkerStrengthControlsPreToolUse(t *testing.T) { + for _, tt := range []struct { + name string + enforceable bool + }{ + {name: "advisory"}, + {name: "enforceable", enforceable: true}, + } { + t.Run(tt.name, func(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir()) + tool := gortexMCPToolPrefix + "explore" + token := captureTerminalAuthToken(t, identity, tool, "terminal-tool", map[string]any{"operation": "localize"}) + if !localizationauth.Publish(token, localizationauth.Receipt{ + FinalResponse: "FILES:\n#1 storage.go\n\nSYMBOLS:\n#1 storage.go::Load\n\nEVIDENCE:\n#1 storage.go:7 — Load\n", + ContractVersion: localizationTerminalContractV2, + Enforceable: tt.enforceable, + }) { + t.Fatal("server receipt publish failed") + } + post := localizationPostToolPayload(t, tool, "terminal-tool", identity, strippedToolResponse()) + if output := captureHookStdout(t, func() { runPostToolUse(post) }); output == "" { + t.Fatal("authenticated answer_ready did not emit terminal context") + } + + marker, marked := localizationTerminalMarkerFor(identity) + if !marked { + t.Fatal("authenticated answer_ready did not persist a marker") + } + if marker.Advisory != !tt.enforceable { + t.Fatalf("marker advisory = %v, want %v", marker.Advisory, !tt.enforceable) + } if got := hasLocalizationTerminal(identity); got != tt.enforceable { t.Fatalf("hard marker = %v, want %v", got, tt.enforceable) } + + checks := []struct { + name string + tool string + input map[string]any + navigation bool + }{ + {name: "direct navigation", tool: gortexMCPToolPrefix + "read", input: map[string]any{"operation": "source"}, navigation: true}, + {name: "plugin navigation", tool: gortexPluginMCPToolPrefix + "search", input: map[string]any{"operation": "symbols"}, navigation: true}, + {name: "host read", tool: "Read", input: map[string]any{"file_path": "README.md"}}, + {name: "host web search", tool: "WebSearch", input: map[string]any{"query": "storage"}}, + {name: "direct non-navigation Gortex", tool: gortexMCPToolPrefix + "workspace", input: map[string]any{"operation": "info"}}, + {name: "plugin non-navigation Gortex", tool: gortexPluginMCPToolPrefix + "change", input: map[string]any{"operation": "impact"}}, + } + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + pre := preToolPayload(t, check.tool, "next-tool", identity, check.input) + encoded := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeEnrich) }) + var output HookOutput + if encoded != "" { + if err := json.Unmarshal([]byte(encoded), &output); err != nil { + t.Fatalf("decode PreToolUse output %q: %v", encoded, err) + } + } + wantDenied := tt.enforceable || check.navigation + if !wantDenied { + if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" { + t.Fatalf("advisory marker denied pass-through tool: %#v", output.HookSpecificOutput) + } + return + } + if output.HookSpecificOutput == nil || output.HookSpecificOutput.PermissionDecision != "deny" { + t.Fatalf("terminal marker did not deny tool: %#v", output) + } + wantReason := localizationAdvisoryDenyReason + if tt.enforceable { + wantReason = localizationTerminalDenyReason + } + if got := output.HookSpecificOutput.PermissionDecisionReason; got != wantReason { + t.Fatalf("terminal deny reason = %q, want %q", got, wantReason) + } + }) + } }) } } +func TestLocalizationTerminalMarkerStrengthIsMonotonic(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, "monotonic-strength", "prompt", t.TempDir()) + hardTool := gortexMCPToolPrefix + "read" + advisoryTool := gortexPluginMCPToolPrefix + "search" + hardToken := captureTerminalAuthToken(t, identity, hardTool, "hard-tool", map[string]any{"operation": "source"}) + advisoryToken := captureTerminalAuthToken(t, identity, advisoryTool, "advisory-tool", map[string]any{"operation": "symbols"}) + publishTestTerminalReceipt(t, hardToken, true) + publishTestTerminalReceipt(t, advisoryToken, false) + + hardPost := localizationPostToolPayload(t, hardTool, "hard-tool", identity, strippedToolResponse()) + if _, observed := observeLocalizationTerminal(hardPost); !observed { + t.Fatal("hard receipt was not observed") + } + advisoryPost := localizationPostToolPayload(t, advisoryTool, "advisory-tool", identity, strippedToolResponse()) + if _, observed := observeLocalizationTerminal(advisoryPost); !observed { + t.Fatal("delayed advisory receipt was not observed") + } + + marker, marked := localizationTerminalMarkerFor(identity) + if !marked || marker.Advisory || !hasLocalizationTerminal(identity) { + t.Fatalf("advisory receipt downgraded hard terminal marker: marker=%#v marked=%v", marker, marked) + } + raw, err := os.ReadFile(localizationTerminalMarkerPath(identity)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), `"advisory"`) { + t.Fatalf("legacy-compatible hard marker unexpectedly encoded advisory field: %s", raw) + } + for _, tool := range []string{"Read", "WebSearch"} { + encoded := captureHookStdout(t, func() { + runPreToolUse(preToolPayload(t, tool, "", identity, map[string]any{"file_path": "README.md"}), 0, ModeEnrich) + }) + var output HookOutput + if err := json.Unmarshal([]byte(encoded), &output); err != nil || output.HookSpecificOutput == nil { + t.Fatalf("invalid hard terminal deny for %s: %v\n%s", tool, err, encoded) + } + if output.HookSpecificOutput.PermissionDecision != "deny" || + output.HookSpecificOutput.PermissionDecisionReason != localizationTerminalDenyReason { + t.Fatalf("hard marker did not remain enforceable for %s: %#v", tool, output.HookSpecificOutput) + } + } +} + +func TestLocalizationAdvisoryMarkerRotatesAndDelayedPostCannotPoisonNewTurn(t *testing.T) { + configureLocalizationTerminalTestHome(t) + cwd := t.TempDir() + oldTurn := beginTestLocalizationTurn(t, "advisory-rotation", "old-prompt", cwd) + tool := gortexMCPToolPrefix + "explore" + loadedToken := captureTerminalAuthToken(t, oldTurn, tool, "loaded-old-tool", map[string]any{"operation": "localize"}) + pendingToken := captureTerminalAuthToken(t, oldTurn, tool, "pending-old-tool", map[string]any{"operation": "localize"}) + publishTestTerminalReceipt(t, loadedToken, false) + publishTestTerminalReceipt(t, pendingToken, false) + + loadedPost := localizationPostToolPayload(t, tool, "loaded-old-tool", oldTurn, strippedToolResponse()) + terminal, observed := observeLocalizationTerminal(loadedPost) + if !observed { + t.Fatal("loaded old-turn advisory receipt was not observed") + } + reset := mustJSON(t, map[string]any{ + "hook_event_name": "UserPromptSubmit", + "session_id": oldTurn.SessionID, + "prompt_id": "new-prompt", + "cwd": oldTurn.CWD, + }) + _ = clearLocalizationTerminalFromHook(reset) + newTurn := currentTestLocalizationTurn(t, oldTurn.SessionID, "new-prompt", "", oldTurn.CWD) + + emitted := true + if output := captureHookStdout(t, func() { emitted = emitLocalizationTerminalContext(terminal) }); output != "" || emitted { + t.Fatalf("loaded delayed PostToolUse emitted stale old-turn context: emitted=%v output=%q", emitted, output) + } + pendingPost := localizationPostToolPayload(t, tool, "pending-old-tool", oldTurn, strippedToolResponse()) + if _, observed := observeLocalizationTerminal(pendingPost); observed { + t.Fatal("pending pre-rotation advisory receipt armed the next turn") + } + if !markLocalizationTerminalReceipt(oldTurn, localizationTerminalContractV2, false) { + t.Fatal("simulate delayed advisory marker write") + } + if _, marked := localizationTerminalMarkerFor(newTurn); marked { + t.Fatal("old-turn advisory marker poisoned the new turn") + } + encoded := captureHookStdout(t, func() { + runPreToolUse(preToolPayload(t, gortexMCPToolPrefix+"search", "new-tool", newTurn, map[string]any{"operation": "symbols"}), 0, ModeEnrich) + }) + var output HookOutput + if encoded != "" { + if err := json.Unmarshal([]byte(encoded), &output); err != nil { + t.Fatalf("decode new-turn PreToolUse output %q: %v", encoded, err) + } + } + if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" { + t.Fatalf("new turn inherited delayed advisory deny: %#v", output.HookSpecificOutput) + } +} + func TestLocalizationAuthPreservesPreToolUsePolicyBranches(t *testing.T) { for _, tt := range []struct { name string @@ -82,8 +271,16 @@ func TestLocalizationAuthPreservesPreToolUsePolicyBranches(t *testing.T) { } { t.Run(tt.name, func(t *testing.T) { configureLocalizationTerminalTestHome(t) - identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir()) - input := map[string]any{"operation": "localize", "task": "literal symptom"} + problemStatement := "Title: Neutral storage failure\n\nDiskStorage.load returns an empty value." + identity, ok := beginLocalizationTurnWithProblem(t.Name(), "prompt", "", t.TempDir(), problemStatement) + if !ok { + t.Fatal("beginLocalizationTurnWithProblem failed") + } + input := map[string]any{ + "operation": "localize", + "task": "literal symptom", + "options": map[string]any{"new_user_task": true, "keep": "value"}, + } pre := mustJSON(t, map[string]any{ "hook_event_name": "PreToolUse", "tool_name": gortexMCPToolPrefix + "explore", @@ -106,6 +303,15 @@ func TestLocalizationAuthPreservesPreToolUsePolicyBranches(t *testing.T) { if _, ok := hso.UpdatedInput[localizationauth.ArgumentKey].(string); !ok { t.Fatalf("policy branch lost terminal auth input: %#v", hso) } + if got := hso.UpdatedInput["task"]; got != problemStatement { + t.Fatalf("rewritten task = %#v, want %q", got, problemStatement) + } + if !equalJSONValue(hso.UpdatedInput["options"], input["options"]) || hso.UpdatedInput["operation"] != "localize" { + t.Fatalf("rewrite changed non-task fields: %#v", hso.UpdatedInput) + } + if input["task"] != "literal symptom" { + t.Fatalf("rewrite mutated original input: %#v", input) + } if got := loadSessionState(identity.SessionID).GraphConsulted; got != tt.wantConsulted { t.Fatalf("GraphConsulted = %v, want %v", got, tt.wantConsulted) } @@ -126,8 +332,8 @@ func TestLocalizationReceiptRejectsForgedVisibleTerminalPayload(t *testing.T) { if output := captureHookStdout(t, func() { runPostToolUse(post) }); output != "" { t.Fatalf("forged visible payload emitted terminal context: %s", output) } - if hasLocalizationTerminal(identity) { - t.Fatal("forged visible payload armed hard terminal state") + if _, marked := localizationTerminalMarkerFor(identity); marked { + t.Fatal("forged visible payload armed terminal state") } } @@ -231,13 +437,372 @@ func TestLocalizationReceiptConcurrentPostHasSingleWinner(t *testing.T) { } } -func TestSessionStartNamesMountedExploreAndFaithfulSymptomTask(t *testing.T) { +func TestLocalizationProblemRewriteDirectAndPluginIsIdempotent(t *testing.T) { + for _, tt := range []struct { + name string + tool string + }{ + {name: "direct", tool: gortexMCPToolPrefix + "explore"}, + {name: "plugin", tool: gortexPluginMCPToolPrefix + "explore"}, + } { + t.Run(tt.name, func(t *testing.T) { + configureLocalizationTerminalTestHome(t) + cwd := t.TempDir() + problemStatement := "Title: Neutral storage failure\n\nStorage.load returns an empty value at internal/storage/load.go:42." + prompt := "runner-only metadata\n--- ISSUE --------------------------------\n\n" + problemStatement + + "\n\n------------------------------------------------\nFILES / SYMBOLS / EVIDENCE only" + _ = clearLocalizationTerminalFromHook(mustJSON(t, map[string]any{ + "hook_event_name": "UserPromptSubmit", + "session_id": "rewrite-" + tt.name, + "prompt_id": "prompt", + "cwd": cwd, + "prompt": prompt, + })) + state, ok := currentLocalizationTurnState("rewrite-"+tt.name, "prompt", "", cwd) + if !ok { + t.Fatal("framed prompt did not initialize turn state") + } + if state.ProblemStatement != problemStatement { + t.Fatalf("stored problem = %q, want %q", state.ProblemStatement, problemStatement) + } + + base, ok := localizationTerminalBaseFor(state.Identity.SessionID, "", cwd) + if !ok { + t.Fatal("localizationTerminalBaseFor failed") + } + statePath := localizationTurnPath(base) + info, err := os.Stat(statePath) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("turn state mode = %o, want 600", got) + } + rawState, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rawState), "runner-only metadata") || strings.Contains(string(rawState), "FILES / SYMBOLS / EVIDENCE only") { + t.Fatalf("turn state retained prompt material outside the framed problem: %s", rawState) + } + + input := map[string]any{ + "operation": "localize", + "task": "model paraphrase", + "options": map[string]any{"new_user_task": true, "keep": "value"}, + "output": map[string]any{"format": "gcx"}, + "custom": "preserved", + } + for _, toolUseID := range []string{"tool-use-1", "tool-use-2"} { + output := captureHookStdout(t, func() { + runPreToolUse(preToolPayload(t, tt.tool, toolUseID, state.Identity, input), 0, ModeDeny) + }) + var decoded HookOutput + if err := json.Unmarshal([]byte(output), &decoded); err != nil || decoded.HookSpecificOutput == nil { + t.Fatalf("invalid PreToolUse output: %v\n%s", err, output) + } + updated := decoded.HookSpecificOutput.UpdatedInput + if updated["task"] != problemStatement { + t.Fatalf("rewritten task = %#v, want %q", updated["task"], problemStatement) + } + if _, ok := updated[localizationauth.ArgumentKey].(string); !ok { + t.Fatalf("rewrite lost localization auth: %#v", updated) + } + for _, key := range []string{"operation", "options", "output", "custom"} { + if !equalJSONValue(updated[key], input[key]) { + t.Fatalf("rewrite changed %q: got %#v want %#v", key, updated[key], input[key]) + } + } + if input["task"] != "model paraphrase" { + t.Fatalf("rewrite mutated original input: %#v", input) + } + } + + post := mustJSON(t, map[string]any{ + "hook_event_name": "PostToolUse", + "tool_name": tt.tool, + "tool_use_id": "tool-use-1", + "tool_input": input, + "session_id": state.Identity.SessionID, + "prompt_id": state.Identity.PromptID, + "cwd": state.Identity.CWD, + "tool_response": strippedToolResponse(), + }) + if output := captureHookStdout(t, func() { runPostToolUse(post) }); output != "" { + t.Fatalf("non-terminal localization PostToolUse emitted %q", output) + } + cleared, ok := currentLocalizationTurnState("rewrite-"+tt.name, "prompt", "", cwd) + if !ok || cleared.ProblemStatement != "" { + t.Fatalf("matching PostToolUse did not clear problem statement: %#v, ok=%v", cleared, ok) + } + rawState, err = os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rawState), problemStatement) { + t.Fatalf("turn state retained the executed private problem: %s", rawState) + } + + afterOutput := captureHookStdout(t, func() { + runPreToolUse(preToolPayload(t, tt.tool, "tool-use-after", state.Identity, input), 0, ModeDeny) + }) + var afterDecoded HookOutput + if err := json.Unmarshal([]byte(afterOutput), &afterDecoded); err != nil || afterDecoded.HookSpecificOutput == nil { + t.Fatalf("invalid post-clear PreToolUse output: %v\n%s", err, afterOutput) + } + if got := afterDecoded.HookSpecificOutput.UpdatedInput["task"]; got != "model paraphrase" { + t.Fatalf("cleared problem was restored again: task=%#v", got) + } + + if !markLocalizationTerminal(state.Identity, localizationTerminalContractV2) { + t.Fatal("markLocalizationTerminal failed") + } + terminalOutput := captureHookStdout(t, func() { + runPreToolUse(preToolPayload(t, tt.tool, "tool-use-terminal", state.Identity, input), 0, ModeDeny) + }) + var terminalDecoded HookOutput + if err := json.Unmarshal([]byte(terminalOutput), &terminalDecoded); err != nil || terminalDecoded.HookSpecificOutput == nil { + t.Fatalf("invalid terminal PreToolUse output: %v\n%s", err, terminalOutput) + } + if terminalDecoded.HookSpecificOutput.PermissionDecision != "deny" || terminalDecoded.HookSpecificOutput.UpdatedInput != nil { + t.Fatalf("terminal deny must precede task rewrite: %#v", terminalDecoded.HookSpecificOutput) + } + }) + } +} + +func TestLocalizationProblemStateRotatesClearsAndIsolatesSubagents(t *testing.T) { + configureLocalizationTerminalTestHome(t) + cwd := t.TempDir() + sessionID := "problem-lifecycle" + problemStatement := "Title: Neutral incident\n\nCloudStorage.load returns stale data." + _ = clearLocalizationTerminalFromHook(mustJSON(t, map[string]any{ + "hook_event_name": "UserPromptSubmit", + "session_id": sessionID, + "prompt_id": "prompt-1", + "cwd": cwd, + "prompt": "--- INCIDENT ---\n" + problemStatement + "\n----------------", + })) + first, ok := currentLocalizationTurnState(sessionID, "prompt-1", "", cwd) + if !ok || first.ProblemStatement != problemStatement { + t.Fatalf("initial problem state = %#v, ok=%v", first, ok) + } + + runSubagentStart(subagentLifecyclePayload(t, "SubagentStart", sessionID, "agent", cwd)) + subagent, ok := currentLocalizationTurnState(sessionID, "", "agent", cwd) + if !ok { + t.Fatal("subagent turn state missing") + } + if subagent.ProblemStatement != "" { + t.Fatalf("parent problem leaked into subagent state: %#v", subagent) + } + + _ = clearLocalizationTerminalFromHook(mustJSON(t, map[string]any{ + "hook_event_name": "UserPromptSubmit", + "session_id": sessionID, + "prompt_id": "prompt-2", + "cwd": cwd, + "prompt": "unframed private follow-up", + })) + second, ok := currentLocalizationTurnState(sessionID, "prompt-2", "", cwd) + if !ok { + t.Fatal("new prompt did not rotate turn state") + } + if second.ProblemStatement != "" { + t.Fatalf("unframed prompt used a raw fallback: %#v", second) + } + if second.Identity.TurnToken == first.Identity.TurnToken { + t.Fatal("new prompt reused prior turn token") + } + base, _ := localizationTerminalBaseFor(sessionID, "", cwd) + rawState, err := os.ReadFile(localizationTurnPath(base)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rawState), "unframed private follow-up") || strings.Contains(string(rawState), problemStatement) { + t.Fatalf("rotated state retained prompt content: %s", rawState) + } + + _ = clearLocalizationTerminalFromHook(mustJSON(t, map[string]any{ + "hook_event_name": "SessionStart", + "session_id": sessionID, + "cwd": cwd, + })) + if _, ok := currentLocalizationTurnState(sessionID, "", "", cwd); ok { + t.Fatal("SessionStart did not clear problem turn state") + } +} + +func TestStopClearsLocalizationProblemStatement(t *testing.T) { + configureLocalizationTerminalTestHome(t) + cwd := t.TempDir() + problemStatement := "Title: Private storage incident\n\nSecretStorage.load returned tenant-specific data." + identity, ok := beginLocalizationTurnWithProblem("problem-stop", "prompt", "", cwd, problemStatement) + if !ok { + t.Fatal("beginLocalizationTurnWithProblem failed") + } + + runPostTask(mustJSON(t, map[string]any{ + "hook_event_name": "Stop", + "session_id": identity.SessionID, + "prompt_id": identity.PromptID, + "cwd": identity.CWD, + "stop_hook_active": true, + }), 0) + + state, ok := currentLocalizationTurnState(identity.SessionID, identity.PromptID, "", identity.CWD) + if !ok || state.ProblemStatement != "" { + t.Fatalf("Stop did not clear problem statement: %#v, ok=%v", state, ok) + } + base, _ := localizationTerminalBaseFor(identity.SessionID, "", identity.CWD) + rawState, err := os.ReadFile(localizationTurnPath(base)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rawState), problemStatement) { + t.Fatalf("Stop retained private problem bytes: %s", rawState) + } +} + +func TestLocalizationProblemTaskRewriteFailsOpen(t *testing.T) { + validInput := func() HookInput { + return HookInput{ + ToolName: gortexMCPToolPrefix + "explore", + ToolInput: map[string]any{ + "operation": "localize", + "task": "model wording", + "options": map[string]any{"new_user_task": true}, + }, + } + } + problemStatement := "Title: Neutral issue\n\nStorage.load returns no value." + tests := []struct { + name string + problem string + alter func(*HookInput) + }{ + {name: "no extracted problem", problem: ""}, + {name: "wrong tool", problem: problemStatement, alter: func(input *HookInput) { input.ToolName = gortexMCPToolPrefix + "read" }}, + {name: "wrong operation", problem: problemStatement, alter: func(input *HookInput) { input.ToolInput["operation"] = "task" }}, + {name: "new user task false", problem: problemStatement, alter: func(input *HookInput) { input.ToolInput["options"] = map[string]any{"new_user_task": false} }}, + {name: "new user task wrong type", problem: problemStatement, alter: func(input *HookInput) { input.ToolInput["options"] = map[string]any{"new_user_task": "true"} }}, + {name: "missing options", problem: problemStatement, alter: func(input *HookInput) { delete(input.ToolInput, "options") }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := validInput() + if tt.alter != nil { + tt.alter(&input) + } + if updated := localizationPreToolUpdatedInput(input, "", tt.problem); updated != nil { + t.Fatalf("ineligible rewrite did not fail open: %#v", updated) + } + updated := localizationPreToolUpdatedInput(input, "opaque-auth", tt.problem) + if updated["task"] != "model wording" || input.ToolInput["task"] != "model wording" { + t.Fatalf("ineligible rewrite changed task: updated=%#v original=%#v", updated, input.ToolInput) + } + if updated[localizationauth.ArgumentKey] != "opaque-auth" { + t.Fatalf("auth injection lost on fail-open path: %#v", updated) + } + }) + } +} + +func TestLocalizationProblemTaskRewriteRequiresClearlyLossyModelTask(t *testing.T) { + problemStatement := "abcdefgh" + tests := []struct { + name string + task string + rewrite bool + }{ + {name: "empty", task: "", rewrite: true}, + {name: "below half", task: "abc", rewrite: true}, + {name: "trimmed below half", task: " abc\n", rewrite: true}, + {name: "exact half", task: "abcd"}, + {name: "trimmed exact half", task: " \tabcd\n"}, + {name: "above half", task: "abcde"}, + } + for _, tool := range []string{gortexMCPToolPrefix + "explore", gortexPluginMCPToolPrefix + "explore"} { + for _, tt := range tests { + t.Run(tool+"/"+tt.name, func(t *testing.T) { + input := HookInput{ + ToolName: tool, + ToolInput: map[string]any{ + "operation": "localize", + "task": tt.task, + "options": map[string]any{"new_user_task": true}, + }, + } + wantTask := tt.task + if tt.rewrite { + wantTask = problemStatement + } + for range 2 { + updated := localizationPreToolUpdatedInput(input, "opaque-auth", problemStatement) + if updated["task"] != wantTask || updated[localizationauth.ArgumentKey] != "opaque-auth" { + t.Fatalf("updated input = %#v, want task %q with auth", updated, wantTask) + } + if input.ToolInput["task"] != tt.task { + t.Fatalf("rewrite mutated original model task: %#v", input.ToolInput) + } + } + if got := localizationProblemTaskRewrite(input, problemStatement); got != tt.rewrite { + t.Fatalf("rewrite decision = %v, want %v", got, tt.rewrite) + } + }) + } + } +} + +func TestLocalizationProblemTaskRewriteConcurrent(t *testing.T) { + input := HookInput{ + ToolName: gortexMCPToolPrefix + "explore", + ToolInput: map[string]any{ + "operation": "localize", + "task": "model wording", + "options": map[string]any{"new_user_task": true, "keep": true}, + }, + } + problemStatement := "Title: Concurrent issue\n\nStorage.load returns no value." + failed := make(chan struct{}, 32) + var wg sync.WaitGroup + for range 32 { + wg.Add(1) + go func() { + defer wg.Done() + for range 100 { + updated := localizationPreToolUpdatedInput(input, "opaque-auth", problemStatement) + if updated["task"] != problemStatement || updated[localizationauth.ArgumentKey] != "opaque-auth" || input.ToolInput["task"] != "model wording" { + failed <- struct{}{} + return + } + } + }() + } + wg.Wait() + close(failed) + if len(failed) != 0 { + t.Fatal("concurrent task rewrite was not deterministic") + } +} + +func TestSessionStartNamesMountedExploreAndPreservesCompleteTask(t *testing.T) { briefing := rulePreamble() for _, required := range []string{ "`mcp__gortex__explore` (never a bare `explore`)", - "faithful symptom-only restatement", - "exact technical identifiers, paths, literals, error text, and observed symptoms", - "add no invented causal hypothesis", + "localize task may be concise", + "faithfully preserve the issue title", + "every user-supplied technical identifier, path, literal, error, symptom, and stated hypothesis", + "never invent a causal hypothesis", + "clearly framed problem section may be restored exactly at execution", + "only for a clearly lossy model task", + "evidence can reflect details beyond a concise tool argument", + "For `needs_recovery`, make one accepted, bounded Gortex MCP `search` or `read` call", + "preserves the recovery allowance", + "the rejected request does not count as the accepted recovery", + "Do not call host Read, Grep, Glob, or Bash", + "intentionally not executed and replays the same retained terminal payload", + "not stale or canned output or an integration failure", } { if !strings.Contains(briefing, required) { t.Fatalf("SessionStart rule is missing %q:\n%s", required, briefing) diff --git a/internal/hooks/localization_terminal_test.go b/internal/hooks/localization_terminal_test.go index b238f9f5..490aa642 100644 --- a/internal/hooks/localization_terminal_test.go +++ b/internal/hooks/localization_terminal_test.go @@ -157,6 +157,15 @@ func TestPostToolUseObservesMatchingFinalResponseContract(t *testing.T) { if !strings.Contains(output, localizationTerminalContext) { t.Fatalf("PostToolUse output %q does not contain terminal context", output) } + for _, required := range []string{ + "Localization for this task is complete", + "completion.final_response", + "do not call another tool", + } { + if !strings.Contains(output, required) { + t.Fatalf("PostToolUse output %q does not contain %q", output, required) + } + } } func TestPostToolUseAnswerReadyEventAuthenticationAndJSONShape(t *testing.T) { @@ -256,14 +265,38 @@ func TestLocalizationTerminalHookFlowDeniesThenPromptRotatesTurn(t *testing.T) { t.Fatalf("PostToolUse output %q does not contain fixed terminal context", postOutput) } - pre := preToolPayload(t, "WebSearch", "", identity, nil) - preOutput := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeDeny) }) - var output HookOutput - if err := json.Unmarshal([]byte(preOutput), &output); err != nil { - t.Fatalf("decode PreToolUse output %q: %v", preOutput, err) - } - if output.HookSpecificOutput == nil || output.HookSpecificOutput.PermissionDecision != "deny" { - t.Fatalf("expected all-tool terminal deny, got %#v", output) + for _, tt := range []struct { + name string + tool string + input map[string]any + }{ + {name: "web search", tool: "WebSearch"}, + {name: "host read", tool: "Read", input: map[string]any{"file_path": "repo/source.go"}}, + {name: "host grep", tool: "Grep", input: map[string]any{"pattern": "Target", "path": "repo"}}, + } { + t.Run(tt.name, func(t *testing.T) { + pre := preToolPayload(t, tt.tool, "", identity, tt.input) + preOutput := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeDeny) }) + var output HookOutput + if err := json.Unmarshal([]byte(preOutput), &output); err != nil { + t.Fatalf("decode PreToolUse output %q: %v", preOutput, err) + } + if output.HookSpecificOutput == nil || output.HookSpecificOutput.PermissionDecision != "deny" { + t.Fatalf("expected all-tool terminal deny, got %#v", output) + } + if got := output.HookSpecificOutput.PermissionDecisionReason; got != localizationTerminalDenyReason { + t.Fatalf("terminal deny reason = %q, want %q", got, localizationTerminalDenyReason) + } + for _, required := range []string{ + "Localization for this task is complete", + "retained evidence", + "do not call another tool", + } { + if !strings.Contains(output.HookSpecificOutput.PermissionDecisionReason, required) { + t.Fatalf("terminal deny reason %q does not contain %q", output.HookSpecificOutput.PermissionDecisionReason, required) + } + } + }) } beginTestLocalizationTurn(t, sessionID, "prompt-2", cwd) diff --git a/internal/hooks/posttask.go b/internal/hooks/posttask.go index e64b0f2b..9a5b6faa 100644 --- a/internal/hooks/posttask.go +++ b/internal/hooks/posttask.go @@ -14,6 +14,8 @@ import ( type PostTaskInput struct { HookEventName string `json:"hook_event_name"` SessionID string `json:"session_id"` + PromptID string `json:"prompt_id"` + AgentID string `json:"agent_id"` TranscriptPath string `json:"transcript_path"` CWD string `json:"cwd"` StopHookActive bool `json:"stop_hook_active"` @@ -35,6 +37,7 @@ func runPostTask(data []byte, port int) { if input.HookEventName != "Stop" { return } + _ = clearLocalizationProblemStatementForTurn(input.SessionID, input.PromptID, input.AgentID, input.CWD) emitted := false defer func() { logHookEffectiveness("Stop", emitted, daemonReachableFn(), 0, time.Since(started)) diff --git a/internal/hooks/posttooluse.go b/internal/hooks/posttooluse.go index 05d86c6a..47b06b35 100644 --- a/internal/hooks/posttooluse.go +++ b/internal/hooks/posttooluse.go @@ -65,8 +65,7 @@ func runPostToolUse(data []byte) { if terminal, observed := observeLocalizationTerminal(data); observed { terminalObserved = true - emitted = true - emitPostToolContext(localizationTerminalAdditionalContext(terminal.TerminalReceipt), false) + emitted = emitLocalizationTerminalContext(terminal) return } @@ -78,6 +77,14 @@ func runPostToolUse(data []byte) { emitPostToolContext(ctx, false) } +func emitLocalizationTerminalContext(terminal localizationTerminalHookInput) bool { + if !localizationTerminalIdentityCurrent(terminal.TerminalIdentity) { + return false + } + emitPostToolContext(localizationTerminalAdditionalContext(terminal.TerminalReceipt), false) + return true +} + func postToolContext(input postHookInput) string { switch input.ToolName { case "Grep": diff --git a/internal/hooks/pretooluse.go b/internal/hooks/pretooluse.go index 93433f3f..33007fb8 100644 --- a/internal/hooks/pretooluse.go +++ b/internal/hooks/pretooluse.go @@ -103,15 +103,27 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { // Terminal enforcement is deliberately the first policy branch. It is a // local marker lookup, so it neither waits for the daemon nor gets bypassed // by permissive permission modes. A new user prompt clears the marker. - terminalIdentity, terminalTurnReady := currentLocalizationTurn(input.SessionID, input.PromptID, input.AgentID, input.CWD) - if terminalTurnReady && hasLocalizationTerminal(terminalIdentity) { - emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ - HookEventName: "PreToolUse", - PermissionDecision: "deny", - PermissionDecisionReason: localizationTerminalDenyReason, - }}) - localizationTerminalTelemetry("denied", true, started) - return + terminalTurn, terminalTurnReady := currentLocalizationTurnState(input.SessionID, input.PromptID, input.AgentID, input.CWD) + terminalIdentity := terminalTurn.Identity + if terminalTurnReady { + if marker, marked := localizationTerminalMarkerFor(terminalIdentity); marked { + reason := "" + switch { + case !marker.Advisory: + reason = localizationTerminalDenyReason + case localizationNavigationTool(input.ToolName): + reason = localizationAdvisoryDenyReason + } + if reason != "" { + emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + PermissionDecision: "deny", + PermissionDecisionReason: reason, + }}) + localizationTerminalTelemetry("denied", true, started) + return + } + } } localizationAuthToken := "" if terminalTurnReady { @@ -124,6 +136,7 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { localizationAuthToken = authToken } } + updatedInput := localizationPreToolUpdatedInput(input, localizationAuthToken, terminalTurn.ProblemStatement) // The installed matcher is deliberately broad so terminal state can stop // any tool. With no marker, tools outside the historical access-policy @@ -159,8 +172,8 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { if adv := gortexReadNudge(input.ToolName, input.ToolInput); adv != "" { hso.AdditionalContext = adv } - if localizationAuthToken != "" { - hso.UpdatedInput = localizationAuthUpdatedInput(input.ToolInput, localizationAuthToken) + if updatedInput != nil { + hso.UpdatedInput = updatedInput } emitted = hso.AdditionalContext != "" || hso.PermissionDecisionReason != "" emitPreToolUse(HookOutput{HookSpecificOutput: hso}) @@ -173,10 +186,10 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { // call itself is a no-op pass-through — nothing to enrich. if mode == ModeConsultUnlock && isGortexMCP { markGraphConsulted(input.SessionID) - if localizationAuthToken != "" { + if updatedInput != nil { emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ HookEventName: "PreToolUse", - UpdatedInput: localizationAuthUpdatedInput(input.ToolInput, localizationAuthToken), + UpdatedInput: updatedInput, }}) } return @@ -185,10 +198,10 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { result := applyMode(input, isGortexMCP, mode, enrich(input, gortexPort)) if result.context == "" && !result.deny { - if localizationAuthToken != "" { + if updatedInput != nil { emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ HookEventName: "PreToolUse", - UpdatedInput: localizationAuthUpdatedInput(input.ToolInput, localizationAuthToken), + UpdatedInput: updatedInput, }}) } return @@ -199,8 +212,8 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { HookEventName: "PreToolUse", }, } - if localizationAuthToken != "" { - output.HookSpecificOutput.UpdatedInput = localizationAuthUpdatedInput(input.ToolInput, localizationAuthToken) + if updatedInput != nil { + output.HookSpecificOutput.UpdatedInput = updatedInput } if result.deny { diff --git a/internal/hooks/sessionstart.go b/internal/hooks/sessionstart.go index 4e53db72..bc8dd3be 100644 --- a/internal/hooks/sessionstart.go +++ b/internal/hooks/sessionstart.go @@ -295,11 +295,13 @@ func hasPathPrefix(path, prefix string) bool { func rulePreamble() string { return "**Rule:** For an explicitly named file that the user asks you to read, review, or summarize, call `read(operation:\"file\", target:{file:\"\"})` directly; do not start localization. " + "Otherwise choose by requested output: when the requested output is files, symbols, or supporting evidence, call the mounted tool `mcp__gortex__explore` (never a bare `explore`) with `operation:\"localize\"`; it returns terminal evidence. " + - "Its localize task must be a faithful symptom-only restatement: preserve the user's exact technical identifiers, paths, literals, error text, and observed symptoms, and add no invented causal hypothesis. " + + "Its localize task may be concise, but must faithfully preserve the issue title and every user-supplied technical identifier, path, literal, error, symptom, and stated hypothesis; never invent a causal hypothesis. " + + "A clearly framed problem section may be restored exactly at execution only for a clearly lossy model task, so evidence can reflect details beyond a concise tool argument. " + "Use `mcp__gortex__explore` with `operation:\"task\"` only when work will actually continue beyond localization into diagnosis, relationship analysis, or implementation. " + - "After starting localization, obey `completion.required_action` and make no further tool calls after `answer_ready`. " + + "After starting localization, obey `completion.required_action`. For `needs_recovery`, make one accepted, bounded Gortex MCP `search` or `read` call. If Gortex explicitly rejects an overbroad request and preserves the recovery allowance, correct it using only Gortex MCP `search` or `read`; the rejected request does not count as the accepted recovery. Do not call host Read, Grep, Glob, or Bash. At `answer_ready`, respond from `completion.final_response` and make no further tool calls. In that final response, each PRIMARY or SUPPORTING row is one aligned file/symbol tuple; preserve the PRIMARY file and symbol identities in the answer, while SUPPORTING rows are optional context. " + + "Any later Gortex navigation call for that same task is intentionally not executed and replays the same retained terminal payload; identical output is expected, not stale or canned output or an integration failure. " + "Inspect indexed code with `search`, `read`, `relations`, or `trace`. " + - "Use native read, search, or edit tools only when Gortex performance or integration is bad. " + + "Outside an active localization contract, use native read, search, or edit tools only when Gortex performance or integration is actually bad. " + "Before mutation call `change(operation:\"impact\")`; " + "for a signature change also call `change(operation:\"verify\")` with the proposed signature. " + "Mutate with `edit` or `refactor`. After mutation call `change(operation:\"detect\")`; " + diff --git a/internal/hooks/sessionstart_test.go b/internal/hooks/sessionstart_test.go index c9d9180a..01b4c60b 100644 --- a/internal/hooks/sessionstart_test.go +++ b/internal/hooks/sessionstart_test.go @@ -30,9 +30,24 @@ func TestRulePreambleRoutesByOutcomeAndPreservesExactIdentifiers(t *testing.T) { "For an explicitly named file", "choose by requested output", "requested output is files, symbols, or supporting evidence", - "preserve the user's exact technical identifiers, paths, literals, error text, and observed symptoms", + "localize task may be concise", + "faithfully preserve the issue title", + "every user-supplied technical identifier, path, literal, error, symptom, and stated hypothesis", + "never invent a causal hypothesis", + "clearly framed problem section may be restored exactly at execution", + "only for a clearly lossy model task", + "evidence can reflect details beyond a concise tool argument", "only when work will actually continue beyond localization into diagnosis, relationship analysis, or implementation", - "make no further tool calls after `answer_ready`", + "For `needs_recovery`, make one accepted, bounded Gortex MCP `search` or `read` call", + "preserves the recovery allowance", + "the rejected request does not count as the accepted recovery", + "Do not call host Read, Grep, Glob, or Bash", + "one aligned file/symbol tuple", + "preserve the PRIMARY file and symbol identities", + "SUPPORTING rows are optional context", + "intentionally not executed and replays the same retained terminal payload", + "not stale or canned output or an integration failure", + "Outside an active localization contract", } { if !strings.Contains(briefing, required) { t.Fatalf("rule preamble missing %q: %s", required, briefing) @@ -68,7 +83,26 @@ func TestRunSessionStartEmitsNeutralRoutingAndIdentifierGuidance(t *testing.T) { t.Fatalf("hook event = %q, want SessionStart", decoded.HookSpecificOutput.HookEventName) } context := decoded.HookSpecificOutput.AdditionalContext - for _, required := range []string{"choose by requested output", "preserve the user's exact technical identifiers", "after `answer_ready`"} { + for _, required := range []string{ + "choose by requested output", + "localize task may be concise", + "faithfully preserve the issue title", + "clearly framed problem section may be restored exactly at execution", + "only for a clearly lossy model task", + "evidence can reflect details beyond a concise tool argument", + "For `needs_recovery`", + "one accepted, bounded Gortex MCP `search` or `read` call", + "preserves the recovery allowance", + "the rejected request does not count as the accepted recovery", + "Do not call host Read, Grep, Glob, or Bash", + "At `answer_ready`", + "respond from `completion.final_response`", + "one aligned file/symbol tuple", + "preserve the PRIMARY file and symbol identities", + "SUPPORTING rows are optional context", + "intentionally not executed and replays the same retained terminal payload", + "not stale or canned output or an integration failure", + } { if !strings.Contains(context, required) { t.Fatalf("SessionStart event missing %q: %s", required, context) } diff --git a/internal/hooks/userpromptsubmit.go b/internal/hooks/userpromptsubmit.go index fb90235a..1ec0723a 100644 --- a/internal/hooks/userpromptsubmit.go +++ b/internal/hooks/userpromptsubmit.go @@ -27,6 +27,132 @@ const userPromptProbeTimeout = 800 * time.Millisecond // block stays a nudge, not a wall of text. const maxInjectedHits = 6 +// localizationProblemStatementMaxBytes bounds persisted prompt material. The +// framing protocol is fail-open: prompts outside this bound are never retained +// or substituted into tool input. +const localizationProblemStatementMaxBytes = 64 << 10 + +// framedLocalizationProblemStatement extracts the one explicitly labelled +// problem block from a submitted prompt. It intentionally has no raw-prompt +// fallback: absence, ambiguity, malformed framing, and overflow all leave the +// model-provided explore task unchanged. +func framedLocalizationProblemStatement(prompt string) (string, bool) { + lines := strings.Split(strings.ReplaceAll(prompt, "\r\n", "\n"), "\n") + openingIndex := -1 + var fence byte + for lineIndex, line := range lines { + candidateFence, ok := localizationProblemFrameOpening(line) + if !ok { + continue + } + if openingIndex >= 0 { + return "", false + } + openingIndex = lineIndex + fence = candidateFence + } + if openingIndex < 0 { + return "", false + } + + closerIndex := -1 + for lineIndex := openingIndex + 1; lineIndex < len(lines); lineIndex++ { + if !localizationProblemFrameClosing(lines[lineIndex], fence) { + continue + } + if closerIndex >= 0 { + return "", false + } + closerIndex = lineIndex + } + if closerIndex < 0 { + return "", false + } + + start, end := openingIndex+1, closerIndex + for start < end && strings.TrimSpace(lines[start]) == "" { + start++ + } + for end > start && strings.TrimSpace(lines[end-1]) == "" { + end-- + } + if start == end { + return "", false + } + + statement := strings.Join(lines[start:end], "\n") + if len(statement) > localizationProblemStatementMaxBytes { + return "", false + } + return statement, true +} + +func localizationProblemFrameOpening(line string) (byte, bool) { + trimmed := strings.TrimSpace(line) + if len(trimmed) < 4 || !localizationProblemFenceByte(trimmed[0]) { + return 0, false + } + fence := trimmed[0] + leading := 0 + for leading < len(trimmed) && trimmed[leading] == fence { + leading++ + } + if leading < 3 { + return 0, false + } + + trailing := 0 + for trailing < len(trimmed)-leading && trimmed[len(trimmed)-1-trailing] == fence { + trailing++ + } + if trailing > 0 && trailing < 3 { + return 0, false + } + labelEnd := len(trimmed) + if trailing >= 3 { + labelEnd -= trailing + } + label := strings.TrimSpace(trimmed[leading:labelEnd]) + if !localizationProblemFrameLabel(label) { + return 0, false + } + return fence, true +} + +func localizationProblemFrameLabel(label string) bool { + words := strings.FieldsFunc(strings.ToLower(label), func(r rune) bool { + return r < 'a' || r > 'z' + }) + for wordIndex, word := range words { + switch word { + case "bug", "issue", "problem", "incident", "failure": + return true + case "change": + if wordIndex+1 < len(words) && words[wordIndex+1] == "request" { + return true + } + } + } + return false +} + +func localizationProblemFrameClosing(line string, fence byte) bool { + trimmed := strings.TrimSpace(line) + if len(trimmed) < 16 { + return false + } + for index := 0; index < len(trimmed); index++ { + if trimmed[index] != fence { + return false + } + } + return true +} + +func localizationProblemFenceByte(value byte) bool { + return value == '-' || value == '=' +} + var userPromptProbe grepProbeFn = probeViaDaemon // runUserPromptSubmit handles a UserPromptSubmit hook: it proactively searches diff --git a/internal/hooks/userpromptsubmit_test.go b/internal/hooks/userpromptsubmit_test.go index 154de39c..919c4bc4 100644 --- a/internal/hooks/userpromptsubmit_test.go +++ b/internal/hooks/userpromptsubmit_test.go @@ -71,3 +71,64 @@ func TestRunUserPromptSubmitTrivialPromptIsNoOp(t *testing.T) { // Wrong event name is also a no-op. runUserPromptSubmit([]byte(`{"hook_event_name":"SessionStart","prompt":"do something big"}`)) } + +func TestFramedLocalizationProblemStatementPreservesOnlyFramedProblem(t *testing.T) { + prompt := "runner metadata\r\n" + + "--- BUG REPORT --------------------------------\r\n" + + "\r\n" + + "Title: Queue entries vanish\r\n" + + "\r\n" + + "Path: internal/queue/store.go\r\n" + + " keep interior spacing\r\n" + + "\r\n" + + "------------------------------------------------\r\n" + + "Do not edit. FILES / SYMBOLS / EVIDENCE only." + + got, ok := framedLocalizationProblemStatement(prompt) + require.True(t, ok) + require.Equal(t, "Title: Queue entries vanish\n\nPath: internal/queue/store.go\n keep interior spacing", got) + require.NotContains(t, got, "runner metadata") + require.NotContains(t, got, "Do not edit") +} + +func TestFramedLocalizationProblemStatementAcceptsApprovedLabels(t *testing.T) { + labels := []string{"BUG", "ISSUE REPORT", "problem details", "Production Incident", "FAILURE", "change request"} + for _, label := range labels { + t.Run(label, func(t *testing.T) { + prompt := "=== " + label + "\n\nTitle: Neutral storage behavior\n\n================" + got, ok := framedLocalizationProblemStatement(prompt) + require.True(t, ok) + require.Equal(t, "Title: Neutral storage behavior", got) + }) + } +} + +func TestFramedLocalizationProblemStatementFailsOpen(t *testing.T) { + valid := "--- BUG ---\nTitle: One\n----------------" + tests := map[string]string{ + "absent": "Title: One without framing", + "unapproved label": "--- TASK ---\nTitle: One\n----------------", + "unsupported fence": "___ BUG ___\nTitle: One\n________________", + "mismatched closer": "--- BUG ---\nTitle: One\n================", + "short closer": "--- BUG ---\nTitle: One\n---------------", + "short trailing run": "--- BUG --\nTitle: One\n----------------", + "empty body": "--- BUG ---\n\n\t\n----------------", + "ambiguous": valid + "\n" + valid, + "interior separator": "--- BUG ---\nTitle: One\n----------------\nMore details\n----------------", + "overflow": "--- BUG ---\n" + strings.Repeat("x", localizationProblemStatementMaxBytes+1) + "\n----------------", + } + for name, prompt := range tests { + t.Run(name, func(t *testing.T) { + got, ok := framedLocalizationProblemStatement(prompt) + require.False(t, ok) + require.Empty(t, got) + }) + } +} + +func TestFramedLocalizationProblemStatementAllowsExactByteLimit(t *testing.T) { + statement := strings.Repeat("x", localizationProblemStatementMaxBytes) + got, ok := framedLocalizationProblemStatement("--- ISSUE ---\n" + statement + "\n----------------") + require.True(t, ok) + require.Equal(t, statement, got) +} diff --git a/internal/mcp/explore_typed_anchor_projection.go b/internal/mcp/explore_typed_anchor_projection.go index e3904b5b..ec159df2 100644 --- a/internal/mcp/explore_typed_anchor_projection.go +++ b/internal/mcp/explore_typed_anchor_projection.go @@ -695,6 +695,29 @@ func exploreTypedAnchorReservedCandidateIDs( return reserved } +// exploreFinalReservedCandidateIDs extends the pre-projection reservation set +// only after typed admission has completed. This keeps a weak marginal concept +// complement from blocking stronger typed proof while still protecting the +// admitted complement from owner-folding eviction. +func exploreFinalReservedCandidateIDs( + candidates []*rerank.Candidate, + protectedAnchors map[int]string, + protectedImplementationID string, +) map[string]struct{} { + reserved := exploreTypedAnchorReservedCandidateIDs( + candidates, protectedAnchors, protectedImplementationID, + ) + for _, candidate := range candidates { + if candidate == nil || candidate.Node == nil || candidate.Signals == nil { + continue + } + if candidate.Signals[exploreConceptComplementSignal] > 0 { + reserved[candidate.Node.ID] = struct{}{} + } + } + return reserved +} + func reserveExploreTypedAnchorProjection( candidates []*rerank.Candidate, projection exploreTypedAnchorProjection, diff --git a/internal/mcp/localization_digest.go b/internal/mcp/localization_digest.go index 5687bf54..69a40b5a 100644 --- a/internal/mcp/localization_digest.go +++ b/internal/mcp/localization_digest.go @@ -61,36 +61,47 @@ type localizationDigestRow struct { // newLocalizationEvidenceDigest retains only concrete ranked evidence rows. // Files and Symbols are rebuilt from those rows, so an item that was shed by // the replay limit or byte budget cannot survive as an unsupported answer -// candidate. The upstream ordering already reserves the strongest direct, -// exact, literal, and promoted structural targets before lower-ranked fan-out. +// candidate. Exact and refinement-authorized rows form a stable protected +// prefix in retained state only; the visible envelope keeps its original order. func newLocalizationEvidenceDigest(envelope localizationExploreEnvelope) *localizationEvidenceDigest { digest := &localizationEvidenceDigest{} + priorityIDs := localizationDigestPriorityIDs(envelope.Completion, envelope.Evidence) + seen := make(map[string]struct{}, localizationReplayEvidenceLimit) - for _, row := range envelope.Evidence { - if len(digest.Evidence) >= localizationReplayEvidenceLimit { - break - } - if row.ID == "" || row.File == "" { - continue - } - if _, exists := seen[row.ID]; exists { - continue + appendRows := func(priority bool) { + for _, row := range envelope.Evidence { + if len(digest.Evidence) >= localizationReplayEvidenceLimit { + return + } + if row.ID == "" || row.File == "" { + continue + } + _, prioritized := priorityIDs[row.ID] + if prioritized != priority { + continue + } + if _, exists := seen[row.ID]; exists { + continue + } + seen[row.ID] = struct{}{} + digest.Evidence = append(digest.Evidence, localizationDigestRow{ + Rank: row.Rank, + ID: row.ID, + Name: row.Name, + QualName: row.QualName, + Kind: row.Kind, + File: row.File, + Line: row.Line, + Signature: row.Signature, + Callers: append([]string(nil), row.Callers...), + Callees: append([]string(nil), row.Callees...), + Provenance: row.Provenance, + }) } - seen[row.ID] = struct{}{} - digest.Evidence = append(digest.Evidence, localizationDigestRow{ - Rank: row.Rank, - ID: row.ID, - Name: row.Name, - QualName: row.QualName, - Kind: row.Kind, - File: row.File, - Line: row.Line, - Signature: row.Signature, - Callers: append([]string(nil), row.Callers...), - Callees: append([]string(nil), row.Callees...), - Provenance: row.Provenance, - }) } + appendRows(true) + appendRows(false) + for { rebuildLocalizationDigestSkeleton(digest) digest.finalResponse = renderLocalizationFinalResponse(digest.Evidence) @@ -116,6 +127,39 @@ func newLocalizationEvidenceDigest(envelope localizationExploreEnvelope) *locali } } +// localizationDigestPriorityIDs protects every identity required to justify a +// live authorization, not only the identity the client may read. Dependencies +// still remain subject to the hard byte cap; post-pack reconciliation below +// removes or downgrades any authorization whose complete proof did not fit. +func localizationDigestPriorityIDs(completion localizationCompletion, evidence []localizationEvidence) map[string]struct{} { + priority := make(map[string]struct{}, 1+len(completion.AllowedSymbols)+len(completion.refinementRoutes)*3) + add := func(symbol string) { + if symbol = strings.TrimSpace(symbol); symbol != "" { + priority[symbol] = struct{}{} + } + } + add(completion.ExactSymbol) + for _, symbol := range completion.AllowedSymbols { + add(symbol) + } + for symbol, route := range completion.refinementRoutes { + add(symbol) + add(route.implementationSymbol) + add(route.proofSymbol) + } + for _, row := range evidence { + switch row.Provenance { + case localizationProvenanceSourceLiteralCallee, + localizationProvenanceDivergentDefault, + localizationProvenanceDivergentDefaultType, + localizationProvenanceImplementationRoute, + localizationProvenanceImplementationTarget: + add(row.ID) + } + } + return priority +} + func cloneLocalizationDigestRows(rows []localizationDigestRow) []localizationDigestRow { if len(rows) == 0 { return nil @@ -181,6 +225,132 @@ func mergeLocalizationEvidenceDigest(current []localizationDigestRow, retained * } } +const localizationSameOwnerEvidenceReserve = 3 + +// mergeLocalizationEvidenceDigestForTask preserves a small coherent method +// cohort around the terminalizing read before byte shedding considers the +// unrelated tail. It only reorders already-retained rows; cardinality and byte +// limits remain centralized in mergeLocalizationEvidenceDigest. +func mergeLocalizationEvidenceDigestForTask(task string, current []localizationDigestRow, retained *localizationEvidenceDigest) *localizationEvidenceDigest { + ordered := &localizationEvidenceDigest{Evidence: localizationTaskAwareRetainedRows(task, current, retained)} + digest := mergeLocalizationEvidenceDigest(current, ordered) + finalResponse := renderLocalizationFinalResponseForTask(task, current, digest.Evidence) + if len(finalResponse) <= localizationFinalResponseMaxBytes { + digest.finalResponse = finalResponse + } + return digest +} + +func localizationTaskAwareRetainedRows(task string, current []localizationDigestRow, retained *localizationEvidenceDigest) []localizationDigestRow { + if retained == nil || len(retained.Evidence) == 0 { + return nil + } + rows := retained.Evidence + ordered := make([]localizationDigestRow, 0, len(rows)) + selected := make(map[string]struct{}, len(rows)+len(current)) + ownerKeys := make(map[string]struct{}, len(current)) + seenFiles := make(map[string]struct{}, len(current)) + for _, row := range current { + if id := strings.TrimSpace(row.ID); id != "" { + selected[id] = struct{}{} + } + if file := strings.TrimSpace(row.File); file != "" { + seenFiles[file] = struct{}{} + } + if key := localizationDigestRowOwnerKey(row); key != "" { + ownerKeys[key] = struct{}{} + } + } + appendWhere := func(limit int, keep func(localizationDigestRow) bool) { + added := 0 + for _, row := range rows { + id := strings.TrimSpace(row.ID) + if id == "" { + continue + } + if _, exists := selected[id]; exists || !keep(row) { + continue + } + selected[id] = struct{}{} + ordered = append(ordered, row) + if file := strings.TrimSpace(row.File); file != "" { + seenFiles[file] = struct{}{} + } + added++ + if limit > 0 && added == limit { + return + } + } + } + sameOwner := func(row localizationDigestRow) bool { + key := localizationDigestRowOwnerKey(row) + _, exists := ownerKeys[key] + return key != "" && exists + } + appendWhere(0, func(row localizationDigestRow) bool { + return localizationDigestRowTaskCited(task, row) + }) + appendWhere(localizationSameOwnerEvidenceReserve, sameOwner) + taskTerms := exploreTerminalTerms(task) + appendWhere(0, func(row localizationDigestRow) bool { + return !sameOwner(row) && localizationDigestRowTaskAligned(task, taskTerms, row) + }) + appendWhere(0, func(row localizationDigestRow) bool { + _, seen := seenFiles[strings.TrimSpace(row.File)] + return !seen + }) + appendWhere(0, func(localizationDigestRow) bool { return true }) + return ordered +} + +func localizationDigestRowTaskCited(task string, row localizationDigestRow) bool { + for _, value := range []string{row.ID, row.Name, row.QualName, row.File, row.Signature} { + if localizationTaskCitesConcreteEvidence(task, value) { + return true + } + } + return false +} + +func localizationDigestRowTaskAligned(task string, taskTerms map[string]struct{}, row localizationDigestRow) bool { + if localizationDigestRowTaskCited(task, row) { + return true + } + values := []string{row.ID, row.Name, row.QualName, row.File, row.Signature} + for term := range exploreTerminalTerms(strings.Join(values, " ")) { + if _, aligned := taskTerms[term]; aligned { + return true + } + } + return false +} + +func localizationDigestRowOwnerKey(row localizationDigestRow) string { + if !strings.EqualFold(strings.TrimSpace(row.Kind), "method") { + return "" + } + file := strings.TrimSpace(row.File) + owner := strings.TrimSpace(row.QualName) + if owner == "" { + owner = strings.TrimSpace(row.ID) + if prefix := file + "::"; file != "" && strings.HasPrefix(owner, prefix) { + owner = strings.TrimPrefix(owner, prefix) + } + } + if cut := strings.LastIndex(owner, "."); cut > 0 { + owner = owner[:cut] + } else if cut := strings.LastIndex(owner, "::"); cut > 0 { + owner = owner[:cut] + } else { + return "" + } + owner = strings.TrimSpace(owner) + if file == "" || owner == "" { + return "" + } + return file + "\x00" + owner +} + func shedLocalizationDigestRowOptionalFields(row *localizationDigestRow) bool { if row == nil { return false @@ -223,35 +393,412 @@ func localizationFinalResponseField(value string) string { return strings.Join(strings.Fields(value), " ") } -func renderLocalizationFinalResponse(rows []localizationDigestRow) string { +const ( + localizationFinalResponsePrimaryLimit = 3 + localizationFinalResponseSupportingLimit = 4 +) + +type localizationFinalResponseRow struct { + row localizationDigestRow + primary bool +} + +type localizationFinalResponseTaskScore struct { + matched int + longest int + callable bool +} + +func localizationFinalResponsePrimaryProvenance(provenance string) bool { + switch provenance { + case localizationProvenanceSourceLiteralCallee, + localizationProvenanceDivergentDefault, + localizationProvenanceImplementationTarget, + localizationProvenanceTypedAnchorProjection: + return true + default: + return false + } +} + +func localizationFinalResponseSupportingProvenance(provenance string) bool { + switch provenance { + case localizationProvenanceDivergentDefaultType, + localizationProvenanceImplementationRoute, + "direct_caller", "direct_callee": + return true + default: + return false + } +} + +func localizationFinalResponseIdentifierText(row localizationDigestRow) string { + id := strings.TrimSpace(row.ID) + if cut := strings.LastIndex(id, "::"); cut >= 0 { + id = id[cut+2:] + } + return strings.ToLower(strings.Join([]string{row.Name, row.QualName, id}, " ")) +} + +func scoreLocalizationFinalResponseTask(taskTerms map[string]struct{}, row localizationDigestRow) localizationFinalResponseTaskScore { + score := localizationFinalResponseTaskScore{ + callable: strings.EqualFold(strings.TrimSpace(row.Kind), "function") || + strings.EqualFold(strings.TrimSpace(row.Kind), "method"), + } + text := localizationFinalResponseIdentifierText(row) + for term := range taskTerms { + if !exploreConceptTermPresent(text, term) { + continue + } + score.matched++ + if len(term) > score.longest { + score.longest = len(term) + } + } + return score +} + +func localizationFinalResponseBetterTaskScore(left, right localizationFinalResponseTaskScore) bool { + if left.matched != right.matched { + return left.matched > right.matched + } + if left.callable != right.callable { + return left.callable + } + return left.longest > right.longest +} + +func localizationFinalResponseNeighborContains(ids []string, id string) bool { + for _, candidate := range ids { + if strings.TrimSpace(candidate) == id { + return true + } + } + return false +} + +func localizationFinalResponseDirectRelation(row localizationDigestRow, primaries []localizationDigestRow) bool { + if localizationFinalResponseSupportingProvenance(row.Provenance) { + return true + } + id := strings.TrimSpace(row.ID) + for _, primary := range primaries { + primaryID := strings.TrimSpace(primary.ID) + if localizationFinalResponseNeighborContains(primary.Callers, id) || + localizationFinalResponseNeighborContains(primary.Callees, id) || + localizationFinalResponseNeighborContains(row.Callers, primaryID) || + localizationFinalResponseNeighborContains(row.Callees, primaryID) { + return true + } + } + return false +} + +// localizationFinalResponseRows selects a bounded model-facing projection +// without changing the retained evidence rows or their positional JSON arrays. +func localizationFinalResponseRows(task string, current, rows []localizationDigestRow) []localizationFinalResponseRow { if len(rows) == 0 { - return "FILES:\n(none)\n\nSYMBOLS:\n(none)\n\nEVIDENCE:\nNo bounded localization evidence was found.\n\n" + localizationAnswerReadyDirective + return nil } - var response strings.Builder - response.WriteString("FILES:\n") - for index, row := range rows { - fmt.Fprintf(&response, "#%d %s\n", index+1, localizationFinalResponseField(row.File)) + primaries := make([]localizationDigestRow, 0, localizationFinalResponsePrimaryLimit) + supporting := make([]localizationDigestRow, 0, localizationFinalResponseSupportingLimit) + selected := make(map[string]struct{}, localizationFinalResponsePrimaryLimit+localizationFinalResponseSupportingLimit) + appendRow := func(dst *[]localizationDigestRow, limit int, row localizationDigestRow) bool { + id := strings.TrimSpace(row.ID) + if id == "" || strings.TrimSpace(row.File) == "" || len(*dst) >= limit { + return false + } + if _, exists := selected[id]; exists { + return false + } + selected[id] = struct{}{} + *dst = append(*dst, row) + return true } - response.WriteString("\nSYMBOLS:\n") - for index, row := range rows { - fmt.Fprintf(&response, "#%d %s\n", index+1, localizationFinalResponseField(row.ID)) + + rowsByID := make(map[string]localizationDigestRow, len(rows)) + for _, row := range rows { + rowsByID[strings.TrimSpace(row.ID)] = row } - response.WriteString("\nEVIDENCE:\n") - for index, row := range rows { - file := localizationFinalResponseField(row.File) - id := localizationFinalResponseField(row.ID) - if row.Line > 0 { - fmt.Fprintf(&response, "#%d %s:%d — %s\n", index+1, file, row.Line, id) + // A successful authorized read is the freshest bounded evidence and leads + // the presentation even when its retained predecessor carried more metadata. + for _, row := range current { + if retained, exists := rowsByID[strings.TrimSpace(row.ID)]; exists { + appendRow(&primaries, localizationFinalResponsePrimaryLimit, retained) + } + } + for _, row := range rows { + if localizationFinalResponsePrimaryProvenance(row.Provenance) { + appendRow(&primaries, localizationFinalResponsePrimaryLimit, row) + } + } + + taskTerms := exploreTerminalTerms(task) + ownerKeys := make(map[string]struct{}, len(current)) + for _, row := range current { + key := localizationDigestRowOwnerKey(row) + if key == "" { + if retained, exists := rowsByID[strings.TrimSpace(row.ID)]; exists { + key = localizationDigestRowOwnerKey(retained) + } + } + if key != "" { + ownerKeys[key] = struct{}{} + } + } + bestSameOwner := -1 + var bestSameOwnerScore localizationFinalResponseTaskScore + if len(primaries) < localizationFinalResponsePrimaryLimit && len(ownerKeys) > 0 { + for index, row := range rows { + if _, exists := selected[strings.TrimSpace(row.ID)]; exists { + continue + } + key := localizationDigestRowOwnerKey(row) + if _, sameOwner := ownerKeys[key]; key == "" || !sameOwner { + continue + } + score := scoreLocalizationFinalResponseTask(taskTerms, row) + if bestSameOwner < 0 || localizationFinalResponseBetterTaskScore(score, bestSameOwnerScore) { + bestSameOwner, bestSameOwnerScore = index, score + } + } + if bestSameOwner >= 0 { + appendRow(&primaries, localizationFinalResponsePrimaryLimit, rows[bestSameOwner]) + } + } + + bestTaskMatch := -1 + var bestTaskScore localizationFinalResponseTaskScore + if len(primaries) < localizationFinalResponsePrimaryLimit && len(taskTerms) > 0 { + for index, row := range rows { + if _, exists := selected[strings.TrimSpace(row.ID)]; exists { + continue + } + score := scoreLocalizationFinalResponseTask(taskTerms, row) + if score.matched == 0 { + continue + } + if bestTaskMatch < 0 || localizationFinalResponseBetterTaskScore(score, bestTaskScore) { + bestTaskMatch, bestTaskScore = index, score + } + } + if bestTaskMatch >= 0 { + appendRow(&primaries, localizationFinalResponsePrimaryLimit, rows[bestTaskMatch]) + } + } + for _, row := range rows { + appendRow(&primaries, localizationFinalResponsePrimaryLimit, row) + } + + for _, row := range rows { + if localizationFinalResponseDirectRelation(row, primaries) { + appendRow(&supporting, localizationFinalResponseSupportingLimit, row) + } + } + for _, row := range rows { + appendRow(&supporting, localizationFinalResponseSupportingLimit, row) + } + + presented := make([]localizationFinalResponseRow, 0, len(primaries)+len(supporting)) + for _, row := range primaries { + presented = append(presented, localizationFinalResponseRow{row: row, primary: true}) + } + for _, row := range supporting { + presented = append(presented, localizationFinalResponseRow{row: row}) + } + return presented +} + +func renderLocalizationFinalResponse(rows []localizationDigestRow) string { + return renderLocalizationFinalResponseForTask("", nil, rows) +} + +func renderLocalizationFinalResponseForTask(task string, current, rows []localizationDigestRow) string { + presented := localizationFinalResponseRows(task, current, rows) + if len(presented) == 0 { + return "LOCALIZATION:\nNo bounded localization evidence was found.\n\n" + localizationAnswerReadyDirective + } + var response strings.Builder + response.WriteString("LOCALIZATION:\n") + for _, item := range presented { + role := "SUPPORTING" + if item.primary { + role = "PRIMARY" + } + file := localizationFinalResponseField(item.row.File) + id := localizationFinalResponseField(item.row.ID) + if item.row.Line > 0 { + fmt.Fprintf(&response, "- %s — %s:%d — %s\n", role, file, item.row.Line, id) continue } - fmt.Fprintf(&response, "#%d %s — %s\n", index+1, file, id) + fmt.Fprintf(&response, "- %s — %s — %s\n", role, file, id) } response.WriteString("\n") response.WriteString(localizationAnswerReadyDirective) return response.String() } -const localizationAnswerReadyDirective = "You already hold the localization answer — respond now using this evidence; do not call another tool." +const localizationAnswerReadyDirective = "Localization for this task is complete. Respond now using this evidence; do not call another tool." + +func localizationDigestRowsByID(digest *localizationEvidenceDigest) map[string]localizationDigestRow { + retained := make(map[string]localizationDigestRow) + if digest == nil { + return retained + } + for _, row := range digest.Evidence { + if symbol := strings.TrimSpace(row.ID); symbol != "" { + retained[symbol] = row + } + } + return retained +} + +func localizationDigestHasProvenance(rows map[string]localizationDigestRow, provenance string) bool { + for _, row := range rows { + if row.Provenance == provenance { + return true + } + } + return false +} + +func localizationDigestStrongProofRetained(rows map[string]localizationDigestRow, symbol string) bool { + row, exists := rows[strings.TrimSpace(symbol)] + if !exists { + return false + } + switch row.Provenance { + case localizationProvenanceSourceLiteralCallee: + return true + case localizationProvenanceDivergentDefault: + return localizationDigestHasProvenance(rows, localizationProvenanceDivergentDefaultType) + case localizationProvenanceImplementationRoute: + return localizationDigestHasProvenance(rows, localizationProvenanceImplementationTarget) + case localizationProvenanceImplementationTarget: + return localizationDigestHasProvenance(rows, localizationProvenanceImplementationRoute) + default: + return false + } +} + +func localizationDigestAnyStrongProofRetained(rows map[string]localizationDigestRow) bool { + for symbol := range rows { + if localizationDigestStrongProofRetained(rows, symbol) { + return true + } + } + return false +} + +// localizationDigestReconcileRoute preserves a concrete advisory read when its +// optional proof was shed, but never preserves a generic-wrapper hop without +// the concrete implementation that makes the route useful. +func localizationDigestReconcileRoute( + symbol string, + route localizationRefinementRoute, + rows map[string]localizationDigestRow, +) (localizationRefinementRoute, bool) { + row, retained := rows[symbol] + if !retained { + return localizationRefinementRoute{}, false + } + if route.implementationSymbol != "" { + implementation, implementationRetained := rows[route.implementationSymbol] + if !implementationRetained { + return localizationRefinementRoute{}, false + } + if route.enforceable && (row.Provenance != localizationProvenanceImplementationRoute || + implementation.Provenance != localizationProvenanceImplementationTarget) { + route.enforceable = false + } + } + if route.proofSymbol != "" { + proof, proofRetained := rows[route.proofSymbol] + if !proofRetained || proof.Provenance != localizationProvenanceImplementationRoute || + row.Provenance != localizationProvenanceImplementationTarget { + route.proofSymbol = "" + route.enforceable = false + } + } + if route.enforceable && route.implementationSymbol == "" && route.proofSymbol == "" && + row.Provenance != localizationProvenanceSourceLiteralCallee { + route.enforceable = false + } + return route, true +} + +func localizationCompletionBoundedByDigest(completion localizationCompletion, digest *localizationEvidenceDigest) localizationCompletion { + if digest == nil { + return completion + } + retained := localizationDigestRowsByID(digest) + advisory := func() localizationCompletion { + bounded := newLocalizationCompletion(true, "") + bounded.taskLead = completion.taskLead + return bounded + } + + switch completion.State { + case localizationStateAnswerReady: + if completion.Enforceable && !localizationDigestAnyStrongProofRetained(retained) { + completion.Enforceable = false + } + case localizationStateNeedsExactRead: + if _, exists := retained[completion.ExactSymbol]; !exists || completion.ExactSymbol == "" { + return advisory() + } + if completion.enforceableOnAnswerReady && + !localizationDigestStrongProofRetained(retained, completion.ExactSymbol) { + completion.enforceableOnAnswerReady = false + } + case localizationStateNeedsRefinement: + allowed := make([]string, 0, len(completion.AllowedSymbols)) + seen := make(map[string]struct{}, len(completion.AllowedSymbols)) + var routes map[string]localizationRefinementRoute + if len(completion.refinementRoutes) > 0 { + routes = make(map[string]localizationRefinementRoute, len(completion.refinementRoutes)) + } + for _, symbol := range completion.AllowedSymbols { + symbol = strings.TrimSpace(symbol) + if symbol == "" { + continue + } + if _, exists := retained[symbol]; !exists { + continue + } + if _, duplicate := seen[symbol]; duplicate { + continue + } + if route, exists := completion.refinementRoutes[symbol]; exists { + reconciled, usable := localizationDigestReconcileRoute(symbol, route, retained) + if !usable { + continue + } + routes[symbol] = reconciled + } + seen[symbol] = struct{}{} + allowed = append(allowed, symbol) + } + if len(allowed) == 0 { + return advisory() + } + preferred := strings.TrimSpace(completion.refinementSymbol) + if _, exists := seen[preferred]; !exists { + preferred = allowed[0] + } + bounded := newLocalizationRefinementCompletionForSymbols(preferred, allowed) + bounded.enforceableOnAnswerReady = completion.enforceableOnAnswerReady + bounded.taskLead = completion.taskLead + if routes != nil { + bounded.refinementRoutes = routes + bounded.correctionSymbol, bounded.correctionRoute = localizationRankedCorrection( + preferred, allowed, bounded.refinementRoutes, + ) + } + return bounded + } + return completion +} func localizationCompletionWithDigest(completion localizationCompletion, digest *localizationEvidenceDigest) localizationCompletion { if digest == nil { diff --git a/internal/mcp/localization_digest_test.go b/internal/mcp/localization_digest_test.go index 42e04055..186e4dee 100644 --- a/internal/mcp/localization_digest_test.go +++ b/internal/mcp/localization_digest_test.go @@ -75,6 +75,15 @@ func requireLocalizationTerminalReplay(t *testing.T, result *mcpgo.CallToolResul if !ok || !strings.Contains(text, localizationAnswerReadyDirective) { t.Fatalf("terminal replay content = %#v", result.Content) } + for _, required := range []string{ + "Localization for this task is complete", + "Respond now using this evidence", + "do not call another tool", + } { + if !strings.Contains(text, required) { + t.Fatalf("terminal replay text %q does not contain %q", text, required) + } + } structured, ok := result.StructuredContent.(map[string]any) if !ok { t.Fatalf("terminal replay structured content = %#v", result.StructuredContent) @@ -112,6 +121,7 @@ func TestPostTerminalNavigationReturnsByteIdenticalSuccessfulReplay(t *testing.T state.armForTask(completion, "find the storage load implementations") var canonical []byte + var canonicalFinalResponse string for _, facade := range []string{"explore", "search", "read", "relations", "trace", "analyze"} { for repeat := 0; repeat < 3; repeat++ { result, reserved := state.authorize(facade, "any_operation", nil) @@ -119,6 +129,11 @@ func TestPostTerminalNavigationReturnsByteIdenticalSuccessfulReplay(t *testing.T t.Fatalf("%s repeat %d reserved a handler call", facade, repeat) } contract := requireLocalizationTerminalReplay(t, result, facade, "any_operation") + if canonicalFinalResponse == "" { + canonicalFinalResponse = contract.Completion.FinalResponse + } else if contract.Completion.FinalResponse != canonicalFinalResponse { + t.Fatalf("%s repeat %d changed final_response bytes", facade, repeat) + } if contract.Completion.Enforceable { t.Fatalf("%s repeat %d unexpectedly upgraded advisory evidence", facade, repeat) } @@ -178,8 +193,8 @@ func TestRefinementPromotionRetainsDigestForReplay(t *testing.T) { t.Fatal("post-promotion navigation reserved a handler") } contract := requireLocalizationTerminalReplay(t, terminal, "search", "symbols") - if !strings.Contains(contract.Completion.FinalResponse, "#1 repo/storage/disk.go") || - !strings.Contains(contract.Completion.FinalResponse, "#2 repo/storage/cloud.go") { + if !strings.Contains(contract.Completion.FinalResponse, "- PRIMARY — repo/storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load") || + !strings.Contains(contract.Completion.FinalResponse, "- PRIMARY — repo/storage/cloud.go:17 — repo/storage/cloud.go::CloudStorage.Load") { t.Fatalf("promotion lost retained aligned evidence: %q", contract.Completion.FinalResponse) } } @@ -270,6 +285,555 @@ func TestDigestByteCapShedsEvidenceTail(t *testing.T) { } } +func authorizationAwareDigestFixture() (localizationExploreEnvelope, []string) { + allowed := []string{ + "repo/storage/level.go::StorageLevel.Normalize", + "repo/storage/batch_gate.go::BatchGate.FlushPending", + "repo/storage/batch_gate.go::BatchGate.ClearPending", + } + longPathSegment := strings.Repeat("very-long-path-segment-", 8) + evidence := make([]localizationEvidence, 0, localizationReplayEvidenceLimit) + for index := 0; index < localizationReplayEvidenceLimit; index++ { + file := fmt.Sprintf("src/%s/%02d.go", longPathSegment, index) + id := fmt.Sprintf( + "repo/%s/%02d.go::Unrelated%02d.%s", + longPathSegment, index, index, strings.Repeat("LongMethod", 6), + ) + row := localizationEvidence{ + Rank: index + 1, ID: id, Name: fmt.Sprintf("Unrelated%02d", index), + QualName: fmt.Sprintf("Unrelated%02d.%s", index, strings.Repeat("LongMethod", 6)), + Kind: "method", File: file, Line: index + 1, + } + switch index { + case 0: + row.ID = allowed[0] + row.Name = "Normalize" + row.QualName = "StorageLevel.Normalize" + row.File = "repo/storage/level.go" + row.Line = 55 + case 2: + row.ID = allowed[1] + row.Name = "FlushPending" + row.QualName = "BatchGate.FlushPending" + row.File = "repo/storage/batch_gate.go" + row.Line = 81 + case 8: + row.ID = allowed[2] + row.Name = "ClearPending" + row.QualName = "BatchGate.ClearPending" + row.File = "repo/storage/batch_gate.go" + row.Line = 72 + } + evidence = append(evidence, row) + } + completion := newLocalizationRefinementCompletionForSymbols(allowed[0], allowed) + completion.refinementRoutes = map[string]localizationRefinementRoute{ + allowed[0]: {enforceable: true}, + allowed[1]: {enforceable: true}, + allowed[2]: {enforceable: true}, + } + envelope := localizationExploreEnvelope{ + Completion: completion, + Files: make([]string, len(evidence)), + Symbols: make([]string, len(evidence)), + Evidence: evidence, + } + for index, row := range evidence { + envelope.Files[index] = row.File + envelope.Symbols[index] = row.ID + } + return envelope, allowed +} + +func TestDigestPrioritizesAuthorizedRowsBeforeUnrelatedByteShedding(t *testing.T) { + envelope, allowed := authorizationAwareDigestFixture() + before, err := json.Marshal(envelope) + if err != nil { + t.Fatalf("marshal fixture: %v", err) + } + + digest := newLocalizationEvidenceDigest(envelope) + after, err := json.Marshal(envelope) + if err != nil { + t.Fatalf("marshal fixture after digest: %v", err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("digest construction mutated the visible envelope") + } + if len(digest.Evidence) >= len(envelope.Evidence) { + t.Fatalf("fixture did not force byte shedding: retained=%d visible=%d", len(digest.Evidence), len(envelope.Evidence)) + } + if len(digest.Evidence) < len(allowed) { + t.Fatalf("retained rows=%d, want all %d authorized rows", len(digest.Evidence), len(allowed)) + } + for index, want := range allowed { + if digest.Evidence[index].ID != want { + t.Fatalf("authorized row %d = %q, want %q; all=%#v", index+1, digest.Evidence[index].ID, want, digest.Evidence) + } + } + authorized := make(map[string]struct{}, len(allowed)) + for _, symbol := range allowed { + authorized[symbol] = struct{}{} + } + seenUnrelated := false + for index, row := range digest.Evidence { + _, isAuthorized := authorized[row.ID] + if !isAuthorized { + seenUnrelated = true + } else if seenUnrelated { + t.Fatalf("authorized row %q followed unrelated evidence at position %d", row.ID, index+1) + } + if row.Rank != index+1 || digest.Files[index] != row.File || digest.Symbols[index] != row.ID { + t.Fatalf("unaligned retained row %d: %#v", index+1, row) + } + } + encoded, err := json.Marshal(digest) + if err != nil || len(encoded) > localizationDigestMaxBytes || len(digest.finalResponse) > localizationFinalResponseMaxBytes { + t.Fatalf("bounded digest bytes=%d final=%d err=%v", len(encoded), len(digest.finalResponse), err) + } +} + +func TestAuthorizedLateOwnerMethodSurvivesInitialDigestAndPostReadMerge(t *testing.T) { + envelope, allowed := authorizationAwareDigestFixture() + initial := newLocalizationEvidenceDigest(envelope) + current := localizationDigestRow{ + ID: allowed[1], Name: "FlushPending", QualName: "BatchGate.FlushPending", + Kind: "method", File: "repo/storage/batch_gate.go", Line: 81, + Signature: strings.Repeat("current argument ", 70), + } + merged := mergeLocalizationEvidenceDigestForTask( + "Investigate BatchGate.ClearPending() after FlushPending()", + []localizationDigestRow{current}, + initial, + ) + if len(merged.Evidence) < 2 || merged.Evidence[0].ID != allowed[1] || merged.Evidence[1].ID != allowed[2] { + t.Fatalf("post-read owner cohort = %#v, want flushBuffer then clear", merged.Evidence) + } + encoded, err := json.Marshal(merged) + if err != nil || len(encoded) > localizationDigestMaxBytes || len(merged.finalResponse) > localizationFinalResponseMaxBytes { + t.Fatalf("post-read digest bytes=%d final=%d err=%v", len(encoded), len(merged.finalResponse), err) + } + for index, row := range merged.Evidence { + if row.Rank != index+1 || merged.Files[index] != row.File || merged.Symbols[index] != row.ID { + t.Fatalf("unaligned post-read row %d: %#v", index+1, row) + } + } +} + +func TestLocalizationCompletionReconcilesShedAllowedSymbols(t *testing.T) { + t.Run("retains preferred and drops oversized alternate", func(t *testing.T) { + preferred := "repo/pkg/preferred.go::Preferred.Run" + alternate := "repo/" + strings.Repeat("oversized-segment-", 400) + "::Alternate.Run" + completion := newLocalizationRefinementCompletionForSymbols(preferred, []string{preferred, alternate}) + completion.refinementRoutes = map[string]localizationRefinementRoute{ + preferred: {enforceable: true}, + alternate: {enforceable: true}, + } + envelope := localizationExploreEnvelope{ + Completion: completion, + Evidence: []localizationEvidence{ + {Rank: 1, ID: preferred, Name: "Run", Kind: "method", File: "pkg/preferred.go", Line: 10}, + {Rank: 2, ID: alternate, Name: "Run", Kind: "method", File: "pkg/alternate.go", Line: 20}, + }, + } + digest := newLocalizationEvidenceDigest(envelope) + bounded := localizationCompletionBoundedByDigest(completion, digest) + bounded = localizationCompletionWithDigest(bounded, digest) + if bounded.State != localizationStateNeedsRefinement || !reflect.DeepEqual(bounded.AllowedSymbols, []string{preferred}) { + t.Fatalf("bounded completion = %#v, want preferred-only refinement", bounded) + } + if !reflect.DeepEqual(bounded.refinementSymbols, bounded.AllowedSymbols) || len(bounded.refinementRoutes) != 1 { + t.Fatalf("session refinement state diverged: symbols=%#v routes=%#v", bounded.refinementSymbols, bounded.refinementRoutes) + } + if _, exists := bounded.refinementRoutes[preferred]; !exists { + t.Fatalf("preferred route missing after reconciliation: %#v", bounded.refinementRoutes) + } + if !strings.Contains(bounded.RequiredAction, preferred) || bounded.FinalResponse != "" { + t.Fatalf("preferred refinement action was not rebuilt: %#v", bounded) + } + if len(digest.Symbols) != 1 || digest.Symbols[0] != preferred { + t.Fatalf("oversized alternate survived digest: %#v", digest.Symbols) + } + }) + + t.Run("downgrades when no authorized identity fits", func(t *testing.T) { + oversized := "repo/" + strings.Repeat("x", localizationDigestMaxBytes) + "::Only.Run" + completion := newLocalizationRefinementCompletion(oversized) + digest := newLocalizationEvidenceDigest(localizationExploreEnvelope{ + Completion: completion, + Evidence: []localizationEvidence{{ + Rank: 1, ID: oversized, Name: "Run", Kind: "method", File: "pkg/only.go", Line: 1, + }}, + }) + bounded := localizationCompletionBoundedByDigest(completion, digest) + bounded = localizationCompletionWithDigest(bounded, digest) + if len(digest.Evidence) != 0 { + t.Fatalf("pathological identity survived digest: %#v", digest.Evidence) + } + if bounded.State != localizationStateAnswerReady || bounded.RequiredAction != "respond" || bounded.AllowedToolCalls != 0 || len(bounded.AllowedSymbols) != 0 { + t.Fatalf("empty authorization did not use advisory fallback: %#v", bounded) + } + if bounded.FinalResponse == "" { + t.Fatal("advisory fallback omitted bounded final response") + } + }) +} + +func TestDigestRetainsAuthorizationDependenciesUnderPressure(t *testing.T) { + t.Run("refinement routes", func(t *testing.T) { + const ( + wrapper = "repo/storage/facade.go::StorageFacade.Flush" + implementation = "repo/storage/batch_gate.go::BatchGate.FlushPending" + concrete = "repo/storage/batch_gate.go::BatchGate.ClearPending" + proof = "repo/storage/facade.go::StorageFacade.Clear" + ) + longSegment := strings.Repeat("unrelated-metadata-", 18) + evidence := make([]localizationEvidence, 0, localizationReplayEvidenceLimit) + for index := 0; index < localizationReplayEvidenceLimit; index++ { + row := localizationEvidence{ + Rank: index + 1, + ID: fmt.Sprintf("repo/noise/%02d.go::Noise%02d.%s", index, index, longSegment), + Name: fmt.Sprintf("Noise%02d", index), QualName: longSegment, + Kind: "method", File: fmt.Sprintf("repo/noise/%02d/%s.go", index, longSegment), + Signature: strings.Repeat("argument ", 30), + } + switch index { + case 0: + row.ID, row.Name, row.QualName, row.File, row.Provenance = wrapper, "Flush", "StorageFacade.Flush", "repo/storage/facade.go", localizationProvenanceImplementationRoute + case 2: + row.ID, row.Name, row.QualName, row.File, row.Provenance = concrete, "ClearPending", "BatchGate.ClearPending", "repo/storage/batch_gate.go", localizationProvenanceImplementationTarget + case 8: + row.ID, row.Name, row.QualName, row.File, row.Provenance = implementation, "FlushPending", "BatchGate.FlushPending", "repo/storage/batch_gate.go", localizationProvenanceImplementationTarget + case 9: + row.ID, row.Name, row.QualName, row.File, row.Provenance = proof, "Clear", "StorageFacade.Clear", "repo/storage/facade.go", localizationProvenanceImplementationRoute + } + evidence = append(evidence, row) + } + completion := newLocalizationRefinementCompletionForSymbols(wrapper, []string{wrapper, concrete}) + completion.refinementRoutes = map[string]localizationRefinementRoute{ + wrapper: {implementationSymbol: implementation, enforceable: true}, + concrete: {proofSymbol: proof, enforceable: true}, + } + digest := newLocalizationEvidenceDigest(localizationExploreEnvelope{Completion: completion, Evidence: evidence}) + if len(digest.Evidence) >= len(evidence) { + t.Fatalf("fixture did not force digest shedding: retained=%d visible=%d", len(digest.Evidence), len(evidence)) + } + retained := localizationDigestRowsByID(digest) + for _, symbol := range []string{wrapper, implementation, concrete, proof} { + if _, exists := retained[symbol]; !exists { + t.Fatalf("authorization dependency %q was shed: %#v", symbol, digest.Symbols) + } + } + bounded := localizationCompletionBoundedByDigest(completion, digest) + if bounded.State != localizationStateNeedsRefinement || len(bounded.refinementRoutes) != 2 { + t.Fatalf("bounded refinement lost retained routes: %#v", bounded) + } + for symbol, route := range bounded.refinementRoutes { + if !route.enforceable { + t.Fatalf("retained route %q was downgraded: %#v", symbol, route) + } + } + }) + + t.Run("divergent exact support", func(t *testing.T) { + const ( + owner = "repo/storage/batch_gate.go::BatchGate.New" + typeID = "repo/storage/batch_gate.go::BatchGate" + ) + longSegment := strings.Repeat("unrelated-metadata-", 18) + evidence := make([]localizationEvidence, 0, localizationReplayEvidenceLimit) + for index := 0; index < localizationReplayEvidenceLimit; index++ { + row := localizationEvidence{ + Rank: index + 1, + ID: fmt.Sprintf("repo/noise/%02d.go::Noise%02d.%s", index, index, longSegment), + Name: fmt.Sprintf("Noise%02d", index), QualName: longSegment, + Kind: "method", File: fmt.Sprintf("repo/noise/%02d/%s.go", index, longSegment), + Signature: strings.Repeat("argument ", 30), + } + switch index { + case 0: + row.ID, row.Name, row.QualName, row.File, row.Provenance = owner, "New", "BatchGate.New", "repo/storage/batch_gate.go", localizationProvenanceDivergentDefault + case 8: + row.ID, row.Name, row.QualName, row.Kind, row.File, row.Provenance = typeID, "BatchGate", "BatchGate", "type", "repo/storage/batch_gate.go", localizationProvenanceDivergentDefaultType + } + evidence = append(evidence, row) + } + completion := newLocalizationCompletion(false, owner) + completion.enforceableOnAnswerReady = true + digest := newLocalizationEvidenceDigest(localizationExploreEnvelope{Completion: completion, Evidence: evidence}) + if len(digest.Evidence) >= len(evidence) { + t.Fatalf("fixture did not force digest shedding: retained=%d visible=%d", len(digest.Evidence), len(evidence)) + } + retained := localizationDigestRowsByID(digest) + for _, symbol := range []string{owner, typeID} { + if _, exists := retained[symbol]; !exists { + t.Fatalf("exact-read proof dependency %q was shed: %#v", symbol, digest.Symbols) + } + } + bounded := localizationCompletionBoundedByDigest(completion, digest) + if bounded.State != localizationStateNeedsExactRead || !bounded.enforceableOnAnswerReady { + t.Fatalf("complete retained proof was downgraded: %#v", bounded) + } + }) +} + +func TestDigestDowngradesAuthorizationWhenDependencyCannotFit(t *testing.T) { + t.Run("generic route loses missing implementation", func(t *testing.T) { + const wrapper = "repo/storage/facade.go::StorageFacade.Flush" + implementation := "repo/storage/" + strings.Repeat("implementation-", localizationDigestMaxBytes) + "::BatchGate.FlushPending" + completion := newLocalizationRefinementCompletion(wrapper) + completion.refinementRoutes = map[string]localizationRefinementRoute{ + wrapper: {implementationSymbol: implementation, enforceable: true}, + } + digest := newLocalizationEvidenceDigest(localizationExploreEnvelope{ + Completion: completion, + Evidence: []localizationEvidence{ + {Rank: 1, ID: wrapper, Name: "Flush", Kind: "method", File: "repo/storage/facade.go", Provenance: localizationProvenanceImplementationRoute}, + {Rank: 2, ID: implementation, Name: "FlushPending", Kind: "method", File: "repo/storage/batch_gate.go", Provenance: localizationProvenanceImplementationTarget}, + }, + }) + bounded := localizationCompletionBoundedByDigest(completion, digest) + if _, retained := localizationDigestRowsByID(digest)[implementation]; retained { + t.Fatal("pathological implementation unexpectedly fit the digest") + } + if bounded.State != localizationStateAnswerReady || len(bounded.AllowedSymbols) != 0 { + t.Fatalf("wrapper remained authorized without its implementation: %#v", bounded) + } + }) + + t.Run("concrete route loses missing proof", func(t *testing.T) { + const concrete = "repo/storage/batch_gate.go::BatchGate.ClearPending" + proof := "repo/storage/" + strings.Repeat("proof-", localizationDigestMaxBytes) + "::StorageFacade.Clear" + completion := newLocalizationRefinementCompletion(concrete) + completion.refinementRoutes = map[string]localizationRefinementRoute{ + concrete: {proofSymbol: proof, enforceable: true}, + } + digest := newLocalizationEvidenceDigest(localizationExploreEnvelope{ + Completion: completion, + Evidence: []localizationEvidence{ + {Rank: 1, ID: concrete, Name: "ClearPending", Kind: "method", File: "repo/storage/batch_gate.go", Provenance: localizationProvenanceImplementationTarget}, + {Rank: 2, ID: proof, Name: "Clear", Kind: "method", File: "repo/storage/facade.go", Provenance: localizationProvenanceImplementationRoute}, + }, + }) + bounded := localizationCompletionBoundedByDigest(completion, digest) + route, exists := bounded.refinementRoutes[concrete] + if bounded.State != localizationStateNeedsRefinement || !exists || route.enforceable || route.proofSymbol != "" { + t.Fatalf("concrete route did not downgrade after proof shedding: %#v", bounded) + } + }) + + t.Run("divergent exact trust loses missing type", func(t *testing.T) { + const owner = "repo/storage/batch_gate.go::BatchGate.New" + typeID := "repo/storage/" + strings.Repeat("type-", localizationDigestMaxBytes) + "::BatchGate" + completion := newLocalizationCompletion(false, owner) + completion.enforceableOnAnswerReady = true + digest := newLocalizationEvidenceDigest(localizationExploreEnvelope{ + Completion: completion, + Evidence: []localizationEvidence{ + {Rank: 1, ID: owner, Name: "New", Kind: "method", File: "repo/storage/batch_gate.go", Provenance: localizationProvenanceDivergentDefault}, + {Rank: 2, ID: typeID, Name: "BatchGate", Kind: "type", File: "repo/storage/batch_gate.go", Provenance: localizationProvenanceDivergentDefaultType}, + }, + }) + bounded := localizationCompletionBoundedByDigest(completion, digest) + if bounded.State != localizationStateNeedsExactRead || bounded.ExactSymbol != owner || bounded.enforceableOnAnswerReady { + t.Fatalf("exact read retained trust without divergent support: %#v", bounded) + } + ready := newLocalizationCompletion(true, "") + ready.Enforceable = true + ready = localizationCompletionBoundedByDigest(ready, digest) + if ready.State != localizationStateAnswerReady || ready.Enforceable { + t.Fatalf("answer-ready contract retained enforcement without divergent support: %#v", ready) + } + }) +} + +func TestTaskAwareDigestMergeRetainsLongTailSameOwnerCohort(t *testing.T) { + const ( + currentID = "repo/gate.go::BatchGate.drainPending" + targetID = "repo/gate.go::BatchGate.discardPending" + ) + row := func(id, name, qualName, kind, file string) localizationDigestRow { + return localizationDigestRow{ + ID: id, Name: name, QualName: qualName, Kind: kind, File: file, + Signature: strings.Repeat("argument ", 22), + } + } + retainedRows := []localizationDigestRow{ + row("repo/level.go::Level.Normalize", "Normalize", "Level.Normalize", "method", "repo/level.go"), + row("repo/filter.go::Filter.Accept", "Accept", "Filter.Accept", "method", "repo/filter.go"), + row(currentID, "drainPending", "BatchGate.drainPending", "method", "repo/gate.go"), + row("repo/gate.go::BatchGate.resolveSink", "resolveSink", "BatchGate.resolveSink", "method", "repo/gate.go"), + row("repo/gate.go::BatchGate", "BatchGate", "BatchGate", "type", "repo/gate.go"), + row("repo/gate.go::BatchGate.newGate", "newGate", "BatchGate.newGate", "method", "repo/gate.go"), + row("repo/logger.go::Logger.Flush", "Flush", "Logger.Flush", "method", "repo/logger.go"), + row("repo/trace.go::Trace.Record", "Record", "Trace.Record", "method", "repo/trace.go"), + row(targetID, "discardPending", "BatchGate.discardPending", "method", "repo/gate.go"), + row("repo/metrics.go::Metrics.Emit", "Emit", "Metrics.Emit", "method", "repo/metrics.go"), + } + retained := mergeLocalizationEvidenceDigest(nil, &localizationEvidenceDigest{Evidence: retainedRows}) + if len(retained.Evidence) != localizationReplayEvidenceLimit { + t.Fatalf("fixture retained %d rows, want %d", len(retained.Evidence), localizationReplayEvidenceLimit) + } + current := row(currentID, "drainPending", "BatchGate.drainPending", "method", "repo/gate.go") + current.Signature = strings.Repeat("current argument ", 70) + + contains := func(digest *localizationEvidenceDigest, id string) bool { + for _, evidence := range digest.Evidence { + if evidence.ID == id { + return true + } + } + return false + } + baseline := mergeLocalizationEvidenceDigest([]localizationDigestRow{current}, retained) + if contains(baseline, targetID) { + t.Fatal("fixture did not force the unrelated tail to shed the coherent target") + } + + digest := mergeLocalizationEvidenceDigestForTask( + "Batch gate drops queued entries during shutdown", + []localizationDigestRow{current}, + retained, + ) + if len(digest.Evidence) < 4 || digest.Evidence[0].ID != currentID || digest.Evidence[3].ID != targetID { + t.Fatalf("same-owner cohort was not reserved after current evidence: %#v", digest.Evidence) + } + if !contains(digest, targetID) { + t.Fatalf("same-owner target was shed from task-aware digest: %#v", digest.Evidence) + } + encoded, err := json.Marshal(digest) + if err != nil || len(encoded) > localizationDigestMaxBytes || len(digest.finalResponse) > localizationFinalResponseMaxBytes { + t.Fatalf("task-aware digest exceeded bounds: bytes=%d final=%d err=%v", len(encoded), len(digest.finalResponse), err) + } + if len(digest.Files) != len(digest.Evidence) || len(digest.Symbols) != len(digest.Evidence) { + t.Fatalf("task-aware digest lost positional arrays: files=%d symbols=%d evidence=%d", len(digest.Files), len(digest.Symbols), len(digest.Evidence)) + } + for index, evidence := range digest.Evidence { + if evidence.Rank != index+1 || digest.Files[index] != evidence.File || digest.Symbols[index] != evidence.ID { + t.Fatalf("task-aware row %d is not ordinally aligned: %#v", index, evidence) + } + } +} + +func TestTaskAwareDigestMergeKeepsExplicitFourthOwnerMethodUnderTightCap(t *testing.T) { + const ( + currentID = "repo/gate.go::BatchGate.run" + explicitID = "repo/gate.go::BatchGate.forceDiscard" + ) + row := func(id, name, qualName, file string) localizationDigestRow { + return localizationDigestRow{ + ID: id, Name: name, QualName: qualName, Kind: "method", File: file, + Signature: strings.Repeat("argument ", 22), + } + } + retainedRows := []localizationDigestRow{ + row("repo/level.go::Level.normalize", "normalize", "Level.normalize", "repo/level.go"), + row(currentID, "run", "BatchGate.run", "repo/gate.go"), + row("repo/gate.go::BatchGate.prepare", "prepare", "BatchGate.prepare", "repo/gate.go"), + row("repo/filter.go::Filter.accept", "accept", "Filter.accept", "repo/filter.go"), + row("repo/gate.go::BatchGate.resolve", "resolve", "BatchGate.resolve", "repo/gate.go"), + row("repo/logger.go::Logger.flush", "flush", "Logger.flush", "repo/logger.go"), + row("repo/gate.go::BatchGate.drain", "drain", "BatchGate.drain", "repo/gate.go"), + row("repo/trace.go::Trace.record", "record", "Trace.record", "repo/trace.go"), + row(explicitID, "forceDiscard", "BatchGate.forceDiscard", "repo/gate.go"), + row("repo/metrics.go::Metrics.emit", "emit", "Metrics.emit", "repo/metrics.go"), + } + retained := mergeLocalizationEvidenceDigest(nil, &localizationEvidenceDigest{Evidence: retainedRows}) + if len(retained.Evidence) != localizationReplayEvidenceLimit { + t.Fatalf("fixture retained %d rows, want %d", len(retained.Evidence), localizationReplayEvidenceLimit) + } + current := row(currentID, "run", "BatchGate.run", "repo/gate.go") + current.Signature = strings.Repeat("current argument ", 70) + task := "Investigate BatchGate.forceDiscard() during shutdown" + if !localizationDigestRowTaskCited(task, retainedRows[8]) { + t.Fatal("qualified fourth owner method was not recognized as concretely cited") + } + digest := mergeLocalizationEvidenceDigestForTask(task, []localizationDigestRow{current}, retained) + wantHead := []string{ + currentID, + explicitID, + "repo/gate.go::BatchGate.prepare", + "repo/gate.go::BatchGate.resolve", + "repo/gate.go::BatchGate.drain", + } + if len(digest.Evidence) < len(wantHead) { + t.Fatalf("tight digest retained %d rows, want at least %d: %#v", len(digest.Evidence), len(wantHead), digest.Evidence) + } + for index, want := range wantHead { + if digest.Evidence[index].ID != want { + t.Fatalf("tight digest row %d = %q, want %q; all=%#v", index, digest.Evidence[index].ID, want, digest.Evidence) + } + } + encoded, err := json.Marshal(digest) + if err != nil || len(encoded) > localizationDigestMaxBytes || len(digest.finalResponse) > localizationFinalResponseMaxBytes { + t.Fatalf("explicit-priority digest exceeded bounds: bytes=%d final=%d err=%v", len(encoded), len(digest.finalResponse), err) + } + if len(digest.Files) != len(digest.Evidence) || len(digest.Symbols) != len(digest.Evidence) { + t.Fatalf("explicit-priority digest lost positional arrays: files=%d symbols=%d evidence=%d", len(digest.Files), len(digest.Symbols), len(digest.Evidence)) + } + for index, evidence := range digest.Evidence { + if evidence.Rank != index+1 || digest.Files[index] != evidence.File || digest.Symbols[index] != evidence.ID { + t.Fatalf("explicit-priority row %d is not aligned: %#v", index, evidence) + } + } +} + +func TestTaskAwareDigestMergeCapsOwnerMethodPriority(t *testing.T) { + method := func(id, name, qualName, file string) localizationDigestRow { + return localizationDigestRow{ID: id, Name: name, QualName: qualName, Kind: "method", File: file} + } + current := method("repo/gate.go::BatchGate.run", "run", "BatchGate.run", "repo/gate.go") + first := method("repo/gate.go::BatchGate.prepare", "prepare", "BatchGate.prepare", "repo/gate.go") + second := method("repo/gate.go::BatchGate.resolve", "resolve", "BatchGate.resolve", "repo/gate.go") + third := method("repo/gate.go::BatchGate.drain", "drain", "BatchGate.drain", "repo/gate.go") + overflow := method("repo/gate.go::BatchGate.shutdownQueue", "shutdownQueue", "BatchGate.shutdownQueue", "repo/gate.go") + distinctTask := method("repo/planner.go::ShutdownPlanner.apply", "apply", "ShutdownPlanner.apply", "repo/planner.go") + distinctFile := method("repo/trace.go::Trace.record", "record", "Trace.record", "repo/trace.go") + retained := &localizationEvidenceDigest{Evidence: []localizationDigestRow{ + first, second, third, overflow, distinctTask, distinctFile, + }} + ordered := localizationTaskAwareRetainedRows("shutdown planner selection fails", []localizationDigestRow{current}, retained) + wantHead := []string{first.ID, second.ID, third.ID, distinctTask.ID} + for index, want := range wantHead { + if len(ordered) <= index || ordered[index].ID != want { + t.Fatalf("priority row %d = %#v, want %q; all=%#v", index, ordered, want, ordered) + } + } + overflowIndex := -1 + distinctIndex := -1 + for index, row := range ordered { + switch row.ID { + case overflow.ID: + overflowIndex = index + case distinctTask.ID: + distinctIndex = index + } + } + if overflowIndex <= distinctIndex { + t.Fatalf("owner overflow outranked distinct task evidence: %#v", ordered) + } +} + +func TestTaskAwareDigestMergeDoesNotCohortQualifiedFunctions(t *testing.T) { + current := localizationDigestRow{ + ID: "repo/queue.go::queue.flush", Name: "flush", QualName: "queue.flush", Kind: "function", File: "repo/queue.go", + } + packageFunction := localizationDigestRow{ + ID: "repo/queue.go::queue.discard", Name: "discard", QualName: "queue.discard", Kind: "function", File: "repo/queue.go", + } + distinctTask := localizationDigestRow{ + ID: "repo/planner.go::ShutdownPlanner.apply", Name: "apply", QualName: "ShutdownPlanner.apply", Kind: "method", File: "repo/planner.go", + } + ordered := localizationTaskAwareRetainedRows( + "shutdown planner selection fails", + []localizationDigestRow{current}, + &localizationEvidenceDigest{Evidence: []localizationDigestRow{packageFunction, distinctTask}}, + ) + if len(ordered) != 2 || ordered[0].ID != distinctTask.ID || ordered[1].ID != packageFunction.ID { + t.Fatalf("package-qualified functions formed a false owner cohort: %#v", ordered) + } +} + func TestDigestByteCapRetainsSingleMandatoryRowAfterSheddingOptionalFields(t *testing.T) { envelope := localizationExploreEnvelope{Evidence: []localizationEvidence{{ Rank: 1, @@ -398,26 +962,116 @@ func TestLocalizationFinalResponseKeepsDuplicateFilesPositionallyAligned(t *test t.Fatalf("positional digest = files %#v symbols %#v", digest.Files, digest.Symbols) } for index, row := range digest.Evidence { - marker := fmt.Sprintf("#%d %s", index+1, row.ID) + marker := fmt.Sprintf("- PRIMARY — %s:%d — %s", row.File, row.Line, row.ID) if row.Rank != index+1 || !strings.Contains(digest.finalResponse, marker) { t.Fatalf("row %d not aligned in final_response:\n%s", index, digest.finalResponse) } } + for _, parallelSection := range []string{"FILES:", "SYMBOLS:", "EVIDENCE:", "#1"} { + if strings.Contains(digest.finalResponse, parallelSection) { + t.Fatalf("final_response retained ambiguous parallel section %q:\n%s", parallelSection, digest.finalResponse) + } + } encoded, err := json.Marshal(digest) if err != nil || strings.Contains(string(encoded), "body") || strings.Contains(digest.finalResponse, "body") { t.Fatalf("retained digest leaked source: %s (%v)", encoded, err) } } +func TestLocalizationFinalResponseCapsRolesAndPrioritizesProofRelations(t *testing.T) { + rows := make([]localizationDigestRow, 10) + for index := range rows { + rows[index] = localizationDigestRow{ + ID: fmt.Sprintf("repo/pkg/file%02d.go::Worker%02d.Run", index, index), + Name: "Run", QualName: fmt.Sprintf("Worker%02d.Run", index), + Kind: "method", File: fmt.Sprintf("pkg/file%02d.go", index), Line: index + 10, + } + } + rows[7].Provenance = localizationProvenanceImplementationTarget + rows[8].Provenance = localizationProvenanceImplementationRoute + rows[9].Provenance = "direct_callee" + + response := renderLocalizationFinalResponse(rows) + if got := strings.Count(response, "- PRIMARY —"); got != localizationFinalResponsePrimaryLimit { + t.Fatalf("primary rows = %d, want %d:\n%s", got, localizationFinalResponsePrimaryLimit, response) + } + if got := strings.Count(response, "- SUPPORTING —"); got != localizationFinalResponseSupportingLimit { + t.Fatalf("supporting rows = %d, want %d:\n%s", got, localizationFinalResponseSupportingLimit, response) + } + for _, want := range []string{ + "- PRIMARY — pkg/file07.go:17 — repo/pkg/file07.go::Worker07.Run", + "- SUPPORTING — pkg/file08.go:18 — repo/pkg/file08.go::Worker08.Run", + "- SUPPORTING — pkg/file09.go:19 — repo/pkg/file09.go::Worker09.Run", + } { + if !strings.Contains(response, want) { + t.Fatalf("bounded response omitted prioritized tuple %q:\n%s", want, response) + } + } + for _, line := range strings.Split(response, "\n") { + if strings.HasPrefix(line, "- ") && strings.Count(line, " — ") != 2 { + t.Fatalf("response row is not one aligned role/file/symbol tuple: %q", line) + } + } + if !strings.HasSuffix(response, localizationAnswerReadyDirective) || strings.Contains(response, "#1") { + t.Fatalf("response lost the stable directive or restored ordinal cross-references:\n%s", response) + } +} + +func TestLocalizationFinalResponsePromotesTaskAlignedSameOwnerMethod(t *testing.T) { + current := localizationDigestRow{ + ID: "repo/gate.go::BatchGate.drainPending", Name: "drainPending", + QualName: "BatchGate.drainPending", Kind: "method", File: "repo/gate.go", Line: 80, + } + retained := &localizationEvidenceDigest{Evidence: []localizationDigestRow{ + {ID: "repo/gate.go::BatchGate.prepare", Name: "prepare", QualName: "BatchGate.prepare", Kind: "method", File: "repo/gate.go", Line: 25}, + {ID: "repo/gate.go::BatchGate.discardPending", Name: "discardPending", QualName: "BatchGate.discardPending", Kind: "method", File: "repo/gate.go", Line: 64}, + {ID: "repo/trace.go::Trace.record", Name: "record", QualName: "Trace.record", Kind: "method", File: "repo/trace.go", Line: 11}, + }} + digest := mergeLocalizationEvidenceDigestForTask( + "discard pending entries during shutdown", + []localizationDigestRow{current}, + retained, + ) + for _, want := range []string{ + "- PRIMARY — repo/gate.go:80 — repo/gate.go::BatchGate.drainPending", + "- PRIMARY — repo/gate.go:64 — repo/gate.go::BatchGate.discardPending", + } { + if !strings.Contains(digest.finalResponse, want) { + t.Fatalf("same-owner response omitted %q:\n%s", want, digest.finalResponse) + } + } +} + +func TestLocalizationFinalResponsePromotesIdentifierOnlyTaskStem(t *testing.T) { + current := localizationDigestRow{ + ID: "repo/command.go::Command.run", Name: "run", QualName: "Command.run", + Kind: "method", File: "repo/command.go", Line: 30, + } + retained := &localizationEvidenceDigest{Evidence: []localizationDigestRow{ + {ID: "repo/writer.go::Writer.flush", Name: "flush", QualName: "Writer.flush", Kind: "method", File: "repo/writer.go", Line: 15}, + {ID: "repo/formatter.go::Formatter.applyAll", Name: "applyAll", QualName: "Formatter.applyAll", Kind: "method", File: "repo/formatter.go", Line: 52}, + {ID: "repo/planner.go::Planner.commit", Name: "commit", QualName: "Planner.commit", Kind: "method", File: "repo/planner.go", Line: 70}, + }} + digest := mergeLocalizationEvidenceDigestForTask( + "format the generated document", + []localizationDigestRow{current}, + retained, + ) + want := "- PRIMARY — repo/formatter.go:52 — repo/formatter.go::Formatter.applyAll" + if !strings.Contains(digest.finalResponse, want) { + t.Fatalf("identifier task-stem response omitted %q:\n%s", want, digest.finalResponse) + } +} + func TestLocalizationAnswerReadyResultReplaysAlignedStableContract(t *testing.T) { completion := newLocalizationCompletion(true, "") completion.digest = testEvidenceDigest() result := localizationAnswerReadyResult(completion) contract := requireLocalizationTerminalReplay(t, result, "", "") wantRows := []string{ - "FILES:\n#1 repo/storage/disk.go\n#2 repo/storage/cloud.go", - "SYMBOLS:\n#1 repo/storage/disk.go::DiskStorage.Load\n#2 repo/storage/cloud.go::CloudStorage.Load", - "EVIDENCE:\n#1 repo/storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load\n#2 repo/storage/cloud.go:17 — repo/storage/cloud.go::CloudStorage.Load", + "LOCALIZATION:\n", + "- PRIMARY — repo/storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load", + "- PRIMARY — repo/storage/cloud.go:17 — repo/storage/cloud.go::CloudStorage.Load", } for _, want := range wantRows { if !strings.Contains(contract.Completion.FinalResponse, want) { diff --git a/internal/mcp/localization_recovery_test.go b/internal/mcp/localization_recovery_test.go index 0a5f6cdc..6ffd20b4 100644 --- a/internal/mcp/localization_recovery_test.go +++ b/internal/mcp/localization_recovery_test.go @@ -352,6 +352,342 @@ func TestStaleRecoveryCannotConsumeNewTaskState(t *testing.T) { } } +func TestRecoveryEvidenceRejectsLongSymptomSinkEcho(t *testing.T) { + task := "Storage writes stall during commit" + row := localizationDigestRow{ + ID: "repo/storage/report.go::Reporter.ReportStorageFailureAsPending", + Name: "ReportStorageFailureAsPending", + QualName: "Reporter.ReportStorageFailureAsPending", + Kind: "method", + File: "repo/storage/report.go", + Signature: "storage writes stall during commit", + } + if localizationRecoveryEvidenceAlignedWithLead(task, localizationTaskLead(task), "storage", "search.text", []localizationDigestRow{row}) { + t.Fatalf("long symptom sink became confident through body echoes: %#v", row) + } +} + +func TestRecoveryEvidenceAcceptsTwoSegmentImplementation(t *testing.T) { + task := "Storage writes stall during commit" + row := localizationDigestRow{ + ID: "repo/storage/flush.go::Storage.Flush", + Name: "StorageFlush", + QualName: "Storage.Flush", + Kind: "method", + File: "repo/storage/flush.go", + } + if !localizationRecoveryEvidenceAlignedWithLead(task, localizationTaskLead(task), "", "read.source", []localizationDigestRow{row}) { + t.Fatalf("two-segment implementation did not satisfy proportional lead confidence: %#v", row) + } +} + +func TestRecoveryEvidenceAcceptsLongTechnicalIdentifierWithShortLeadTerms(t *testing.T) { + task := "JWT auth failures block requests" + row := localizationDigestRow{ + ID: "repo/auth/policy.go::Policy.ApplyJWTAuthPolicy", + Name: "ApplyJWTAuthPolicy", + QualName: "Policy.ApplyJWTAuthPolicy", + Kind: "method", + File: "repo/auth/policy.go", + Signature: "jwt auth request failure", + } + if !localizationRecoveryEvidenceAlignedWithLead(task, localizationTaskLead(task), "", "read.source", []localizationDigestRow{row}) { + t.Fatalf("short technical lead terms did not establish long identifier coverage: %#v", row) + } +} + +func TestRecoveryEvidenceRejectsGenericCallableWithSignatureOnlyOverlap(t *testing.T) { + task := "Storage commits stall during flush" + row := localizationDigestRow{ + ID: "repo/storage/handler.go::Handler.Handle", + Name: "Handle", + QualName: "Handler.Handle", + Kind: "method", + File: "repo/storage/handler.go", + Signature: "func Handle() storage commits stall during flush", + } + if localizationRecoveryEvidenceAlignedWithLead(task, localizationTaskLead(task), "storage", "search.text", []localizationDigestRow{row}) { + t.Fatalf("generic callable borrowed confidence from its signature: %#v", row) + } +} + +func TestRecoveryWeakResultPreservesAllowanceForEveryOperation(t *testing.T) { + longSink := localizationDigestRow{ + ID: "repo/storage/report.go::Reporter.ReportStorageFailureAsPending", + Name: "ReportStorageFailureAsPending", + QualName: "Reporter.ReportStorageFailureAsPending", + Kind: "method", + File: "repo/storage/report.go", + Signature: "storage writes stall during commit", + } + implementation := localizationDigestRow{ + ID: "repo/storage/flush.go::Storage.Flush", + Name: "StorageFlush", + QualName: "Storage.Flush", + Kind: "method", + File: "repo/storage/flush.go", + } + tests := []struct { + name string + facade string + operation string + arguments map[string]any + retryArguments map[string]any + }{ + { + name: "text search", facade: "search", operation: "text", + arguments: map[string]any{"query": "storage"}, retryArguments: map[string]any{"query": "storage"}, + }, + { + name: "symbol search", facade: "search", operation: "symbols", + arguments: map[string]any{"query": "storage"}, retryArguments: map[string]any{"query": implementation.Name}, + }, + { + name: "source read", facade: "read", operation: "source", + arguments: map[string]any{"target": map[string]any{"symbol": longSink.ID}}, + retryArguments: map[string]any{"target": map[string]any{"symbol": implementation.ID}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := newLocalizationTerminalState() + state.armForTask(newLocalizationRecoveryCompletion(), "Storage writes stall during commit") + + blocked, token := state.authorizeWithToken(tt.facade, tt.operation, tt.arguments) + if blocked != nil || token == 0 { + t.Fatalf("first recovery authorization = (%#v, %d)", blocked, token) + } + completion := state.finishReservedReadTokenWithDigest(token, true, []localizationDigestRow{longSink}, true) + if completion.State != localizationStateNeedsRecovery || completion.AllowedToolCalls != 1 { + t.Fatalf("weak recovery completion = %#v, want preserved allowance", completion) + } + + blocked, retryToken := state.authorizeWithToken(tt.facade, tt.operation, tt.retryArguments) + if blocked != nil || retryToken == 0 { + t.Fatalf("retry recovery authorization = (%#v, %d)", blocked, retryToken) + } + completion = state.finishReservedReadTokenWithDigest(retryToken, true, []localizationDigestRow{implementation}, true) + if completion.State != localizationStateAnswerReady || completion.AllowedToolCalls != 0 { + t.Fatalf("strong recovery completion = %#v, want answer_ready", completion) + } + }) + } +} + +func TestPlannedRecoveryDerivesOneProductionCallableFamily(t *testing.T) { + current := []localizationDigestRow{{ + ID: "repo/stream.go::stream_multi_line_handler", + Name: "stream_multi_line_handler", + Kind: "function", + File: "repo/stream.go", + }} + retained := &localizationEvidenceDigest{Evidence: []localizationDigestRow{ + { + ID: "repo/codec.go::Codec.transform_with_capture", + Name: "transform_with_capture", + Kind: "method", + File: "repo/codec.go", + }, + { + ID: "repo/codec.go::Codec.transform_with_capture_at", + Kind: "method", + File: "repo/codec.go", + }, + { + ID: "repo/tests/codec_test.go::transform_via_fixture", + Name: "transform_via_fixture", + Kind: "function", + File: "repo/tests/codec_test.go", + }, + }} + operation, anchor, ok := localizationPlannedRecoveryForWeakRefinement( + "combining --multi-line with --transform duplicates output", + current, + retained, + ) + if !ok || operation != "search.symbols" || anchor != "transform_with" { + t.Fatalf("planned recovery = (%q, %q, %v), want search.symbols transform_with", operation, anchor, ok) + } +} + +func TestPlannedRecoveryRequiresUniqueComplementaryFamily(t *testing.T) { + current := []localizationDigestRow{{ + ID: "repo/stream.go::multi_line_handler", Name: "multi_line_handler", Kind: "function", File: "repo/stream.go", + }} + tests := []struct { + name string + task string + current []localizationDigestRow + retained []localizationDigestRow + }{ + { + name: "ambiguous families", + task: "combining --multi-line with --transform duplicates output", + current: current, + retained: []localizationDigestRow{ + {ID: "repo/codec.go::transform_with_capture", Name: "transform_with_capture", Kind: "function", File: "repo/codec.go"}, + {ID: "repo/codec.go::transform_via_buffer", Name: "transform_via_buffer", Kind: "function", File: "repo/codec.go"}, + }, + }, + { + name: "current read covers no explicit concept", + task: "combining --multi-line with --transform duplicates output", + current: []localizationDigestRow{{ID: "repo/sink.go::output_sink", Name: "output_sink", Kind: "function", File: "repo/sink.go"}}, + retained: []localizationDigestRow{{ID: "repo/codec.go::transform_with_capture", Name: "transform_with_capture", Kind: "function", File: "repo/codec.go"}}, + }, + { + name: "only one explicit concept", + task: "--transform duplicates output", + current: current, + retained: []localizationDigestRow{{ID: "repo/codec.go::transform_with_capture", Name: "transform_with_capture", Kind: "function", File: "repo/codec.go"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if operation, anchor, ok := localizationPlannedRecoveryForWeakRefinement( + tt.task, + tt.current, + &localizationEvidenceDigest{Evidence: tt.retained}, + ); ok { + t.Fatalf("unexpected planned recovery = (%q, %q)", operation, anchor) + } + }) + } +} + +func TestPlannedRecoveryReplaceFamilyRegression(t *testing.T) { + current := []localizationDigestRow{{ + ID: "crates/searcher/src/sink.rs::sink_slow_multi_line_only_matching", + Name: "sink_slow_multi_line_only_matching", + Kind: "function", + File: "crates/searcher/src/sink.rs", + }} + retained := &localizationEvidenceDigest{Evidence: []localizationDigestRow{ + current[0], + { + ID: "crates/searcher/src/glue.rs::Matcher.replace_with_captures", + Name: "replace_with_captures", + QualName: "Matcher.replace_with_captures", + Kind: "method", + File: "crates/searcher/src/glue.rs", + }, + { + ID: "crates/searcher/src/glue.rs::Matcher.replace_with_captures_at", + Name: "replace_with_captures_at", + QualName: "Matcher.replace_with_captures_at", + Kind: "method", + File: "crates/searcher/src/glue.rs", + }, + }} + operation, anchor, ok := localizationPlannedRecoveryForWeakRefinement( + "--multiline with --replace duplicates output while --only-matching spans lines", + current, + retained, + ) + if !ok || operation != "search.symbols" || anchor != "replace_with" { + t.Fatalf("planned recovery = (%q, %q, %v), want search.symbols replace_with", operation, anchor, ok) + } +} + +func TestPlannedRecoveryIsExactRetriableAndFallsBackToGeneric(t *testing.T) { + task := "Printed records are duplicated unexpectedly\ncombining --multiline with --replace while --only-matching spans lines" + preferred := "crates/searcher/src/sink.rs::sink_slow_multi_line_only_matching" + current := []localizationDigestRow{{ + ID: preferred, Name: "sink_slow_multi_line_only_matching", Kind: "function", File: "crates/searcher/src/sink.rs", + }} + retained := &localizationEvidenceDigest{Evidence: []localizationDigestRow{ + current[0], + {ID: "crates/searcher/src/glue.rs::Matcher.replace_with_captures", Name: "replace_with_captures", Kind: "method", File: "crates/searcher/src/glue.rs"}, + {ID: "crates/searcher/src/glue.rs::Matcher.replace_with_captures_at", Name: "replace_with_captures_at", Kind: "method", File: "crates/searcher/src/glue.rs"}, + }} + state := newLocalizationTerminalState() + state.armRefinementForTask(task, preferred, []string{preferred}, retained) + blocked, token := state.authorizeWithToken("read", "source", map[string]any{"target": map[string]any{"symbol": preferred}}) + if blocked != nil || token == 0 { + t.Fatalf("refinement authorization = (%#v, %d)", blocked, token) + } + completion := state.finishReservedReadTokenWithDigest(token, true, current, true) + if completion.State != localizationStateNeedsRecovery || completion.RequiredAction != `search.symbols("replace_with")` { + t.Fatalf("planned completion = %#v", completion) + } + if len(completion.AllowedOperations) != 1 || completion.AllowedOperations[0] != "search.symbols" { + t.Fatalf("planned allowed operations = %#v", completion.AllowedOperations) + } + wantInstruction := `Call Gortex MCP search(operation:"symbols", query:"replace_with"); then respond from the accepted result and follow its completion.` + if completion.Instruction != wantInstruction { + t.Fatalf("planned instruction = %q, want %q", completion.Instruction, wantInstruction) + } + encoded, err := json.Marshal(completion) + if err != nil { + t.Fatalf("marshal planned completion: %v", err) + } + var wire map[string]any + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatalf("decode planned completion: %v", err) + } + if _, exists := wire["recoveryOperation"]; exists { + t.Fatalf("session-only recovery operation leaked onto wire: %s", encoded) + } + if _, exists := wire["recoveryAnchor"]; exists { + t.Fatalf("session-only recovery anchor leaked onto wire: %s", encoded) + } + + wrong, generation := state.interceptAnswerReady("read", "file", map[string]any{"target": map[string]any{"file": "CHANGELOG.md"}}) + if wrong == nil || !wrong.IsError || generation != 0 { + t.Fatalf("wrong planned navigation = (%#v, %d), want retriable block", wrong, generation) + } + text, ok := singleTextContent(wrong) + if !ok { + t.Fatalf("wrong planned navigation content = %#v", wrong.Content) + } + var mismatch struct { + Retriable bool `json:"retriable"` + } + if err := json.Unmarshal([]byte(text), &mismatch); err != nil || !mismatch.Retriable { + t.Fatalf("planned mismatch = (%#v, %v)", mismatch, err) + } + state.mu.Lock() + stored := state.completionLocked() + state.mu.Unlock() + if stored.RequiredAction != completion.RequiredAction || stored.AllowedToolCalls != 1 { + t.Fatalf("planned allowance changed after wrong navigation: %#v", stored) + } + + wrong, token = state.authorizeWithToken("search", "symbols", map[string]any{"query": "replace"}) + if wrong == nil || token != 0 { + t.Fatalf("wrong planned anchor = (%#v, %d)", wrong, token) + } + blocked, token = state.authorizeWithToken("search", "symbols", map[string]any{"query": "replace_with"}) + if blocked != nil || token == 0 { + t.Fatalf("exact planned recovery authorization = (%#v, %d)", blocked, token) + } + weak := []localizationDigestRow{{ + ID: "crates/searcher/src/glue.rs::Matcher.replace_with_captures", + Name: "replace_with_captures", Kind: "method", File: "crates/searcher/src/glue.rs", + }} + completion = state.finishReservedReadTokenWithDigest(token, true, weak, true) + generic := newLocalizationRecoveryCompletion() + if completion.RequiredAction != generic.RequiredAction || completion.Instruction != generic.Instruction || + len(completion.AllowedOperations) != len(generic.AllowedOperations) { + t.Fatalf("weak planned result did not fall back to generic recovery: %#v", completion) + } +} + +func TestPlannedRecoveryEmptyResultFallsBackToGeneric(t *testing.T) { + state := newLocalizationTerminalState() + state.armForTask(newLocalizationPlannedRecoveryCompletion("search.symbols", "transform_with"), "--multi-line with --transform duplicates output") + blocked, token := state.authorizeWithToken("search", "symbols", map[string]any{"query": "transform_with"}) + if blocked != nil || token == 0 { + t.Fatalf("planned recovery authorization = (%#v, %d)", blocked, token) + } + completion := state.finishReservedReadTokenWithDigest(token, true, nil, true) + generic := newLocalizationRecoveryCompletion() + if completion.State != localizationStateNeedsRecovery || completion.RequiredAction != generic.RequiredAction || + completion.Instruction != generic.Instruction || len(completion.AllowedOperations) != len(generic.AllowedOperations) { + t.Fatalf("empty planned result did not fall back to generic recovery: %#v", completion) + } +} + func localizationRecoveryRequest(facade, operation string, arguments map[string]any) mcpgo.CallToolRequest { arguments["operation"] = operation return mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{Name: facade, Arguments: arguments}} @@ -385,7 +721,7 @@ func requireLocalizationResultStateEqual( if wire.RequiredAction != "recover_once" || len(wire.AllowedOperations) != len(localizationRecoveryOperations) { t.Fatalf("recovery completion is not directional/machine-readable: %#v", wire) } - wantInstruction := `Make exactly one bounded Gortex recovery call: search(operation:"text" or "symbols", query:) or read(operation:"source", target:{symbol:}); then respond from the returned evidence.` + wantInstruction := `Make one accepted, bounded Gortex MCP recovery call: search(operation:"text" or "symbols", query:) or read(operation:"source", target:{symbol:}). If Gortex explicitly rejects an overbroad request and preserves the recovery allowance, correct it using only Gortex MCP search or read; the rejected request does not count as the accepted recovery. Do not call host Read, Grep, Glob, Bash, or any other non-Gortex tool. Then respond from the accepted result and follow its completion.` if wire.Instruction != wantInstruction { t.Fatalf("recovery instruction = %q, want %q", wire.Instruction, wantInstruction) } diff --git a/internal/mcp/localization_terminal.go b/internal/mcp/localization_terminal.go index 05c2b361..67db237d 100644 --- a/internal/mcp/localization_terminal.go +++ b/internal/mcp/localization_terminal.go @@ -56,6 +56,15 @@ type localizationCompletion struct { // authorized read without claiming that the current response is terminal. // It defaults false until the evidence policy explicitly opts in. enforceableOnAnswerReady bool + // taskLead is the bounded, normalized first issue line used only to check + // whether an advisory read covers the task's primary claim. The full prompt + // is never retained here. + taskLead string + // recoveryOperation and recoveryAnchor are a session-only refinement plan. + // They intentionally have no JSON fields: the existing completion contract + // renders the exact required call without expanding the wire schema. + recoveryOperation string + recoveryAnchor string // digest is the bounded evidence projection carried session-only through // reservation staging (see localization_digest.go). Post-terminal results @@ -109,13 +118,29 @@ func newLocalizationRecoveryCompletion() localizationCompletion { State: localizationStateNeedsRecovery, Scope: "localization", RequiredAction: "recover_once", - Instruction: `Make exactly one bounded Gortex recovery call: search(operation:"text" or "symbols", query:) or read(operation:"source", target:{symbol:}); then respond from the returned evidence.`, + Instruction: `Make one accepted, bounded Gortex MCP recovery call: search(operation:"text" or "symbols", query:) or read(operation:"source", target:{symbol:}). If Gortex explicitly rejects an overbroad request and preserves the recovery allowance, correct it using only Gortex MCP search or read; the rejected request does not count as the accepted recovery. Do not call host Read, Grep, Glob, Bash, or any other non-Gortex tool. Then respond from the accepted result and follow its completion.`, AllowedToolCalls: 1, ContractVersion: localizationTerminalContractV2, AllowedOperations: append([]string(nil), localizationRecoveryOperations...), } } +func newLocalizationPlannedRecoveryCompletion(operation, anchor string) localizationCompletion { + operation = strings.TrimSpace(operation) + anchor = strings.TrimSpace(anchor) + return localizationCompletion{ + State: localizationStateNeedsRecovery, + Scope: "localization", + RequiredAction: fmt.Sprintf(`%s(%q)`, operation, anchor), + Instruction: fmt.Sprintf(`Call Gortex MCP search(operation:"symbols", query:%q); then respond from the accepted result and follow its completion.`, anchor), + AllowedToolCalls: 1, + ContractVersion: localizationTerminalContractV2, + AllowedOperations: []string{operation}, + recoveryOperation: operation, + recoveryAnchor: anchor, + } +} + func newLocalizationCompletion(answerReady bool, exactSymbol string) localizationCompletion { if answerReady { return localizationCompletion{ @@ -214,11 +239,17 @@ type localizationTerminalState struct { inFlightImplementationSymbol string inFlightEnforceable bool inFlightCorrectionSymbol string - exactReadIsCorrection bool - exactReadRoute localizationRefinementRoute - correctionRetriesRemaining uint8 - refinementRetriesRemaining uint8 - recoveryRetriesRemaining uint8 + inFlightRecoveryAnchor string + inFlightRecoveryOperation string + // recoveryOperation/recoveryAnchor retain an exact, session-only recovery + // plan while its permitted search is pending or in flight. + recoveryOperation string + recoveryAnchor string + exactReadIsCorrection bool + exactReadRoute localizationRefinementRoute + correctionRetriesRemaining uint8 + refinementRetriesRemaining uint8 + recoveryRetriesRemaining uint8 // Read reservations are tokenized independently of localization calls. A // reset or newly armed task invalidates an old token, so a late read cannot // finish (or decorate itself with) a newer task's contract. @@ -229,6 +260,7 @@ type localizationTerminalState struct { // exact/refinement read. Its zero value is deliberately advisory. enforceableOnAnswerReady bool taskFingerprint string + taskLead string generation uint64 nextReservation uint64 reservation *localizationReservation @@ -267,6 +299,10 @@ func (s *localizationTerminalState) reset() { s.inFlightImplementationSymbol = "" s.inFlightEnforceable = false s.inFlightCorrectionSymbol = "" + s.inFlightRecoveryAnchor = "" + s.inFlightRecoveryOperation = "" + s.recoveryOperation = "" + s.recoveryAnchor = "" s.exactReadIsCorrection = false s.exactReadRoute = localizationRefinementRoute{} s.correctionRetriesRemaining = 0 @@ -276,6 +312,7 @@ func (s *localizationTerminalState) reset() { s.readReservationGen = 0 s.enforceableOnAnswerReady = false s.taskFingerprint = "" + s.taskLead = "" s.digest = nil // Keep an in-flight reservation until its owner finishes. Its captured // generation is now stale, so finishLocalize cannot commit it, while a @@ -294,6 +331,9 @@ func (s *localizationTerminalState) armForTask(completion localizationCompletion s.mu.Lock() defer s.mu.Unlock() fingerprint := localizationTaskFingerprint(task) + if completion.State != localizationStateInactive { + completion.taskLead = localizationTaskLead(task) + } if s.reservation != nil { s.reservation.pendingCompletion = completion s.reservation.pendingTaskFingerprint = fingerprint @@ -387,6 +427,10 @@ func (s *localizationTerminalState) commitLocalizationLocked(completion localiza s.inFlightImplementationSymbol = "" s.inFlightEnforceable = false s.inFlightCorrectionSymbol = "" + s.inFlightRecoveryAnchor = "" + s.inFlightRecoveryOperation = "" + s.recoveryOperation = completion.recoveryOperation + s.recoveryAnchor = completion.recoveryAnchor s.exactReadIsCorrection = false s.exactReadRoute = localizationRefinementRoute{} s.correctionRetriesRemaining = 0 @@ -413,6 +457,7 @@ func (s *localizationTerminalState) commitLocalizationLocked(completion localiza s.recoveryRetriesRemaining = 1 } s.taskFingerprint = fingerprint + s.taskLead = completion.taskLead // The digest follows the contract: an inactive commit (keepOpenForTask) // carries nil and clears it; every localize commit replaces it. s.digest = completion.digest @@ -432,6 +477,9 @@ func (s *localizationTerminalState) completionLocked() localizationCompletion { completion = newLocalizationExactReadCompletion(s.exactSymbol, s.exactReadIsCorrection) case localizationStateNeedsRecovery, localizationStateRecoveryInFlight: completion = newLocalizationRecoveryCompletion() + if s.recoveryOperation != "" && s.recoveryAnchor != "" { + completion = newLocalizationPlannedRecoveryCompletion(s.recoveryOperation, s.recoveryAnchor) + } if s.state == localizationStateRecoveryInFlight { completion.State = localizationStateRecoveryInFlight completion.RequiredAction = "wait" @@ -444,6 +492,7 @@ func (s *localizationTerminalState) completionLocked() localizationCompletion { completion = newLocalizationCompletion(true, "") } completion.enforceableOnAnswerReady = s.enforceableOnAnswerReady + completion.taskLead = s.taskLead if completion.State == localizationStateAnswerReady { completion.Enforceable = s.enforceableOnAnswerReady } @@ -466,6 +515,9 @@ func (s *localizationTerminalState) interceptAnswerReady(facade, operation strin case localizationStateAnswerReady: return localizationTerminalResult(s.completionLocked(), facade, operation), 0 case localizationStateNeedsRecovery: + if s.localizationRecoveryPlannedLocked() && !s.localizationRecoveryAllowsLocked(facade, operation, arguments) { + return localizationPlannedRecoveryMismatchResult(s.completionLocked(), facade, operation), 0 + } if s.localizationRecoveryAllowsLocked(facade, operation, arguments) { // Carry the task generation through later schema validation. A stale // invalid request must never consume a newly committed task's recovery. @@ -493,15 +545,29 @@ func (s *localizationTerminalState) refinementAllowsLocked(symbol string) bool { return authorized } +func (s *localizationTerminalState) localizationRecoveryPlannedLocked() bool { + return s.recoveryOperation != "" && s.recoveryAnchor != "" +} + +func (s *localizationTerminalState) localizationRecoveryPlanAllowsLocked(facade, operation string, arguments map[string]any) bool { + return s.localizationRecoveryPlannedLocked() && + s.recoveryOperation == facade+"."+operation && + s.recoveryAnchor == localizationRecoveryAnchor(facade, operation, arguments) +} + func localizationRecoveryAllows(facade, operation string, arguments map[string]any) bool { + return localizationRecoveryAnchor(facade, operation, arguments) != "" +} + +func localizationRecoveryAnchor(facade, operation string, arguments map[string]any) string { switch facade + "." + operation { case "search.text", "search.symbols": query, _ := arguments["query"].(string) - return strings.TrimSpace(query) != "" + return strings.TrimSpace(query) case "read.source": - return exactLocalizationSymbol(arguments) != "" + return exactLocalizationSymbol(arguments) default: - return false + return "" } } @@ -513,6 +579,9 @@ func localizationRecoveryAllows(facade, operation string, arguments map[string]a // // s.mu must be held by the caller. func (s *localizationTerminalState) localizationRecoveryAllowsLocked(facade, operation string, arguments map[string]any) bool { + if s.localizationRecoveryPlannedLocked() { + return s.localizationRecoveryPlanAllowsLocked(facade, operation, arguments) + } if !localizationRecoveryAllows(facade, operation, arguments) { return false } @@ -597,18 +666,21 @@ func localizationRecoveryConcreteIdentifier(query string) bool { return hasQualifier || hasCaseBoundary || (unicode.IsUpper(runes[0]) && hasUpper && hasLower) } -// localizationReservedReadEvidenceAligned recognizes a successful reserved read -// as sufficient only when its typed graph identity agrees with a concrete task -// anchor. Two independent semantic terms are also accepted; a single generic -// overlap keeps the bounded recovery path open. -func localizationReservedReadEvidenceAligned(task, requested string, rows []localizationDigestRow) bool { +func localizationReservedReadEvidenceAlignedWithLead(task, lead, requested string, rows []localizationDigestRow) bool { if strings.TrimSpace(task) == "" || len(rows) == 0 { return false } if localizationTaskCitesConcreteEvidence(task, requested) { return true } + if strings.TrimSpace(lead) == "" { + lead = localizationTaskLead(task) + } taskTerms := exploreTerminalTerms(task) + leadTerms := exploreTerminalTerms(lead) + if len(taskTerms) == 0 || len(leadTerms) == 0 { + return false + } for _, row := range rows { values := []string{row.ID, row.Name, row.QualName, row.File, row.Signature} for _, value := range values { @@ -616,19 +688,371 @@ func localizationReservedReadEvidenceAligned(task, requested string, rows []loca return true } } - overlap := 0 - for term := range exploreTerminalTerms(strings.Join(values, " ")) { + candidateTerms := exploreTerminalTerms(strings.Join(values, " ")) + overallOverlap := 0 + leadOverlap := 0 + for term := range candidateTerms { if _, aligned := taskTerms[term]; aligned { - overlap++ + overallOverlap++ + } + if _, aligned := leadTerms[term]; aligned { + leadOverlap++ + } + } + if overallOverlap >= 2 && leadOverlap > 0 { + return true + } + if localizationDigestRowHasStrongCompoundLeadMatch(row, taskTerms, leadTerms) { + return true + } + } + return false +} + +// localizationRecoveryEvidenceAlignedWithLead applies the reserved-read +// confidence test to recovery pages while refusing non-explicit long callable +// names that borrow their second semantic hit from source or signature text. +func localizationRecoveryEvidenceAlignedWithLead(task, lead, requested, operation string, rows []localizationDigestRow) bool { + if strings.TrimSpace(task) == "" || len(rows) == 0 { + return false + } + if localizationTaskCitesConcreteEvidence(task, requested) { + return true + } + if strings.TrimSpace(lead) == "" { + lead = localizationTaskLead(task) + } + taskTerms := exploreTerminalTerms(task) + leadTerms := exploreTerminalTerms(lead) + if len(taskTerms) == 0 || len(leadTerms) == 0 { + return false + } + for _, row := range rows { + values := []string{row.ID, row.Name, row.QualName, row.File, row.Signature} + for _, value := range values { + if localizationTaskCitesConcreteEvidence(task, value) { + return true } } - if overlap >= 2 { + if operation == "search.symbols" && localizationRecoveryAnchorMatchesRow(requested, row) { + return true + } + if localizationDigestRowLacksRecoveryLeadCoverage(row, leadTerms) { + continue + } + if localizationReservedReadEvidenceAlignedWithLead(task, lead, "", []localizationDigestRow{row}) { return true } } return false } +func localizationRecoveryAnchorMatchesRow(requested string, row localizationDigestRow) bool { + requested = strings.Trim(strings.TrimSpace(requested), "`'\"") + if requested == "" { + return false + } + for _, value := range []string{row.ID, row.Name, row.QualName} { + if strings.EqualFold(strings.TrimSpace(value), requested) { + return true + } + } + if !localizationRecoveryConcreteIdentifier(requested) { + return false + } + lowerRequested := strings.ToLower(requested) + return strings.HasSuffix(strings.ToLower(strings.TrimSpace(row.ID)), "::"+lowerRequested) || + strings.HasSuffix(strings.ToLower(strings.TrimSpace(row.QualName)), "."+lowerRequested) +} + +// localizationDigestRowLacksRecoveryLeadCoverage prevents callable source or +// signature text from substituting for a task-aligned identifier. Every +// callable needs one whole filtered issue-lead term in Name; descriptive names +// with more than two identifier segments need two. Citation and exact query +// bypasses are handled before this recovery-only check. +func localizationDigestRowLacksRecoveryLeadCoverage(row localizationDigestRow, leadTerms map[string]struct{}) bool { + if !localizationDigestRowCallable(row) { + return false + } + required := 1 + if exploreIdentifierSegmentCountBounded(row.Name) > 2 { + required = 2 + } + matchedConcepts := 0 + for term := range leadTerms { + if exploreIdentifierTerminalMatches(row.Name, []string{term}) == 0 { + continue + } + matchedConcepts++ + if matchedConcepts >= required { + return false + } + } + return true +} + +func localizationDigestRowHasStrongCompoundLeadMatch(row localizationDigestRow, taskTerms, leadTerms map[string]struct{}) bool { + if !localizationDigestRowCallable(row) { + return false + } + segments := 0 + totalLetters := 0 + matchedLetters := 0 + for offset := 0; offset < len(row.Name); { + start, end, next, ascii := nextExploreASCIIIdentifierToken(row.Name, offset) + if !ascii { + return false + } + if start < 0 { + break + } + segments++ + segmentLetters := 0 + for index := start; index < end; index++ { + if exploreASCIILower(row.Name[index]) || exploreASCIIUpper(row.Name[index]) { + segmentLetters++ + } + } + totalLetters += segmentLetters + for term := range leadTerms { + if len(term) < 5 { + continue + } + if _, aligned := taskTerms[term]; !aligned { + continue + } + if exploreIdentifierTerminalMatches(row.Name[start:end], []string{term}) != 0 { + matchedLetters += segmentLetters + break + } + } + offset = next + } + return segments == 2 && totalLetters > 0 && matchedLetters*2 >= totalLetters +} + +func localizationDigestRowCallable(row localizationDigestRow) bool { + switch strings.ToLower(strings.TrimSpace(row.Kind)) { + case "function", "method": + return true + default: + return false + } +} + +// localizationPlannedRecoveryForWeakRefinement derives one exact symbol-search +// family only when the successful refinement read covers one explicit task +// concept and the retained production evidence contains exactly one family for +// a different, uncovered concept. It never consults source or signature text. +func localizationPlannedRecoveryForWeakRefinement( + task string, + current []localizationDigestRow, + retained *localizationEvidenceDigest, +) (string, string, bool) { + concepts := localizationExplicitTaskConcepts(task) + if len(concepts) < 2 || len(current) == 0 || retained == nil || len(retained.Evidence) == 0 { + return "", "", false + } + missing := make(map[string]struct{}, len(concepts)) + covered := false + for _, concept := range concepts { + if localizationRowsCoverExplicitConcept(current, concept) { + covered = true + continue + } + missing[concept] = struct{}{} + } + if !covered || len(missing) == 0 { + return "", "", false + } + + currentIDs := make(map[string]struct{}, len(current)) + for _, row := range current { + if id := strings.TrimSpace(row.ID); id != "" { + currentIDs[id] = struct{}{} + } + } + families := make(map[string]struct{}, 2) + for _, row := range retained.Evidence { + if !localizationDigestRowProductionCallable(row) { + continue + } + if _, alreadyRead := currentIDs[strings.TrimSpace(row.ID)]; alreadyRead && strings.TrimSpace(row.ID) != "" { + continue + } + name := localizationDigestCallableName(row) + for concept := range missing { + anchor, ok := localizationComplementaryCallableFamily(name, concept) + if !ok { + continue + } + families[anchor] = struct{}{} + if len(families) > 1 { + return "", "", false + } + } + } + for anchor := range families { + return "search.symbols", anchor, true + } + return "", "", false +} + +func localizationExplicitTaskConcepts(task string) []string { + seen := make(map[string]struct{}) + concepts := make([]string, 0, 4) + for offset := 0; offset+2 < len(task); { + relative := strings.Index(task[offset:], "--") + if relative < 0 { + break + } + start := offset + relative + if start > 0 && (exploreASCIILower(task[start-1]) || exploreASCIIUpper(task[start-1]) || exploreASCIIDigit(task[start-1]) || task[start-1] == '-') { + offset = start + 2 + continue + } + end := start + 2 + for end < len(task) { + ch := task[end] + if !exploreASCIILower(ch) && !exploreASCIIUpper(ch) && !exploreASCIIDigit(ch) && ch != '-' && ch != '_' { + break + } + end++ + } + concept := localizationNormalizedConcept(task[start+2 : end]) + if len(concept) >= 3 { + if _, duplicate := seen[concept]; !duplicate { + seen[concept] = struct{}{} + concepts = append(concepts, concept) + } + } + if end <= start+2 { + offset = start + 2 + } else { + offset = end + } + } + return concepts +} + +func localizationNormalizedConcept(value string) string { + var normalized strings.Builder + normalized.Grow(len(value)) + for index := 0; index < len(value); index++ { + ch := value[index] + switch { + case exploreASCIIUpper(ch): + normalized.WriteByte(ch + ('a' - 'A')) + case exploreASCIILower(ch), exploreASCIIDigit(ch): + normalized.WriteByte(ch) + } + } + return normalized.String() +} + +func localizationRowsCoverExplicitConcept(rows []localizationDigestRow, concept string) bool { + for _, row := range rows { + for _, value := range []string{row.Name, localizationDigestCallableName(row)} { + segments := localizationIdentifierSegments(value) + if _, _, ok := localizationIdentifierConceptSpan(segments, concept); ok { + return true + } + } + } + return false +} + +func localizationDigestRowProductionCallable(row localizationDigestRow) bool { + if !localizationDigestRowCallable(row) { + return false + } + path := "/" + strings.ToLower(strings.ReplaceAll(strings.TrimSpace(row.File), "\\", "/")) + return !strings.HasSuffix(path, "_test.go") && + !strings.Contains(path, "/test/") && + !strings.Contains(path, "/tests/") && + !strings.Contains(path, ".test.") && + !strings.Contains(path, ".spec.") +} + +func localizationDigestCallableName(row localizationDigestRow) string { + if name := strings.TrimSpace(row.Name); name != "" { + return name + } + for _, value := range []string{row.QualName, row.ID} { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if index := strings.LastIndex(value, "::"); index >= 0 { + value = value[index+2:] + } + if index := strings.LastIndexByte(value, '.'); index >= 0 { + value = value[index+1:] + } + if name := strings.TrimSpace(value); name != "" { + return name + } + } + return "" +} + +func localizationIdentifierSegments(value string) []string { + segments := make([]string, 0, 6) + for offset := 0; offset < len(value); { + start, end, next, ascii := nextExploreASCIIIdentifierToken(value, offset) + if !ascii { + return nil + } + if start < 0 { + break + } + segment := localizationNormalizedConcept(value[start:end]) + if segment != "" { + segments = append(segments, segment) + } + if next <= offset { + break + } + offset = next + } + return segments +} + +func localizationIdentifierConceptSpan(segments []string, concept string) (int, int, bool) { + concept = localizationNormalizedConcept(concept) + for start := range segments { + joined := "" + for end := start; end < len(segments); end++ { + joined += segments[end] + if joined == concept { + return start, end, true + } + if len(joined) >= len(concept) { + break + } + } + } + return 0, 0, false +} + +func localizationComplementaryCallableFamily(name, concept string) (string, bool) { + segments := localizationIdentifierSegments(name) + start, end, ok := localizationIdentifierConceptSpan(segments, concept) + if !ok || end+2 >= len(segments) || !localizationRecoveryFamilyConnector(segments[end+1]) { + return "", false + } + return strings.Join(segments[start:end+2], "_"), true +} + +func localizationRecoveryFamilyConnector(segment string) bool { + switch segment { + case "as", "at", "by", "for", "from", "in", "into", "of", "on", "to", "using", "via", "with", "without": + return true + default: + return false + } +} + func localizationTaskCitesConcreteEvidence(task, value string) bool { value = strings.TrimSpace(value) if value == "" { @@ -698,6 +1122,20 @@ func (s *localizationTerminalState) consumeInvalidRecovery(facade, operation str return s.completionLocked(), true } +func localizationPlannedRecoveryMismatchResult(completion localizationCompletion, facade, operation string) *mcpgo.CallToolResult { + return newStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeLocalizationComplete, + Message: fmt.Sprintf("the planned localization recovery is %s; the recovery allowance is still available", completion.RequiredAction), + Retriable: true, + Data: map[string]any{ + "contract": localizationContractFor(completion), + "facade": facade, + "operation": operation, + "allowed_operations": append([]string(nil), completion.AllowedOperations...), + }, + }, true) +} + func localizationRecoveryMisalignedResult(completion localizationCompletion, facade, operation string) *mcpgo.CallToolResult { return newStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeLocalizationComplete, @@ -816,6 +1254,32 @@ func localizationInProgressResult() *mcpgo.CallToolResult { }) } +const localizationTaskLeadMaxRunes = 240 + +// localizationTaskLead retains only the normalized first non-empty issue line +// (or its leading clause) so terminal confidence can distinguish a report's +// primary claim from supporting body vocabulary. +func localizationTaskLead(task string) string { + lead := "" + for _, line := range strings.Split(task, "\n") { + if line = strings.TrimSpace(line); line != "" { + lead = line + break + } + } + if lead == "" { + return "" + } + if clause := inlineLeadClause(lead); clause != "" { + lead = clause + } + lead = strings.Join(strings.Fields(lead), " ") + if runes := []rune(lead); len(runes) > localizationTaskLeadMaxRunes { + lead = string(runes[:localizationTaskLeadMaxRunes]) + } + return lead +} + func localizationTaskFingerprint(task string) string { return strings.Join(strings.Fields(task), " ") } @@ -849,7 +1313,12 @@ func (s *localizationTerminalState) authorizeWithToken(facade, operation string, return localizationTerminalResult(s.completionLocked(), facade, operation), 0 } if s.state == localizationStateNeedsRecovery { + if s.localizationRecoveryPlannedLocked() && !s.localizationRecoveryAllowsLocked(facade, operation, arguments) { + return localizationPlannedRecoveryMismatchResult(s.completionLocked(), facade, operation), 0 + } if s.localizationRecoveryAllowsLocked(facade, operation, arguments) { + s.inFlightRecoveryAnchor = localizationRecoveryAnchor(facade, operation, arguments) + s.inFlightRecoveryOperation = facade + "." + operation s.state = localizationStateRecoveryInFlight return nil, s.beginReadReservationLocked() } @@ -968,7 +1437,7 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( zeroResult := wireSuccess && evidenceRecorded && len(currentEvidence) == 0 var mergedDigest *localizationEvidenceDigest if capturedResult { - mergedDigest = mergeLocalizationEvidenceDigest(currentEvidence, s.digest) + mergedDigest = mergeLocalizationEvidenceDigestForTask(s.taskFingerprint, currentEvidence, s.digest) } if captureRequired && zeroResult { // An explicitly recorded empty typed page is a bounded zero-result, not @@ -991,16 +1460,40 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( switch s.state { case localizationStateRecoveryInFlight: - if success { + requested := s.inFlightRecoveryAnchor + operation := s.inFlightRecoveryOperation + plannedRecovery := s.localizationRecoveryPlannedLocked() + s.inFlightRecoveryAnchor = "" + s.inFlightRecoveryOperation = "" + legacyRecovery := !captureRequired || (wireSuccess && !evidenceRecorded) || + (operation == "" && strings.TrimSpace(s.taskFingerprint) == "") + confidentRecovery := legacyRecovery || (capturedResult && mergedDigest != nil && len(mergedDigest.Evidence) > 0 && + localizationRecoveryEvidenceAlignedWithLead(s.taskFingerprint, s.taskLead, requested, operation, currentEvidence)) + if success && confidentRecovery { + s.recoveryOperation = "" + s.recoveryAnchor = "" s.state = localizationStateAnswerReady s.recoveryRetriesRemaining = 0 return s.completionLocked() } + if success || (plannedRecovery && zeroResult) { + // A successful but structurally weak page is not an accepted recovery. + // A planned weak or empty page clears only the plan and returns to the + // byte-identical generic recovery contract with its allowance intact. + if plannedRecovery { + s.recoveryOperation = "" + s.recoveryAnchor = "" + } + s.state = localizationStateNeedsRecovery + return s.completionLocked() + } if s.recoveryRetriesRemaining > 0 { s.recoveryRetriesRemaining-- s.state = localizationStateNeedsRecovery return s.completionLocked() } + s.recoveryOperation = "" + s.recoveryAnchor = "" s.state = localizationStateAnswerReady return s.completionLocked() case localizationStateExactReadInFlight: @@ -1008,7 +1501,7 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( routeEnforceable := s.inFlightEnforceable wasCorrection := s.exactReadIsCorrection confidentRead := capturedResult && mergedDigest != nil && len(mergedDigest.Evidence) > 0 && - localizationReservedReadEvidenceAligned(s.taskFingerprint, s.exactSymbol, currentEvidence) + localizationReservedReadEvidenceAlignedWithLead(s.taskFingerprint, s.taskLead, s.exactSymbol, currentEvidence) s.inFlightImplementationSymbol = "" s.inFlightEnforceable = false s.inFlightCorrectionSymbol = "" @@ -1053,7 +1546,7 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( s.state = localizationStateNeedsExactRead case localizationStateRefineInFlight: confidentRead := capturedResult && mergedDigest != nil && len(mergedDigest.Evidence) > 0 && - localizationReservedReadEvidenceAligned(s.taskFingerprint, s.refinementSymbol, currentEvidence) + localizationReservedReadEvidenceAlignedWithLead(s.taskFingerprint, s.taskLead, s.refinementSymbol, currentEvidence) if success { implementationSymbol := s.inFlightImplementationSymbol enforceable := s.inFlightEnforceable @@ -1086,6 +1579,12 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( return s.completionLocked() } if !enforceable && !confidentRead { + s.recoveryOperation = "" + s.recoveryAnchor = "" + if operation, anchor, planned := localizationPlannedRecoveryForWeakRefinement(s.taskFingerprint, currentEvidence, s.digest); planned { + s.recoveryOperation = operation + s.recoveryAnchor = anchor + } s.state = localizationStateNeedsRecovery s.recoveryRetriesRemaining = 1 return s.completionLocked() diff --git a/internal/mcp/localization_terminal_evidence_v8_test.go b/internal/mcp/localization_terminal_evidence_v8_test.go index 7e8b0628..3a14afc0 100644 --- a/internal/mcp/localization_terminal_evidence_v8_test.go +++ b/internal/mcp/localization_terminal_evidence_v8_test.go @@ -105,6 +105,136 @@ func TestLocalizationDirectRelationsInterleaveDeterministicallyWithinExistingCap } } +func TestLocalizationDirectRelationsRequireRelevantOrProvenRoute(t *testing.T) { + makeTargets := func() ([]exploreTarget, *graph.Node, *graph.Node) { + wrapper := localizationV8Node("repo/wrapper.go::Forwarder.Forward", "Forward", "repo/wrapper.go") + implementation := localizationV8Node("repo/implementation.go::Engine.Execute", "Execute", "repo/implementation.go") + first := localizationV8Node("repo/first.go::FirstCandidate", "FirstCandidate", "repo/first.go") + second := localizationV8Node("repo/second.go::SecondCandidate", "SecondCandidate", "repo/second.go") + wrapperTarget := exploreTarget{ + node: wrapper, source: "func Forward() { Engine.Execute() }", + conceptImplementation: true, directCalleesComplete: true, + callees: []*graph.Node{implementation}, + } + return []exploreTarget{ + wrapperTarget, + {node: first}, + {node: second}, + {node: implementation, source: "func Execute() {}"}, + }, wrapper, implementation + } + const task = "locate policy persistence behavior" + + t.Run("irrelevant relation rejected", func(t *testing.T) { + targets, _, _ := makeTargets() + got := interleaveLocalizationDirectRelations(task, "", targets) + for index := range targets { + if got[index].node.ID != targets[index].node.ID || got[index].localizationRelation != "" { + t.Fatalf("irrelevant relation changed row %d: %#v", index+1, got[index]) + } + } + }) + + t.Run("enforceable visible wrapper route admitted", func(t *testing.T) { + targets, wrapper, implementation := makeTargets() + routes := map[string]localizationRefinementRoute{ + wrapper.ID: {implementationSymbol: implementation.ID, enforceable: true}, + implementation.ID: {proofSymbol: wrapper.ID, enforceable: true}, + } + got := interleaveLocalizationDirectRelationsWithRoutes(task, "", targets, routes) + want := []string{wrapper.ID, implementation.ID, targets[1].node.ID, targets[2].node.ID} + for index, id := range want { + if got[index].node.ID != id { + t.Fatalf("proven route row %d = %q, want %q", index+1, got[index].node.ID, id) + } + } + }) + + t.Run("non-enforceable route rejected", func(t *testing.T) { + targets, wrapper, implementation := makeTargets() + routes := map[string]localizationRefinementRoute{ + wrapper.ID: {implementationSymbol: implementation.ID}, + } + got := interleaveLocalizationDirectRelationsWithRoutes(task, "", targets, routes) + for index := range targets { + if got[index].node.ID != targets[index].node.ID { + t.Fatalf("non-enforceable route changed row %d: %#v", index+1, got[index]) + } + } + }) + + t.Run("invisible implementation rejected", func(t *testing.T) { + targets, wrapper, implementation := makeTargets() + targets = targets[:3] + routes := map[string]localizationRefinementRoute{ + wrapper.ID: {implementationSymbol: implementation.ID, enforceable: true}, + } + got := interleaveLocalizationDirectRelationsWithRoutes(task, "", targets, routes) + for index := range targets { + if got[index].node.ID != targets[index].node.ID { + t.Fatalf("invisible route changed row %d: %#v", index+1, got[index]) + } + } + }) +} + +func TestLocalizationConceptComplementSurvivesTightAlignedPacking(t *testing.T) { + targets := make([]exploreTarget, 0, localizationReplayEvidenceLimit) + for index := 0; index < localizationReplayEvidenceLimit; index++ { + name := "GenericCandidate" + string(rune('A'+index)) + node := localizationV8Node( + "repo/candidate_"+string(rune('a'+index))+".go::"+name, + name, + "repo/candidate_"+string(rune('a'+index))+".go", + ) + targets = append(targets, exploreTarget{node: node}) + } + targets[1].node.Name = "match_ignore_rules" + targets[1].node.QualName = "PathWalker.match_ignore_rules" + targets[1].conceptImplementation = true + targets[2].node.Name = "check_hidden" + targets[2].node.QualName = "HiddenMatcher.check_hidden" + targets[2].conceptComplement = true + targets[0].callees = []*graph.Node{ + localizationV8Node("repo/relation.go::match_ignore_relation", "match_ignore_relation", "repo/relation.go"), + } + + completion := newLocalizationCompletion(true, "") + result, _, digest, packed := buildLocalizationExploreResultForTaskFinalized( + completion, + "match ignore rules while hidden whitelisted files are skipped", + targets, + exploreMinBudgetTokens, + ) + if result == nil || result.IsError || digest == nil || packed.State == localizationStateInactive { + t.Fatalf("packed complement result = (%#v, %#v, %#v)", result, digest, packed) + } + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("packed complement content = %#v", result.Content) + } + if len(text) > exploreMinBudgetTokens*localizationEnvelopeBytesPerToken { + t.Fatalf("packed envelope bytes = %d, want <= %d", len(text), exploreMinBudgetTokens*localizationEnvelopeBytesPerToken) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatalf("decode complement envelope: %v", err) + } + wantHead := []string{targets[0].node.ID, targets[1].node.ID, targets[2].node.ID} + if len(envelope.Evidence) < len(wantHead) { + t.Fatalf("packed rows = %d, want at least %d", len(envelope.Evidence), len(wantHead)) + } + for index, want := range wantHead { + row := envelope.Evidence[index] + if row.ID != want || row.Rank != index+1 || envelope.Symbols[index] != want || envelope.Files[index] != row.File { + t.Fatalf("unaligned protected row %d: %#v", index+1, row) + } + } + if len(envelope.Files) != len(envelope.Evidence) || len(envelope.Symbols) != len(envelope.Evidence) { + t.Fatalf("packed arrays diverged: files=%d symbols=%d evidence=%d", len(envelope.Files), len(envelope.Symbols), len(envelope.Evidence)) + } +} + func TestRecoveryAllowsInferredConcreteIdentifierOnlyForScopedSymbolEvidence(t *testing.T) { retained := mergeLocalizationEvidenceDigest([]localizationDigestRow{ captureTestRow("repo/registry.go::Registry.Configure", "repo/registry.go"), @@ -198,6 +328,68 @@ func TestTypedReservedReadStopsOnlyWhenEvidenceAlignsWithConcreteTaskAnchor(t *t t.Fatalf("low-alignment typed read skipped recovery: %#v", open) } }) + t.Run(name+" body-only alignment", func(t *testing.T) { + symbol := "repo/worker.go::Worker.ScanRetryBatch" + task := "Archive entries repeat stale records\nThe worker scans retry batches after the timeout policy expires." + state, token := makeState(task, symbol, refinement) + row := captureTestRow(symbol, "repo/worker.go") + row.Name = "ScanRetryBatch" + row.QualName = "Worker.ScanRetryBatch" + row.Kind = "method" + open := state.finishReservedReadTokenWithDigest(token, true, []localizationDigestRow{row}, true) + if open.State != localizationStateNeedsRecovery || open.AllowedToolCalls != 1 { + t.Fatalf("body-only semantic overlap terminalized advisory read: %#v", open) + } + }) + t.Run(name+" strong compound lead", func(t *testing.T) { + symbol := "repo/rotation.go::EntryStore.RotateArchive" + task := "Archive entries vanish unexpectedly\nDiagnostics mention queue timeouts." + state, token := makeState(task, symbol, refinement) + row := captureTestRow(symbol, "repo/rotation.go") + row.Name = "RotateArchive" + row.QualName = "EntryStore.RotateArchive" + row.Kind = "method" + ready := state.finishReservedReadTokenWithDigest(token, true, []localizationDigestRow{row}, true) + if ready.State != localizationStateAnswerReady || ready.Enforceable { + t.Fatalf("strong compound lead match did not terminalize advisory read: %#v", ready) + } + }) + } +} + +func TestStrongCompoundLeadRequiresTwoBalancedSegments(t *testing.T) { + taskTerms := exploreTerminalTerms("Archive entries vanish unexpectedly") + leadTerms := exploreTerminalTerms("Archive entries vanish unexpectedly") + for _, test := range []struct { + name string + want bool + }{ + {name: "RotateArchive", want: true}, + {name: "RotateArchivePayload", want: false}, + {name: "ArchiveConfiguration", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + row := localizationDigestRow{Name: test.name, Kind: "method"} + if got := localizationDigestRowHasStrongCompoundLeadMatch(row, taskTerms, leadTerms); got != test.want { + t.Fatalf("strong compound lead match = %v, want %v", got, test.want) + } + }) + } +} + +func TestLocalizationTaskLeadIsBoundedAndClearedWithState(t *testing.T) { + state := newLocalizationTerminalState() + completion := newLocalizationExactReadCompletion("repo/store.go::Store.Load", false) + state.armForTask(completion, strings.Repeat("é", localizationTaskLeadMaxRunes+20)+"\nbody detail") + if got := len([]rune(state.taskLead)); got != localizationTaskLeadMaxRunes { + t.Fatalf("retained task lead = %d runes, want %d", got, localizationTaskLeadMaxRunes) + } + if strings.Contains(state.taskLead, "body detail") { + t.Fatalf("retained task lead included report body: %q", state.taskLead) + } + state.reset() + if state.taskLead != "" { + t.Fatalf("reset retained task lead: %q", state.taskLead) } } diff --git a/internal/mcp/localization_terminal_test.go b/internal/mcp/localization_terminal_test.go index 3d17bf7c..25a1cf5c 100644 --- a/internal/mcp/localization_terminal_test.go +++ b/internal/mcp/localization_terminal_test.go @@ -208,6 +208,15 @@ func TestLocalizationCompletionEnvelope(t *testing.T) { if envelope.Completion.State != localizationStateNeedsRecovery || envelope.Completion.RequiredAction != "recover_once" || envelope.Completion.AllowedToolCalls != 1 { t.Fatalf("unexpected completion: %#v", envelope.Completion) } + for _, required := range []string{ + "Gortex MCP recovery call", + "Do not call host Read, Grep, Glob, Bash", + "follow its completion", + } { + if !strings.Contains(envelope.Completion.Instruction, required) { + t.Fatalf("recovery instruction %q does not contain %q", envelope.Completion.Instruction, required) + } + } if len(envelope.Files) != 1 || len(envelope.Symbols) != 1 || envelope.Symbols[0] != "repo/pkg/file.go::Run" || len(envelope.Evidence) != 1 { t.Fatalf("missing localization payload: %#v", envelope) } diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index e4fd6390..a4f4bac5 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -87,7 +87,8 @@ type exploreTarget struct { source string // full body (may be empty for non-source kinds) divergentDefaultOwner bool // unique child constructor whose concrete default causes the queried behavior divergentDefaultType bool // owning type paired with divergentDefaultOwner for coherent file/symbol output - conceptImplementation bool // one identifier-backed callable protected from final truncation + conceptImplementation bool // primary identifier-backed callable; may establish answer readiness + conceptComplement bool // marginal concept callable protected as evidence, never as terminal proof exactContent bool // verified full quoted-literal hit from content_fts exactContentAmbiguous bool // exact evidence has visible or possibly truncated peers sourceLiteral bool // exact source-body hit that must survive final envelope packing @@ -1593,6 +1594,171 @@ func exploreTerminalTermRoot(term string) string { return term } +var exploreConceptComplementLowValueTerms = map[string]struct{}{ + "arg": {}, "argument": {}, "debug": {}, "diagnostic": {}, "input": {}, + "line": {}, "log": {}, "output": {}, "path": {}, "read": {}, + "reader": {}, "search": {}, "trace": {}, "version": {}, +} + +// exploreConceptIssueLead returns the report headline carried by the canonical +// explore query. shapeExploreQuery repeats a multiline headline before the +// distilled body; inline agent paraphrases keep it as their first sentence. +func exploreConceptIssueLead(query string) string { + lead := strings.TrimSpace(query) + if lead == "" { + return "" + } + end := len(lead) + for _, separator := range [...]string{"\n", "\r", ". ", "? ", "! ", "; ", " - "} { + if index := strings.Index(lead, separator); index >= 12 && index < end { + end = index + } + } + return strings.TrimSpace(lead[:end]) +} + +// exploreConceptBehavioralTermGroup collapses the bounded inflection family +// accepted by exploreConceptTermPresent. Each behavioral root can therefore +// contribute at most one independent issue-lead group. +func exploreConceptBehavioralTermGroup(term string) string { + for _, suffix := range [...]string{"ing", "ed"} { + if !strings.HasSuffix(term, suffix) { + continue + } + stem := strings.TrimSuffix(term, suffix) + if len(stem) < 5 { + continue + } + if len(stem) > 1 && stem[len(stem)-1] == stem[len(stem)-2] { + stem = stem[:len(stem)-1] + } + return stem + } + return term +} + +func exploreConceptDiagnosticLiteral(token string) bool { + token = strings.Trim(token, "`'\"()[]{}<>,;") + if token == "" { + return false + } + if colon := strings.LastIndexByte(token, ':'); colon >= 0 && colon+1 < len(token) { + line := token[colon+1:] + digits := true + for index := 0; index < len(line); index++ { + if line[index] < '0' || line[index] > '9' { + digits = false + break + } + } + if digits { + return true + } + } + if !strings.ContainsAny(token, "/\\") { + return false + } + separator := strings.LastIndexAny(token, "/\\") + base := token[separator+1:] + if colon := strings.LastIndexByte(base, ':'); colon >= 0 { + base = base[:colon] + } + dot := strings.LastIndexByte(base, '.') + if dot <= 0 || dot+1 >= len(base) || len(base)-dot-1 > 8 { + return false + } + for index := dot + 1; index < len(base); index++ { + value := base[index] + if !exploreASCIIAlphaNumeric(value) { + return false + } + } + return true +} + +// exploreConceptComplementLowValueTermSet marks structural report noise only +// for complement selection. The ordinary retrieval and primary implementation +// ranking retain their established contract. +func exploreConceptComplementLowValueTermSet(query string) map[string]struct{} { + low := make(map[string]struct{}, len(exploreConceptComplementLowValueTerms)+8) + for term := range exploreConceptComplementLowValueTerms { + low[term] = struct{}{} + } + for _, token := range strings.Fields(query) { + if !exploreConceptDiagnosticLiteral(token) { + continue + } + for _, raw := range rerank.Tokenize(token) { + term := exploreTerminalTermRoot(strings.ToLower(strings.TrimSpace(raw))) + if len(term) >= 3 { + low[term] = struct{}{} + } + } + } + return low +} + +func exploreConceptComplementHasTerm(node *graph.Node, term string) bool { + if exploreConceptImplementationHasTerm(node, term) { + return true + } + if node == nil { + return false + } + text := strings.ToLower(strings.TrimSpace(node.Name + " " + node.QualName)) + return exploreConceptTermPresent(text, term) +} + +func exploreConceptCallableFamily(name string) string { + for _, raw := range rerank.Tokenize(name) { + term := exploreTerminalTermRoot(strings.ToLower(strings.TrimSpace(raw))) + if len(term) >= 3 { + return exploreConceptBehavioralTermGroup(term) + } + } + return "" +} + +func exploreConceptOwnerFamily(node *graph.Node) string { + if node == nil { + return "" + } + owner := strings.TrimSpace(node.QualName) + separator := strings.LastIndex(owner, "::") + if dot := strings.LastIndexByte(owner, '.'); dot > separator { + separator = dot + } + if separator <= 0 { + return "" + } + return strings.ToLower(owner[:separator]) +} + +func exploreConceptComplementFamilyPenalty(node *graph.Node, reserved []*graph.Node) int { + if node == nil { + return 0 + } + family := exploreConceptCallableFamily(node.Name) + owner := exploreConceptOwnerFamily(node) + file := strings.ToLower(strings.TrimSpace(node.FilePath)) + penalty := 0 + for _, other := range reserved { + if other == nil { + continue + } + if family != "" && family == exploreConceptCallableFamily(other.Name) { + penalty += 2 + } + if owner != "" && owner == exploreConceptOwnerFamily(other) { + penalty += 2 + } + if file != "" && file == strings.ToLower(strings.TrimSpace(other.FilePath)) { + penalty++ + } + } + return penalty +} + type exploreConceptImplementationMetric struct { index int overlap int @@ -1602,9 +1768,12 @@ type exploreConceptImplementationMetric struct { matchedMask uint64 } -// reserveExploreConceptImplementation keeps one identifier-backed callable in -// the final window without widening retrieval or increasing response size. -// The semantic head is preserved whenever maxSymbols permits a second slot. +const exploreConceptComplementSignal = "explore_concept_complement" + +// reserveExploreConceptImplementation keeps the strongest identifier-backed +// callable plus up to two callables that cover distinct task concepts in the +// final window. Retrieval width and response size remain unchanged. The +// semantic head stays first; reserved implementations occupy the next slots. func reserveExploreConceptImplementation( query string, queryClass rerank.QueryClass, @@ -1630,6 +1799,7 @@ func reserveExploreConceptImplementation( for term := range queryTermSet { queryTerms = append(queryTerms, term) } + sort.Strings(queryTerms) var frequencyStorage [64]int var frequency []int if len(queryTerms) <= len(frequencyStorage) { @@ -1708,18 +1878,204 @@ func reserveExploreConceptImplementation( if best.index < 0 { return candidates, "" } + protected := candidates[best.index] targetIndex := 0 if maxSymbols > 1 && best.index > 0 { targetIndex = 1 } - if best.index == targetIndex { + + // Complements are chosen iteratively. Independent behavioral groups from + // the issue lead dominate; marginal coverage from the full canonical task + // breaks broader ties. Low-value report mechanics can still break a final + // tie but cannot authorize a complement by themselves. + type complementScore struct { + metric exploreConceptImplementationMetric + behavioralMarginal int + behavioralTotal int + highMarginal int + marginalRare int + marginalWeight int + marginal int + familyPenalty int + longest int + } + lowValue := exploreConceptComplementLowValueTermSet(query) + leadTerms := exploreTerminalTerms(exploreConceptIssueLead(query)) + behavioralGroups := make([]string, len(queryTerms)) + for index, term := range queryTerms { + if _, low := lowValue[term]; low { + continue + } + if _, inLead := leadTerms[term]; inLead { + behavioralGroups[index] = exploreConceptBehavioralTermGroup(term) + } + } + complementFrequency := make([]int, len(queryTerms)) + for _, metric := range metrics { + node := candidates[metric.index].Node + for index, term := range queryTerms { + if exploreConceptComplementHasTerm(node, term) { + complementFrequency[index]++ + } + } + } + coveredTerms := make([]bool, len(queryTerms)) + coveredBehavior := make(map[string]struct{}, len(leadTerms)) + markCoverage := func(node *graph.Node) { + for index, term := range queryTerms { + if !exploreConceptComplementHasTerm(node, term) { + continue + } + coveredTerms[index] = true + if group := behavioralGroups[index]; group != "" { + coveredBehavior[group] = struct{}{} + } + } + } + markCoverage(candidates[0].Node) + markCoverage(protected.Node) + reservedNodes := []*graph.Node{candidates[0].Node} + if protected.Node != candidates[0].Node { + reservedNodes = append(reservedNodes, protected.Node) + } + available := maxSymbols - targetIndex - 1 + if available > 2 { + available = 2 + } + selected := make([]complementScore, 0, max(0, available)) + selectedIndexes := make(map[int]struct{}, max(0, available)) + for len(selected) < available { + complement := complementScore{metric: exploreConceptImplementationMetric{index: -1}} + for _, metric := range metrics { + if metric.index == best.index { + continue + } + if _, exists := selectedIndexes[metric.index]; exists { + continue + } + node := candidates[metric.index].Node + duplicateCallable := false + for _, reserved := range reservedNodes { + if reserved != nil && node.Name != "" && strings.EqualFold(node.Name, reserved.Name) { + duplicateCallable = true + break + } + } + if duplicateCallable { + continue + } + + score := complementScore{ + metric: metric, + familyPenalty: exploreConceptComplementFamilyPenalty(node, reservedNodes), + } + candidateBehavior := make(map[string]struct{}, len(leadTerms)) + for index, term := range queryTerms { + if !exploreConceptComplementHasTerm(node, term) { + continue + } + if group := behavioralGroups[index]; group != "" { + candidateBehavior[group] = struct{}{} + } + if coveredTerms[index] { + continue + } + score.marginal++ + frequency := complementFrequency[index] + if frequency < 1 { + frequency = 1 + } + if _, low := lowValue[term]; low { + score.marginalWeight++ + continue + } + score.highMarginal++ + score.marginalWeight += 1000 / frequency + if frequency == 1 { + score.marginalRare++ + } + if len(term) > score.longest { + score.longest = len(term) + } + } + score.behavioralTotal = len(candidateBehavior) + for group := range candidateBehavior { + if _, covered := coveredBehavior[group]; !covered { + score.behavioralMarginal++ + } + } + if score.behavioralMarginal == 0 && score.highMarginal == 0 { + continue + } + if score.behavioralMarginal < 2 && score.highMarginal < 2 && + (score.longest < 5 || metric.segments < 2) { + continue + } + better := complement.metric.index < 0 || + score.behavioralMarginal > complement.behavioralMarginal || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal > complement.behavioralTotal) || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal == complement.behavioralTotal && score.highMarginal > complement.highMarginal) || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal == complement.behavioralTotal && score.highMarginal == complement.highMarginal && score.marginalWeight > complement.marginalWeight) || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal == complement.behavioralTotal && score.highMarginal == complement.highMarginal && score.marginalWeight == complement.marginalWeight && score.marginalRare > complement.marginalRare) || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal == complement.behavioralTotal && score.highMarginal == complement.highMarginal && score.marginalWeight == complement.marginalWeight && score.marginalRare == complement.marginalRare && score.marginal > complement.marginal) || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal == complement.behavioralTotal && score.highMarginal == complement.highMarginal && score.marginalWeight == complement.marginalWeight && score.marginalRare == complement.marginalRare && score.marginal == complement.marginal && score.familyPenalty < complement.familyPenalty) || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal == complement.behavioralTotal && score.highMarginal == complement.highMarginal && score.marginalWeight == complement.marginalWeight && score.marginalRare == complement.marginalRare && score.marginal == complement.marginal && score.familyPenalty == complement.familyPenalty && score.metric.overlap > complement.metric.overlap) || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal == complement.behavioralTotal && score.highMarginal == complement.highMarginal && score.marginalWeight == complement.marginalWeight && score.marginalRare == complement.marginalRare && score.marginal == complement.marginal && score.familyPenalty == complement.familyPenalty && score.metric.overlap == complement.metric.overlap && score.longest > complement.longest) || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal == complement.behavioralTotal && score.highMarginal == complement.highMarginal && score.marginalWeight == complement.marginalWeight && score.marginalRare == complement.marginalRare && score.marginal == complement.marginal && score.familyPenalty == complement.familyPenalty && score.metric.overlap == complement.metric.overlap && score.longest == complement.longest && score.metric.segments > complement.metric.segments) || + (score.behavioralMarginal == complement.behavioralMarginal && score.behavioralTotal == complement.behavioralTotal && score.highMarginal == complement.highMarginal && score.marginalWeight == complement.marginalWeight && score.marginalRare == complement.marginalRare && score.marginal == complement.marginal && score.familyPenalty == complement.familyPenalty && score.metric.overlap == complement.metric.overlap && score.longest == complement.longest && score.metric.segments == complement.metric.segments && score.metric.index < complement.metric.index) + if better { + complement = score + } + } + if complement.metric.index < 0 { + break + } + selected = append(selected, complement) + selectedIndexes[complement.metric.index] = struct{}{} + node := candidates[complement.metric.index].Node + markCoverage(node) + reservedNodes = append(reservedNodes, node) + } + if best.index == targetIndex && len(selected) == 0 { return candidates, protected.Node.ID } + result := append([]*rerank.Candidate(nil), candidates...) - if best.index > targetIndex { - copy(result[targetIndex+1:best.index+1], result[targetIndex:best.index]) - result[targetIndex] = protected + candidateIndex := func(want *rerank.Candidate) int { + for index, candidate := range result { + if candidate == want || (candidate != nil && candidate.Node != nil && want != nil && want.Node != nil && + candidate.Node.ID != "" && candidate.Node.ID == want.Node.ID) { + return index + } + } + return -1 + } + moveBefore := func(candidate *rerank.Candidate, target int) { + current := candidateIndex(candidate) + if current < 0 || current <= target || target < 0 || target >= len(result) { + return + } + copy(result[target+1:current+1], result[target:current]) + result[target] = candidate + } + moveBefore(protected, targetIndex) + + for offset, complement := range selected { + candidate := candidates[complement.metric.index] + complementIndex := targetIndex + 1 + offset + if complementIndex < maxSymbols && complementIndex < len(result) { + moveBefore(candidate, complementIndex) + } + if index := candidateIndex(candidate); index >= 0 && index < maxSymbols { + clone := *result[index] + clone.Signals = make(map[string]float64, len(result[index].Signals)+1) + for key, value := range result[index].Signals { + clone.Signals[key] = value + } + clone.Signals[exploreConceptComplementSignal] = 1 + result[index] = &clone + } } return result, protected.Node.ID } @@ -2082,7 +2438,7 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m protectedSyntacticAnchors, protectedImplementationID, ) } - protectedFinalCandidateIDs := exploreTypedAnchorReservedCandidateIDs( + protectedFinalCandidateIDs := exploreFinalReservedCandidateIDs( cands, protectedSyntacticAnchors, protectedImplementationID, ) if len(cands) == 0 && len(artifactLane.targets) == 0 { @@ -2125,6 +2481,7 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m conceptImplementation: n.ID == protectedImplementationID, } if c.Signals != nil { + t.conceptComplement = c.Signals[exploreConceptComplementSignal] > 0 t.exactContent = c.Signals[exploreContentRecallExactSignal] > 0 t.exactContentAmbiguous = c.Signals[exploreContentRecallAmbiguousSignal] > 0 t.sourceLiteral = c.Signals[exploreSourceLiteralSignal] > 0 @@ -2753,6 +3110,21 @@ func localizationEvidenceTargetsFromDraft(task, exactID string, targets []explor appendTarget(target) } } + // Concept reservations are weaker than exact, source-literal, typed, and + // divergent causal evidence, but stronger than draft relations. Keep the + // primary and up to two diverse complements adjacent whenever those stronger + // contracts leave room; only the primary may establish answer readiness. + for _, target := range targets { + if target.conceptImplementation { + appendTarget(target) + break + } + } + for _, target := range targets { + if target.conceptComplement { + appendTarget(target) + } + } appendEntry := func(entry exploreDraftEntry) { if entry.node == nil { return @@ -2789,6 +3161,14 @@ const ( // came from the same scoped graph walks as the parent target; this helper never // performs a new lookup or reconstructs an identity from rendered output. func interleaveLocalizationDirectRelations(task, requiredID string, targets []exploreTarget) []exploreTarget { + return interleaveLocalizationDirectRelationsWithRoutes(task, requiredID, targets, nil) +} + +func interleaveLocalizationDirectRelationsWithRoutes( + task, requiredID string, + targets []exploreTarget, + routes map[string]localizationRefinementRoute, +) []exploreTarget { if len(targets) == 0 { return targets } @@ -2813,11 +3193,12 @@ func interleaveLocalizationDirectRelations(task, requiredID string, targets []ex } direct[target.node.ID] = target if index < localizationDirectEvidenceReserve || target.node.ID == requiredID || - target.divergentDefaultOwner || target.divergentDefaultType || target.conceptImplementation || + target.divergentDefaultOwner || target.divergentDefaultType || target.conceptImplementation || target.conceptComplement || target.exactContent || target.sourceLiteral || target.typedAnchorProjection { protected[target.node.ID] = struct{}{} } if target.node.ID == requiredID || target.divergentDefaultOwner || target.divergentDefaultType || + target.conceptImplementation || target.conceptComplement || target.exactContent || target.sourceLiteral || target.typedAnchorProjection { orderedPrefix = index + 1 } @@ -2832,6 +3213,33 @@ func interleaveLocalizationDirectRelations(task, requiredID string, targets []ex orderedPrefix++ } terms := exploreTerminalTerms(shapeExploreQuery(task)) + type refinementPair struct { + wrapper string + implementation string + } + provenWrapperRoutes := make(map[refinementPair]struct{}, len(routes)) + for symbol, route := range routes { + if !route.enforceable { + continue + } + wrapper, implementation := "", "" + switch { + case route.implementationSymbol != "": + wrapper, implementation = symbol, route.implementationSymbol + case route.proofSymbol != "": + wrapper, implementation = route.proofSymbol, symbol + } + if wrapper == "" || implementation == "" { + continue + } + if _, wrapperVisible := direct[wrapper]; !wrapperVisible { + continue + } + if _, implementationVisible := direct[implementation]; !implementationVisible { + continue + } + provenWrapperRoutes[refinementPair{wrapper: wrapper, implementation: implementation}] = struct{}{} + } selected := make([]exploreTarget, 0, limit) seen := make(map[string]struct{}, limit) appendTarget := func(target exploreTarget) bool { @@ -2860,6 +3268,12 @@ func interleaveLocalizationDirectRelations(task, requiredID string, targets []ex continue } overlap, longest := exploreDraftTermOverlap(terms, node) + _, provenWrapper := provenWrapperRoutes[refinementPair{ + wrapper: target.node.ID, implementation: node.ID, + }] + if overlap == 0 && (direction != "direct_callee" || !provenWrapper) { + continue + } candidate := relationCandidate{ node: node, direction: direction, @@ -2891,6 +3305,13 @@ func interleaveLocalizationDirectRelations(task, requiredID string, targets []ex relation, exists := direct[best.node.ID] if !exists { relation = exploreTarget{node: best.node, localizationRelation: best.direction} + } else if relation.node.ID != requiredID && + !relation.divergentDefaultOwner && !relation.divergentDefaultType && + !relation.conceptImplementation && !relation.conceptComplement && + !relation.exactContent && !relation.sourceLiteral && !relation.typedAnchorProjection { + // A draft-promoted graph neighbor may already be present in the direct + // map. Preserve its hydrated data while retaining the relation proof. + relation.localizationRelation = best.direction } return relation, true } @@ -3058,7 +3479,9 @@ func buildLocalizationExploreResultForTaskFinalized( if refinementFirst { targets = prioritizeLocalizationEvidenceTarget(requiredSymbol, targets) } - targets = interleaveLocalizationDirectRelations(task, requiredSymbol, targets) + targets = interleaveLocalizationDirectRelationsWithRoutes( + task, requiredSymbol, targets, completion.refinementRoutes, + ) contract := localizationContractFor(completion) envelope := localizationExploreEnvelope{ Completion: contract.Completion, @@ -3083,6 +3506,9 @@ func buildLocalizationExploreResultForTaskFinalized( if target.typedAnchorProjection && index+1 > mandatoryCount { mandatoryCount = index + 1 } + if (target.conceptImplementation || target.conceptComplement) && index+1 > mandatoryCount { + mandatoryCount = index + 1 + } } // Primary retrieval evidence and the authorized exact/refinement symbol are // both mandatory regardless of which one leads the serialized projection. @@ -3231,6 +3657,7 @@ func buildLocalizationExploreResultForTaskFinalized( envelope.Completion = contract.Completion envelope.Terminal = contract.Terminal digest := newLocalizationEvidenceDigest(envelope) + envelope.Completion = localizationCompletionBoundedByDigest(envelope.Completion, digest) // The serialized completion, returned state, structuredContent, and host // metadata must carry the same final_response. Build the digest first, then // enrich the one completion value before any wire representation is made. diff --git a/internal/mcp/tools_explore_protected_test.go b/internal/mcp/tools_explore_protected_test.go index 37956bc9..fb8bb4dc 100644 --- a/internal/mcp/tools_explore_protected_test.go +++ b/internal/mcp/tools_explore_protected_test.go @@ -51,6 +51,247 @@ func TestReserveExploreConceptImplementationAcrossLanguages(t *testing.T) { } } +func TestReserveExploreConceptImplementationAddsMarginalComplement(t *testing.T) { + tests := []struct { + name string + task string + primary string + complement string + }{ + { + name: "replace", task: "print matched output when replace capture groups duplicate lines", + primary: "print_matched_output", complement: "replace_all", + }, + { + name: "alternation", task: "case insensitive search returns false negatives for alternation branches", + primary: "case_insensitive_search", complement: "extract_alternation", + }, + { + name: "ignore hidden", task: "match ignore rules while hidden whitelisted files are skipped", + primary: "match_ignore_rules", complement: "check_hidden", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + head := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/symptom.go::SymptomState", Name: "SymptomState", QualName: "SymptomState", + Kind: graph.KindField, + }} + primary := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/primary.go::" + test.primary, Name: test.primary, + QualName: "Primary." + test.primary, Kind: graph.KindMethod, + }} + duplicate := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/duplicate.go::" + test.primary, Name: test.primary, + QualName: "Mirror." + test.primary, Kind: graph.KindMethod, + }} + complement := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/complement.go::" + test.complement, Name: test.complement, + QualName: "Complement." + test.complement, Kind: graph.KindMethod, + }} + candidates := []*rerank.Candidate{head, primary, duplicate, complement} + + got, protectedID := reserveExploreConceptImplementation( + test.task, rerank.QueryClassConcept, candidates, 3, + ) + if len(got) != len(candidates) { + t.Fatalf("candidate count = %d, want %d", len(got), len(candidates)) + } + if got[0] != head || got[1].Node.ID != primary.Node.ID || got[2].Node.ID != complement.Node.ID { + t.Fatalf("reserved order = [%q %q %q], want head/primary/complement", + got[0].Node.ID, got[1].Node.ID, got[2].Node.ID) + } + if protectedID != primary.Node.ID { + t.Fatalf("primary protected ID = %q, want %q", protectedID, primary.Node.ID) + } + if got[2].Signals[exploreConceptComplementSignal] != 1 { + t.Fatalf("complement signal = %#v", got[2].Signals) + } + if complement.Signals != nil { + t.Fatal("reservation mutated the input complement candidate") + } + + bounded, _ := reserveExploreConceptImplementation( + test.task, rerank.QueryClassConcept, candidates, 2, + ) + for _, candidate := range bounded { + if candidate.Signals[exploreConceptComplementSignal] != 0 { + t.Fatal("max_symbols=2 admitted a second concept reservation") + } + } + }) + } +} + +func TestReserveExploreConceptImplementationPrioritizesBehavioralLeadOverDiagnostics(t *testing.T) { + const task = "Ancestor ignore whitelist matching changes with root path shape. Version 14.1.1 diagnostics at crates/core/flags/hiargs.rs:1108 report generic search input path behavior" + head := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/state.go::ObservedState", Name: "ObservedState", Kind: graph.KindField, + }} + primary := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/search.go::SearchWorker.search_input_path_diagnostic_version", + Name: "search_input_path_diagnostic_version", + QualName: "SearchWorker.search_input_path_diagnostic_version", + FilePath: "repo/search.go", Kind: graph.KindMethod, + }} + diagnostic := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/input.go::InputReader.read_search_input_path_log", + Name: "read_search_input_path_log", + QualName: "InputReader.read_search_input_path_log", + FilePath: "repo/input.go", Kind: graph.KindMethod, + }} + behavioral := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/ignore.go::Ignore.match_ancestor_ignore_path_shape", + Name: "match_ancestor_ignore_path_shape", + QualName: "Ignore.match_ancestor_ignore_path_shape", + FilePath: "repo/ignore.go", Kind: graph.KindMethod, + }} + whitelist := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/parents.go::Parents.apply_whitelist_root_override", + Name: "apply_whitelist_root_override", + QualName: "Parents.apply_whitelist_root_override", + FilePath: "repo/parents.go", Kind: graph.KindMethod, + }} + candidates := []*rerank.Candidate{head, primary, diagnostic, behavioral, whitelist} + + got, protectedID := reserveExploreConceptImplementation( + task, rerank.QueryClassConcept, candidates, 4, + ) + if len(got) != len(candidates) { + t.Fatalf("candidate width = %d, want unchanged %d", len(got), len(candidates)) + } + want := []string{head.Node.ID, primary.Node.ID, behavioral.Node.ID, whitelist.Node.ID} + for index, id := range want { + if got[index].Node.ID != id { + t.Fatalf("rank %d = %q, want %q; order=%v", index+1, got[index].Node.ID, id, + []string{got[0].Node.ID, got[1].Node.ID, got[2].Node.ID, got[3].Node.ID}) + } + } + if protectedID != primary.Node.ID { + t.Fatalf("primary protected ID = %q, want %q", protectedID, primary.Node.ID) + } + for _, index := range []int{2, 3} { + if got[index].Signals[exploreConceptComplementSignal] != 1 { + t.Fatalf("rank %d missing complement signal: %#v", index+1, got[index].Signals) + } + } + if diagnostic.Signals != nil || behavioral.Signals != nil || whitelist.Signals != nil { + t.Fatal("reservation mutated an input candidate") + } +} + +func TestReserveExploreConceptImplementationKeepsReplacementAfterDuplicateFamily(t *testing.T) { + const task = "Matched output formatting duplicates while replace captures and trim line terminators" + head := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/state.go::ObservedState", Name: "ObservedState", Kind: graph.KindField, + }} + primary := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/printer.go::Matcher.matched_output_formatting_duplicates", + Name: "matched_output_formatting_duplicates", + QualName: "Matcher.matched_output_formatting_duplicates", + FilePath: "repo/printer.go", Kind: graph.KindMethod, + }} + duplicate := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/mirror.go::Mirror.matched_output_formatting_duplicates", + Name: "matched_output_formatting_duplicates", + QualName: "Mirror.matched_output_formatting_duplicates", + FilePath: "repo/mirror.go", Kind: graph.KindMethod, + }} + trim := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/trim.go::Formatter.trim_line_terminators", + Name: "trim_line_terminators", QualName: "Formatter.trim_line_terminators", + FilePath: "repo/trim.go", Kind: graph.KindMethod, + }} + replace := &rerank.Candidate{Node: &graph.Node{ + ID: "repo/replace.go::Replacer.replace_all_captures", + Name: "replace_all_captures", QualName: "Replacer.replace_all_captures", + FilePath: "repo/replace.go", Kind: graph.KindMethod, + }} + candidates := []*rerank.Candidate{head, primary, duplicate, trim, replace} + + got, protectedID := reserveExploreConceptImplementation( + task, rerank.QueryClassConcept, candidates, 4, + ) + want := []string{head.Node.ID, primary.Node.ID, trim.Node.ID, replace.Node.ID} + for index, id := range want { + if got[index].Node.ID != id { + t.Fatalf("rank %d = %q, want %q; order=%v", index+1, got[index].Node.ID, id, + []string{got[0].Node.ID, got[1].Node.ID, got[2].Node.ID, got[3].Node.ID}) + } + } + if protectedID != primary.Node.ID { + t.Fatalf("primary protected ID = %q, want %q", protectedID, primary.Node.ID) + } + if got[2].Signals[exploreConceptComplementSignal] != 1 || + got[3].Signals[exploreConceptComplementSignal] != 1 { + t.Fatalf("two complement signals missing: rank3=%#v rank4=%#v", got[2].Signals, got[3].Signals) + } + if duplicate.Signals != nil || trim.Signals != nil || replace.Signals != nil { + t.Fatal("reservation mutated an input candidate") + } +} + +func TestExploreTwoComplementsPrecedeRelationsWithoutChangingContractWidth(t *testing.T) { + node := func(id, file string) *graph.Node { + return &graph.Node{ID: id, Name: id, QualName: id, FilePath: file, Kind: graph.KindMethod} + } + head := exploreTarget{node: node("head", "repo/head.go")} + primary := exploreTarget{node: node("primary", "repo/primary.go"), conceptImplementation: true} + complementOne := exploreTarget{node: node("complement_one", "repo/one.go"), conceptComplement: true} + complementTwo := exploreTarget{node: node("complement_two", "repo/two.go"), conceptComplement: true} + relation := exploreTarget{node: node("relation", "repo/relation.go"), localizationRelation: "direct_callee"} + targets := []exploreTarget{head, primary, complementOne, relation, complementTwo} + + projected := localizationEvidenceTargetsFromDraft("", "", targets, []exploreDraftEntry{}) + projected = interleaveLocalizationDirectRelations("", "", projected) + if len(projected) != len(targets) { + t.Fatalf("contract width = %d, want unchanged %d", len(projected), len(targets)) + } + want := []exploreTarget{head, primary, complementOne, complementTwo, relation} + for index, target := range want { + if projected[index].node.ID != target.node.ID || projected[index].node.FilePath != target.node.FilePath { + t.Fatalf("contract row %d = %q/%q, want %q/%q", index+1, + projected[index].node.ID, projected[index].node.FilePath, + target.node.ID, target.node.FilePath) + } + } +} + +func TestExploreFinalReservationsProtectComplementAfterTypedProjection(t *testing.T) { + head := &rerank.Candidate{Node: &graph.Node{ID: "head", Name: "Symptom", Kind: graph.KindField}} + primary := &rerank.Candidate{Node: &graph.Node{ID: "primary", Name: "match_primary", Kind: graph.KindMethod}} + complement := &rerank.Candidate{ + Node: &graph.Node{ID: "complement", Name: "extract_alternation", Kind: graph.KindMethod}, + Signals: map[string]float64{exploreConceptComplementSignal: 1}, + } + complementTwo := &rerank.Candidate{ + Node: &graph.Node{ID: "complement_two", Name: "replace_all", Kind: graph.KindMethod}, + Signals: map[string]float64{exploreConceptComplementSignal: 1}, + } + typed := &rerank.Candidate{ + Node: &graph.Node{ID: "typed", Name: "typed_consumer", Kind: graph.KindMethod}, + Signals: map[string]float64{exploreTypedAnchorProjectionSignal: 1}, + } + candidates := []*rerank.Candidate{head, primary, complement, complementTwo, typed} + + preTyped := exploreTypedAnchorReservedCandidateIDs(candidates, nil, primary.Node.ID) + for _, id := range []string{complement.Node.ID, complementTwo.Node.ID} { + if _, protected := preTyped[id]; protected { + t.Fatalf("concept complement %q must not block stronger typed projection admission", id) + } + } + if _, protected := preTyped[typed.Node.ID]; !protected { + t.Fatal("typed projection must remain protected before final owner folding") + } + + final := exploreFinalReservedCandidateIDs(candidates, nil, primary.Node.ID) + for _, id := range []string{head.Node.ID, primary.Node.ID, complement.Node.ID, complementTwo.Node.ID, typed.Node.ID} { + if _, protected := final[id]; !protected { + t.Fatalf("final owner-folding reservation is missing %q", id) + } + } +} + func TestExploreAnswerDraftReservesMentionedShortSameOwnerCallee(t *testing.T) { parent := &graph.Node{ ID: "literal::extract_alternation", Name: "extract_alternation", diff --git a/internal/mcp/tools_explore_test.go b/internal/mcp/tools_explore_test.go index 7844acbd..a45d95ae 100644 --- a/internal/mcp/tools_explore_test.go +++ b/internal/mcp/tools_explore_test.go @@ -1198,3 +1198,94 @@ func TestRefinementEnvelopeAuthorizesSerializedEvidenceAndOmitsSource(t *testing } } } + +func TestRefinementEnvelopeAndHostDigestShareBoundedAuthorization(t *testing.T) { + allowed := []string{ + "repo/storage/level.go::StorageLevel.Normalize", + "repo/storage/batch_gate.go::BatchGate.FlushPending", + "repo/storage/batch_gate.go::BatchGate.ClearPending", + } + longPathSegment := strings.Repeat("very-long-path-segment-", 8) + targets := make([]exploreTarget, 0, localizationReplayEvidenceLimit) + visibleIDs := make([]string, 0, localizationReplayEvidenceLimit) + for index := 0; index < localizationReplayEvidenceLimit; index++ { + file := fmt.Sprintf("src/%s/%02d.go", longPathSegment, index) + id := fmt.Sprintf( + "repo/%s/%02d.go::Unrelated%02d.%s", + longPathSegment, index, index, strings.Repeat("LongMethod", 6), + ) + name := fmt.Sprintf("Unrelated%02d", index) + qualName := fmt.Sprintf("Unrelated%02d.%s", index, strings.Repeat("LongMethod", 6)) + switch index { + case 0: + id, name, qualName, file = allowed[0], "Normalize", "StorageLevel.Normalize", "repo/storage/level.go" + case 2: + id, name, qualName, file = allowed[1], "FlushPending", "BatchGate.FlushPending", "repo/storage/batch_gate.go" + case 8: + id, name, qualName, file = allowed[2], "ClearPending", "BatchGate.ClearPending", "repo/storage/batch_gate.go" + } + node := &graph.Node{ + ID: id, Name: name, QualName: qualName, Kind: graph.KindMethod, + FilePath: file, StartLine: index + 1, EndLine: index + 2, + } + targets = append(targets, exploreTarget{node: node, source: "func " + name + "() {}"}) + visibleIDs = append(visibleIDs, id) + } + routes := map[string]localizationRefinementRoute{ + allowed[0]: {enforceable: true}, + allowed[1]: {enforceable: true}, + allowed[2]: {enforceable: true}, + } + result, packed, _, digest := buildLocalizationRefinementResultForTask( + allowed[0], "", targets, exploreMaxBudgetTokens, routes, + ) + if result == nil || result.IsError || digest == nil || packed.State != localizationStateNeedsRefinement { + t.Fatalf("packed refinement = result=%#v completion=%#v digest=%#v", result, packed, digest) + } + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("refinement result content = %#v", result.Content) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatalf("decode refinement envelope: %v", err) + } + if len(envelope.Evidence) != len(visibleIDs) || len(envelope.Files) != len(visibleIDs) || len(envelope.Symbols) != len(visibleIDs) { + t.Fatalf("visible cardinality changed: files=%d symbols=%d evidence=%d want=%d", len(envelope.Files), len(envelope.Symbols), len(envelope.Evidence), len(visibleIDs)) + } + for index, want := range visibleIDs { + row := envelope.Evidence[index] + if envelope.Symbols[index] != want || row.ID != want || envelope.Files[index] != row.File || row.Rank != index+1 { + t.Fatalf("visible row %d changed or diverged: want=%q row=%#v", index+1, want, row) + } + } + if len(digest.Evidence) >= len(envelope.Evidence) { + t.Fatalf("fixture did not force retained byte shedding: retained=%d visible=%d", len(digest.Evidence), len(envelope.Evidence)) + } + if result.Meta == nil || result.Meta.AdditionalFields == nil { + t.Fatal("refinement result omitted authenticated host envelope") + } + host, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok || host.Evidence == nil { + t.Fatalf("host localization envelope = %#v", result.Meta.AdditionalFields[localizationHostMetaKey]) + } + wantAllowed := strings.Join(allowed, "\n") + if strings.Join(packed.AllowedSymbols, "\n") != wantAllowed || + strings.Join(envelope.Completion.AllowedSymbols, "\n") != wantAllowed || + strings.Join(host.Contract.Completion.AllowedSymbols, "\n") != wantAllowed { + t.Fatalf("completion authorization diverged: packed=%#v visible=%#v host=%#v", packed.AllowedSymbols, envelope.Completion.AllowedSymbols, host.Contract.Completion.AllowedSymbols) + } + if len(host.Evidence.Evidence) < len(allowed) { + t.Fatalf("host retained %d rows, want %d authorized rows", len(host.Evidence.Evidence), len(allowed)) + } + for index, want := range allowed { + row := host.Evidence.Evidence[index] + if row.ID != want || host.Evidence.Symbols[index] != want || host.Evidence.Files[index] != row.File || row.Rank != index+1 { + t.Fatalf("host authorized row %d diverged: want=%q row=%#v", index+1, want, row) + } + } + encoded, err := json.Marshal(host.Evidence) + if err != nil || len(encoded) > localizationDigestMaxBytes || len(host.Evidence.finalResponse) > localizationFinalResponseMaxBytes { + t.Fatalf("host digest bytes=%d final=%d err=%v", len(encoded), len(host.Evidence.finalResponse), err) + } +} From a1a93bbdb4aa4aaaf036857a29283b7baa5a5452 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 24 Jul 2026 16:55:55 +0200 Subject: [PATCH 5/5] fix(localization): keep single-result sessions open --- internal/hooks/localization_terminal_test.go | 66 +++++++++++++++++ internal/mcp/localization_terminal.go | 28 ++++++- .../localization_terminal_evidence_v8_test.go | 64 ++++++++++++++++ internal/mcp/localization_terminal_test.go | 74 +++++++++++++++++++ internal/mcp/tools_explore.go | 39 +++++++++- internal/mcp/tools_explore_test.go | 29 ++++++++ 6 files changed, 298 insertions(+), 2 deletions(-) diff --git a/internal/hooks/localization_terminal_test.go b/internal/hooks/localization_terminal_test.go index 490aa642..fcb9478b 100644 --- a/internal/hooks/localization_terminal_test.go +++ b/internal/hooks/localization_terminal_test.go @@ -44,6 +44,14 @@ func TestObserveLocalizationTerminalRequiresExactAnswerReadyV2Contract(t *testin {name: "v1", mutate: func(root map[string]any) { completionMap(root)["contract_version"] = 1 }}, {name: "missing final response", mutate: func(root map[string]any) { delete(completionMap(root), "final_response") }}, {name: "needs refinement", mutate: func(root map[string]any) { completionMap(root)["state"] = "needs_refinement" }}, + {name: "localized continue task", mutate: func(root map[string]any) { + root["terminal"] = false + completion := completionMap(root) + completion["state"] = "localized" + completion["required_action"] = "continue_task" + completion["enforceable"] = false + delete(completion, "final_response") + }}, {name: "wrong scope", mutate: func(root map[string]any) { completionMap(root)["scope"] = "diagnosis" }}, {name: "tool allowed", mutate: func(root map[string]any) { completionMap(root)["allowed_tool_calls"] = 1 }}, {name: "not terminal", mutate: func(root map[string]any) { root["terminal"] = false }}, @@ -62,6 +70,64 @@ func TestObserveLocalizationTerminalRequiresExactAnswerReadyV2Contract(t *testin } } +func TestLocalizedCompletionCreatesNoTerminalMarker(t *testing.T) { + configureLocalizationTerminalTestHome(t) + identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir()) + tool := gortexMCPToolPrefix + "explore" + toolUseID := "localized-tool" + snapshotTestLocalizationTool(t, identity, tool, toolUseID) + contract := terminalContractMap() + contract["terminal"] = false + completion := completionMap(contract) + completion["state"] = "localized" + completion["required_action"] = "continue_task" + completion["enforceable"] = false + delete(completion, "final_response") + data := localizationPostToolPayload( + t, tool, toolUseID, identity, terminalToolResponse(t, contract, true, false), + ) + if output := strings.TrimSpace(captureHookStdout(t, func() { runPostToolUse(data) })); output != "" { + t.Fatalf("localized PostToolUse emitted terminal context: %s", output) + } + if marker, marked := localizationTerminalMarkerFor(identity); marked { + t.Fatalf("localized completion created a terminal marker: %#v", marker) + } + + t.Setenv(editBlockingEnvVar, "0") + nextTools := []struct { + name string + input map[string]any + }{ + {name: gortexMCPToolPrefix + "search", input: map[string]any{"operation": "symbols", "query": "registerFacadeTools"}}, + {name: gortexMCPToolPrefix + "read", input: map[string]any{"operation": "source", "target": map[string]any{"symbol": "pkg/file.go::Run"}}}, + {name: gortexMCPToolPrefix + "edit", input: map[string]any{"operation": "file", "target": map[string]any{"file": "pkg/file.go"}}}, + {name: gortexMCPToolPrefix + "change", input: map[string]any{"operation": "impact", "target": map[string]any{"file": "pkg/file.go"}}}, + {name: "Edit", input: map[string]any{"file_path": filepath.Join(identity.CWD, "notes.txt")}}, + {name: "Write", input: map[string]any{"file_path": filepath.Join(identity.CWD, "notes.txt")}}, + {name: "Bash", input: map[string]any{"command": "go build ./cmd/gortex"}}, + {name: "Bash", input: map[string]any{"command": "go test ./internal/mcp"}}, + } + for index, next := range nextTools { + payload := preToolPayload(t, next.name, fmt.Sprintf("next-%d", index), identity, next.input) + output := strings.TrimSpace(captureHookStdout(t, func() { runPreToolUse(payload, 0, ModeDeny) })) + if output == "" { + continue + } + var decoded HookOutput + if err := json.Unmarshal([]byte(output), &decoded); err != nil { + t.Fatalf("decode %s PreToolUse output: %v\n%s", next.name, err, output) + } + if decoded.Decision == "deny" || + (decoded.HookSpecificOutput != nil && decoded.HookSpecificOutput.PermissionDecision == "deny") { + t.Fatalf("%s received a denial after localized completion: %#v", next.name, decoded) + } + if strings.Contains(decoded.Reason, "Localization for this task is complete") || + (decoded.HookSpecificOutput != nil && strings.Contains(decoded.HookSpecificOutput.PermissionDecisionReason, "Localization for this task is complete")) { + t.Fatalf("%s received terminal localization guidance after localized completion: %#v", next.name, decoded) + } + } +} + func TestObserveLocalizationTerminalRequiresMatchingAuthoritativeMeta(t *testing.T) { configureLocalizationTerminalTestHome(t) tests := []struct { diff --git a/internal/mcp/localization_terminal.go b/internal/mcp/localization_terminal.go index 67db237d..b68dd853 100644 --- a/internal/mcp/localization_terminal.go +++ b/internal/mcp/localization_terminal.go @@ -18,16 +18,21 @@ const ( localizationStateRefineInFlight = "refinement_in_flight" localizationStateExactReadInFlight = "exact_read_in_flight" localizationStateRecoveryInFlight = "recovery_in_flight" + localizationStateLocalized = "localized" localizationStateAnswerReady = "answer_ready" localizationTerminalContractV2 = 2 ) var localizationRecoveryOperations = []string{"search.text", "search.symbols", "read.source"} +const localizationSingleResultInstruction = "The indexed localization pass is complete for this request. Its bounded evidence contains exactly one supported primary production implementation candidate and no competing direct candidate. Continue the requested coding work using this result. Editing, building, testing, and other task tools remain available; further localization is unnecessary unless they reveal contradictory evidence." + // localizationCompletion is the host-neutral terminality contract returned by // explore(operation:"localize"). Hosts may stop the turn from this payload; // the server also enforces it for later Gortex navigation calls in the same -// MCP session. +// MCP session. AllowedToolCalls bounds only follow-up localization calls +// prescribed by this completion; it never limits editing, building, testing, +// or other task-execution tools. type localizationCompletion struct { State string `json:"state"` Scope string `json:"scope"` @@ -154,6 +159,21 @@ func newLocalizationCompletion(answerReady bool, exactSymbol string) localizatio return newLocalizationExactReadCompletion(exactSymbol, false) } +// newLocalizationSingleResultCompletion reports that localization has enough +// unique implementation evidence to continue the task without arming terminal +// state. The model gets a strong completeness cue, while every coding and +// navigation tool remains available if later implementation evidence disagrees. +func newLocalizationSingleResultCompletion() localizationCompletion { + return localizationCompletion{ + State: localizationStateLocalized, + Scope: "localization", + RequiredAction: "continue_task", + Instruction: localizationSingleResultInstruction, + AllowedToolCalls: 0, + ContractVersion: localizationTerminalContractV2, + } +} + func newLocalizationExactReadCompletion(exactSymbol string, correction bool) localizationCompletion { instruction := fmt.Sprintf(`Call Gortex MCP read(operation:"source", target:{symbol:%q}); then respond.`, exactSymbol) if correction { @@ -328,6 +348,12 @@ func (s *localizationTerminalState) armForTask(completion localizationCompletion if s == nil { return } + // `localized` is a wire-only confidence cue. Normalize it at the state + // boundary as a second line of defense so no current or future caller can + // accidentally turn the non-blocking cue into an unknown blocking state. + if completion.State == localizationStateLocalized { + completion = newLocalizationOpenCompletion() + } s.mu.Lock() defer s.mu.Unlock() fingerprint := localizationTaskFingerprint(task) diff --git a/internal/mcp/localization_terminal_evidence_v8_test.go b/internal/mcp/localization_terminal_evidence_v8_test.go index 3a14afc0..3be1b00d 100644 --- a/internal/mcp/localization_terminal_evidence_v8_test.go +++ b/internal/mcp/localization_terminal_evidence_v8_test.go @@ -425,6 +425,70 @@ func TestSearchTextCaptureResolvesInScopeOwnerThenFileEvidence(t *testing.T) { } } +func TestFacadeLocalizedCompletionStripsAuthWithoutPublishingReceipt(t *testing.T) { + t.Setenv("GORTEX_HOOK_SESSION_DIR", t.TempDir()) + registry := newFacadeRegistry() + var server *Server + handlerSawAuth := false + registry.capture(mcpgo.NewTool("explore", mcpgo.WithString("task", mcpgo.Required())), func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if arguments, ok := req.Params.Arguments.(map[string]any); ok { + _, handlerSawAuth = arguments[localizationauth.ArgumentKey] + } + completion := newLocalizationSingleResultCompletion() + targets := []exploreTarget{{ + node: localizationV8Node("internal/mcp/facade_tools.go::registerFacadeTools", "registerFacadeTools", "internal/mcp/facade_tools.go"), + source: "func registerFacadeTools() { registerRoutingSchema() }", + }} + result := newLocalizationExploreResultForTask( + completion, req.GetString("task", ""), targets, 1600, + ) + // Exercise the defensive state boundary too: even a caller that arms the + // wire-only completion directly must persist inactive navigation state. + server.localizationFor(ctx).armForTask(completion, req.GetString("task", "")) + return result, nil + }) + server = &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + + token, ok := localizationauth.NewToken() + if !ok { + t.Fatal("could not create localization auth token") + } + ctx := WithSessionID(context.Background(), "auth_localized_nonterminal") + arguments := map[string]any{ + "operation": "localize", + "task": "investigate facade routing schema registration behavior", + localizationauth.ArgumentKey: token, + } + result, err := server.handleFacade(ctx, "explore", mcpgo.CallToolRequest{ + Params: mcpgo.CallToolParams{Name: "explore", Arguments: arguments}, + }) + if err != nil || result == nil || result.IsError { + t.Fatalf("localized facade result = (%#v, %v)", result, err) + } + if handlerSawAuth { + t.Fatal("reserved auth argument reached the legacy handler") + } + if _, exists := arguments[localizationauth.ArgumentKey]; exists { + t.Fatal("reserved auth argument survived facade entry") + } + if _, published := localizationauth.Consume(token); published { + t.Fatal("localized completion published a terminal receipt") + } + host, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok { + t.Fatalf("localized typed host envelope = %T", result.Meta.AdditionalFields[localizationHostMetaKey]) + } + if host.Contract.Terminal || host.Contract.Completion.State != localizationStateLocalized || + host.Contract.Completion.Enforceable || host.Contract.Completion.FinalResponse != "" { + t.Fatalf("localized host contract became terminal: %#v", host.Contract) + } + for _, facade := range []string{"explore", "search", "read", "relations", "trace", "analyze", "edit", "change", "refactor"} { + if blocked := server.localizationFor(ctx).block(facade, "anything", nil); blocked != nil { + t.Fatalf("%s blocked after localized completion: %#v", facade, blocked) + } + } +} + func TestFacadeAuthArgumentIsStrippedAndPublishesTypedTerminalReceipts(t *testing.T) { t.Setenv("GORTEX_HOOK_SESSION_DIR", t.TempDir()) registry := newFacadeRegistry() diff --git a/internal/mcp/localization_terminal_test.go b/internal/mcp/localization_terminal_test.go index 25a1cf5c..aee6d770 100644 --- a/internal/mcp/localization_terminal_test.go +++ b/internal/mcp/localization_terminal_test.go @@ -228,6 +228,80 @@ func TestLocalizationCompletionEnvelope(t *testing.T) { } } +func TestLocalizationSingleResultCompletionDoesNotArmTerminalState(t *testing.T) { + task := "investigate facade routing schema registration dispatch behavior" + completion := newLocalizationSingleResultCompletion() + targets := []exploreTarget{{ + node: &graph.Node{ + ID: "internal/mcp/facade_tools.go::registerFacadeTools", + Name: "registerFacadeTools", + Kind: graph.KindFunction, + FilePath: "internal/mcp/facade_tools.go", + StartLine: 42, + }, + conceptImplementation: true, + source: "func registerFacadeTools() { registerRoutingSchema() }", + }} + result, _, _, finalized := buildLocalizationExploreResultForTaskFinalized( + completion, task, targets, 1600, + ) + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("expected one text result: %#v", result) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatalf("decode localized envelope: %v\n%s", err, text) + } + if finalized.State != localizationStateLocalized || envelope.Completion.State != localizationStateLocalized || + envelope.Completion.RequiredAction != "continue_task" || envelope.Terminal || envelope.Completion.Enforceable || + envelope.Completion.FinalResponse != "" { + t.Fatalf("single-result completion must remain non-terminal: finalized=%#v envelope=%#v", finalized, envelope) + } + for _, required := range []string{ + "indexed localization pass is complete", + "bounded evidence contains exactly one supported primary production implementation candidate", + "no competing direct candidate", + "Continue the requested coding work", + "Editing, building, testing", + } { + if !strings.Contains(envelope.Completion.Instruction, required) { + t.Fatalf("localized instruction %q does not contain %q", envelope.Completion.Instruction, required) + } + } + for _, forbidden := range []string{"Respond now", "do not call another tool"} { + if strings.Contains(envelope.Completion.Instruction, forbidden) || strings.Contains(envelope.Completion.FinalResponse, forbidden) { + t.Fatalf("localized completion retained terminal wording %q: %#v", forbidden, envelope.Completion) + } + } + host, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok { + t.Fatalf("localized host envelope missing: %#v", result.Meta) + } + if host.Contract.Terminal || host.Contract.Completion.State != localizationStateLocalized || + host.Contract.Completion.Enforceable || host.Contract.Completion.FinalResponse != "" { + t.Fatalf("localized host contract was terminally enriched: %#v", host.Contract) + } + + state := newLocalizationTerminalState() + state.arm(newLocalizationCompletion(true, "")) + state.armForTask(newLocalizationSingleResultCompletion(), task) + state.mu.Lock() + persistedState := state.state + state.mu.Unlock() + if persistedState != localizationStateInactive { + t.Fatalf("wire-only localized cue persisted as %q instead of inactive", persistedState) + } + for _, facade := range []string{ + "explore", "search", "read", "relations", "trace", "analyze", + "change", "edit", "refactor", "workspace", "session", "recall", "remember", "capabilities", + } { + if blocked := state.block(facade, "anything", nil); blocked != nil { + t.Fatalf("%s must remain dispatchable after a localized cue: %#v", facade, blocked) + } + } +} + func TestLocalizationEnvelopeRowsStayPositionallyAlignedForDuplicateFiles(t *testing.T) { targets := []exploreTarget{ {node: &graph.Node{ID: "repo/pkg/shared.go::First", Name: "First", Kind: graph.KindFunction, FilePath: "pkg/shared.go", StartLine: 10}}, diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index a4f4bac5..de634f7f 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -1195,6 +1195,34 @@ func exploreDraftSymbol(n *graph.Node) string { return n.Name } +// exploreSingleQualifiedImplementation recognizes the narrow case where one +// hydrated production callable, and only one, satisfies the same evidence +// threshold that made localization answer-ready. Supporting graph rows may +// remain visible, but none can independently justify another implementation. +func exploreSingleQualifiedImplementation(task string, targets []exploreTarget) bool { + // This cue is intentionally narrower than answer readiness. The direct + // bounded result must contain exactly one hydrated production callable; + // supporting graph relations may still be rendered later, but a selected + // alternate can never be described as absent merely because it ranked lower. + if len(targets) != 1 || len(exploreQuotedRecallTerms(task)) > 0 || + !exploreHydratedProductionCallable(targets[0]) { + return false + } + return exploreAnswerReady(task, targets) +} + +func exploreLocalizationCompletion( + answerReady, artifactReady bool, + task string, + targets []exploreTarget, + exactSymbol string, +) localizationCompletion { + if answerReady && !artifactReady && exploreSingleQualifiedImplementation(task, targets) { + return newLocalizationSingleResultCompletion() + } + return newLocalizationCompletion(answerReady, exactSymbol) +} + // exploreAnswerReady decides whether the ranked head is strong enough to end // localization. A result is terminal only when its symbols visibly align with // the shaped query; rank alone is not enough because broad review/audit prompts @@ -2625,7 +2653,9 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m ) return result, nil } - completion := newLocalizationCompletion(answerReady, exactSymbol) + completion := exploreLocalizationCompletion( + answerReady, artifactLane.ready, task, symbolTargets, exactSymbol, + ) // The digest derives from the same serialized projection the host sees, // and is retained for post-terminal replay — for the exact-read contract // too, whose success promotes to answer_ready with the evidence already @@ -2655,6 +2685,13 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m ) return refined, nil } + if completion.State == localizationStateLocalized { + // `localized` is a wire-only confidence cue. Persisting that state would + // make the session authorizer treat it as a live contract, so commit the + // inactive state and leave every later navigation and coding tool open. + s.localizationFor(ctx).keepOpenForTask(task) + return result, nil + } completion.digest = digest s.localizationFor(ctx).armForTask(completion, task) return result, nil diff --git a/internal/mcp/tools_explore_test.go b/internal/mcp/tools_explore_test.go index a45d95ae..6c406535 100644 --- a/internal/mcp/tools_explore_test.go +++ b/internal/mcp/tools_explore_test.go @@ -595,6 +595,35 @@ func TestExploreBroadWellAlignedNeighborhoodGetsExplicitAnswerReadyContract(t *t if !exploreAnswerReady(task, targets) { t.Fatal("well-aligned hydrated implementation should produce answer_ready for explore(localize)") } + if !exploreSingleQualifiedImplementation(task, targets) { + t.Fatal("one hydrated implementation above the confidence threshold should be recognized as the only qualified result") + } + localized := exploreLocalizationCompletion(true, false, task, targets, "") + if localized.State != localizationStateLocalized || localized.RequiredAction != "continue_task" { + t.Fatalf("single qualified implementation should produce a non-terminal localized cue: %#v", localized) + } + if contract := localizationContractFor(localized); contract.Terminal || contract.Completion.Enforceable { + t.Fatalf("single-result cue must not create a terminal contract: %#v", contract) + } + if artifact := exploreLocalizationCompletion(true, true, task, targets, ""); artifact.State != localizationStateAnswerReady { + t.Fatalf("artifact terminality must retain its existing contract: %#v", artifact) + } + competitor := exploreTarget{ + node: &graph.Node{ + ID: "internal/mcp/facade_tools.go::dispatchFacadeRoutingSchema", + Name: "dispatchFacadeRoutingSchema", + Kind: graph.KindFunction, + FilePath: "internal/mcp/facade_tools.go", + }, + source: "func dispatchFacadeRoutingSchema() { dispatchOperation() }", + } + multiple := append(targets, competitor) + if exploreSingleQualifiedImplementation(task, multiple) { + t.Fatal("a second implementation above the same confidence threshold must prevent the single-result cue") + } + if completion := exploreLocalizationCompletion(true, false, task, multiple, ""); completion.State != localizationStateAnswerReady { + t.Fatalf("multiple qualified implementations must retain the existing completion path: %#v", completion) + } metadataOnly := targets[0] metadataOnly.conceptImplementation = false metadataOnly.source = ""