Skip to content

Commit b79eb71

Browse files
committed
fix(anthropic): treat a blank tool_use id as absent, not as an id
`block.id ?? synthesizeToolUseId()` only synthesizes when the field is missing. An Anthropic-compatible relay that sends "" or " " satisfies the nullish check, so the call went out carrying a blank id and the client had nothing to echo back — the next turn cannot pair the result with its call. usableToolUseId() treats blank as absent. Both the streaming content_block_start path and the non-stream block path use it. Last item of #765's tool-id half. Ablated: restoring the ?? form fails the new blank-id test for both "" and " ".
1 parent ebd4cdf commit b79eb71

2 files changed

Lines changed: 34 additions & 2 deletions

File tree

src/adapters/anthropic.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,16 @@ function synthesizeToolUseId(): string {
266266
return `toolu_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
267267
}
268268

269+
/**
270+
* A tool_use id that a client can actually echo back. `??` only catches a missing
271+
* field, so an Anthropic-compatible relay that sends `""` or `" "` produced a
272+
* call whose id round-trips as blank — the next turn then cannot pair the result
273+
* with its call. Treat blank as absent and synthesize (#765).
274+
*/
275+
function usableToolUseId(id: unknown): string {
276+
return typeof id === "string" && id.trim() ? id : synthesizeToolUseId();
277+
}
278+
269279
function toolUseArguments(input: unknown): string {
270280
if (typeof input === "string") {
271281
const trimmed = input.trim();
@@ -824,7 +834,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
824834
if (!block) break;
825835
currentBlockType = block.type;
826836
if (block.type === "tool_use") {
827-
currentToolCallId = block.id ?? synthesizeToolUseId();
837+
currentToolCallId = usableToolUseId(block.id);
828838
currentToolCallName = toolNames.fromWire(block.name ?? "");
829839
currentToolCallJson = "";
830840
yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
@@ -938,7 +948,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
938948
} else if (block.type === "redacted_thinking" && typeof block.data === "string") {
939949
events.push({ type: "redacted_thinking", data: block.data });
940950
} else if (block.type === "tool_use") {
941-
const id = block.id ?? synthesizeToolUseId();
951+
const id = usableToolUseId(block.id);
942952
events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") });
943953
events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) });
944954
events.push({ type: "tool_call_end" });

tests/anthropic-stream-hardening.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,28 @@ describe("anthropic stream hardening", () => {
6767
expect(start && "id" in start && start.id.startsWith("toolu_")).toBe(true);
6868
});
6969

70+
// A relay that sends `""` or `" "` is the case `??` misses: the field is present,
71+
// so no synthesis happened and the call went out with an id the client cannot echo
72+
// back. Blank must be treated as absent (#765).
73+
test("tool_use with a blank id synthesizes one instead of shipping it", async () => {
74+
for (const blank of ['""', '" "']) {
75+
const response = new Response([
76+
"event: content_block_start\n",
77+
`data: {"type":"content_block_start","content_block":{"type":"tool_use","id":${blank},"name":"get_weather"}}\n\n`,
78+
"event: content_block_delta\n",
79+
'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{}"}}\n\n',
80+
"event: content_block_stop\n",
81+
'data: {"type":"content_block_stop"}\n\n',
82+
"event: message_stop\n",
83+
'data: {"type":"message_stop"}\n\n',
84+
].join(""));
85+
const events = await collect(createAnthropicAdapter(provider).parseStream(response));
86+
const start = events.find(e => e.type === "tool_call_start");
87+
expect(start && "id" in start && start.id.trim()).toBeTruthy();
88+
expect(start && "id" in start && start.id.startsWith("toolu_")).toBe(true);
89+
}
90+
});
91+
7092
test("empty EOF without content still errors", async () => {
7193
const response = new Response("");
7294
const events = await collect(createAnthropicAdapter(provider).parseStream(response));

0 commit comments

Comments
 (0)