diff --git a/internal/hooks/codex_test.go b/internal/hooks/codex_test.go index d378e84e..0ef9285c 100644 --- a/internal/hooks/codex_test.go +++ b/internal/hooks/codex_test.go @@ -43,8 +43,10 @@ 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, "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/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 385dc18e..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`") || !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 5bee332f..8edc0634 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 ( @@ -23,8 +25,9 @@ const ( localizationTerminalAgentHardCap = 64 localizationTerminalJanitorDeletes = 32 - localizationTerminalContext = "Gortex localization is complete. Respond to the user now; do not call another 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" ) @@ -63,15 +66,17 @@ 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 { 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"` } @@ -81,23 +86,29 @@ 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"` + 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 { 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"` @@ -136,17 +147,44 @@ func observeLocalizationTerminal(data []byte) (localizationTerminalHookInput, bo if !localizationNavigationTool(input.ToolName) { return localizationTerminalHookInput{}, false } - identity, ok := consumeLocalizationToolSnapshot(input) - if !ok { + identity, authToken, ok := consumeLocalizationToolSnapshot(input) + 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 + // 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 !markLocalizationTerminalReceipt(identity, receipt.ContractVersion, receipt.Enforceable) { + 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 || !enforceableLocalizationTerminalContract(contract) { + if !ok || !answerReadyLocalizationTerminalContract(contract) { return localizationTerminalHookInput{}, false } - if !markLocalizationTerminal(identity, contract.Completion.ContractVersion) { + if !markLocalizationTerminalReceipt(identity, contract.Completion.ContractVersion, contract.Completion.Enforceable) { return localizationTerminalHookInput{}, false } + input.TerminalReceipt = localizationauth.Receipt{ + FinalResponse: contract.Completion.FinalResponse, + ContractVersion: contract.Completion.ContractVersion, + Enforceable: contract.Completion.Enforceable, + } return input, true } @@ -199,7 +237,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 @@ -240,14 +278,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) { @@ -268,15 +307,15 @@ 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" && completion.Scope == "localization" && completion.RequiredAction == "respond" && + strings.TrimSpace(completion.FinalResponse) != "" && completion.AllowedToolCalls != nil && *completion.AllowedToolCalls == 0 && - completion.ContractVersion >= localizationTerminalContractV2 && - completion.Enforceable + completion.ContractVersion >= localizationTerminalContractV2 } func localizationNavigationTool(tool string) bool { @@ -300,6 +339,52 @@ func preToolUsePolicyTool(tool string) bool { return ok } +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 + } + 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 +} + func localizationTerminalBaseFor(sessionID, agentID, cwd string) (localizationTerminalBase, bool) { base := localizationTerminalBase{ SessionID: strings.TrimSpace(sessionID), @@ -381,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 @@ -398,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 @@ -410,47 +508,86 @@ 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 { + _, 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 @@ -458,16 +595,37 @@ 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 } - return snapshot.Identity, true + claimToken, ok := localizationauth.NewToken() + if !ok { + return localizationTerminalIdentity{}, "", false + } + claimPath := path + ".consume-" + claimToken + if err := os.Rename(path, claimPath); err != nil { + return localizationTerminalIdentity{}, "", false + } + defer os.Remove(claimPath) //nolint:errcheck // one-shot cleanup is best effort + 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 @@ -477,19 +635,38 @@ func markLocalizationTerminal(identity localizationTerminalIdentity, contractVer ContractVersion: contractVersion, Identity: identity, ObservedUnixNano: time.Now().UnixNano(), + Advisory: advisory, } - return writeBoundedLocalizationState(localizationTerminalMarkerPath(identity), marker) + path := localizationTerminalMarkerPath(identity) + if advisory { + path = localizationTerminalAdvisoryMarkerPath(identity) + } + 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 { @@ -711,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 new file mode 100644 index 00000000..798b533d --- /dev/null +++ b/internal/hooks/localization_terminal_receipt_test.go @@ -0,0 +1,873 @@ +package hooks + +import ( + "encoding/json" + "os" + "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) + } + 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 + 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) + 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", + "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 := 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) + } + }) + } +} + +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 _, marked := localizationTerminalMarkerFor(identity); marked { + t.Fatal("forged visible payload armed 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 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`)", + "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) + } + } + 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 16ac4db2..fcb9478b 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 { @@ -42,8 +42,16 @@ func TestObserveLocalizationTerminalRequiresExactEnforceableV2Contract(t *testin mutate func(map[string]any) }{ {name: "v1", mutate: func(root map[string]any) { completionMap(root)["contract_version"] = 1 }}, - {name: "advisory", mutate: func(root map[string]any) { completionMap(root)["enforceable"] = false }}, + {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 TestObserveLocalizationTerminalRequiresExactEnforceableV2Contract(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 { @@ -117,6 +183,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 +209,115 @@ 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) + } + 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) { + 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 + }, + }, + } + finalResponse := completionMap(terminalContractMap())["final_response"].(string) + want := string(mustJSON(t, HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PostToolUse", + AdditionalContext: localizationTerminalContext + "\n\n" + finalResponse, + }})) + 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" @@ -144,14 +331,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) @@ -762,6 +973,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/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 409891c3..47b06b35 100644 --- a/internal/hooks/posttooluse.go +++ b/internal/hooks/posttooluse.go @@ -63,10 +63,9 @@ 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) + 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 365447d2..33007fb8 100644 --- a/internal/hooks/pretooluse.go +++ b/internal/hooks/pretooluse.go @@ -103,22 +103,40 @@ 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 { - // 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 + } } + 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 @@ -154,6 +172,9 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { if adv := gortexReadNudge(input.ToolName, input.ToolInput); adv != "" { hso.AdditionalContext = adv } + if updatedInput != nil { + hso.UpdatedInput = updatedInput + } emitted = hso.AdditionalContext != "" || hso.PermissionDecisionReason != "" emitPreToolUse(HookOutput{HookSpecificOutput: hso}) return @@ -165,12 +186,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 updatedInput != nil { + emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + UpdatedInput: updatedInput, + }}) + } return } result := applyMode(input, isGortexMCP, mode, enrich(input, gortexPort)) if result.context == "" && !result.deny { + if updatedInput != nil { + emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + UpdatedInput: updatedInput, + }}) + } return } @@ -179,6 +212,9 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { HookEventName: "PreToolUse", }, } + if updatedInput != nil { + output.HookSpecificOutput.UpdatedInput = updatedInput + } if result.deny { output.HookSpecificOutput.PermissionDecision = "deny" diff --git a/internal/hooks/sessionstart.go b/internal/hooks/sessionstart.go index 60b352a3..bc8dd3be 100644 --- a/internal/hooks/sessionstart.go +++ b/internal/hooks/sessionstart.go @@ -293,12 +293,15 @@ 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. " + - "After starting localization, obey `completion.required_action` and make no further tool calls after `answer_ready`. " + + 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 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`. 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 02cd61b1..01b4c60b 100644 --- a/internal/hooks/sessionstart_test.go +++ b/internal/hooks/sessionstart_test.go @@ -24,6 +24,94 @@ 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", + "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", + "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) + } + } + 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", + "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) + } + } + 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/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/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/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/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/facade_tools.go b/internal/mcp/facade_tools.go index 41f7d226..cc81322f 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -9,11 +9,13 @@ import ( "slices" "sort" "strings" + "sync" "time" 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" ) @@ -417,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()) @@ -451,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 } } @@ -496,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 @@ -516,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 { @@ -526,7 +532,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. @@ -537,19 +543,30 @@ 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 + 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) @@ -561,9 +578,206 @@ 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 { + 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 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)) + 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) { + 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..e8f09bb5 --- /dev/null +++ b/internal/mcp/localization_current_capture_test.go @@ -0,0 +1,272 @@ +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), 6; 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", + "repo/storage/archive.go::Archive.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 TestFinishReservedReadWithDigestRetriesRecordedZeroThenPreservesCurrentTaskDigest(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 != 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) + } +} + +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 c0e2375e..69a40b5a 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,20 +11,21 @@ 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 // 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 // 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 +38,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 { @@ -54,40 +61,153 @@ 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 + 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, + }) + } + } + appendRows(true) + appendRows(false) + + 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 row.ID == "" || row.File == "" { + if len(digest.Evidence) == 0 { + return digest + } + last := len(digest.Evidence) - 1 + if shedLocalizationDigestRowOptionalFields(&digest.Evidence[last]) { continue } - if _, exists := seen[row.ID]; exists { + // 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 { + digest.Evidence = nil 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, - }) + digest.Evidence = digest.Evidence[:last] + } +} + +// 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 + } + 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 { + if err == nil && len(encoded) <= localizationDigestMaxBytes && len(digest.finalResponse) <= localizationFinalResponseMaxBytes { return digest } if len(digest.Evidence) == 0 { @@ -98,15 +218,139 @@ func newLocalizationEvidenceDigest(envelope localizationExploreEnvelope) *locali continue } 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] } } +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 @@ -135,22 +379,465 @@ 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 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), " ") +} + +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 nil + } + 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 + } + + rowsByID := make(map[string]localizationDigestRow, len(rows)) + for _, row := range rows { + rowsByID[strings.TrimSpace(row.ID)] = row + } + // 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, "- %s — %s — %s\n", role, file, id) + } + response.WriteString("\n") + response.WriteString(localizationAnswerReadyDirective) + return response.String() +} + +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 _, exists := seenFiles[row.File]; !exists { - seenFiles[row.File] = struct{}{} - digest.Files = append(digest.Files, row.File) + if symbol := strings.TrimSpace(row.ID); symbol != "" { + retained[symbol] = row } - if _, exists := seenSymbols[row.ID]; !exists { - seenSymbols[row.ID] = struct{}{} - digest.Symbols = append(digest.Symbols, row.ID) + } + return retained +} + +func localizationDigestHasProvenance(rows map[string]localizationDigestRow, provenance string) bool { + for _, row := range rows { + if row.Provenance == provenance { + return true } } + return false } -const localizationAnswerReadyNotice = "Localization is complete. Do not call another tool. " + - "Answer the user now in your own words using the evidence already returned." +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 { + 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 +856,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 +874,23 @@ 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 + // 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 433873be..186e4dee 100644 --- a/internal/mcp/localization_digest_test.go +++ b/internal/mcp/localization_digest_test.go @@ -66,19 +66,74 @@ 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) + } + 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) + } + 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 { + 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 +} + +func TestPostTerminalNavigationReturnsByteIdenticalSuccessfulReplay(t *testing.T) { state := &localizationTerminalState{} completion := newLocalizationCompletion(true, "") completion.digest = testEvidenceDigest() 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) 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 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) } @@ -86,11 +141,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 +168,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 +192,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, "- 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) } } @@ -163,7 +226,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 +246,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 +272,565 @@ 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) + } +} + +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) } } @@ -251,6 +868,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 +908,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) { @@ -282,11 +927,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) } @@ -305,41 +950,137 @@ func TestLocalizationDigestKeepsOnlyConcreteBoundedEvidence(t *testing.T) { } } -func TestLocalizationAnswerReadyResultIsTinyNeutralAndStructured(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) +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("- 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) } - visible, ok := singleTextContent(result) - if !ok || visible != localizationAnswerReadyNotice { - t.Fatalf("terminal result text = %q", visible) +} + +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, + } } - 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) + 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) } } - structured, ok := result.StructuredContent.(map[string]any) - if !ok || structured["terminal"] != true || structured["completion"] == nil { - t.Fatalf("structured terminal contract = %#v", result.StructuredContent) + 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 _, exists := structured["evidence_digest"]; exists { - t.Fatalf("terminal contract replayed evidence: %#v", structured) + if !strings.HasSuffix(response, localizationAnswerReadyDirective) || strings.Contains(response, "#1") { + t.Fatalf("response lost the stable directive or restored ordinal cross-references:\n%s", response) } - 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) +} + +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) } - if len(visibleEncoded) > 512 { - t.Fatalf("visible terminal result = %d bytes, want <= 512", len(visibleEncoded)) +} + +func TestLocalizationAnswerReadyResultReplaysAlignedStableContract(t *testing.T) { + completion := newLocalizationCompletion(true, "") + completion.digest = testEvidenceDigest() + result := localizationAnswerReadyResult(completion) + contract := requireLocalizationTerminalReplay(t, result, "", "") + wantRows := []string{ + "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) { + t.Fatalf("final_response omitted aligned rows %q:\n%s", want, contract.Completion.FinalResponse) + } } - 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 +1089,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 +1294,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 +1336,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 +1398,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 +1414,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 +1426,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 +1436,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 +1456,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..6ffd20b4 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,343 @@ 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") + } +} + +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) } } @@ -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_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..b68dd853 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" ) @@ -17,21 +18,27 @@ 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"` 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"` @@ -54,6 +61,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 @@ -92,6 +108,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, @@ -103,13 +123,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{ @@ -123,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 { @@ -208,11 +259,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. @@ -223,6 +280,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 @@ -261,6 +319,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 @@ -270,6 +332,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 @@ -285,9 +348,18 @@ 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) + if completion.State != localizationStateInactive { + completion.taskLead = localizationTaskLead(task) + } if s.reservation != nil { s.reservation.pendingCompletion = completion s.reservation.pendingTaskFingerprint = fingerprint @@ -381,6 +453,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 @@ -407,6 +483,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 @@ -426,6 +503,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" @@ -438,11 +518,12 @@ func (s *localizationTerminalState) completionLocked() localizationCompletion { completion = newLocalizationCompletion(true, "") } completion.enforceableOnAnswerReady = s.enforceableOnAnswerReady + completion.taskLead = s.taskLead if completion.State == localizationStateAnswerReady { 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 @@ -460,6 +541,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. @@ -487,15 +571,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 "" } } @@ -507,6 +605,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 } @@ -514,6 +615,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) } @@ -545,6 +652,466 @@ 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) +} + +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 { + if localizationTaskCitesConcreteEvidence(task, value) { + return true + } + } + candidateTerms := exploreTerminalTerms(strings.Join(values, " ")) + overallOverlap := 0 + leadOverlap := 0 + for term := range candidateTerms { + if _, aligned := taskTerms[term]; aligned { + 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 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 == "" { + 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 @@ -581,6 +1148,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, @@ -596,7 +1177,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, @@ -607,6 +1188,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. @@ -698,6 +1280,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), " ") } @@ -731,7 +1339,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() } @@ -811,6 +1424,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() } @@ -821,24 +1457,77 @@ 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 + var mergedDigest *localizationEvidenceDigest + if capturedResult { + mergedDigest = mergeLocalizationEvidenceDigestForTask(s.taskFingerprint, 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 + // with no capture record keeps the legacy transition contract. + success = false + } + defer func() { + if !captureRequired || s.state != localizationStateAnswerReady { + return + } + 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() + }() + 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: implementationSymbol := s.inFlightImplementationSymbol routeEnforceable := s.inFlightEnforceable wasCorrection := s.exactReadIsCorrection + confidentRead := capturedResult && mergedDigest != nil && len(mergedDigest.Evidence) > 0 && + localizationReservedReadEvidenceAlignedWithLead(s.taskFingerprint, s.taskLead, s.exactSymbol, currentEvidence) s.inFlightImplementationSymbol = "" s.inFlightEnforceable = false s.inFlightCorrectionSymbol = "" @@ -858,7 +1547,7 @@ func (s *localizationTerminalState) finishReservedReadToken(token uint64, succes 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() @@ -882,6 +1571,8 @@ func (s *localizationTerminalState) finishReservedReadToken(token uint64, succes } s.state = localizationStateNeedsExactRead case localizationStateRefineInFlight: + confidentRead := capturedResult && mergedDigest != nil && len(mergedDigest.Evidence) > 0 && + localizationReservedReadEvidenceAlignedWithLead(s.taskFingerprint, s.taskLead, s.refinementSymbol, currentEvidence) if success { implementationSymbol := s.inFlightImplementationSymbol enforceable := s.inFlightEnforceable @@ -913,7 +1604,13 @@ func (s *localizationTerminalState) finishReservedReadToken(token uint64, succes s.correctionRetriesRemaining = 1 return s.completionLocked() } - if !enforceable { + 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() @@ -940,23 +1637,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_evidence_v8_test.go b/internal/mcp/localization_terminal_evidence_v8_test.go new file mode 100644 index 00000000..3be1b00d --- /dev/null +++ b/internal/mcp/localization_terminal_evidence_v8_test.go @@ -0,0 +1,576 @@ +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 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"), + }, 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) + } + }) + 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) + } +} + +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 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() + 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/localization_terminal_test.go b/internal/mcp/localization_terminal_test.go index 222e02fb..aee6d770 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") } @@ -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) } @@ -219,12 +228,115 @@ 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}}, + {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 +403,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 +474,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 +487,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 +534,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 +585,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 +608,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 +662,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 +732,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 +854,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 +1016,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 +1046,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_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_explore.go b/internal/mcp/tools_explore.go index 7bac62bf..de634f7f 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 @@ -95,6 +96,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 { @@ -1193,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 @@ -1592,6 +1622,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 @@ -1601,9 +1796,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, @@ -1629,6 +1827,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) { @@ -1707,18 +1906,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 } @@ -2081,7 +2466,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 { @@ -2124,6 +2509,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 @@ -2267,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 @@ -2297,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 @@ -2752,6 +3147,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 @@ -2778,6 +3188,202 @@ 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 { + return interleaveLocalizationDirectRelationsWithRoutes(task, requiredID, targets, nil) +} + +func interleaveLocalizationDirectRelationsWithRoutes( + task, requiredID string, + targets []exploreTarget, + routes map[string]localizationRefinementRoute, +) []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.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 + } + 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)) + 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 { + 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) + _, provenWrapper := provenWrapperRoutes[refinementPair{ + wrapper: target.node.ID, implementation: node.ID, + }] + if overlap == 0 && (direction != "direct_callee" || !provenWrapper) { + continue + } + 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} + } 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 + } + + 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 +3516,9 @@ func buildLocalizationExploreResultForTaskFinalized( if refinementFirst { targets = prioritizeLocalizationEvidenceTarget(requiredSymbol, targets) } + targets = interleaveLocalizationDirectRelationsWithRoutes( + task, requiredSymbol, targets, completion.refinementRoutes, + ) contract := localizationContractFor(completion) envelope := localizationExploreEnvelope{ Completion: contract.Completion, @@ -2934,6 +3543,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. @@ -2976,7 +3588,6 @@ func buildLocalizationExploreResultForTaskFinalized( } } - seenFiles := make(map[string]struct{}) acceptedTargets := make([]exploreTarget, 0, len(targets)) for _, target := range targets { if target.node == nil { @@ -2985,6 +3596,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), @@ -2994,16 +3609,16 @@ 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 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 +3636,6 @@ func buildLocalizationExploreResultForTaskFinalized( candidate.Evidence[len(candidate.Evidence)-1] = evidence } envelope = candidate - seenFiles[path] = struct{}{} acceptedTargets = append(acceptedTargets, target) } @@ -3079,11 +3693,19 @@ func buildLocalizationExploreResultForTaskFinalized( contract = localizationContractFor(envelope.Completion) 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. + 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_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 0f7fc349..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 = "" @@ -1042,8 +1071,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) } } @@ -1198,3 +1227,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) + } +} 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,