Skip to content

Commit 7ff4c7c

Browse files
committed
fix(anthropic): guard against empty text content blocks (400 rejection)
Anthropic rejects requests containing { type: 'text', text: '' } blocks with 'text content blocks must be non-empty'. This surfaces when Codex replays history with empty user messages, empty assistant text parts (e.g. tool-call-only turns with a zero-length text prefix), or tool results with empty output. - user/developer: empty string -> '(empty)' placeholder; empty text parts filtered from content arrays (fallback to '(empty)' if all gone) - assistant: empty text parts silently dropped (tool_use blocks suffice) - tool_result: empty string content -> '(empty tool output)' placeholder - tests: 5 cases covering each path
1 parent dc4c818 commit 7ff4c7c

2 files changed

Lines changed: 91 additions & 7 deletions

File tree

src/adapters/anthropic.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -331,9 +331,16 @@ function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name:
331331
function toAnthropicToolResult(msg: OcxToolResultMessage): Record<string, unknown> {
332332
// Anthropic tool_result accepts a string OR content blocks — render images natively
333333
// (e.g. Codex view_image output) instead of dropping them.
334-
const content = typeof msg.content === "string"
335-
? msg.content
336-
: (msg.content as OcxContentPart[]).map(toAnthropicContentPart);
334+
let content: string | unknown[];
335+
if (typeof msg.content === "string") {
336+
// Anthropic rejects tool_result with empty text content blocks.
337+
content = msg.content || "(empty tool output)";
338+
} else {
339+
const parts = (msg.content as OcxContentPart[])
340+
.map(toAnthropicContentPart)
341+
.filter(p => !((p as { type?: string }).type === "text" && !(p as { text?: string }).text));
342+
content = parts.length > 0 ? parts : "(empty tool output)";
343+
}
337344
return {
338345
type: "tool_result",
339346
tool_use_id: msg.toolCallId,
@@ -370,9 +377,16 @@ function messagesToAnthropicFormat(
370377
switch (msg.role) {
371378
case "user":
372379
case "developer": {
373-
const content = typeof msg.content === "string"
374-
? msg.content
375-
: (msg.content as OcxContentPart[]).map(toAnthropicContentPart);
380+
let content: string | unknown[];
381+
if (typeof msg.content === "string") {
382+
// Anthropic rejects empty string text content blocks.
383+
content = msg.content || "(empty)";
384+
} else {
385+
const parts = (msg.content as OcxContentPart[])
386+
.map(toAnthropicContentPart)
387+
.filter(p => !((p as { type?: string }).type === "text" && !(p as { text?: string }).text));
388+
content = parts.length > 0 ? parts : "(empty)";
389+
}
376390
messages.push({ role: "user", content });
377391
break;
378392
}
@@ -382,7 +396,8 @@ function messagesToAnthropicFormat(
382396
const toolUseIds: string[] = [];
383397
for (const part of aMsg.content) {
384398
if (part.type === "text") {
385-
content.push({ type: "text", text: (part as OcxTextContent).text });
399+
const text = (part as OcxTextContent).text;
400+
if (text) content.push({ type: "text", text });
386401
} else if (part.type === "thinking") {
387402
const t = part as OcxThinkingContent;
388403
// Redacted blocks replay verbatim FIRST (they preceded the visible thinking block
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { createAnthropicAdapter } from "../src/adapters/anthropic";
3+
import type { OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../src/types";
4+
5+
const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-x", authMode: "apiKey" } as unknown as OcxProviderConfig;
6+
7+
function parsed(messages: OcxMessage[]): OcxParsedRequest {
8+
return {
9+
modelId: "anthropic/claude-sonnet-4.5",
10+
stream: false,
11+
options: {},
12+
context: { messages },
13+
};
14+
}
15+
16+
function bodyOf(p: OcxParsedRequest): { messages: Array<{ role: string; content: unknown }> } {
17+
const { body } = createAnthropicAdapter(provider).buildRequest(p);
18+
return JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { messages: Array<{ role: string; content: unknown }> };
19+
}
20+
21+
describe("anthropic empty content block guard", () => {
22+
test("user message with empty string content is replaced with placeholder", () => {
23+
const body = bodyOf(parsed([
24+
{ role: "user", content: "", timestamp: 0 },
25+
]));
26+
const msg = body.messages[0] as { role: string; content: string };
27+
expect(msg.content).toBe("(empty)");
28+
});
29+
30+
test("user message with empty text part is filtered out", () => {
31+
const body = bodyOf(parsed([
32+
{ role: "user", content: [{ type: "text", text: "" }], timestamp: 0 },
33+
]));
34+
const msg = body.messages[0] as { role: string; content: string };
35+
// Empty part filtered -> falls back to placeholder string
36+
expect(msg.content).toBe("(empty)");
37+
});
38+
39+
test("user message with mixed empty and non-empty parts keeps only non-empty", () => {
40+
const body = bodyOf(parsed([
41+
{ role: "user", content: [{ type: "text", text: "" }, { type: "text", text: "hello" }], timestamp: 0 },
42+
]));
43+
const msg = body.messages[0] as { role: string; content: Array<{ type: string; text: string }> };
44+
expect(msg.content).toEqual([{ type: "text", text: "hello" }]);
45+
});
46+
47+
test("assistant message with empty text part is dropped silently", () => {
48+
const body = bodyOf(parsed([
49+
{ role: "user", content: "start", timestamp: 0 },
50+
{ role: "assistant", content: [{ type: "text", text: "" }, { type: "text", text: "visible" }], model: "claude", timestamp: 0 },
51+
]));
52+
const assistantMsg = body.messages.find(m => (m as { role: string }).role === "assistant") as { content: Array<{ type: string; text?: string }> };
53+
expect(assistantMsg.content).toEqual([{ type: "text", text: "visible" }]);
54+
});
55+
56+
test("tool result with empty string content gets a placeholder", () => {
57+
const body = bodyOf(parsed([
58+
{ role: "user", content: "start", timestamp: 0 },
59+
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "run", arguments: {} }], model: "claude", timestamp: 0 },
60+
{ role: "toolResult", toolCallId: "tc1", toolName: "run", content: "", isError: false, timestamp: 0 },
61+
]));
62+
const toolResultMsg = body.messages.find(m => {
63+
const content = (m as { content?: unknown[] }).content;
64+
return Array.isArray(content) && content.some((c: { type?: string }) => c.type === "tool_result");
65+
}) as { content: Array<{ type: string; content?: string }> };
66+
const toolResult = toolResultMsg.content.find((c: { type: string }) => c.type === "tool_result") as { content: string };
67+
expect(toolResult.content).toBe("(empty tool output)");
68+
});
69+
});

0 commit comments

Comments
 (0)