From 9bde195b93c679d7d40924702a4e8e9143993476 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Thu, 30 Jul 2026 15:16:26 -0500 Subject: [PATCH] fix: replay scripted tool calls in session history --- src/ResponseItemHistoryFallback.ts | 13 ++- .../response-item-history-fallback.test.ts | 102 ++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/ResponseItemHistoryFallback.ts b/src/ResponseItemHistoryFallback.ts index 7292df20..13952a42 100644 --- a/src/ResponseItemHistoryFallback.ts +++ b/src/ResponseItemHistoryFallback.ts @@ -104,6 +104,7 @@ export function parseResponseItemHistoryFallback( case "reasoning": pushUpdates(createReasoningUpdates(item)); break; + case "custom_tool_call": case "function_call": { const toolCallId = stringValue(item["call_id"]); if (toolCallId && existingToolCallIds.has(toolCallId)) { @@ -128,6 +129,7 @@ export function parseResponseItemHistoryFallback( pushUpdates([result.update]); break; } + case "custom_tool_call_output": case "function_call_output": { const toolCallId = stringValue(item["call_id"]); if (toolCallId && skippedToolCallIds.has(toolCallId)) { @@ -368,8 +370,8 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate | return null; } - const isExecCommand = name === "exec_command"; - const args = parseFunctionArguments(item["arguments"]); + const isExecCommand = name === "exec_command" || name === "exec"; + const args = parseFunctionArguments(item["input"] ?? item["arguments"]); const command = isExecCommand ? commandFromFunctionArguments(args) : null; const cwd = isExecCommand ? cwdFromFunctionArguments(args) : ""; const commandAction = command ? inferCommandAction(command, cwd) : null; @@ -390,7 +392,7 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate | rawInput: rawInputForFunctionCall(name, args), }; - if (!functionCallUsesTerminal(item)) { + if (!isExecCommand) { return { update, usesTerminal: false, isExecCommand }; } @@ -500,6 +502,7 @@ function titleForFunctionCall(name: string, args: unknown): string { function toolKindForFunctionCall(name: string): AcpToolKind { switch (name) { + case "exec": case "exec_command": case "multi_tool_use.parallel": return "execute"; @@ -512,10 +515,6 @@ function toolKindForFunctionCall(name: string): AcpToolKind { } } -function functionCallUsesTerminal(item: JsonRecord): boolean { - return item["name"] === "exec_command"; -} - function commandFromFunctionArguments(args: unknown): string | null { const record = asRecord(args); if (!record) { diff --git a/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts b/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts index aaeef097..46abca48 100644 --- a/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts +++ b/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts @@ -31,6 +31,90 @@ describe("ResponseItemHistoryFallback", () => { expect(toolCallUpdateStatuses(updates)).toEqual([]); }); + it("recovers scripted tool calls recorded as custom_tool_call", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + customToolCall( + "call-scripted", + 'const r = await tools.exec_command({"cmd":"echo hello"});\ntext(r.output);\n', + ), + customToolCallOutput("call-scripted", "Script completed\nOutput:\n", "hello\n"), + ]), "terminal_output", new Set()); + + expect(toolCallIds(updates)).toEqual(["call-scripted"]); + expect(toolCallUpdateStatuses(updates)).toEqual([ + { toolCallId: "call-scripted", status: "completed" }, + ]); + }); + + it("surfaces the script itself, since what a script ran is not recorded", () => { + const script = 'for (const cmd of ["echo alpha", "echo beta"]) { await tools.exec_command({cmd}); }'; + const updates = parseResponseItemHistoryFallback(jsonl([ + customToolCall("call-loop", script), + ]), "terminal_output", new Set()); + + const serialized = JSON.stringify(updates); + expect(serialized).toContain("echo alpha"); + expect(serialized).toContain("echo beta"); + }); + + it("maps a scripted call to the execute kind", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + customToolCall("call-kind", 'await tools.exec_command({"cmd":"ls"});'), + ]), "terminal_output", new Set()); + + const toolCall = (updates ?? []).find((update) => update.sessionUpdate === "tool_call"); + expect(toolCall && "kind" in toolCall ? toolCall.kind : null).toBe("execute"); + }); + + it("carries the combined script output through", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + customToolCall("call-out", 'await tools.exec_command({"cmd":"ls"});'), + customToolCallOutput("call-out", "Output:\n", "1: alpha\n", "2: beta\n"), + ]), "terminal_output", new Set()); + + const serialized = JSON.stringify(updates); + expect(serialized).toContain("1: alpha"); + expect(serialized).toContain("2: beta"); + }); + + it("skips scripted calls already present in the parsed thread", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + customToolCall("call-existing", 'await tools.exec_command({"cmd":"ls"});'), + customToolCallOutput("call-existing", "Output:\n", "ok\n"), + ]), "terminal_output", new Set(["call-existing"])); + + expect(toolCallIds(updates)).toEqual([]); + expect(toolCallUpdateStatuses(updates)).toEqual([]); + }); + + it("reports a failing script as failed", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + customToolCall("call-fail", 'await tools.exec_command({"cmd":"false"});'), + customToolCallOutput("call-fail", "Permission denied\n"), + ]), "terminal_output", new Set()); + + expect(toolCallUpdateStatuses(updates)).toEqual([ + { toolCallId: "call-fail", status: "failed" }, + ]); + }); + + it("still recovers a scripted call whose tool name is unfamiliar", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + { + type: "response_item", + payload: { + type: "custom_tool_call", + call_id: "call-other", + name: "some_other_sandbox", + input: 'await tools.exec_command({"cmd":"ls"});', + }, + }, + ]), "terminal_output", new Set()); + + expect(toolCallIds(updates)).toEqual(["call-other"]); + expect(JSON.stringify(updates)).toContain("tools.exec_command"); + }); + it("does not duplicate adjacent reasoning from event and response item records", () => { const updates = parseResponseItemHistoryFallback(jsonl([ { @@ -129,6 +213,24 @@ function functionCallOutput(callId: string, output: string): unknown { }; } +function customToolCall(callId: string, input: string): unknown { + return { + type: "response_item", + payload: { type: "custom_tool_call", call_id: callId, name: "exec", input }, + }; +} + +function customToolCallOutput(callId: string, ...texts: string[]): unknown { + return { + type: "response_item", + payload: { + type: "custom_tool_call_output", + call_id: callId, + output: texts.map((text) => ({ type: "input_text", text })), + }, + }; +} + function toolCallIds(updates: UpdateSessionEvent[] | null): string[] { return (updates ?? []) .filter((update): update is Extract => (