From 70031f4704ad5adb3e7bdb60a6654bc68971cdd9 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:18:11 +0200 Subject: [PATCH 1/8] fix: address issue #765 Fixes #765 --- src/adapters/anthropic.ts | 53 +++++++++++--- tests/anthropic-stream-hardening.test.ts | 91 ++++++++++++++++++++++++ tests/claude-messages-endpoint.test.ts | 5 +- tests/claude-native-passthrough.test.ts | 7 +- 4 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 tests/anthropic-stream-hardening.test.ts diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index d54918f22..4ee98ac37 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -250,6 +250,36 @@ function usesNativeAnthropicEndpoint(provider: OcxProviderConfig): boolean { } } +/** Normalize provider baseUrl paths ending in `/`, `/v1`, or `/v1/messages` to `{origin}/v1/messages`. */ +export function anthropicMessagesUrl(baseUrl: string): string { + try { + new URL(baseUrl); + } catch { + throw new Error(`anthropic provider has malformed baseUrl: ${baseUrl}`); + } + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + const root = trimmed.replace(/\/v1\/messages\/?$/i, "").replace(/\/v1\/?$/i, "").replace(/\/+$/, ""); + return `${root}/v1/messages`; +} + +function synthesizeToolUseId(): string { + return `toolu_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; +} + +function toolUseArguments(input: unknown): string { + if (typeof input === "string") { + const trimmed = input.trim(); + if (!trimmed) return "{}"; + try { + JSON.parse(trimmed); + return trimmed; + } catch { + return JSON.stringify(trimmed); + } + } + return JSON.stringify(input ?? {}); +} + function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean { return provider.apiKeyTransport === "bearer"; } @@ -680,8 +710,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: toolNames.toWire(resolveToolChoiceWireName(parsed.context.tools, tc.name)) }; } - const base = provider.baseUrl.replace(/\/v1\/?$/, ""); - const url = `${base}/v1/messages`; + const url = anthropicMessagesUrl(provider.baseUrl); const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0]; if (unresolvedPlaceholder) { throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`); @@ -735,6 +764,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti let pendingUsage: Record | undefined; let pendingStopReason: string | undefined; let emittedDone = false; + let sawContent = false; const emitDone = function* (): Generator { if (emittedDone) return; @@ -773,8 +803,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti if (!block) break; currentBlockType = block.type; if (block.type === "tool_use") { - currentToolCallId = block.id ?? ""; + currentToolCallId = block.id ?? synthesizeToolUseId(); currentToolCallName = toolNames.fromWire(block.name ?? ""); + sawContent = true; yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName }; } if (block.type === "redacted_thinking" && typeof block.data === "string") { @@ -787,19 +818,24 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const delta = data.delta as Record | undefined; if (!delta) break; if (delta.type === "text_delta" && typeof delta.text === "string") { + sawContent = true; yield { type: "text_delta", text: delta.text }; } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { + sawContent = true; yield { type: "thinking_delta", thinking: delta.thinking }; } else if (delta.type === "reasoning_delta" && typeof delta.reasoning === "string") { // Some Anthropic-compatible reasoning models use `reasoning` names for the // otherwise equivalent thinking block. Preserve it as raw reasoning and keep // later text blocks independent. + sawContent = true; yield { type: "thinking_delta", thinking: delta.reasoning }; } else if (delta.type === "signature_delta" && typeof delta.signature === "string" && (currentBlockType === "thinking" || currentBlockType === "reasoning")) { // Arrives once, just before the thinking block's content_block_stop; block-scoped // so a stray signature on a non-thinking block can never be captured. + sawContent = true; yield { type: "thinking_signature", signature: delta.signature }; - } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { + } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") { + sawContent = true; yield { type: "tool_call_delta", arguments: delta.partial_json }; } break; @@ -831,12 +867,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } } if (!emittedDone) { - if (pendingStopReason !== undefined) { + if (pendingStopReason !== undefined || sawContent) { const stopReason = pendingStopReason === "max_tokens" ? "max_tokens" : pendingStopReason === "refusal" || pendingStopReason === "content_filter" ? "content_filter" - : undefined; + : pendingStopReason; emittedDone = true; yield { type: "done", @@ -867,8 +903,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } else if (block.type === "redacted_thinking" && typeof block.data === "string") { events.push({ type: "redacted_thinking", data: block.data }); } else if (block.type === "tool_use") { - events.push({ type: "tool_call_start", id: block.id ?? "", name: toolNames.fromWire(block.name ?? "") }); - events.push({ type: "tool_call_delta", arguments: JSON.stringify(block.input ?? {}) }); + const id = block.id ?? synthesizeToolUseId(); + events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") }); + events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) }); events.push({ type: "tool_call_end" }); } } diff --git a/tests/anthropic-stream-hardening.test.ts b/tests/anthropic-stream-hardening.test.ts new file mode 100644 index 000000000..7302ac568 --- /dev/null +++ b/tests/anthropic-stream-hardening.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { anthropicMessagesUrl, createAnthropicAdapter } from "../src/adapters/anthropic"; +import type { AdapterEvent } from "../src/types"; + +const provider = { adapter: "anthropic", baseUrl: "https://example.test", apiKey: "key" }; + +async function collect(gen: AsyncGenerator): Promise { + const out: AdapterEvent[] = []; + for await (const e of gen) out.push(e); + return out; +} + +describe("anthropicMessagesUrl", () => { + test.each([ + ["https://example.test", "https://example.test/v1/messages"], + ["https://example.test/", "https://example.test/v1/messages"], + ["https://example.test/v1", "https://example.test/v1/messages"], + ["https://example.test/v1/", "https://example.test/v1/messages"], + ["https://example.test/v1/messages", "https://example.test/v1/messages"], + ["https://example.test/v1/messages/", "https://example.test/v1/messages"], + ] as const)("normalizes %s", (input, expected) => { + expect(anthropicMessagesUrl(input)).toBe(expected); + }); +}); + +describe("anthropic stream hardening", () => { + test("EOF after content without message_stop completes as done", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"text","text":""}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(e => e.type === "error")).toBe(false); + }); + + test("input_json_delta outside tool_use is ignored", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"text","text":""}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\\"x\\":1}"}}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.some(e => e.type === "tool_call_delta")).toBe(false); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("tool_use without id synthesizes a toolu_ id", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"get_weather"}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{}"}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop"}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + const start = events.find(e => e.type === "tool_call_start"); + expect(start).toMatchObject({ type: "tool_call_start", name: "get_weather" }); + expect(start && "id" in start && start.id.startsWith("toolu_")).toBe(true); + }); + + test("empty EOF without content still errors", async () => { + const response = new Response(""); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(e => e.type === "done")).toBe(false); + }); +}); + +describe("anthropic non-stream tool_use input", () => { + test("parses string tool_use.input", async () => { + const adapter = createAnthropicAdapter(provider); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: "{\"city\":\"Paris\"}" }], + stop_reason: "tool_use", + }))); + expect(events.find(e => e.type === "tool_call_delta")).toMatchObject({ + type: "tool_call_delta", + arguments: "{\"city\":\"Paris\"}", + }); + expect(events.at(-1)?.type).toBe("done"); + }); +}); diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 15e7b7f16..543647aae 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -1,3 +1,4 @@ +import { logsFromApiBody } from "./helpers/logs-api"; import { afterEach, beforeEach, expect, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -112,9 +113,7 @@ test("POST /v1/messages?beta=true streams an Anthropic-shaped turn end to end", // Request log regression (live smoke round 2): the tap must see the PRE-translation // Responses stream — the translated Anthropic stream has no response.completed, which // used to record a bogus 502 with no usage. - const logs = await (await fetch(new URL("/api/logs", server.url))).json() as { - status: number; model: string; usage?: { inputTokens: number; outputTokens: number }; usageStatus: string; - }[]; + const logs = logsFromApiBody(await (await fetch(new URL("/api/logs", server.url))).json()); const row = logs.find(l => l.model === "test-model" || l.model === "mock/test-model"); expect(row).toBeDefined(); expect(row!.status).toBe(200); diff --git a/tests/claude-native-passthrough.test.ts b/tests/claude-native-passthrough.test.ts index 6743ded19..8f0fa5563 100644 --- a/tests/claude-native-passthrough.test.ts +++ b/tests/claude-native-passthrough.test.ts @@ -1,3 +1,4 @@ +import { logsFromApiBody } from "./helpers/logs-api"; import { afterEach, beforeEach, expect, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; import { mkdtempSync, rmSync } from "node:fs"; @@ -116,7 +117,7 @@ test("unmapped claude model + sk-ant credential passes through verbatim", async expect(hit.body).toEqual(claudeBody()); // Request log: native provider tag + usage incl. cache detail from the SSE tap. - const logs = await (await fetch(new URL("/api/logs", server.url))).json() as any[]; + const logs = logsFromApiBody(await (await fetch(new URL("/api/logs", server.url))).json()); const row = logs.find(l => l.provider === "anthropic-native"); expect(row).toBeDefined(); expect(row.status).toBe(200); @@ -168,10 +169,10 @@ test("native passthrough persists conversationId from metadata.user_id", async ( expect(res.status).toBe(200); await res.text(); - const logs = await (await fetch(new URL("/api/logs?tail=1", server.url))).json() as Array<{ + const logs = logsFromApiBody<{ provider?: string; conversationId?: string; - }>; + }>(await (await fetch(new URL("/api/logs?tail=1", server.url))).json()); expect(logs).toHaveLength(1); expect(logs[0]?.provider).toBe("anthropic-native"); expect(logs[0]?.conversationId).toBe(createHash("sha256").update(userId).digest("hex").slice(0, 32)); From 2f6c031cc19592c06aaa74f1109884eab43b26fa Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:38:59 +0200 Subject: [PATCH 2/8] test: add missing logs-api helper for claude tests The branch refactors log parsing to logsFromApiBody but omitted the helper file. --- tests/helpers/logs-api.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/helpers/logs-api.ts diff --git a/tests/helpers/logs-api.ts b/tests/helpers/logs-api.ts new file mode 100644 index 000000000..82a324723 --- /dev/null +++ b/tests/helpers/logs-api.ts @@ -0,0 +1,7 @@ +export function logsFromApiBody = Record>(body: unknown): T[] { + if (Array.isArray(body)) return body as T[]; + if (body && typeof body === "object" && Array.isArray((body as { logs?: unknown }).logs)) { + return (body as { logs: T[] }).logs; + } + return []; +} From 39052f9ec21c38fb090182e1ca41f53bd959875a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:13:42 +0200 Subject: [PATCH 3/8] fix(anthropic): fail closed on EOF without terminal stop reason Streams that deliver content but omit message_stop now surface truncation errors; only message_delta.stop_reason remains a narrow EOF completion path. --- src/adapters/anthropic.ts | 3 ++- tests/anthropic-stream-hardening.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 4ee98ac37..1cadb9781 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -867,7 +867,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } } if (!emittedDone) { - if (pendingStopReason !== undefined || sawContent) { + // Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason. + if (pendingStopReason !== undefined) { const stopReason = pendingStopReason === "max_tokens" ? "max_tokens" : pendingStopReason === "refusal" || pendingStopReason === "content_filter" diff --git a/tests/anthropic-stream-hardening.test.ts b/tests/anthropic-stream-hardening.test.ts index 7302ac568..189f05c9b 100644 --- a/tests/anthropic-stream-hardening.test.ts +++ b/tests/anthropic-stream-hardening.test.ts @@ -24,7 +24,7 @@ describe("anthropicMessagesUrl", () => { }); describe("anthropic stream hardening", () => { - test("EOF after content without message_stop completes as done", async () => { + test("EOF after content without message_stop fails closed", async () => { const response = new Response([ "event: content_block_start\n", 'data: {"type":"content_block_start","content_block":{"type":"text","text":""}}\n\n', @@ -32,8 +32,8 @@ describe("anthropic stream hardening", () => { 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n', ].join("")); const events = await collect(createAnthropicAdapter(provider).parseStream(response)); - expect(events.at(-1)?.type).toBe("done"); - expect(events.some(e => e.type === "error")).toBe(false); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(e => e.type === "done")).toBe(false); }); test("input_json_delta outside tool_use is ignored", async () => { From c294df50a5babb0cf3be791e94996f4cb20bd0fd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:36:33 +0200 Subject: [PATCH 4/8] test(gui): revert visibility poll CI tweak; use dev test timing The extra delay did not fix the makeup fetch and could mask real regressions. --- gui/tests/client-resource-poll.test.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/gui/tests/client-resource-poll.test.tsx b/gui/tests/client-resource-poll.test.tsx index 93c7b916b..76c72eab6 100644 --- a/gui/tests/client-resource-poll.test.tsx +++ b/gui/tests/client-resource-poll.test.tsx @@ -148,10 +148,7 @@ test("a hidden tab stops passive polling and one quiet fetch makes up for it on expect(fetches).toBe(atHide); await setVisibility("visible"); - await act(async () => { - await new Promise((resolve) => testWindow.setTimeout(resolve, 50)); - }); - await waitFor(() => fetches === atHide + 1, 3000); + await waitFor(() => fetches === atHide + 1); expect(fetches).toBe(atHide + 1); await act(async () => { root.unmount(); }); From 0bb36b6f7dbd3544505b7eac62938cb335b4acd2 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:22:01 +0200 Subject: [PATCH 5/8] fix(anthropic): drop unused sawContent stream flag EOF completion already keys off stop_reason; remove dead tracking. --- src/adapters/anthropic.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 1cadb9781..337399c56 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -764,7 +764,6 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti let pendingUsage: Record | undefined; let pendingStopReason: string | undefined; let emittedDone = false; - let sawContent = false; const emitDone = function* (): Generator { if (emittedDone) return; @@ -805,7 +804,6 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti if (block.type === "tool_use") { currentToolCallId = block.id ?? synthesizeToolUseId(); currentToolCallName = toolNames.fromWire(block.name ?? ""); - sawContent = true; yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName }; } if (block.type === "redacted_thinking" && typeof block.data === "string") { @@ -818,24 +816,19 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const delta = data.delta as Record | undefined; if (!delta) break; if (delta.type === "text_delta" && typeof delta.text === "string") { - sawContent = true; yield { type: "text_delta", text: delta.text }; } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { - sawContent = true; yield { type: "thinking_delta", thinking: delta.thinking }; } else if (delta.type === "reasoning_delta" && typeof delta.reasoning === "string") { // Some Anthropic-compatible reasoning models use `reasoning` names for the // otherwise equivalent thinking block. Preserve it as raw reasoning and keep // later text blocks independent. - sawContent = true; yield { type: "thinking_delta", thinking: delta.reasoning }; } else if (delta.type === "signature_delta" && typeof delta.signature === "string" && (currentBlockType === "thinking" || currentBlockType === "reasoning")) { // Arrives once, just before the thinking block's content_block_stop; block-scoped // so a stray signature on a non-thinking block can never be captured. - sawContent = true; yield { type: "thinking_signature", signature: delta.signature }; } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") { - sawContent = true; yield { type: "tool_call_delta", arguments: delta.partial_json }; } break; From 16860ad5d074fd214eeef598759da80859c49e32 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:12:38 +0200 Subject: [PATCH 6/8] fix(anthropic): address CodeRabbit URL and tool-id review Use pathname-based /v1/messages normalization without query/hash, static malformed-baseUrl errors, usableToolUseId for blank IDs, and EOF+stop_reason test coverage. --- src/adapters/anthropic.ts | 26 ++++++++--- tests/anthropic-stream-hardening.test.ts | 55 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 337399c56..d150da663 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -250,22 +250,34 @@ function usesNativeAnthropicEndpoint(provider: OcxProviderConfig): boolean { } } +const MALFORMED_ANTHROPIC_BASE_URL = "anthropic provider has malformed baseUrl"; + /** Normalize provider baseUrl paths ending in `/`, `/v1`, or `/v1/messages` to `{origin}/v1/messages`. */ export function anthropicMessagesUrl(baseUrl: string): string { + let parsed: URL; try { - new URL(baseUrl); + parsed = new URL(baseUrl.trim()); } catch { - throw new Error(`anthropic provider has malformed baseUrl: ${baseUrl}`); + throw new Error(MALFORMED_ANTHROPIC_BASE_URL); + } + if (parsed.search || parsed.hash) { + throw new Error(MALFORMED_ANTHROPIC_BASE_URL); } - const trimmed = baseUrl.trim().replace(/\/+$/, ""); - const root = trimmed.replace(/\/v1\/messages\/?$/i, "").replace(/\/v1\/?$/i, "").replace(/\/+$/, ""); - return `${root}/v1/messages`; + let path = parsed.pathname.replace(/\/+$/, ""); + path = path.replace(/\/v1\/messages\/?$/i, "").replace(/\/v1\/?$/i, "").replace(/\/+$/, ""); + const suffix = path ? `${path}/v1/messages` : "/v1/messages"; + return `${parsed.origin}${suffix}`; } function synthesizeToolUseId(): string { return `toolu_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; } +function usableToolUseId(id: string | undefined): string { + const trimmed = id?.trim(); + return trimmed || synthesizeToolUseId(); +} + function toolUseArguments(input: unknown): string { if (typeof input === "string") { const trimmed = input.trim(); @@ -802,7 +814,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti if (!block) break; currentBlockType = block.type; if (block.type === "tool_use") { - currentToolCallId = block.id ?? synthesizeToolUseId(); + currentToolCallId = usableToolUseId(block.id); currentToolCallName = toolNames.fromWire(block.name ?? ""); yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName }; } @@ -897,7 +909,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } else if (block.type === "redacted_thinking" && typeof block.data === "string") { events.push({ type: "redacted_thinking", data: block.data }); } else if (block.type === "tool_use") { - const id = block.id ?? synthesizeToolUseId(); + const id = usableToolUseId(block.id); events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") }); events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) }); events.push({ type: "tool_call_end" }); diff --git a/tests/anthropic-stream-hardening.test.ts b/tests/anthropic-stream-hardening.test.ts index 189f05c9b..3cc050dac 100644 --- a/tests/anthropic-stream-hardening.test.ts +++ b/tests/anthropic-stream-hardening.test.ts @@ -21,6 +21,25 @@ describe("anthropicMessagesUrl", () => { ] as const)("normalizes %s", (input, expected) => { expect(anthropicMessagesUrl(input)).toBe(expected); }); + + test("malformed baseUrl throws without echoing the URL", () => { + const sensitive = "https://user:secret@example.test/v1?token=abc"; + expect(() => anthropicMessagesUrl(sensitive)).toThrow("anthropic provider has malformed baseUrl"); + try { + anthropicMessagesUrl(sensitive); + } catch (err) { + expect(String(err)).not.toContain("secret"); + expect(String(err)).not.toContain("token=abc"); + } + }); + + test.each([ + "https://example.test/v1?tenant=a", + "https://example.test/v1#frag", + "https://example.test/v1/messages?stream=1", + ] as const)("rejects search/hash in %s", (input) => { + expect(() => anthropicMessagesUrl(input)).toThrow("anthropic provider has malformed baseUrl"); + }); }); describe("anthropic stream hardening", () => { @@ -36,6 +55,18 @@ describe("anthropic stream hardening", () => { expect(events.some(e => e.type === "done")).toBe(false); }); + test("EOF after message_delta stop_reason completes without message_stop", async () => { + const response = new Response([ + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n', + "event: message_delta\n", + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "end_turn" }); + expect(events.some(e => e.type === "error")).toBe(false); + }); + test("input_json_delta outside tool_use is ignored", async () => { const response = new Response([ "event: content_block_start\n", @@ -67,6 +98,20 @@ describe("anthropic stream hardening", () => { expect(start && "id" in start && start.id.startsWith("toolu_")).toBe(true); }); + test("whitespace tool_use id is treated as missing", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"tool_use","id":" ","name":"get_weather"}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop"}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + const start = events.find(e => e.type === "tool_call_start"); + expect(start && "id" in start && start.id.startsWith("toolu_")).toBe(true); + }); + test("empty EOF without content still errors", async () => { const response = new Response(""); const events = await collect(createAnthropicAdapter(provider).parseStream(response)); @@ -88,4 +133,14 @@ describe("anthropic non-stream tool_use input", () => { }); expect(events.at(-1)?.type).toBe("done"); }); + + test("whitespace tool_use id synthesizes in non-stream response", async () => { + const adapter = createAnthropicAdapter(provider); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + content: [{ type: "tool_use", id: " ", name: "get_weather", input: {} }], + stop_reason: "tool_use", + }))); + const start = events.find(e => e.type === "tool_call_start"); + expect(start && "id" in start && start.id.startsWith("toolu_")).toBe(true); + }); }); From 367ff854065b8a11e500248d6980e8e0c71be401 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:22:51 +0200 Subject: [PATCH 7/8] fix(bridge): cancel open tool calls on terminal error (#765) Adapter stream paths can already surface malformed tool arguments, but the bridge was still closing the open call as completed before response.failed. Add a cancel path (status incomplete, no arguments.done), refuse to complete unparseable assembled args at tool_call_end, and restore Anthropic stream validation that errors instead of ending the call. --- src/adapters/anthropic.ts | 37 +++++++ src/bridge.ts | 113 +++++++++++++++++++-- structure/01_runtime.md | 3 + tests/anthropic-stream-hardening.test.ts | 86 ++++++++++++++++ tests/responses-stream-tool-events.test.ts | 43 ++++++++ 5 files changed, 273 insertions(+), 9 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 3a5654300..aacdd054b 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -284,6 +284,23 @@ function toolUseArguments(input: unknown): string { return JSON.stringify(input ?? {}); } +/** + * Whether arguments assembled from a stream's `input_json_delta` fragments are usable. + * A tool block that sent no fragments at all is fine — that is a no-argument call. Anything + * else has to parse, because unlike the non-stream path the fragments have already been + * forwarded to the client and cannot be repaired after the fact. + */ +function streamedToolArgumentsParse(assembled: string): boolean { + const trimmed = assembled.trim(); + if (!trimmed) return true; + try { + JSON.parse(trimmed); + return true; + } catch { + return false; + } +} + function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean { return provider.apiKeyTransport === "bearer"; } @@ -765,6 +782,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti let currentBlockType = ""; let currentToolCallId = ""; let currentToolCallName = ""; + let currentToolCallJson = ""; let pendingUsage: Record | undefined; let pendingStopReason: string | undefined; let emittedDone = false; @@ -808,6 +826,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti if (block.type === "tool_use") { currentToolCallId = block.id ?? synthesizeToolUseId(); currentToolCallName = toolNames.fromWire(block.name ?? ""); + currentToolCallJson = ""; yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName }; } if (block.type === "redacted_thinking" && typeof block.data === "string") { @@ -833,14 +852,32 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // so a stray signature on a non-thinking block can never be captured. yield { type: "thinking_signature", signature: delta.signature }; } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") { + // Forwarded immediately: the bridge maps each delta to a client-visible + // response.function_call_arguments.delta frame, so withholding fragments until + // block close would leave a started call showing empty arguments. A copy is kept + // to validate the assembled payload at content_block_stop. + currentToolCallJson += delta.partial_json; yield { type: "tool_call_delta", arguments: delta.partial_json }; } break; } case "content_block_stop": { if (currentBlockType === "tool_use") { + // The non-stream path repairs an unparseable payload in toolUseArguments(); the + // stream cannot, because the fragments are already downstream. Fail the turn + // instead of ending a tool call whose arguments will not parse — the bridge's + // terminal-error path cancels the open call (status incomplete) rather than + // completing it before response.failed (#765). + if (!streamedToolArgumentsParse(currentToolCallJson)) { + yield { + type: "error", + message: "Anthropic stream sent malformed tool_use arguments (invalid JSON)", + }; + return; + } yield { type: "tool_call_end" }; currentToolCallId = ""; + currentToolCallJson = ""; } currentBlockType = ""; break; diff --git a/src/bridge.ts b/src/bridge.ts index 6bfad291d..3a8eb0baa 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -63,6 +63,23 @@ function responseError(status: number, type: string, message: string): OcxErrorP return classifyError(status, type, message); } +/** + * Whether assembled function-call arguments are usable JSON. + * An empty buffer is valid (no-arg tools send no deltas). Non-empty must parse — + * once fragments have been streamed to the client they cannot be repaired the way + * non-stream adapters degrade a bad payload to `{}`. + */ +function toolCallArgumentsUsable(args: string): boolean { + const trimmed = args.trim(); + if (!trimmed) return true; + try { + JSON.parse(trimmed); + return true; + } catch { + return false; + } +} + function adapterFailureFromEvent(event: Extract): { httpStatus: number; error: OcxErrorPayload } { if (event.status === undefined && event.errorType === undefined && event.code === undefined) { return adapterFailureFromMessage(event.message); @@ -417,6 +434,39 @@ export function bridgeToResponsesSSE( currentToolCall = null; }; + // Terminal-error / incomplete path for an open tool call (#765 remainder). + // Closing via closeCurrentToolCall() would emit function_call_arguments.done and + // status:"completed" BEFORE response.failed — the client still sees an issued call. + // Cancel instead: no *.done argument frames, status:"incomplete" (same pattern as an + // in-flight web_search_call closing as "failed"). Args still serialize as "{}" when + // empty so echoed items cannot poison the next turn with JSON.parse(""). + const failCurrentToolCall = () => { + if (!currentToolCall) return; + const argsStr = currentToolCall.args || "{}"; + const item = currentToolCall.toolSearch + ? { + type: "tool_search_call", id: currentToolCall.itemId, + call_id: currentToolCall.callId, execution: "client", + arguments: parseArgsObj(currentToolCall.args), status: "incomplete", + } + : currentToolCall.freeform + ? { + type: "custom_tool_call", id: currentToolCall.itemId, + call_id: currentToolCall.callId, name: currentToolCall.name, + input: freeformInput(currentToolCall.args), status: "incomplete", + } + : { + type: "function_call", id: currentToolCall.itemId, + call_id: currentToolCall.callId, name: currentToolCall.name, + arguments: argsStr, status: "incomplete", + ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}), + }; + emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item }); + finishedItems.push(item as OutputItem); + outputIndex++; + currentToolCall = null; + }; + // Finalize an open web-search cell. `status` is "completed" on a normal end, or "failed" when // the stream terminates (error/incomplete) while a search was still in flight, so Codex never // leaves a "Searching the web" spinner spinning forever. @@ -653,6 +703,32 @@ export function bridgeToResponsesSSE( break; } case "tool_call_end": { + // Fragments already streamed cannot be repaired. Refuse to complete a function call + // whose assembled arguments do not parse — cancel the item and fail the turn so the + // client never sees status:"completed" for unusable args (#765 stream remainder). + if ( + currentToolCall + && !currentToolCall.freeform + && !currentToolCall.toolSearch + && !toolCallArgumentsUsable(currentToolCall.args) + ) { + failCurrentToolCall(); + const failure = responseError( + 502, + "upstream_error", + "upstream stream produced malformed tool call arguments", + ); + emit("response.failed", { + response: { + ...responseSnapshot("failed", finishedItems), + error: failure, + last_error: failure, + }, + }); + reportTerminal("failed"); + terminalEvent = true; + break; + } closeCurrentToolCall(); break; } @@ -749,7 +825,7 @@ export function bridgeToResponsesSSE( if (currentReasoning) closeCurrentReasoning(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); - if (currentToolCall) closeCurrentToolCall(); + if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); flushHiddenReasoningEnvelope(); options?.onUsage?.(event.usage); @@ -773,7 +849,7 @@ export function bridgeToResponsesSSE( if (currentReasoning) closeCurrentReasoning(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); - if (currentToolCall) closeCurrentToolCall(); + if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); const failure = adapterFailureFromEvent(event); if (event.usage) options?.onUsage?.(event.usage); @@ -803,6 +879,7 @@ export function bridgeToResponsesSSE( } catch (err) { if (!terminated) { flushHiddenRawReasoning(); + if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); emit("response.failed", { response: { @@ -832,7 +909,7 @@ export function bridgeToResponsesSSE( if (currentReasoning) closeCurrentReasoning(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); - if (currentToolCall) closeCurrentToolCall(); + if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); options?.onUsage?.(undefined); emit("response.incomplete", { @@ -872,7 +949,7 @@ export function bridgeToResponsesSSE( if (currentReasoning) closeCurrentReasoning(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); - if (currentToolCall) closeCurrentToolCall(); + if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); emit("response.incomplete", { response: { @@ -1021,7 +1098,7 @@ export function buildResponseJSON( }); currentRawReasoning = ""; }; - const flushToolCall = () => { + const flushToolCall = (status: "completed" | "incomplete" = "completed") => { if (!currentToolCallId) return; const mapped = options?.toolNsMap?.get(currentToolCallName); const realName = mapped?.name ?? currentToolCallName; @@ -1032,19 +1109,19 @@ export function buildResponseJSON( output.push({ type: "tool_search_call", id: `tsc_${uuid()}`, call_id: currentToolCallId, execution: "client", - arguments: parseArgsObj(currentToolCallArgs), status: "completed", + arguments: parseArgsObj(currentToolCallArgs), status, }); } else if (freeform) { output.push({ type: "custom_tool_call", id: `ctc_${uuid()}`, call_id: currentToolCallId, name: realName, - input: freeformInput(currentToolCallArgs), status: "completed", + input: freeformInput(currentToolCallArgs), status, }); } else { output.push({ type: "function_call", id: `fc_${uuid()}`, call_id: currentToolCallId, name: realName, - arguments: currentToolCallArgs || "{}", status: "completed", + arguments: currentToolCallArgs || "{}", status, ...(ns ? { namespace: ns } : {}), }); } @@ -1110,6 +1187,23 @@ export function buildResponseJSON( currentToolCallArgs += e.arguments; break; case "tool_call_end": + if (!toolCallArgumentsUsable(currentToolCallArgs) && currentToolCallId) { + // Mirror the streaming path: refuse to complete unusable arguments. + const mapped = options?.toolNsMap?.get(currentToolCallName); + const realName = mapped?.name ?? currentToolCallName; + const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false; + const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false); + if (!freeform && !toolSearch) { + flushToolCall("incomplete"); + errorEvent = { + type: "error", + message: "upstream stream produced malformed tool call arguments", + status: 502, + errorType: "upstream_error", + }; + break; + } + } flushToolCall(); break; case "web_search_call_begin": @@ -1155,7 +1249,8 @@ export function buildResponseJSON( flushText(cleanDone && !errorEvent && !incompleteEvent ? "final_answer" : undefined); flushSummaryReasoning(); flushRawReasoning(); - flushToolCall(); + // Open tool call on a failed/incomplete turn must not land as status:"completed". + if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent ? "incomplete" : "completed"); // A truncated turn must never be installed as replacement history: emit the // compaction item only when the turn actually completed (#422). if ( diff --git a/structure/01_runtime.md b/structure/01_runtime.md index aaa21b715..07e733b76 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -63,6 +63,9 @@ The bridge enforces a heartbeat stall deadline. It defaults to 300 seconds sampl invariant; sidecars keep their own clocks. On expiry the stream is closed and the upstream request cancelled. If the adapter generator ends without an explicit done/error event, the response is marked `incomplete` rather than `completed` so Codex can distinguish a clean finish from a truncated stream. +On `error` / incomplete / stall / EOF — and when assembled non-freeform tool arguments fail to parse — +an open tool call is cancelled as `status: "incomplete"` without `function_call_arguments.done`, so +the client never sees a completed call ahead of `response.failed` / `response.incomplete`. The server exposes `POST /api/stop` which restores native Codex config, stops any installed service (to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. diff --git a/tests/anthropic-stream-hardening.test.ts b/tests/anthropic-stream-hardening.test.ts index 71e42cddb..510c70630 100644 --- a/tests/anthropic-stream-hardening.test.ts +++ b/tests/anthropic-stream-hardening.test.ts @@ -104,6 +104,92 @@ describe("anthropic non-stream tool_use input", () => { }); }); + test("malformed streamed tool arguments fail the turn instead of completing the call", async () => { + // The stream forwards fragments as they arrive (the bridge turns each into a client-visible + // frame), so a malformed payload cannot be repaired the way the non-stream path repairs it. + // It must not end the call normally either: a client that already received `not json` would + // be told the tool call completed. The turn errors instead; the bridge cancels the open call. + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"tool_use","id":"toolu_9","name":"get_weather"}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"not json"}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop"}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(e => e.type === "tool_call_end")).toBe(false); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("valid streamed tool arguments still arrive as incremental deltas", async () => { + // The bridge maps each adapter delta to response.function_call_arguments.delta, so the + // fragments must keep flowing as they arrive rather than being withheld until block close. + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"tool_use","id":"toolu_10","name":"get_weather"}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\\"city\\":"}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"\\"Paris\\"}"}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop"}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.filter(e => e.type === "tool_call_delta")).toMatchObject([ + { type: "tool_call_delta", arguments: '{"city":' }, + { type: "tool_call_delta", arguments: '"Paris"}' }, + ]); + expect(events.some(e => e.type === "tool_call_end")).toBe(true); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("two tool_use blocks in one message keep separate argument buffers", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"tool_use","id":"toolu_a","name":"one"}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\\"one\\":1}"}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop"}\n\n', + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"tool_use","id":"toolu_b","name":"two"}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\\"two\\":2}"}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop"}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.filter(e => e.type === "tool_call_delta")).toMatchObject([ + { type: "tool_call_delta", arguments: '{"one":1}' }, + { type: "tool_call_delta", arguments: '{"two":2}' }, + ]); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(2); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("a tool_use block with no argument fragments is not treated as malformed", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"tool_use","id":"toolu_c","name":"now"}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop"}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.some(e => e.type === "error")).toBe(false); + expect(events.some(e => e.type === "tool_call_end")).toBe(true); + expect(events.at(-1)?.type).toBe("done"); + }); + test("EOF after message_delta.stop_reason settles instead of erroring", async () => { // The other two EOF tests assert the pre-existing error path and never send a stop reason, // so they stay green with this fallback reverted -- they do not test the change they shipped diff --git a/tests/responses-stream-tool-events.test.ts b/tests/responses-stream-tool-events.test.ts index dcbcb80b3..5fe63dd3b 100644 --- a/tests/responses-stream-tool-events.test.ts +++ b/tests/responses-stream-tool-events.test.ts @@ -51,4 +51,47 @@ describe("Responses streaming tool event contract", () => { status: "completed", }); }); + + test("terminal error cancels an open tool call instead of completing it", async () => { + // #765 remainder: adapter error with an open tool call must not emit + // function_call_arguments.done / status:"completed" before response.failed. + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: "call_bad", name: "get_weather" }, + { type: "tool_call_delta", arguments: "not json" }, + { type: "error", message: "Anthropic stream sent malformed tool_use arguments (invalid JSON)" }, + ]), "routed/model")); + + expect(frames.some(frame => frame.event === "response.function_call_arguments.done")).toBe(false); + const itemDone = frames.filter(frame => frame.event === "response.output_item.done") + .map(frame => frame.data.item as Record) + .find(item => item?.type === "function_call"); + expect(itemDone).toMatchObject({ + type: "function_call", + call_id: "call_bad", + status: "incomplete", + }); + const failed = frames.find(frame => frame.event === "response.failed"); + expect(failed).toBeTruthy(); + const failedOutput = (failed?.data.response as Record).output as Record[]; + expect(failedOutput.some(item => item.type === "function_call" && item.status === "completed")).toBe(false); + expect(frames.some(frame => frame.event === "response.completed")).toBe(false); + }); + + test("malformed assembled arguments at tool_call_end fail the turn without completing", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: "call_1", name: "get_weather" }, + { type: "tool_call_delta", arguments: "not json" }, + { type: "tool_call_end" }, + { type: "done" }, + ]), "routed/model")); + + expect(frames.some(frame => frame.event === "response.function_call_arguments.done")).toBe(false); + expect(frames.some(frame => frame.event === "response.completed")).toBe(false); + const failed = frames.find(frame => frame.event === "response.failed"); + expect(failed).toBeTruthy(); + const itemDone = frames.filter(frame => frame.event === "response.output_item.done") + .map(frame => frame.data.item as Record) + .find(item => item?.type === "function_call"); + expect(itemDone).toMatchObject({ status: "incomplete" }); + }); }); From 2022b60c2c4caf5ce214198580ba881500de9a17 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:24:33 +0200 Subject: [PATCH 8/8] test(anthropic): group streamed tool-argument validation cases Move the new stream-path cases into their own describe so they are not nested under the non-stream tool_use input suite. --- tests/anthropic-stream-hardening.test.ts | 38 +++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/tests/anthropic-stream-hardening.test.ts b/tests/anthropic-stream-hardening.test.ts index 510c70630..afc454e98 100644 --- a/tests/anthropic-stream-hardening.test.ts +++ b/tests/anthropic-stream-hardening.test.ts @@ -104,6 +104,26 @@ describe("anthropic non-stream tool_use input", () => { }); }); + test("EOF after message_delta.stop_reason settles instead of erroring", async () => { + // The other two EOF tests assert the pre-existing error path and never send a stop reason, + // so they stay green with this fallback reverted -- they do not test the change they shipped + // with. This drives the fallback itself: a stream that reported why it stopped but never + // sent message_stop. + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"text","text":""}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n', + "event: message_delta\n", + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "end_turn" }); + expect(events.some(e => e.type === "error")).toBe(false); + }); +}); + +describe("anthropic streamed tool argument validation", () => { test("malformed streamed tool arguments fail the turn instead of completing the call", async () => { // The stream forwards fragments as they arrive (the bridge turns each into a client-visible // frame), so a malformed payload cannot be repaired the way the non-stream path repairs it. @@ -189,22 +209,4 @@ describe("anthropic non-stream tool_use input", () => { expect(events.some(e => e.type === "tool_call_end")).toBe(true); expect(events.at(-1)?.type).toBe("done"); }); - - test("EOF after message_delta.stop_reason settles instead of erroring", async () => { - // The other two EOF tests assert the pre-existing error path and never send a stop reason, - // so they stay green with this fallback reverted -- they do not test the change they shipped - // with. This drives the fallback itself: a stream that reported why it stopped but never - // sent message_stop. - const response = new Response([ - "event: content_block_start\n", - 'data: {"type":"content_block_start","content_block":{"type":"text","text":""}}\n\n', - "event: content_block_delta\n", - 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n', - "event: message_delta\n", - 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', - ].join("")); - const events = await collect(createAnthropicAdapter(provider).parseStream(response)); - expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "end_turn" }); - expect(events.some(e => e.type === "error")).toBe(false); - }); });