diff --git a/src/lib/tool-call-normalization.test.ts b/src/lib/tool-call-normalization.test.ts index 43aeca74b0..3e9111f5ff 100644 --- a/src/lib/tool-call-normalization.test.ts +++ b/src/lib/tool-call-normalization.test.ts @@ -378,6 +378,95 @@ describe("inferLiveToolName meta.claudeCode.toolName override", () => { }) }) +describe("inferLiveToolName query-bearing MCP calls", () => { + it("keeps an explicit OpenCode MCP tool title", () => { + expect( + inferLiveToolName({ + title: "codegraph_explore", + kind: "other", + rawInput: JSON.stringify({ query: "find the auth flow" }), + }) + ).toBe("codegraph_explore") + }) + + it("still classifies a query as websearch when the wire names websearch", () => { + expect( + inferLiveToolName({ + title: "web_search", + kind: "other", + rawInput: JSON.stringify({ query: "Codeg" }), + }) + ).toBe("websearch") + + expect( + inferLiveToolName({ + title: "Search", + kind: "websearch", + rawInput: JSON.stringify({ query: "Codeg" }), + }) + ).toBe("websearch") + }) + + it("recognizes Codex web-search action frames", () => { + expect( + inferLiveToolName({ + title: "Open page: https://example.com", + kind: "search", + rawInput: JSON.stringify({ + query: "Codeg", + action: { type: "openPage", url: "https://example.com" }, + }), + }) + ).toBe("websearch") + + // session/load replay omits the `type` marker, but keeps the action title + // and payload. `kind: "search"` must not be enough on its own because + // local fuzzy-file searches use the same ACP kind. + expect( + inferLiveToolName({ + title: "Find in page for 'ACP' in https://example.com", + kind: "search", + rawInput: JSON.stringify({ + query: "Codeg", + action: { + type: "findInPage", + pattern: "ACP", + url: "https://example.com", + }, + }), + }) + ).toBe("websearch") + + // The marker is also sufficient when a title is too generic to identify + // the action on its own. + expect( + inferLiveToolName({ + title: "Search", + kind: "other", + rawInput: JSON.stringify({ type: "webSearch", query: "Codeg" }), + }) + ).toBe("websearch") + }) + + it("does not infer websearch from a query field alone", () => { + expect( + inferLiveToolName({ + title: "MCP: tool", + kind: "other", + rawInput: JSON.stringify({ query: "find usages" }), + }) + ).not.toBe("websearch") + + expect( + inferLiveToolName({ + title: "Search for 'find usages'", + kind: "search", + rawInput: JSON.stringify({ query: "find usages" }), + }) + ).toBe("grep") + }) +}) + describe("normalizeToolName collapses delegate_to_agent across hosts", () => { // The codeg multi-agent delegation MCP tool is named the same across hosts // (`delegate_to_agent`) but each host serializes the server prefix diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index a3873211b4..18cdecec2e 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -257,6 +257,26 @@ function hasAnyKey(obj: Record, keys: string[]): boolean { ) } +/** + * Codex ACP uses human titles for web-search follow-up actions. Those frames + * keep `kind: "search"` (the same kind used by local fuzzy-file search), so + * the title is the only identity signal when the raw-input type is omitted on + * session replay. + */ +function isCodexWebSearchTitle(input: string | null | undefined): boolean { + const title = input?.trim() + if (!title) return false + return /^(?:web\s+search|open\s+page|find\s+in\s+page)(?:\s*:|\s|$)/i.test( + title + ) +} + +/** Codex's raw-input marker is camelCase on the ACP wire (`webSearch`). */ +function isCodexWebSearchType(input: unknown): boolean { + if (typeof input !== "string") return false + return canonicalizeToolName(input).replace(/_/g, "") === "websearch" +} + /** * Wire spellings that mean the same argument as one of the canonical * (snake_case) keys every tool card reads. OpenCode names its tool arguments in @@ -389,7 +409,23 @@ function inferFromInput( return "edit" if (hasAnyKey(parsed, ["changes"])) return "edit" if (hasAnyKey(parsed, ["todos"])) return "todowrite" - if (hasAnyKey(parsed, ["query"])) return "websearch" + // `query` is a common MCP argument (for example CodeGraph's + // `codegraph_explore` and Context7's query tools), not a web-search + // discriminator. Only classify it as websearch when the wire also names a + // web-search tool, or when Codex's action title/type identifies the call; + // otherwise `inferLiveToolName` can preserve the explicit tool title instead + // of showing every query-bearing MCP call as "WebSearch". Codex's generic + // `kind: "search"` intentionally remains a local file search (`grep`). + if ( + hasAnyKey(parsed, ["query"]) && + (normalizedTitle === "websearch" || + normalizedTitle === "web_search" || + normalizedKind === "websearch" || + normalizedKind === "web_search" || + isCodexWebSearchTitle(title) || + isCodexWebSearchType(parsed.type)) + ) + return "websearch" if (hasAnyKey(parsed, ["url"])) return "webfetch" const hasPattern = hasAnyKey(parsed, ["pattern"])