Skip to content

Commit 87566be

Browse files
fix: defer tool-input-start until real tool call ID is available (#103)
* fix: defer tool-input-start until real tool call ID is available When streaming tool calls from Gemini-on-Vertex, the function name and tool call ID may arrive in separate chunks. Previously, tool-input-start was emitted with a placeholder ID (tool_0) as soon as the function name was known, causing a mismatch with subsequent tool-input-delta events that carried the real ID. This fix: - Makes ToolCallInProgress.id optional (undefined until real ID arrives) - Gates tool-input-start emission on both toolName AND id being present - Makes the ID immutable once set (first real value wins) - Falls back to generateToolCallId() (UUID) in flush/finishReason paths Aligns with the Vercel AI SDK StreamingToolCallTracker pattern where the ID is set once and never mutated. Closes #93 * test: assert tool-input-delta count and content in Vertex regression test Strengthen the regression test to verify that tool-input-delta events are actually emitted (not vacuously passing via empty loop) and that concatenated deltas match the expected tool call input. * fix: replay buffered arguments as tool-input-delta after deferred start When arguments arrive on a chunk before the tool call ID, they are buffered but not emitted as tool-input-delta. After tool-input-start is finally emitted (once the ID arrives), replay any accumulated arguments as a single tool-input-delta to ensure streaming consumers see all intermediate argument data. * test: add coverage for buffered-arguments replay path Exercise the code path where arguments arrive before the tool call ID, verifying that buffered arguments are replayed as tool-input-delta immediately after the deferred tool-input-start is emitted. * test: add coverage for fallback UUID generation when API provides no id Verify that when the API never provides a tool call ID across any chunk, the provider generates a valid UUID and uses it consistently across tool-input-start and tool-call events. * test: update existing test to reflect deferred tool-input-start behavior The test 'should flush tool calls that never received input-start' now exercises a different code path with the fix: tool-input-start IS emitted during streaming (when toolName arrives on chunk 2 and id was already set on chunk 1). Rename and strengthen assertions to match actual behavior: verify tool-input-start, buffered args replay as deltas, and tool-call. * fix: replay buffered arguments in flush and finishReason paths For protocol completeness, emit buffered arguments as tool-input-delta between tool-input-start and tool-input-end in finalization paths (flush and finishReason handler), matching the behavior of the streaming path. * refactor: extract emitToolInputStart helper and guard against empty string IDs Address review comments: - Extract repeated tool-input-start + buffered replay pattern into a single helper to prevent drift between the 3 emission sites - Guard against empty string IDs (consistent with existing toolName guard that checks length > 0) - Strengthen UUID fallback test to assert tool-input-delta emission
1 parent d5145c1 commit 87566be

2 files changed

Lines changed: 248 additions & 28 deletions

File tree

src/sap-ai-language-model.test.ts

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2401,6 +2401,186 @@ describe("SAPAILanguageModel", () => {
24012401
expect(parsed2.query).toHaveLength(5000);
24022402
});
24032403

2404+
it("should emit consistent tool call IDs when id arrives on a later chunk than function name (Vertex pattern)", async () => {
2405+
const realId = "vertex_tool_be5b294b-ece3-46f0-8b0d-22cd00000000";
2406+
2407+
await setStreamChunksForApi(api, [
2408+
createMockStreamChunk({
2409+
deltaToolCalls: [
2410+
{
2411+
function: { name: "query_bookings" },
2412+
index: 0,
2413+
},
2414+
],
2415+
}),
2416+
createMockStreamChunk({
2417+
deltaToolCalls: [
2418+
{
2419+
function: { arguments: '{"customer":' },
2420+
id: realId,
2421+
index: 0,
2422+
},
2423+
],
2424+
}),
2425+
createMockStreamChunk({
2426+
deltaToolCalls: [
2427+
{
2428+
function: { arguments: '"Acme"}' },
2429+
index: 0,
2430+
},
2431+
],
2432+
finishReason: "tool_calls",
2433+
usage: { completion_tokens: 10, prompt_tokens: 5, total_tokens: 15 },
2434+
}),
2435+
]);
2436+
2437+
const model = createModelForApi(api);
2438+
const prompt = createPrompt("show bookings");
2439+
2440+
const { stream } = await model.doStream({ prompt });
2441+
const parts = await readAllStreamParts(stream);
2442+
2443+
const toolInputStart = parts.find(
2444+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-input-start" }> =>
2445+
p.type === "tool-input-start",
2446+
);
2447+
const toolInputDeltas = parts.filter(
2448+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-input-delta" }> =>
2449+
p.type === "tool-input-delta",
2450+
);
2451+
const toolCall = parts.find(
2452+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-call" }> =>
2453+
p.type === "tool-call",
2454+
);
2455+
2456+
expect(toolInputStart).toBeDefined();
2457+
expect(toolInputStart?.id).toBe(realId);
2458+
expect(toolInputStart?.toolName).toBe("query_bookings");
2459+
2460+
expect(toolInputDeltas).toHaveLength(2);
2461+
for (const delta of toolInputDeltas) {
2462+
expect(delta.id).toBe(realId);
2463+
}
2464+
const concatenatedDeltas = toolInputDeltas.map((d) => d.delta).join("");
2465+
expect(concatenatedDeltas).toBe('{"customer":"Acme"}');
2466+
2467+
expect(toolCall).toBeDefined();
2468+
expect(toolCall?.toolCallId).toBe(realId);
2469+
expect(toolCall?.toolName).toBe("query_bookings");
2470+
expect(toolCall?.input).toBe('{"customer":"Acme"}');
2471+
});
2472+
2473+
it("should replay buffered arguments as tool-input-delta when id arrives after arguments", async () => {
2474+
const realId = "vertex_tool_buffered_args_test";
2475+
2476+
await setStreamChunksForApi(api, [
2477+
createMockStreamChunk({
2478+
deltaToolCalls: [
2479+
{
2480+
function: { arguments: '{"a":', name: "buffered_fn" },
2481+
index: 0,
2482+
},
2483+
],
2484+
}),
2485+
createMockStreamChunk({
2486+
deltaToolCalls: [
2487+
{
2488+
function: { arguments: '"b"}' },
2489+
id: realId,
2490+
index: 0,
2491+
},
2492+
],
2493+
finishReason: "tool_calls",
2494+
usage: { completion_tokens: 5, prompt_tokens: 3, total_tokens: 8 },
2495+
}),
2496+
]);
2497+
2498+
const model = createModelForApi(api);
2499+
const prompt = createPrompt("test buffered args");
2500+
2501+
const { stream } = await model.doStream({ prompt });
2502+
const parts = await readAllStreamParts(stream);
2503+
2504+
const toolInputStart = parts.find(
2505+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-input-start" }> =>
2506+
p.type === "tool-input-start",
2507+
);
2508+
const toolInputDeltas = parts.filter(
2509+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-input-delta" }> =>
2510+
p.type === "tool-input-delta",
2511+
);
2512+
const toolCall = parts.find(
2513+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-call" }> =>
2514+
p.type === "tool-call",
2515+
);
2516+
2517+
expect(toolInputStart).toBeDefined();
2518+
expect(toolInputStart?.id).toBe(realId);
2519+
expect(toolInputStart?.toolName).toBe("buffered_fn");
2520+
2521+
expect(toolInputDeltas).toHaveLength(2);
2522+
expect(toolInputDeltas[0]?.id).toBe(realId);
2523+
expect(toolInputDeltas[0]?.delta).toBe('{"a":');
2524+
expect(toolInputDeltas[1]?.id).toBe(realId);
2525+
expect(toolInputDeltas[1]?.delta).toBe('"b"}');
2526+
2527+
const concatenatedDeltas = toolInputDeltas.map((d) => d.delta).join("");
2528+
expect(concatenatedDeltas).toBe('{"a":"b"}');
2529+
2530+
expect(toolCall).toBeDefined();
2531+
expect(toolCall?.toolCallId).toBe(realId);
2532+
expect(toolCall?.input).toBe('{"a":"b"}');
2533+
});
2534+
2535+
it("should generate a fallback UUID when tool call id is never provided by the API", async () => {
2536+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2537+
2538+
await setStreamChunksForApi(api, [
2539+
createMockStreamChunk({
2540+
deltaToolCalls: [
2541+
{
2542+
function: { arguments: '{"x":1}', name: "no_id_tool" },
2543+
index: 0,
2544+
},
2545+
],
2546+
finishReason: "tool_calls",
2547+
usage: { completion_tokens: 5, prompt_tokens: 3, total_tokens: 8 },
2548+
}),
2549+
]);
2550+
2551+
const model = createModelForApi(api);
2552+
const prompt = createPrompt("test fallback id");
2553+
2554+
const { stream } = await model.doStream({ prompt });
2555+
const parts = await readAllStreamParts(stream);
2556+
2557+
const toolInputStart = parts.find(
2558+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-input-start" }> =>
2559+
p.type === "tool-input-start",
2560+
);
2561+
const toolInputDeltas = parts.filter(
2562+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-input-delta" }> =>
2563+
p.type === "tool-input-delta",
2564+
);
2565+
const toolCall = parts.find(
2566+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-call" }> =>
2567+
p.type === "tool-call",
2568+
);
2569+
2570+
expect(toolInputStart).toBeDefined();
2571+
expect(toolInputStart?.id).toMatch(uuidRegex);
2572+
expect(toolInputStart?.toolName).toBe("no_id_tool");
2573+
2574+
expect(toolInputDeltas).toHaveLength(1);
2575+
expect(toolInputDeltas[0]?.id).toBe(toolInputStart?.id);
2576+
expect(toolInputDeltas[0]?.delta).toBe('{"x":1}');
2577+
2578+
expect(toolCall).toBeDefined();
2579+
expect(toolCall?.toolCallId).toMatch(uuidRegex);
2580+
expect(toolCall?.toolCallId).toBe(toolInputStart?.id);
2581+
expect(toolCall?.input).toBe('{"x":1}');
2582+
});
2583+
24042584
it("should handle Unicode and multi-byte characters in large streams without corruption", async () => {
24052585
const unicodeContent =
24062586
"Hello 世界! 🌍🌎🌏 Привет мир! مرحبا بالعالم " +
@@ -2727,7 +2907,7 @@ describe("SAPAILanguageModel", () => {
27272907
});
27282908
});
27292909

2730-
it("should flush tool calls that never received input-start", async () => {
2910+
it("should emit tool-input-start when toolName arrives on a later chunk than id", async () => {
27312911
await setStreamChunksForApi(api, [
27322912
createMockStreamChunk({
27332913
deltaToolCalls: [
@@ -2760,7 +2940,24 @@ describe("SAPAILanguageModel", () => {
27602940
const { stream } = await model.doStream({ prompt });
27612941
const parts = await readAllStreamParts(stream);
27622942

2943+
const toolInputStart = parts.find(
2944+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-input-start" }> =>
2945+
p.type === "tool-input-start",
2946+
);
2947+
const toolInputDeltas = parts.filter(
2948+
(p): p is Extract<LanguageModelV3StreamPart, { type: "tool-input-delta" }> =>
2949+
p.type === "tool-input-delta",
2950+
);
27632951
const toolCall = parts.find((p) => p.type === "tool-call");
2952+
2953+
expect(toolInputStart).toBeDefined();
2954+
expect(toolInputStart?.id).toBe("call_no_start");
2955+
expect(toolInputStart?.toolName).toBe("delayed_name");
2956+
2957+
expect(toolInputDeltas).toHaveLength(2);
2958+
expect(toolInputDeltas[0]?.delta).toBe('{"partial":');
2959+
expect(toolInputDeltas[1]?.delta).toBe('"value"}');
2960+
27642961
expect(toolCall).toBeDefined();
27652962
expect(toolCall).toEqual({
27662963
input: '{"partial":"value"}',

src/strategy-utils.ts

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ export interface ToolCallInProgress {
332332
arguments: string;
333333
didEmitCall: boolean;
334334
didEmitInputStart: boolean;
335-
id: string;
335+
id: string | undefined;
336336
toolName?: string;
337337
}
338338

@@ -353,6 +353,13 @@ export class StreamIdGenerator {
353353
generateTextBlockId(): string {
354354
return crypto.randomUUID();
355355
}
356+
357+
/**
358+
* @returns A UUID string for identifying a tool call when the API does not provide one.
359+
*/
360+
generateToolCallId(): string {
361+
return crypto.randomUUID();
362+
}
356363
}
357364

358365
/**
@@ -782,6 +789,31 @@ export function createStreamTransformer(
782789
const streamState = createInitialStreamState();
783790
const toolCallsInProgress = new Map<number, ToolCallInProgress>();
784791

792+
/**
793+
* Emits tool-input-start and replays any buffered arguments as a delta.
794+
* @param tc - The in-progress tool call state.
795+
* @param controller - The transform stream controller to enqueue events into.
796+
*/
797+
function emitToolInputStart(
798+
tc: ToolCallInProgress,
799+
controller: TransformStreamDefaultController<LanguageModelV3StreamPart>,
800+
): void {
801+
if (tc.didEmitInputStart || tc.id == null) return;
802+
tc.didEmitInputStart = true;
803+
controller.enqueue({
804+
id: tc.id,
805+
toolName: tc.toolName ?? "",
806+
type: "tool-input-start",
807+
});
808+
if (tc.arguments.length > 0) {
809+
controller.enqueue({
810+
delta: tc.arguments,
811+
id: tc.id,
812+
type: "tool-input-delta",
813+
});
814+
}
815+
}
816+
785817
return convertAsyncIteratorToReadableStream(
786818
safeIterate(sdkStream)[Symbol.asyncIterator](),
787819
).pipeThrough(
@@ -795,14 +827,8 @@ export function createStreamTransformer(
795827
continue;
796828
}
797829

798-
if (!tc.didEmitInputStart) {
799-
tc.didEmitInputStart = true;
800-
controller.enqueue({
801-
id: tc.id,
802-
toolName: tc.toolName ?? "",
803-
type: "tool-input-start",
804-
});
805-
}
830+
tc.id ??= idGenerator.generateToolCallId();
831+
emitToolInputStart(tc, controller);
806832

807833
didEmitAnyToolCalls = true;
808834
tc.didEmitCall = true;
@@ -952,15 +978,22 @@ export function createStreamTransformer(
952978
arguments: "",
953979
didEmitCall: false,
954980
didEmitInputStart: false,
955-
id: toolCallChunk.id ?? `tool_${String(index)}`,
981+
id:
982+
typeof toolCallChunk.id === "string" && toolCallChunk.id.length > 0
983+
? toolCallChunk.id
984+
: undefined,
956985
toolName: toolCallChunk.function?.name,
957986
});
958987
}
959988

960989
const tc = toolCallsInProgress.get(index);
961990
if (!tc) continue;
962991

963-
if (toolCallChunk.id) {
992+
if (
993+
typeof toolCallChunk.id === "string" &&
994+
toolCallChunk.id.length > 0 &&
995+
tc.id === undefined
996+
) {
964997
tc.id = toolCallChunk.id;
965998
}
966999

@@ -969,20 +1002,15 @@ export function createStreamTransformer(
9691002
tc.toolName = nextToolName;
9701003
}
9711004

972-
if (!tc.didEmitInputStart && tc.toolName) {
973-
tc.didEmitInputStart = true;
974-
controller.enqueue({
975-
id: tc.id,
976-
toolName: tc.toolName,
977-
type: "tool-input-start",
978-
});
1005+
if (!tc.didEmitInputStart && tc.toolName != null && tc.id != null) {
1006+
emitToolInputStart(tc, controller);
9791007
}
9801008

9811009
const argumentsDelta = toolCallChunk.function?.arguments;
9821010
if (typeof argumentsDelta === "string" && argumentsDelta.length > 0) {
9831011
tc.arguments += argumentsDelta;
9841012

985-
if (tc.didEmitInputStart) {
1013+
if (tc.didEmitInputStart && tc.id != null) {
9861014
controller.enqueue({
9871015
delta: argumentsDelta,
9881016
id: tc.id,
@@ -1003,14 +1031,9 @@ export function createStreamTransformer(
10031031
if (tc.didEmitCall) {
10041032
continue;
10051033
}
1006-
if (!tc.didEmitInputStart) {
1007-
tc.didEmitInputStart = true;
1008-
controller.enqueue({
1009-
id: tc.id,
1010-
toolName: tc.toolName ?? "",
1011-
type: "tool-input-start",
1012-
});
1013-
}
1034+
1035+
tc.id ??= idGenerator.generateToolCallId();
1036+
emitToolInputStart(tc, controller);
10141037

10151038
tc.didEmitCall = true;
10161039
controller.enqueue({ id: tc.id, type: "tool-input-end" });

0 commit comments

Comments
 (0)