feat(ai): enabled streaming tool call argument deltas and validated identifier formats

- Streamed parameter chunk deltas as `toolArgDelta` events during execution for Anthropic and DeepSeek dialects.
- Integrated streaming tool call tracking in `InbandStreamProjector` to preserve native tool identifiers and partial JSON arguments.
- Added message validation functions to detect and drop malformed tool calls with empty or whitespace-only IDs.
- Introduced comprehensive suite of integration and unit tests validating argument delta streaming and tool-call sanitization.
This commit is contained in:
can1357
2026-07-01 00:54:01 +02:00
parent c7fa7f5285
commit 92394f5c07
8 changed files with 428 additions and 42 deletions
+10
View File
@@ -2,8 +2,18 @@
## [Unreleased]
### Added
- Added streaming support for keyed parameter argument deltas in XML-family in-band tool call scanners (Anthropic, DeepSeek, XML, Minimax)
### Changed
- Improved native tool-call passthrough in `wrapInbandToolStream` to accurately mirror live streaming IDs, arguments, and partial JSON states from the underlying provider
### Fixed
- Fixed a bug where tool calls with empty or missing IDs were not detected as malformed, causing API validation failures (e.g., 400 errors with Anthropic) on subsequent requests
- Raised Gemini header runaway threshold to prevent premature interruption of complex reasoning loops
- Fixed leaked ` ```thinking ` fences with nested language-tagged Markdown code blocks so inner fences remain inside structured thinking instead of leaking as visible reply text.
+19 -7
View File
@@ -132,7 +132,7 @@ export class AnthropicInbandScanner implements InbandScanner {
progressed = this.#consumeInvoke(final, events);
break;
case "parameter":
progressed = this.#consumeParameter(final);
progressed = this.#consumeParameter(final, events);
break;
case "thinking":
progressed = this.#consumeThinking(final, events);
@@ -272,7 +272,7 @@ export class AnthropicInbandScanner implements InbandScanner {
return true;
}
#consumeParameter(final: boolean): boolean {
#consumeParameter(final: boolean, events: InbandScanEvent[]): boolean {
const tagStart = this.#buffer.indexOf("<");
if (tagStart === -1) {
if (final) {
@@ -280,14 +280,14 @@ export class AnthropicInbandScanner implements InbandScanner {
this.#buffer = "";
return false;
}
this.#appendParameterValue(this.#buffer);
this.#appendParameterValue(this.#buffer, events);
this.#rawBlock += this.#buffer;
this.#buffer = "";
return false;
}
if (tagStart > 0) {
const consumed = this.#buffer.slice(0, tagStart);
this.#appendParameterValue(consumed);
this.#appendParameterValue(consumed, events);
this.#rawBlock += consumed;
this.#buffer = this.#buffer.slice(tagStart);
return true;
@@ -307,7 +307,7 @@ export class AnthropicInbandScanner implements InbandScanner {
return false;
}
const consumed = this.#buffer[0]!;
this.#appendParameterValue(consumed);
this.#appendParameterValue(consumed, events);
this.#rawBlock += consumed;
this.#buffer = this.#buffer.slice(1);
return true;
@@ -378,10 +378,22 @@ export class AnthropicInbandScanner implements InbandScanner {
this.#state = "parameter";
}
#appendParameterValue(delta: string): void {
#appendParameterValue(delta: string, events: InbandScanEvent[]): void {
if (delta.length === 0) return;
const remaining = MAX_PARAMETER_VALUE_LENGTH - this.#paramValue.length;
if (remaining > 0) this.#paramValue += delta.slice(0, remaining);
const accepted = remaining > 0 ? delta.slice(0, remaining) : "";
if (accepted.length > 0) {
this.#paramValue += accepted;
if (this.#started && this.#paramName.length > 0) {
events.push({
type: "toolArgDelta",
id: this.#id,
name: this.#name,
key: this.#paramName,
delta: accepted,
});
}
}
if (delta.length > remaining) this.#paramTruncated = true;
}
+19 -5
View File
@@ -99,6 +99,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
#dsmlArgs: Record<string, unknown> = {};
#dsmlParamName = "";
#dsmlParamIsString = true;
#dsmlParamRaw = "";
#rawBlock = "";
#stripLeadingWhitespace = false;
@@ -153,7 +154,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
if (!this.#consumeDsmlInvoke(final, events)) break;
continue;
}
if (!this.#consumeDsmlParam(final)) break;
if (!this.#consumeDsmlParam(final, events)) break;
}
if (final && this.#state === "thinking") this.#endThinking(events);
if (final && this.#buffer.length === 0 && this.#rawBlock.length > 0) this.#rawBlock = "";
@@ -396,18 +397,23 @@ export class DeepSeekInbandScanner implements InbandScanner {
return final;
}
#consumeDsmlParam(final: boolean): boolean {
#consumeDsmlParam(final: boolean, events: InbandScanEvent[]): boolean {
const close = findEarliestToken(this.#buffer, DSML_PARAMETER_CLOSE_TOKENS);
if (!close) {
const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, DSML_PARAMETER_CLOSE_TOKENS);
const chunk = this.#buffer.slice(0, this.#buffer.length - hold);
this.#streamDsmlParam(chunk, events);
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
if (final) this.#resetDsmlTool();
return false;
}
const rawValue = this.#buffer.slice(0, close.index);
this.#dsmlArgs[this.#dsmlParamName] = coerceDsmlValue(rawValue, this.#dsmlParamIsString);
this.#rawBlock += rawValue + close.token;
this.#streamDsmlParam(this.#buffer.slice(0, close.index), events);
this.#dsmlArgs[this.#dsmlParamName] = coerceDsmlValue(this.#dsmlParamRaw, this.#dsmlParamIsString);
this.#rawBlock += close.token;
this.#buffer = this.#buffer.slice(close.index + close.token.length);
this.#dsmlParamName = "";
this.#dsmlParamIsString = true;
this.#dsmlParamRaw = "";
this.#state = "dsmlInvoke";
return true;
}
@@ -418,6 +424,13 @@ export class DeepSeekInbandScanner implements InbandScanner {
events.push({ type: "toolStart", id: this.#id, name: this.#name });
}
#streamDsmlParam(chunk: string, events: InbandScanEvent[]): void {
if (chunk.length === 0) return;
this.#dsmlParamRaw += chunk;
this.#rawBlock += chunk;
events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#dsmlParamName, delta: chunk });
}
#emitThinking(delta: string, events: InbandScanEvent[]): void {
if (delta.length === 0) return;
if (this.#parseThinking) {
@@ -509,6 +522,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
this.#dsmlArgs = {};
this.#dsmlParamName = "";
this.#dsmlParamIsString = true;
this.#dsmlParamRaw = "";
this.#rawBlock = "";
}
}
+59 -16
View File
@@ -5,6 +5,12 @@ import type {
ThinkingContent,
ToolCall,
} from "../types";
import {
clearStreamingPartialJson,
getStreamingPartialJson,
type StreamingPartialJsonCarrier,
setStreamingPartialJson,
} from "../utils/block-symbols";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { buildStringArgsResolver } from "./coercion";
import { createInbandScanner } from "./factory";
@@ -36,6 +42,31 @@ function firstTokenIndex(text: string, tokens: readonly string[]): number {
type OpenText = { index: number } | undefined;
type OpenThinking = { index: number; text: string } | undefined;
type StreamingToolCall = ToolCall & StreamingPartialJsonCarrier;
function cloneToolCall(source: StreamingToolCall): StreamingToolCall {
const block: StreamingToolCall = {
type: "toolCall",
id: source.id,
name: source.name,
arguments: source.arguments,
...(source.rawBlock !== undefined ? { rawBlock: source.rawBlock } : {}),
};
const partialJson = getStreamingPartialJson(source);
if (partialJson !== undefined) setStreamingPartialJson(block, partialJson);
return block;
}
function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): void {
target.id = source.id;
target.name = source.name;
target.arguments = source.arguments;
target.rawBlock = source.rawBlock;
const partialJson = getStreamingPartialJson(source);
if (partialJson === undefined) clearStreamingPartialJson(target);
else setStreamingPartialJson(target, partialJson);
}
export function parseInbandToolMessage(
message: AssistantMessage,
dialect: Dialect,
@@ -95,12 +126,18 @@ export function wrapInbandToolStream(
// projector ignores nameless "ghost" parts and de-conflicts with the
// in-band channel.
const src = event.partial.content[event.contentIndex];
projector?.nativeToolStart(event.contentIndex, src?.type === "toolCall" ? src.name : "");
projector?.nativeToolStart(event.contentIndex, src?.type === "toolCall" ? src : undefined);
break;
}
case "toolcall_delta":
projector?.nativeToolDelta(event.contentIndex, event.delta);
case "toolcall_delta": {
const src = event.partial.content[event.contentIndex];
projector?.nativeToolDelta(
event.contentIndex,
event.delta,
src?.type === "toolCall" ? src : undefined,
);
break;
}
case "toolcall_end":
projector?.nativeToolEnd(event.contentIndex, event.toolCall);
break;
@@ -138,7 +175,7 @@ class InbandStreamProjector {
// `contentIndex`. `#toolChannel` records which channel produced the turn's
// first real call so the other is dropped — no double-dispatch, and no
// guessing from emptiness. Nameless "ghost" parts never lock a channel.
#nativeBlocks = new Map<number, { index: number; block: ToolCall }>();
#nativeBlocks = new Map<number, { index: number; block: StreamingToolCall }>();
#toolChannel: "native" | "inband" | undefined;
constructor(
@@ -167,27 +204,33 @@ class InbandStreamProjector {
this.#partial.content.push(block);
}
// Forward a native tool call's lifecycle live. `name` comes from the inner
// stream's partial (set at start for well-behaved providers). Empty `name`
// means a not-yet-identified or "ghost" call — skip until `nativeToolEnd`
// can confirm. Once the in-band channel owns the turn, native calls are
// dropped to avoid double-dispatch.
nativeToolStart(srcIndex: number, name: string): void {
if (this.#stopped || !name || this.#toolChannel === "inband") return;
// Forward a native tool call's lifecycle live. `source` comes from the inner
// stream's current partial block. When owned mode wraps a provider that still
// emits native tool calls, the projected block must mirror the provider's live
// id / args / partial-json state rather than inventing `{ id: "", arguments:
// {} }` placeholders — otherwise the UI loses streaming args and can mis-key
// the call until `toolcall_end`.
nativeToolStart(srcIndex: number, source: StreamingToolCall | undefined): void {
if (this.#stopped || !source?.name || this.#toolChannel === "inband") return;
this.#toolChannel = "native";
this.#closeText();
this.#closeThinking();
const block: ToolCall = { type: "toolCall", id: "", name, arguments: {} };
const block = cloneToolCall(source);
this.#partial.content.push(block);
const index = this.#partial.content.length - 1;
this.#nativeBlocks.set(srcIndex, { index, block });
if (this.#emitEvents) this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
}
nativeToolDelta(srcIndex: number, delta: string): void {
nativeToolDelta(srcIndex: number, delta: string, source: StreamingToolCall | undefined): void {
if (this.#stopped) return;
const entry = this.#nativeBlocks.get(srcIndex);
let entry = this.#nativeBlocks.get(srcIndex);
if (!entry && source?.name && this.#toolChannel !== "inband") {
this.nativeToolStart(srcIndex, source);
entry = this.#nativeBlocks.get(srcIndex);
}
if (!entry) return;
if (source) syncToolCall(entry.block, source);
if (this.#emitEvents)
this.#out.push({ type: "toolcall_delta", contentIndex: entry.index, delta, partial: this.#partial });
}
@@ -196,7 +239,7 @@ class InbandStreamProjector {
if (this.#stopped) return;
const entry = this.#nativeBlocks.get(srcIndex);
if (entry) {
Object.assign(entry.block, toolCall);
syncToolCall(entry.block, toolCall);
if (this.#emitEvents)
this.#out.push({
type: "toolcall_end",
@@ -214,7 +257,7 @@ class InbandStreamProjector {
this.#toolChannel = "native";
this.#closeText();
this.#closeThinking();
const block: ToolCall = { ...toolCall };
const block = cloneToolCall(toolCall);
this.#partial.content.push(block);
const index = this.#partial.content.length - 1;
if (this.#emitEvents) {
+25 -14
View File
@@ -126,18 +126,20 @@ function deduplicateToolCallIds(
}
/**
* Drop assistant `toolCall` blocks whose `name` is empty or whitespace-only,
* Drop assistant `toolCall` blocks whose `id` or `name` is empty / whitespace-only,
* the `toolResult` messages they point at, and any assistant turn that has no
* replayable content left.
*
* Models occasionally emit `{ "name": "", "arguments": "{}" }` (observed:
* GLM-5.2 + thinking on long turns, #3458). The agent loop rejects the call
* at execution time with `Tool not found`, but the malformed block and its
* error tool-result stay in `currentContext.messages`, so every subsequent
* request replays them. Every provider validates the function name —
* Anthropic 400s on `tool_use.name` (alongside an orphan `tool_result`),
* OpenAI Chat Completions 400s on `tool_calls[i].function.name` — wedging the
* session in a 400 loop until manual `/clear`.
* Models occasionally emit malformed calls such as `{ "name": "", "arguments": "{}" }`
* (observed: GLM-5.2 + thinking on long turns, #3458) or a structurally valid
* `toolCall` whose provider/native passthrough id never materialized (`id: ""`).
* The agent loop rejects or skips these at execution time, but the malformed block
* and its error tool-result can stay in `currentContext.messages`, so every
* subsequent request replays them. Every provider validates the call shape —
* Anthropic 400s on `tool_use.name` / `tool_use.id` (alongside an orphan
* `tool_result`), OpenAI Chat Completions 400s on malformed
* `tool_calls[i].function.*` — wedging the session in a 400 loop until manual
* `/clear`.
*
* Run before any other transform so the rest of the pipeline never sees a
* malformed call. Idempotent: a re-run on an already-sanitized list returns
@@ -147,13 +149,21 @@ function isMalformedToolCallName(name: string | undefined): boolean {
return !name || name.trim().length === 0;
}
function isMalformedToolCallId(id: string | undefined): boolean {
return !id || id.trim().length === 0;
}
function isMalformedToolCall(block: { id: string; name: string }): boolean {
return isMalformedToolCallId(block.id) || isMalformedToolCallName(block.name);
}
function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
// Fast path: skip the rewrite entirely when nothing is malformed.
let hasMalformed = false;
outer: for (const msg of messages) {
if (msg.role !== "assistant") continue;
for (const block of msg.content) {
if (block.type === "toolCall" && isMalformedToolCallName(block.name)) {
if (block.type === "toolCall" && isMalformedToolCall(block)) {
hasMalformed = true;
break outer;
}
@@ -179,7 +189,7 @@ function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
const filtered: AssistantMessage["content"] = [];
for (const block of msg.content) {
if (block.type === "toolCall") {
const malformed = isMalformedToolCallName(block.name);
const malformed = isMalformedToolCall(block);
const queue = dropQueues.get(block.id);
if (queue) queue.push(malformed);
else dropQueues.set(block.id, [malformed]);
@@ -283,9 +293,10 @@ export function transformMessages<TApi extends Api>(
duplicateToolCallIdSuffixPrefix = "_dup",
targetCompat: Model<TApi>["compat"] = model.compat,
): Message[] {
// Drop assistant `toolCall` blocks with empty/whitespace `name` (and their
// matched `toolResult` messages) before anything else looks at the history.
// Replays of these would 400 every provider — see `sanitizeMalformedToolCalls`.
// Drop assistant `toolCall` blocks with empty/whitespace `id` or `name`
// (and their matched `toolResult` messages) before anything else looks at
// the history. Replays of these would 400 every provider — see
// `sanitizeMalformedToolCalls`.
messages = sanitizeMalformedToolCalls(messages);
// Build a map of original tool call IDs to normalized IDs
+93
View File
@@ -94,6 +94,61 @@ function expectRawBlock(dialect: Dialect, text: string, expected: string): void
expect(firstRawBlock(dialect, text), dialect).toBe(expected);
}
function parameterDeltaEvents(
events: readonly InbandScanEvent[],
): Extract<InbandScanEvent, { type: "toolArgDelta" }>[] {
return events.filter((event): event is Extract<InbandScanEvent, { type: "toolArgDelta" }> => {
return event.type === "toolArgDelta";
});
}
const XML_PARAMETER_STREAMS: readonly { dialect: Dialect; chunks: readonly string[] }[] = [
{
dialect: "anthropic",
chunks: [
'<function_calls>\n<invoke name="read"><parameter name="path">',
"src/",
"a.ts</para",
'meter><parameter name="count" string="false">',
"2</para",
"meter></invoke>\n</function_calls>",
],
},
{
dialect: "xml",
chunks: [
'<function_calls>\n<invoke name="read"><parameter name="path">',
"src/",
"a.ts</para",
'meter><parameter name="count" string="false">',
"2</para",
"meter></invoke>\n</function_calls>",
],
},
{
dialect: "minimax",
chunks: [
'<minimax:tool_call>\n<invoke name="read"><parameter name="path">',
"src/",
"a.ts</para",
'meter><parameter name="count" string="false">',
"2</para",
"meter></invoke>\n</minimax:tool_call>",
],
},
{
dialect: "deepseek",
chunks: [
'<|DSML|tool_calls>\n<|DSML|invoke name="read"><|DSML|parameter name="path" string="true">',
"src/",
"a.ts</|DSML|para",
'meter><|DSML|parameter name="count" string="false">',
"2</|DSML|para",
"meter></|DSML|invoke>\n</|DSML|tool_calls>",
],
},
];
describe("in-band tool dialects", () => {
it("renders a tool prompt for every dialect", () => {
for (const dialect of DIALECTS) {
@@ -122,6 +177,44 @@ describe("in-band tool dialects", () => {
}
});
it("streams keyed parameter argument deltas before the final XML-family tool end", () => {
for (const { dialect, chunks } of XML_PARAMETER_STREAMS) {
const scanner = createInbandScanner(dialect, { tools: TOOLS, parseThinking: true });
const perFeedEvents = chunks.map(chunk => scanner.feed(chunk));
const events = perFeedEvents.flat();
events.push(...scanner.flush());
const starts = events.filter((event): event is Extract<InbandScanEvent, { type: "toolStart" }> => {
return event.type === "toolStart";
});
expect(starts, dialect).toHaveLength(1);
const callId = starts[0]!.id;
expect(starts[0], dialect).toMatchObject({ id: callId, name: "read" });
expect(parameterDeltaEvents(perFeedEvents[1]!), dialect).toEqual([
{ type: "toolArgDelta", id: callId, name: "read", key: "path", delta: "src/" },
]);
expect(parameterDeltaEvents(perFeedEvents[2]!), dialect).toEqual([
{ type: "toolArgDelta", id: callId, name: "read", key: "path", delta: "a.ts" },
]);
expect(toolEnds(perFeedEvents[2]!), dialect).toHaveLength(0);
expect(parameterDeltaEvents(perFeedEvents[4]!), dialect).toEqual([
{ type: "toolArgDelta", id: callId, name: "read", key: "count", delta: "2" },
]);
const calls = toolEnds(events);
expect(calls, dialect).toHaveLength(1);
expect(calls[0], dialect).toMatchObject({
id: callId,
name: "read",
arguments: { path: "src/a.ts", count: 2 },
});
const finalIndex = events.findIndex(event => event.type === "toolEnd");
const lastDeltaIndex = events.findLastIndex(event => event.type === "toolArgDelta");
expect(lastDeltaIndex, dialect).toBeGreaterThan(-1);
expect(finalIndex, dialect).toBeGreaterThan(lastDeltaIndex);
}
});
it("captures exact raw tool call blocks for debugging", () => {
expectRawBlock(
"glm",
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";
import { wrapInbandToolStream } from "../src/dialect/owned-stream";
import type { AssistantMessage, AssistantMessageEvent, ThinkingContent, ToolCall, Usage } from "../src/types";
import { getStreamingPartialJson, setStreamingPartialJson } from "../src/utils/block-symbols";
import { AssistantMessageEventStream } from "../src/utils/event-stream";
const TOOLS = [
@@ -70,6 +71,44 @@ function geminiNativeOnly(): AssistantMessageEventStream {
});
}
function controlledNativeToolArgGrowth(): {
stream: AssistantMessageEventStream;
pushStart: () => void;
pushFirstDelta: () => void;
pushSecondDelta: () => void;
pushEnd: () => void;
finish: () => void;
} {
const stream = new AssistantMessageEventStream();
const out = makeAssistant([]);
const block: ToolCall = { type: "toolCall", id: "tool_todo_streaming", name: "todo", arguments: {} };
stream.push({ type: "start", partial: out });
return {
stream,
pushStart: () => {
out.content.push(block);
stream.push({ type: "toolcall_start", contentIndex: 0, partial: out });
},
pushFirstDelta: () => {
setStreamingPartialJson(block, '{"ops":[');
stream.push({ type: "toolcall_delta", contentIndex: 0, delta: '{"ops":[', partial: out });
},
pushSecondDelta: () => {
block.arguments = { ops: [{ op: "view" }] };
setStreamingPartialJson(block, '{"ops":[{"op":"view"}]}');
stream.push({ type: "toolcall_delta", contentIndex: 0, delta: '{"op":"view"}]}', partial: out });
},
pushEnd: () => {
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: block, partial: out });
},
finish: () => {
stream.push({ type: "done", reason: "toolUse", message: out });
stream.end(out);
},
};
}
// A nameless native "ghost" part (Gemini emits these beside a real call) must be
// dropped, while the real native call is still forwarded.
function ghostThenRealNative(): AssistantMessageEventStream {
@@ -111,6 +150,60 @@ function inbandPlusNative(): AssistantMessageEventStream {
});
}
function cloneArgs(args: Record<string, unknown>): Record<string, unknown> {
return JSON.parse(JSON.stringify(args)) as Record<string, unknown>;
}
type ToolCallSnapshot = {
type: "toolcall_start" | "toolcall_delta" | "toolcall_end";
delta?: string;
id: string;
name: string;
arguments: Record<string, unknown>;
partialJson: string | undefined;
endToolCall?: {
id: string;
name: string;
arguments: Record<string, unknown>;
partialJson: string | undefined;
};
};
function snapshotToolCallEvent(event: AssistantMessageEvent): ToolCallSnapshot | undefined {
if (event.type !== "toolcall_start" && event.type !== "toolcall_delta" && event.type !== "toolcall_end") {
return undefined;
}
const block = event.partial.content[event.contentIndex];
if (block?.type !== "toolCall") return undefined;
return {
type: event.type,
...(event.type === "toolcall_delta" ? { delta: event.delta } : {}),
id: block.id,
name: block.name,
arguments: cloneArgs(block.arguments),
partialJson: getStreamingPartialJson(block),
...(event.type === "toolcall_end"
? {
endToolCall: {
id: event.toolCall.id,
name: event.toolCall.name,
arguments: cloneArgs(event.toolCall.arguments),
partialJson: getStreamingPartialJson(event.toolCall),
},
}
: {}),
};
}
async function nextToolCallSnapshot(iterator: AsyncIterator<AssistantMessageEvent>): Promise<ToolCallSnapshot> {
for (;;) {
const next = await iterator.next();
if (next.done) throw new Error("stream ended before the next tool-call event");
const snapshot = snapshotToolCallEvent(next.value);
if (snapshot) return snapshot;
}
}
async function collect(stream: AssistantMessageEventStream): Promise<{ message: AssistantMessage; events: string[] }> {
const events: string[] = [];
for await (const event of stream) events.push(event.type);
@@ -118,6 +211,76 @@ async function collect(stream: AssistantMessageEventStream): Promise<{ message:
}
describe("wrapInbandToolStream native tool-call passthrough", () => {
it("preserves the provider call id and streamed argument state across native deltas", async () => {
const controlled = controlledNativeToolArgGrowth();
const stream = wrapInbandToolStream(controlled.stream, TOOLS, "gemini");
const iterator = stream[Symbol.asyncIterator]();
const startPromise = nextToolCallSnapshot(iterator);
controlled.pushStart();
const start = await startPromise;
const firstDeltaPromise = nextToolCallSnapshot(iterator);
controlled.pushFirstDelta();
const firstDelta = await firstDeltaPromise;
const secondDeltaPromise = nextToolCallSnapshot(iterator);
controlled.pushSecondDelta();
const secondDelta = await secondDeltaPromise;
const endPromise = nextToolCallSnapshot(iterator);
controlled.pushEnd();
const end = await endPromise;
controlled.finish();
const message = await stream.result();
expect([start, firstDelta, secondDelta, end]).toEqual([
{
type: "toolcall_start",
id: "tool_todo_streaming",
name: "todo",
arguments: {},
partialJson: undefined,
},
{
type: "toolcall_delta",
delta: '{"ops":[',
id: "tool_todo_streaming",
name: "todo",
arguments: {},
partialJson: '{"ops":[',
},
{
type: "toolcall_delta",
delta: '{"op":"view"}]}',
id: "tool_todo_streaming",
name: "todo",
arguments: { ops: [{ op: "view" }] },
partialJson: '{"ops":[{"op":"view"}]}',
},
{
type: "toolcall_end",
id: "tool_todo_streaming",
name: "todo",
arguments: { ops: [{ op: "view" }] },
partialJson: '{"ops":[{"op":"view"}]}',
endToolCall: {
id: "tool_todo_streaming",
name: "todo",
arguments: { ops: [{ op: "view" }] },
partialJson: '{"ops":[{"op":"view"}]}',
},
},
]);
const calls = message.content.filter((b): b is ToolCall => b.type === "toolCall");
expect(calls).toHaveLength(1);
expect(calls[0]!.id).toBe("tool_todo_streaming");
expect(calls[0]!.name).toBe("todo");
expect(calls[0]!.arguments).toEqual({ ops: [{ op: "view" }] });
});
it("streams a provider-native tool call that arrives without in-band text", async () => {
const { message, events } = await collect(wrapInbandToolStream(geminiNativeOnly(), TOOLS, "gemini"));
@@ -103,6 +103,46 @@ describe("transformMessages drops malformed (empty-name) tool calls", () => {
expect(continuation).toMatchObject({ role: "user", content: "continue" });
});
it("treats an empty tool-call id as malformed and preserves surrounding replayable content", () => {
const messages: Message[] = [
{ role: "user", content: "Read both files", timestamp: 1 },
assistant(
[
{ type: "text", text: "Reading the files." },
{ type: "toolCall", id: "", name: "read", arguments: { path: "bad.ts" } },
{ type: "toolCall", id: "call_keep", name: "read", arguments: { path: "good.ts" } },
],
2,
),
toolResult("", "empty-id result that Anthropic cannot match", 3),
toolResult("call_keep", "good file contents", 4),
{ role: "user", content: "continue", timestamp: 5 },
];
const transformed = transformMessages(messages, model);
const survivingCalls = getToolCalls(transformed);
expect(survivingCalls).toHaveLength(1);
expect(survivingCalls[0]).toMatchObject({
id: "call_keep",
name: "read",
arguments: { path: "good.ts" },
});
const assistantOut = transformed.find((m): m is AssistantMessage => m.role === "assistant");
expect(assistantOut?.content).toEqual([
{ type: "text", text: "Reading the files." },
{ type: "toolCall", id: "call_keep", name: "read", arguments: { path: "good.ts" } },
]);
const toolResults = transformed.filter((m): m is ToolResultMessage => m.role === "toolResult");
expect(toolResults).toHaveLength(1);
expect(toolResults[0]).toMatchObject({ toolCallId: "call_keep", toolName: "read" });
expect(toolResults[0]?.content).toEqual([{ type: "text", text: "good file contents" }]);
expect(transformed.find(m => m.role === "toolResult" && m.toolCallId === "")).toBeUndefined();
expect(transformed.findLast(m => m.role === "user")).toMatchObject({ role: "user", content: "continue" });
});
it("drops the whole assistant turn when only the malformed tool call was emitted", () => {
const messages: Message[] = [
{ role: "user", content: "Help me out", timestamp: 1 },