Skip to content

Commit 47defbe

Browse files
fix: repair anthropic tool result history
Absorb PR #22 into dev and keep Anthropic Messages histories valid when Codex emits missing, duplicate, or orphan tool results. Co-authored-by: jaekwonhong <148875547+jaekwonhong@users.noreply.github.com>
1 parent 38bca18 commit 47defbe

2 files changed

Lines changed: 221 additions & 14 deletions

File tree

src/adapters/anthropic.ts

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
OcxTextContent,
1111
OcxThinkingContent,
1212
OcxToolCall,
13+
OcxToolResultMessage,
1314
OcxUsage,
1415
} from "../types";
1516
import { namespacedToolName } from "../types";
@@ -75,14 +76,37 @@ function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name:
7576
return { toWire: (name) => name, fromWire: (name) => name };
7677
}
7778

79+
function toAnthropicToolResult(msg: OcxToolResultMessage): Record<string, unknown> {
80+
// Anthropic tool_result accepts a string OR content blocks — render images natively
81+
// (e.g. Codex view_image output) instead of dropping them.
82+
const content = typeof msg.content === "string"
83+
? msg.content
84+
: (msg.content as OcxContentPart[]).map(toAnthropicContentPart);
85+
return {
86+
type: "tool_result",
87+
tool_use_id: msg.toolCallId,
88+
content,
89+
...(msg.isError ? { is_error: true } : {}),
90+
};
91+
}
92+
93+
function orphanToolResultText(msg: OcxToolResultMessage): string {
94+
const label = msg.toolName ? `${msg.toolName} (${msg.toolCallId})` : msg.toolCallId;
95+
const content = typeof msg.content === "string"
96+
? msg.content
97+
: JSON.stringify(msg.content);
98+
return `[tool_result without adjacent tool_use: ${label}]\n${content}`;
99+
}
100+
78101
function messagesToAnthropicFormat(
79102
parsed: OcxParsedRequest,
80103
toolNames: { toWire: (name: string) => string },
81104
): { system: string | undefined; messages: unknown[] } {
82105
const system = parsed.context.systemPrompt?.join("\n\n") || undefined;
83106
const messages: unknown[] = [];
84107

85-
for (const msg of parsed.context.messages) {
108+
for (let i = 0; i < parsed.context.messages.length; i++) {
109+
const msg = parsed.context.messages[i];
86110
switch (msg.role) {
87111
case "user":
88112
case "developer": {
@@ -95,6 +119,7 @@ function messagesToAnthropicFormat(
95119
case "assistant": {
96120
const aMsg = msg as OcxAssistantMessage;
97121
const content: unknown[] = [];
122+
const toolUseIds: string[] = [];
98123
for (const part of aMsg.content) {
99124
if (part.type === "text") {
100125
content.push({ type: "text", text: (part as OcxTextContent).text });
@@ -104,26 +129,46 @@ function messagesToAnthropicFormat(
104129
} else if (part.type === "toolCall") {
105130
const tc = part as OcxToolCall;
106131
const flatName = namespacedToolName(tc.namespace, tc.name);
132+
toolUseIds.push(tc.id);
107133
content.push({ type: "tool_use", id: tc.id, name: toolNames.toWire(flatName), input: tc.arguments });
108134
}
109135
}
110136
messages.push({ role: "assistant", content });
137+
if (toolUseIds.length > 0) {
138+
const requiredIds = new Set(toolUseIds);
139+
const resultBlocks: Record<string, unknown>[] = [];
140+
const orphanBlocks: Record<string, unknown>[] = [];
141+
const seen = new Set<string>();
142+
let j = i + 1;
143+
while (j < parsed.context.messages.length && parsed.context.messages[j].role === "toolResult") {
144+
const tr = parsed.context.messages[j] as OcxToolResultMessage;
145+
if (requiredIds.has(tr.toolCallId) && !seen.has(tr.toolCallId)) {
146+
resultBlocks.push(toAnthropicToolResult(tr));
147+
seen.add(tr.toolCallId);
148+
} else {
149+
orphanBlocks.push({ type: "text", text: orphanToolResultText(tr) });
150+
}
151+
j++;
152+
}
153+
for (const id of toolUseIds) {
154+
if (!seen.has(id)) {
155+
resultBlocks.push({
156+
type: "tool_result",
157+
tool_use_id: id,
158+
content: "[opencodex: missing tool_result for this tool_use in Codex history]",
159+
is_error: true,
160+
});
161+
}
162+
}
163+
messages.push({ role: "user", content: [...resultBlocks, ...orphanBlocks] });
164+
i = j - 1;
165+
}
111166
break;
112167
}
113168
case "toolResult": {
114-
// Anthropic tool_result accepts a string OR content blocks — render images natively
115-
// (e.g. Codex view_image output) instead of dropping them.
116-
const trContent = typeof msg.content === "string"
117-
? msg.content
118-
: (msg.content as OcxContentPart[]).map(toAnthropicContentPart);
119-
messages.push({
120-
role: "user",
121-
content: [{
122-
type: "tool_result",
123-
tool_use_id: msg.toolCallId,
124-
content: trContent,
125-
}],
126-
});
169+
// A standalone Anthropic tool_result is invalid unless it immediately follows an
170+
// assistant tool_use. Preserve the information as text instead of sending a 400-prone block.
171+
messages.push({ role: "user", content: orphanToolResultText(msg as OcxToolResultMessage) });
127172
break;
128173
}
129174
}

tests/adapter-usage.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,165 @@ describe("openai-chat tool history repair", () => {
211211
expect(body.messages[1]).toMatchObject({ role: "tool", tool_call_id: "call_1" });
212212
});
213213
});
214+
215+
describe("anthropic tool result history repair", () => {
216+
test("merges adjacent tool results after multiple tool uses into one user message", () => {
217+
const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" });
218+
const request = adapter.buildRequest({
219+
modelId: "claude-sonnet",
220+
context: {
221+
messages: [
222+
{ role: "user", content: "start", timestamp: 0 },
223+
{
224+
role: "assistant",
225+
content: [
226+
{ type: "toolCall", id: "call_1", name: "first_tool", arguments: {} },
227+
{ type: "toolCall", id: "call_2", name: "second_tool", arguments: {} },
228+
],
229+
model: "claude-sonnet",
230+
timestamp: 0,
231+
},
232+
{ role: "toolResult", toolCallId: "call_1", toolName: "first_tool", content: "one", isError: false, timestamp: 0 },
233+
{ role: "toolResult", toolCallId: "call_2", toolName: "second_tool", content: "two", isError: false, timestamp: 0 },
234+
{ role: "user", content: "continue", timestamp: 0 },
235+
],
236+
},
237+
stream: true,
238+
options: {},
239+
});
240+
const body = JSON.parse(request.body) as { messages: Array<{ role: string; content: any }> };
241+
242+
expect(body.messages).toHaveLength(4);
243+
expect(body.messages[2].role).toBe("user");
244+
expect(body.messages[2].content).toEqual([
245+
{ type: "tool_result", tool_use_id: "call_1", content: "one" },
246+
{ type: "tool_result", tool_use_id: "call_2", content: "two" },
247+
]);
248+
});
249+
250+
test("adds an error tool result when history is missing a tool result", () => {
251+
const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" });
252+
const request = adapter.buildRequest({
253+
modelId: "claude-sonnet",
254+
context: {
255+
messages: [{
256+
role: "assistant",
257+
content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: {} }],
258+
model: "claude-sonnet",
259+
timestamp: 0,
260+
}],
261+
},
262+
stream: true,
263+
options: {},
264+
});
265+
const body = JSON.parse(request.body) as { messages: Array<{ role: string; content: any }> };
266+
267+
expect(body.messages[1]).toEqual({
268+
role: "user",
269+
content: [{
270+
type: "tool_result",
271+
tool_use_id: "call_1",
272+
content: "[opencodex: missing tool_result for this tool_use in Codex history]",
273+
is_error: true,
274+
}],
275+
});
276+
});
277+
278+
test("preserves orphan tool results as text instead of invalid Anthropic tool_result blocks", () => {
279+
const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" });
280+
const request = adapter.buildRequest({
281+
modelId: "claude-sonnet",
282+
context: {
283+
messages: [{
284+
role: "toolResult",
285+
toolCallId: "orphan_call",
286+
toolName: "lost_tool",
287+
content: "orphan output",
288+
isError: false,
289+
timestamp: 0,
290+
}],
291+
},
292+
stream: true,
293+
options: {},
294+
});
295+
const body = JSON.parse(request.body) as { messages: Array<{ role: string; content: string }> };
296+
297+
expect(body.messages).toEqual([{
298+
role: "user",
299+
content: "[tool_result without adjacent tool_use: lost_tool (orphan_call)]\norphan output",
300+
}]);
301+
});
302+
303+
test("preserves duplicate adjacent tool results as text after the matching result", () => {
304+
const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" });
305+
const request = adapter.buildRequest({
306+
modelId: "claude-sonnet",
307+
context: {
308+
messages: [
309+
{
310+
role: "assistant",
311+
content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: {} }],
312+
model: "claude-sonnet",
313+
timestamp: 0,
314+
},
315+
{ role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "first", isError: false, timestamp: 0 },
316+
{ role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "duplicate", isError: false, timestamp: 0 },
317+
],
318+
},
319+
stream: true,
320+
options: {},
321+
});
322+
const body = JSON.parse(request.body) as { messages: Array<{ role: string; content: any }> };
323+
324+
expect(body.messages[1]).toEqual({
325+
role: "user",
326+
content: [
327+
{ type: "tool_result", tool_use_id: "call_1", content: "first" },
328+
{ type: "text", text: "[tool_result without adjacent tool_use: read_file (call_1)]\nduplicate" },
329+
],
330+
});
331+
});
332+
333+
test("maps non-string tool result content through Anthropic content blocks", () => {
334+
const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" });
335+
const request = adapter.buildRequest({
336+
modelId: "claude-sonnet",
337+
context: {
338+
messages: [
339+
{
340+
role: "assistant",
341+
content: [{ type: "toolCall", id: "call_1", name: "view_image", arguments: {} }],
342+
model: "claude-sonnet",
343+
timestamp: 0,
344+
},
345+
{
346+
role: "toolResult",
347+
toolCallId: "call_1",
348+
toolName: "view_image",
349+
content: [
350+
{ type: "text", text: "image attached" },
351+
{ type: "image", imageUrl: "data:image/png;base64,AAAA", detail: "high" },
352+
],
353+
isError: false,
354+
timestamp: 0,
355+
},
356+
],
357+
},
358+
stream: true,
359+
options: {},
360+
});
361+
const body = JSON.parse(request.body) as { messages: Array<{ role: string; content: any }> };
362+
363+
expect(body.messages[1]).toEqual({
364+
role: "user",
365+
content: [{
366+
type: "tool_result",
367+
tool_use_id: "call_1",
368+
content: [
369+
{ type: "text", text: "image attached" },
370+
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } },
371+
],
372+
}],
373+
});
374+
});
375+
});

0 commit comments

Comments
 (0)