fix(codex): preserve native web search action metadata (#85378)

This commit is contained in:
Vincent Koc
2026-05-23 17:06:01 +08:00
committed by GitHub
parent 492d656d74
commit bcf756ce36
4 changed files with 156 additions and 8 deletions

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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<string, unknown> | 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<string, unknown> | undefine
return undefined;
}
function webSearchToolArgs(item: CodexThreadItem): Record<string, unknown> {
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<string, unknown> = {};
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<string, unknown> } {
if (item.type === "commandExecution") {
return {
@@ -1856,11 +1915,19 @@ function itemToolResult(item: CodexThreadItem): { result?: Record<string, unknow
};
}
if (item.type === "webSearch") {
return { result: sanitizeCodexAgentEventRecord({ status: "completed" }) };
return { result: webSearchToolResult(item) };
}
return {};
}
function webSearchToolResult(item: CodexThreadItem): Record<string, unknown> {
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) {

View File

@@ -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));
}