From cbb87171e6751541ad220eb2b3f647e303461b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 11 Jun 2026 00:23:52 -0400 Subject: [PATCH] Accumulate Codex stream output items so tool calls aren't lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the GPT-account "empty turn: no text and no tool calls" failures, found by replaying the real stored response: the CEO actually called hire_agent five times, but the ChatGPT/Codex backend delivers each output item in a response.output_item.done event and then sends a response.completed whose response.output is []. accumulateResponsesStream read only the completion event, so every tool call was discarded and the turn parsed as empty — the run failed and the company never staffed. Collect items from output_item.done and fall back to them when the completion carries no output of its own; keep using the completion output when it is populated (non-Codex Responses backends). Verified against the real captured stream (5 hires recovered) and unit-tested with the exact event shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../provider-tool-turns/responses-format.ts | 27 ++++++++++++++----- .../tests/unit/provider-tool-turns.test.ts | 14 ++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/responses-format.ts b/AgentCompany-New/src/main/services/provider-tool-turns/responses-format.ts index 2d84e61f..51f023a8 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/responses-format.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/responses-format.ts @@ -74,24 +74,37 @@ function errorText(data: JsonRecord): string { } /** - * Accumulate a Responses SSE stream (Codex/Copilot) into the final result. The - * result is accepted from the completion event's `response.output`. + * Accumulate a Responses SSE stream (Codex/Copilot) into the final result. + * + * The ChatGPT/Codex backend streams each output item in a + * `response.output_item.done` event and then sends a `response.completed` + * whose `response.output` is EMPTY. Reading only the completion event there + * loses every tool call (the model acted, but the turn parses as empty). So + * collect items as they finish and fall back to them when the completion + * carries no output of its own. */ export async function accumulateResponsesStream(messages: AsyncIterable): Promise { const raw: string[] = []; + const streamed: unknown[] = []; for await (const message of messages) { raw.push(message.rawData); const type = eventType(message); if (!type) continue; if (type === "error" || type === "response.failed") throw new Error(errorText(message.data)); + if (type === "response.output_item.done" && isRecord(message.data.item)) { + streamed.push(message.data.item); + } if (type === "response.completed" || type === "response.done" || type === "response.incomplete") { const response = isRecord(message.data.response) ? message.data.response : null; - if (response && Array.isArray(response.output)) { - const parsed = parseResponsesOutput(response); - return { text: parsed.text, toolCalls: parsed.toolCalls, rawResponseJson: JSON.stringify(raw) }; - } - throw new Error("Responses completion event was missing response.output."); + const completionOutput = response && Array.isArray(response.output) ? response.output : []; + const output = completionOutput.length > 0 ? completionOutput : streamed; + const parsed = parseResponsesOutput({ output }); + return { text: parsed.text, toolCalls: parsed.toolCalls, rawResponseJson: JSON.stringify(raw) }; } } + if (streamed.length > 0) { + const parsed = parseResponsesOutput({ output: streamed }); + return { text: parsed.text, toolCalls: parsed.toolCalls, rawResponseJson: JSON.stringify(raw) }; + } throw new Error("Responses stream ended before a completion event."); } diff --git a/AgentCompany-New/tests/unit/provider-tool-turns.test.ts b/AgentCompany-New/tests/unit/provider-tool-turns.test.ts index 605e72bf..06ddc1f3 100644 --- a/AgentCompany-New/tests/unit/provider-tool-turns.test.ts +++ b/AgentCompany-New/tests/unit/provider-tool-turns.test.ts @@ -60,6 +60,20 @@ describe("openai responses encoding", () => { ])); expect(result.toolCalls).toEqual([{ id: "c3", name: "write_deliverable", arguments: { path: "a" } }]); }); + + test("accumulates output_item.done when the completion output is empty (Codex/ChatGPT backend)", async () => { + // The Codex backend streams each tool call as an output_item.done event and + // then sends a response.completed whose response.output is []. + const result = await accumulateResponsesStream(stream([ + { type: "response.output_item.added", item: { id: "fc1", type: "function_call", status: "in_progress", arguments: "", call_id: "call_1", name: "hire_agent" } }, + { type: "response.function_call_arguments.done", arguments: '{"name":"Dr. Li"}', item_id: "fc1" }, + { type: "response.output_item.done", item: { id: "fc1", type: "function_call", status: "completed", arguments: '{"name":"Dr. Li","department":"Engineering"}', call_id: "call_1", name: "hire_agent" } }, + { type: "response.output_item.done", item: { id: "m1", type: "message", content: [{ type: "output_text", text: "Staffing the team." }] } }, + { type: "response.completed", response: { output: [], status: "completed" } }, + ])); + expect(result.toolCalls).toEqual([{ id: "call_1", name: "hire_agent", arguments: { name: "Dr. Li", department: "Engineering" } }]); + expect(result.text).toBe("Staffing the team."); + }); }); describe("openai chat completions encoding", () => {