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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user