From bcf756ce36397febcdc92fdbea825824c72d3427 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 23 May 2026 17:06:01 +0800 Subject: [PATCH] fix(codex): preserve native web search action metadata (#85378) --- CHANGELOG.md | 2 +- .../src/app-server/event-projector.test.ts | 83 ++++++++++++++++++- .../codex/src/app-server/event-projector.ts | 77 +++++++++++++++-- extensions/codex/src/app-server/protocol.ts | 2 +- 4 files changed, 156 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de3423d75d..d497d6c7def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,7 +102,7 @@ Docs: https://docs.openclaw.ai - Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945. - Gateway chat display: preserve OpenAI-compatible `prompt_tokens`, `completion_tokens`, and `total_tokens` usage fields in sanitized chat history so llama.cpp sessions keep context counts. Fixes #77992. Thanks @MarTT79. - Dashboard/CLI: allow macOS browser launching through `open` even when SSH environment variables are present, while preserving Linux SSH no-display protection. Fixes #67088. Thanks @theglove44. -- Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving tool progress telemetry. Fixes #85109. Thanks @ugitmebaby. +- Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving available action query metadata in tool progress telemetry. Fixes #85109. Thanks @ugitmebaby. - OpenCode Go: strip unsupported Kimi reasoning replay fields before provider requests so repeated `kimi-k2.6` turns do not fail schema validation. Fixes #83812. Thanks @Sleeck. - Browser/CDP: add a WSL2 portproxy self-loop hint when Chrome DevTools endpoints accept connections but return an empty HTTP reply. Fixes #59209. Thanks @Owlock. - Agents/tools: add bounded tool-policy audit log entries that identify which allow/deny rule removed tools or blocked a sandboxed tool call. Fixes #55801. Thanks @justinjkline. diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index f786534a225..54f49861691 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -1947,7 +1947,88 @@ describe("CodexAppServerEventProjector", () => { expect(event.params).toEqual({ query: "native tool observability" }); expect(event.runId).toBe("run-1"); expect(event.toolCallId).toBe("search-observed"); - expect(event.result).toEqual({ status: "completed" }); + expect(event.result).toEqual({ + status: "completed", + durationMs: 5, + query: "native tool observability", + }); + }); + + it("uses Codex web search action metadata when the top-level query is empty", async () => { + const afterToolCall = vi.fn(); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]), + ); + const projector = await createProjector(); + + await projector.handleNotification( + forCurrentTurn("item/completed", { + item: { + type: "webSearch", + id: "search-observed", + query: "", + action: { + type: "search", + query: "native action query", + queries: ["native action query", "secondary query"], + }, + status: "completed", + durationMs: 5, + }, + }), + ); + + await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1)); + const event = requireRecord( + mockCallArg(afterToolCall, 0, 0, "after_tool_call event"), + "after_tool_call event", + ); + expect(event.toolName).toBe("web_search"); + expect(event.params).toEqual({ + query: "native action query", + queries: ["native action query", "secondary query"], + }); + expect(event.result).toEqual({ + status: "completed", + durationMs: 5, + query: "native action query", + queries: ["native action query", "secondary query"], + }); + }); + + it("marks unavailable Codex web search queries explicitly", async () => { + const afterToolCall = vi.fn(); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]), + ); + const projector = await createProjector(); + + await projector.handleNotification( + forCurrentTurn("item/completed", { + item: { + type: "webSearch", + id: "search-observed", + query: "", + action: { type: "other" }, + status: "completed", + }, + }), + ); + + await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1)); + const event = requireRecord( + mockCallArg(afterToolCall, 0, 0, "after_tool_call event"), + "after_tool_call event", + ); + expect(event.params).toEqual({ + action: "other", + queryUnavailable: true, + }); + expect(event.result).toEqual({ + status: "completed", + action: "other", + queryUnavailable: true, + }); }); it("records dynamic OpenClaw tool calls in mirrored transcript snapshots", async () => { diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index f089d6ecfae..6087c1d92c8 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -1528,6 +1528,32 @@ function readString(record: JsonObject, key: string): string | undefined { return typeof value === "string" ? value : undefined; } +function normalizeNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + return value.trim() || undefined; +} + +function readNonEmptyString(record: JsonObject, key: string): string | undefined { + return normalizeNonEmptyString(record[key]); +} + +function readNonEmptyStringArray(record: JsonObject, key: string): string[] { + const value = record[key]; + if (!Array.isArray(value)) { + return []; + } + const entries: string[] = []; + for (const entry of value) { + const normalized = normalizeNonEmptyString(entry); + if (normalized) { + entries.push(normalized); + } + } + return entries; +} + function readNullableString(record: JsonObject, key: string): string | null | undefined { const value = record[key]; if (value === null) { @@ -1818,8 +1844,8 @@ function itemToolArgs(item: CodexThreadItem): Record | undefine changes: itemFileChanges(item), }); } - if (item.type === "webSearch" && typeof item.query === "string") { - return sanitizeCodexAgentEventRecord({ query: item.query }); + if (item.type === "webSearch") { + return webSearchToolArgs(item); } if (item.type === "dynamicToolCall" || item.type === "mcpToolCall") { return sanitizeCodexToolArguments(item.arguments); @@ -1827,6 +1853,39 @@ function itemToolArgs(item: CodexThreadItem): Record | undefine return undefined; } +function webSearchToolArgs(item: CodexThreadItem): Record { + const action = isJsonObject(item.action) ? item.action : undefined; + const actionType = action ? readNonEmptyString(action, "type") : undefined; + const queries = + action && actionType === "search" ? readNonEmptyStringArray(action, "queries") : []; + const query = + normalizeNonEmptyString(item.query) ?? + (action && actionType === "search" ? readNonEmptyString(action, "query") : undefined) ?? + queries[0]; + const url = action ? readNonEmptyString(action, "url") : undefined; + const pattern = action ? readNonEmptyString(action, "pattern") : undefined; + const args: Record = {}; + if (query) { + args.query = query; + } + if (queries.length > 0) { + args.queries = queries; + } + if (actionType && actionType !== "search") { + args.action = actionType; + } + if (url) { + args.url = url; + } + if (pattern) { + args.pattern = pattern; + } + if (!query && !url && !pattern) { + args.queryUnavailable = true; + } + return sanitizeCodexAgentEventRecord(args); +} + function itemToolResult(item: CodexThreadItem): { result?: Record } { if (item.type === "commandExecution") { return { @@ -1856,11 +1915,19 @@ function itemToolResult(item: CodexThreadItem): { result?: Record { + return sanitizeCodexAgentEventRecord({ + status: itemStatus(item), + ...(typeof item.durationMs === "number" ? { durationMs: item.durationMs } : {}), + ...webSearchToolArgs(item), + }); +} + function itemFileChanges(item: CodexThreadItem): Array<{ path: string; kind: string }> { return Array.isArray(item.changes) ? item.changes.map((change) => ({ path: change.path, kind: change.kind })) @@ -1895,8 +1962,8 @@ function itemMeta( { detailMode }, ); } - if (item.type === "webSearch" && typeof item.query === "string") { - return item.query; + if (item.type === "webSearch") { + return inferToolMetaFromArgs("web_search", webSearchToolArgs(item), { detailMode }); } const toolName = itemName(item); if ((item.type === "dynamicToolCall" || item.type === "mcpToolCall") && toolName) { diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index da5e2e084c8..4daee2290ba 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -529,7 +529,7 @@ type CodexAppServerRequestResultMap = { "turn/steer": JsonValue; }; -export function isJsonObject(value: JsonValue | undefined): value is JsonObject { +export function isJsonObject(value: unknown): value is JsonObject { return Boolean(value && typeof value === "object" && !Array.isArray(value)); }