Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/lib/tool-call-normalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion src/lib/tool-call-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,26 @@ function hasAnyKey(obj: Record<string, unknown>, 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
Expand Down Expand Up @@ -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"])
Expand Down
Loading