Skip to content

Commit dc4fd1c

Browse files
Merge pull request #1414 from corbitsdev/cl-10582-codex-parallel-tool-calls-arrive-with-empty-arguments-when
fix(provider): recover Codex parallel call arguments sent only on done
2 parents c40bef5 + ee33f9c commit dc4fd1c

4 files changed

Lines changed: 165 additions & 1 deletion

File tree

‎fixtures/codex-sse/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ or user content — placeholders only.
1515
| `failed.json` | `response.failed` with error message |
1616
| `error.json` | Top-level stream `error` event |
1717
| `lifecycle-ignored.json` | `response.created` / `in_progress` / content_part / `*.done` envelopes |
18+
| `parallel-tools-arguments-on-done.json` | Parallel function_calls whose arguments arrive only on `*.done` |
1819

1920
Loaded by `src/provider/codex-responses-sse.test.ts`.
2021

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
[
2+
{
3+
"type": "response.created",
4+
"response": {
5+
"id": "resp_fixture_parallel",
6+
"status": "in_progress",
7+
"model": "gpt-fixture-codex"
8+
}
9+
},
10+
{
11+
"type": "response.output_item.added",
12+
"output_index": 0,
13+
"item": {
14+
"type": "function_call",
15+
"id": "fc_fixture_a",
16+
"call_id": "call_fixture_a",
17+
"name": "read_file",
18+
"arguments": "",
19+
"status": "in_progress"
20+
}
21+
},
22+
{
23+
"type": "response.function_call_arguments.done",
24+
"item_id": "fc_fixture_a",
25+
"output_index": 0,
26+
"arguments": "{\"path\":\"a.txt\"}"
27+
},
28+
{
29+
"type": "response.output_item.done",
30+
"output_index": 0,
31+
"item": {
32+
"type": "function_call",
33+
"id": "fc_fixture_a",
34+
"call_id": "call_fixture_a",
35+
"name": "read_file",
36+
"arguments": "{\"path\":\"a.txt\"}",
37+
"status": "completed"
38+
}
39+
},
40+
{
41+
"type": "response.output_item.added",
42+
"output_index": 1,
43+
"item": {
44+
"type": "function_call",
45+
"id": "fc_fixture_b",
46+
"call_id": "call_fixture_b",
47+
"name": "read_file",
48+
"arguments": "",
49+
"status": "in_progress"
50+
}
51+
},
52+
{
53+
"type": "response.function_call_arguments.done",
54+
"item_id": "fc_fixture_b",
55+
"output_index": 1,
56+
"arguments": "{\"path\":\"b.txt\"}"
57+
},
58+
{
59+
"type": "response.output_item.done",
60+
"output_index": 1,
61+
"item": {
62+
"type": "function_call",
63+
"id": "fc_fixture_b",
64+
"call_id": "call_fixture_b",
65+
"name": "read_file",
66+
"arguments": "{\"path\":\"b.txt\"}",
67+
"status": "completed"
68+
}
69+
},
70+
{
71+
"type": "response.completed",
72+
"response": {
73+
"id": "resp_fixture_parallel",
74+
"status": "completed",
75+
"usage": {
76+
"input_tokens": 80,
77+
"output_tokens": 20,
78+
"input_tokens_details": { "cached_tokens": 0 },
79+
"output_tokens_details": { "reasoning_tokens": 0 }
80+
}
81+
}
82+
}
83+
]

‎src/provider/codex-responses-sse.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,26 @@ describe("codex-sse fixtures (golden parse)", () => {
212212
expect(String(error)).toMatch(/fixture stream error \(sanitized\)/);
213213
});
214214

