Skip to content

Commit f8b3d82

Browse files
author
Codex
committed
Fix orphan chat tool results
1 parent 41e75ba commit f8b3d82

2 files changed

Lines changed: 109 additions & 1 deletion

File tree

src/adapters/openai-chat.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { contentPartsToText } from "./image";
77
function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
88
const out: unknown[] = [];
99
const { context, options } = parsed;
10+
let pendingToolCallIds = new Set<string>();
1011

1112
if (context.systemPrompt && context.systemPrompt.length > 0) {
1213
// Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
@@ -39,6 +40,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
3940
out.push({ role: "user", content: chatParts });
4041
}
4142
}
43+
pendingToolCallIds = new Set();
4244
break;
4345
}
4446
case "assistant": {
@@ -61,14 +63,33 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
6163
// like DeepSeek reject an assistant message with neither content nor tool_calls.
6264
if (chatMsg.content === undefined && chatMsg.tool_calls === undefined) break;
6365
out.push(chatMsg);
66+
pendingToolCallIds = new Set(toolCalls.map(tc => tc.id).filter(Boolean));
6467
break;
6568
}
6669
case "toolResult": {
70+
let toolCallId = msg.toolCallId;
71+
if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
72+
if (!pendingToolCallIds.has(toolCallId)) {
73+
// WS turns can arrive with only tool outputs; chat-completions providers reject a bare
74+
// role:"tool" message unless an assistant tool_call with the same id immediately precedes it.
75+
const name = safeToolName(msg.toolName);
76+
out.push({
77+
role: "assistant",
78+
content: null,
79+
tool_calls: [{
80+
id: toolCallId,
81+
type: "function",
82+
function: { name, arguments: "{}" },
83+
}],
84+
});
85+
pendingToolCallIds = new Set([toolCallId]);
86+
}
6787
out.push({
6888
role: "tool",
69-
tool_call_id: msg.toolCallId,
89+
tool_call_id: toolCallId,
7090
content: contentPartsToText(msg.content),
7191
});
92+
pendingToolCallIds.delete(toolCallId);
7293
break;
7394
}
7495
}
@@ -77,6 +98,12 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
7798
return out;
7899
}
79100

101+
function safeToolName(name: string | undefined): string {
102+
const raw = name && name.trim().length > 0 ? name : "tool_result";
103+
const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_");
104+
return sanitized.length > 0 ? sanitized : "tool_result";
105+
}
106+
80107
function toolsToChatFormat(parsed: OcxParsedRequest): unknown[] | undefined {
81108
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
82109
return parsed.context.tools.map(t => ({

tests/adapter-usage.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,84 @@ describe("usage and content retention (F2)", () => {
130130
expect(dones[0]).toEqual({ type: "done", usage: { inputTokens: 4, outputTokens: 2 } });
131131
});
132132
});
133+
134+
describe("openai-chat tool history repair", () => {
135+
test("inserts a synthetic assistant tool_call before orphan tool results", () => {
136+
const adapter = createOpenAIChatAdapter(provider);
137+
const request = adapter.buildRequest({
138+
modelId: "deepseek-v4",
139+
context: {
140+
messages: [{
141+
role: "toolResult",
142+
toolCallId: "call_1",
143+
toolName: "codex.list_mcp_resources",
144+
content: '{"resources":[]}',
145+
isError: false,
146+
timestamp: 0,
147+
}],
148+
},
149+
stream: true,
150+
options: {},
151+
});
152+
const body = JSON.parse(request.body) as { messages: Record<string, unknown>[] };
153+
154+
expect(body.messages).toHaveLength(2);
155+
expect(body.messages[0]).toMatchObject({
156+
role: "assistant",
157+
content: null,
158+
tool_calls: [{
159+
id: "call_1",
160+
type: "function",
161+
function: { name: "codex_list_mcp_resources", arguments: "{}" },
162+
}],
163+
});
164+
expect(body.messages[1]).toMatchObject({
165+
role: "tool",
166+
tool_call_id: "call_1",
167+
content: '{"resources":[]}',
168+
});
169+
});
170+
171+
test("keeps paired tool results attached to the prior assistant tool_call", () => {
172+
const adapter = createOpenAIChatAdapter(provider);
173+
const request = adapter.buildRequest({
174+
modelId: "deepseek-v4",
175+
context: {
176+
messages: [
177+
{
178+
role: "assistant",
179+
content: [{
180+
type: "toolCall",
181+
id: "call_1",
182+
name: "read_file",
183+
arguments: { path: "README.md" },
184+
}],
185+
model: "deepseek-v4",
186+
timestamp: 0,
187+
},
188+
{
189+
role: "toolResult",
190+
toolCallId: "call_1",
191+
toolName: "read_file",
192+
content: "contents",
193+
isError: false,
194+
timestamp: 0,
195+
},
196+
],
197+
},
198+
stream: true,
199+
options: {},
200+
});
201+
const body = JSON.parse(request.body) as { messages: Record<string, unknown>[] };
202+
203+
expect(body.messages).toHaveLength(2);
204+
expect(body.messages[0]).toMatchObject({
205+
role: "assistant",
206+
tool_calls: [{
207+
id: "call_1",
208+
function: { name: "read_file", arguments: '{"path":"README.md"}' },
209+
}],
210+
});
211+
expect(body.messages[1]).toMatchObject({ role: "tool", tool_call_id: "call_1" });
212+
});
213+
});

0 commit comments

Comments
 (0)