Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down Expand Up @@ -765,6 +782,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
let currentBlockType = "";
let currentToolCallId = "";
let currentToolCallName = "";
let currentToolCallJson = "";
let pendingUsage: Record<string, number> | undefined;
let pendingStopReason: string | undefined;
let emittedDone = false;
Expand Down Expand Up @@ -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") {
Expand All @@ -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;
Expand Down
113 changes: 104 additions & 9 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdapterEvent, { type: "error" }>): { httpStatus: number; error: OcxErrorPayload } {
if (event.status === undefined && event.errorType === undefined && event.code === undefined) {
return adapterFailureFromMessage(event.message);
Expand Down Expand Up @@ -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;
};
Comment on lines +443 to +468

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether "incomplete" status function_call items get echoed back into
# subsequent-turn history/replay (the mechanism that would make this leak harmful).
rg -n "previous_response_id" -g '*.ts' -A5 -B5 src | head -100
rg -n 'status.*"incomplete"' -g '*.ts' src/server src/codex 2>/dev/null | head -50

Repository: lidge-jun/opencodex

Length of output: 7362


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- bridge relevant definitions and call sites ---'
sed -n '380,475p' src/bridge.ts
sed -n '690,735p' src/bridge.ts
sed -n '1085,1140p' src/bridge.ts
sed -n '1180,1210p' src/bridge.ts
sed -n '1235,1265p' src/bridge.ts
printf '%s\n' '--- replay and response-state handling ---'
sed -n '360,445p' src/server/responses/core.ts
rg -n "rememberResponseState|_replayPrefixLen|output\\.|function_call|incomplete" src/server src -g '*.ts' | head -180
printf '%s\n' '--- relevant tests ---'
rg -n "malformed|incomplete|arguments|tool_call_end" tests -g '*.ts' | head -160

Repository: lidge-jun/opencodex

Length of output: 49612


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
p = Path("src/bridge.ts")
text = p.read_text()
for needle in ["toolCallArgumentsUsable", "failCurrentToolCall", "flushToolCall", "rememberResponseState"]:
    print(f"--- {needle} ---")
    for i, line in enumerate(text.splitlines(), 1):
        if needle in line:
            print(i, line.strip())
PY
printf '%s\n' '--- response state call graph excerpts ---'
rg -n "rememberResponseState|onCompletedResponse|_previousResponseInputExpanded|_replayPrefixLen|previousResponseId" src -g '*.ts' -A8 -B8 | head -260

Repository: lidge-jun/opencodex

Length of output: 20534


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- argument usability helper ---'
sed -n '55,90p' src/bridge.ts
printf '%s\n' '--- response-state policy and replay construction ---'
sed -n '405,490p' src/responses/state.ts
sed -n '1395,1440p' src/server/responses/core.ts
sed -n '1635,1675p' src/server/responses/core.ts
sed -n '1860,1905p' src/server/responses/core.ts
sed -n '2010,2090p' src/server/responses/core.ts
printf '%s\n' '--- malformed tool-event tests ---'
sed -n '1,115p' tests/responses-stream-tool-events.test.ts
printf '%s\n' '--- bridge response construction ---'
sed -n '780,850p' src/bridge.ts
sed -n '895,970p' src/bridge.ts
sed -n '1265,1305p' src/bridge.ts

Repository: lidge-jun/opencodex

Length of output: 28073


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
text = Path("src/server/responses/core.ts").read_text().splitlines()
needles = (
    "bridgeToResponsesSSE(",
    "buildResponseJSON(",
    "onCompletedResponse",
    "rememberResponseState(",
)
for i, line in enumerate(text, 1):
    if any(n in line for n in needles):
        lo, hi = max(1, i - 10), min(len(text), i + 18)
        print(f"--- core.ts:{i} ---")
        for j in range(lo, hi + 1):
            print(f"{j}:{text[j-1]}")
PY
printf '%s\n' '--- all response.failed persistence and replay references ---'
rg -n "response\\.failed|status === \"failed\"|status.*failed|rememberResponseState" src/responses src/server -g '*.ts' -A6 -B6 | head -260

Repository: lidge-jun/opencodex

Length of output: 38332


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- response-state policy and replay construction ---'
sed -n '405,490p' src/responses/state.ts
sed -n '1395,1440p' src/server/responses/core.ts
sed -n '1635,1675p' src/server/responses/core.ts
sed -n '1860,1905p' src/server/responses/core.ts
sed -n '2010,2090p' src/server/responses/core.ts
printf '%s\n' '--- malformed tool-event tests ---'
sed -n '1,115p' tests/responses-stream-tool-events.test.ts

Repository: lidge-jun/opencodex

Length of output: 18444


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- incomplete adapter-event producers and reasons ---'
rg -n "type:\s*[\"']incomplete[\"']|yield\s*\{[^}]*incomplete|reason:\s*[\"'](max|content|adapter|upstream)" src/adapters src -g '*.ts' -A4 -B4 | head -300
printf '%s\n' '--- AdapterEvent definition ---'
rg -n "type AdapterEvent|interface.*AdapterEvent|incomplete.*reason" src/types.ts src -g '*.ts' -A35 -B8 | head -180
printf '%s\n' '--- non-stream bridge callers and tests for max-output partials ---'
rg -n "max_output_tokens|max_tokens|stopReason|incompleteEvent|buildResponseJSON" tests src -g '*.ts' -A8 -B8 | head -320

Repository: lidge-jun/opencodex

Length of output: 44051


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- incomplete adapter-event producers and reasons ---'
rg -n "type:\s*[\"']incomplete[\"']|yield\s*\{[^}]*incomplete|reason:\s*[\"'](max|content|adapter|upstream)" src/adapters src -g '*.ts' -A4 -B4 | head -300
printf '%s\n' '--- AdapterEvent definition ---'
rg -n "type AdapterEvent|interface.*AdapterEvent|incomplete.*reason" src/types.ts src -g '*.ts' -A35 -B8 | head -180

Repository: lidge-jun/opencodex

Length of output: 19747


Normalize malformed arguments on every incomplete tool-call path.

The failure paths at src/bridge.ts:443 and src/bridge.ts:1101 expose malformed arguments, but failed responses are not replayed. The replay risk occurs when max_tokens ends with an open tool call:

  • src/bridge.ts:780 calls closeCurrentToolCall() before emitting an incomplete response.
  • src/bridge.ts:1253 flushes the batch call as "completed" because stopReason is not included in the condition.
  • rememberResponseState() caches max_output_tokens incomplete responses.

If the truncated buffer is invalid JSON, the cached replay contains invalid function_call.arguments. Use "{}" for unusable arguments in failCurrentToolCall() and flushToolCall(). Route open calls through the incomplete path when stopReason is "max_tokens" or "content_filter". Add streaming and non-streaming regression assertions for arguments: "{}".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bridge.ts` around lines 443 - 468, Normalize unusable tool-call arguments
to "{}" in both failCurrentToolCall() and flushToolCall(), covering function,
tool-search, and freeform incomplete-call payloads without exposing malformed
JSON. Ensure open calls use the incomplete-response path when stopReason is
"max_tokens" or "content_filter", including the batch flow around the
completed-condition logic, so replayed responses remain valid. Add streaming and
non-streaming regression assertions confirming arguments is "{}".


// 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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -803,6 +879,7 @@ export function bridgeToResponsesSSE(
} catch (err) {
if (!terminated) {
flushHiddenRawReasoning();
if (currentToolCall) failCurrentToolCall();
if (currentWebSearch) closeCurrentWebSearch("failed", []);
emit("response.failed", {
response: {
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand All @@ -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 } : {}),
});
}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 (
Expand Down
3 changes: 3 additions & 0 deletions structure/01_runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
88 changes: 88 additions & 0 deletions tests/anthropic-stream-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,91 @@ describe("anthropic non-stream tool_use input", () => {
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.
// 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");
});
});
Loading
Loading