215+
test("parallel calls whose arguments arrive only on done still carry their arguments", () => {
216+
const out = parseFixture("parallel-tools-arguments-on-done.json");
217+
218+
expect(eventTypes(out)).toEqual([
219+
"inference.tool_call.start",
220+
"inference.tool_call.delta",
221+
"inference.tool_call.start",
222+
"inference.tool_call.delta",
223+
"inference.usage",
224+
]);
225+
expect(out[1]).toMatchObject({
226+
type: "inference.tool_call.delta",
227+
data: { callId: "0", argumentFragment: '{"path":"a.txt"}', index: 0 },
228+
});
229+
expect(out[3]).toMatchObject({
230+
type: "inference.tool_call.delta",
231+
data: { callId: "1", argumentFragment: '{"path":"b.txt"}', index: 1 },
232+
});
233+
});
234+
215235
test("lifecycle envelopes alone produce no InferenceEvents", () => {
216236
const out = parseFixture("lifecycle-ignored.json");
217237
expect(out).toEqual([]);

‎src/provider/codex-responses.ts‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
CODEX_SESSION_ID_OPTION,
66
withCodexContentTypeRepair,
77
} from "@corbits/codex-provider";
8+
import { type } from "arktype";
89
import type { AdapterFactory, ProviderAdapter } from "@intx/inference";
910
import type { InferenceOptions } from "@intx/types/runtime";
1011
import { ENVIRONMENT_TAG_NAME, PRODUCT_NAME } from "../branding.js";
@@ -73,11 +74,70 @@ export function withParallelToolCalls(
7374
};
7475
}
7576

77+
const FunctionCallItemDone = type({
78+
type: "'response.output_item.done'",
79+
item: { type: "'function_call'", call_id: "string", arguments: "string" },
80+
});
81+
82+
// With parallel calls on, some Codex backends send a call's arguments only on
83+
// output_item.done and never as deltas. The packaged parser reads deltas
84+
// alone, so without this replay the harness assembles those calls as `{}`
85+
// (CL-10582).
86+
function withArgumentsFromDone(adapter: ProviderAdapter): ProviderAdapter {
87+
let indexByCallId = new Map<string, number>();
88+
let streamedIndices = new Set<number>();
89+
return {
90+
...adapter,
91+
buildRequest: (messages, model, options) => {
92+
indexByCallId = new Map();
93+
streamedIndices = new Set();
94+
return adapter.buildRequest(messages, model, options);
95+
},
96+
parseResponse: (sseData) => {
97+
const events = adapter.parseResponse(sseData);
98+
for (const event of events) {
99+
if (event.type === "inference.tool_call.start") {
100+
if (event.data.index !== undefined) {
101+
indexByCallId.set(event.data.callId, event.data.index);
102+
}
103+
} else if (event.type === "inference.tool_call.delta") {
104+
if (event.data.index !== undefined) {
105+
streamedIndices.add(event.data.index);
106+
}
107+
}
108+
}
109+
if (!sseData.includes('"response.output_item.done"')) return events;
110+
const done = FunctionCallItemDone(JSON.parse(sseData));
111+
if (done instanceof type.errors || done.item.arguments.length === 0) {
112+
return events;
113+
}
114+
const index = indexByCallId.get(done.item.call_id);
115+
if (index === undefined || streamedIndices.has(index)) return events;
116+
streamedIndices.add(index);
117+
return [
118+
...events,
119+
{
120+
type: "inference.tool_call.delta",
121+
seq: 0,
122+
data: {
123+
callId: String(index),
124+
argumentFragment: done.item.arguments,
125+
partial: { text: "" },
126+
index,
127+
},
128+
},
129+
];
130+
},
131+
};
132+
}
133+
76134
// CodexQuirks are required by the package factory; Responses option keys are
77135
// not part of that bag, so host reasoning_effort is aliased at buildRequest.
78136
export const createCodexResponsesAdapter: AdapterFactory = (source) =>
79137
withHostReasoningEffort(
80138
withParallelToolCalls(
81-
createPackageCodexResponsesAdapter(source, CODEX_QUIRKS),
139+
withArgumentsFromDone(
140+
createPackageCodexResponsesAdapter(source, CODEX_QUIRKS),
141+
),
82142
),
83143
);

0 commit comments

Comments
 (0)