From 09c101d39c184356dbdcc3aa743f073c110cf68a Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:18:40 +1000 Subject: [PATCH 1/3] fix: migrate Gemini Interactions emitter to SDK 2.x event protocol The Interactions mock emitter produced the SDK 1.x streamed event shapes (interaction.start/complete, content.start/delta/stop), which have zero event_type overlap with the SDK 2.x adapter (@google/genai v2 'Interactions breaking changes, May 2026'). A v2 consumer hit its switch default for every event and rendered an empty assistant message. - Rewrite the three SSE builders to emit interaction.created/completed and step.start/delta/stop. Tool-call identity (id/name) now lives on step.start with an empty arguments placeholder; arguments stream as a dedicated arguments_delta carrying a JSON-string fragment (valid JSON by step.stop). - Count step.delta (not content.delta) for truncateAfterChunks budget. - Teach collapseGeminiInteractionsSSE the 2.x shapes (step.start identity + arguments_delta assembly, nested thought_summary), keeping 1.x parsing for backward compatibility with previously recorded fixtures. - Update unit/integration/collapse tests and drift SDK shapes to 2.x; add round-trip and legacy backward-compat coverage. Closes #277 --- src/__tests__/drift/sdk-shapes.ts | 55 ++--- src/__tests__/gemini-interactions.test.ts | 235 +++++++++++++++------- src/gemini-interactions.ts | 105 +++++----- src/stream-collapse.ts | 75 ++++++- 4 files changed, 319 insertions(+), 151 deletions(-) diff --git a/src/__tests__/drift/sdk-shapes.ts b/src/__tests__/drift/sdk-shapes.ts index b1e53785..08fb582d 100644 --- a/src/__tests__/drift/sdk-shapes.ts +++ b/src/__tests__/drift/sdk-shapes.ts @@ -1613,43 +1613,43 @@ export function geminiInteractionsToolCallResponseShape(): ShapeNode { export function geminiInteractionsStreamEventShapes(): SSEEventShape[] { return [ { - type: "interaction.start", + type: "interaction.created", dataShape: extractShape({ - event_type: "interaction.start", + event_type: "interaction.created", interaction: { id: "int_abc123", status: "in_progress" }, event_id: "evt_1", }), }, { - type: "content.start", + type: "step.start", dataShape: extractShape({ - event_type: "content.start", + event_type: "step.start", index: 0, - content: { type: "text" }, + step: { type: "model_output" }, event_id: "evt_2", }), }, { - type: "content.delta", + type: "step.delta", dataShape: extractShape({ - event_type: "content.delta", + event_type: "step.delta", index: 0, delta: { type: "text", text: "Hello" }, event_id: "evt_3", }), }, { - type: "content.stop", + type: "step.stop", dataShape: extractShape({ - event_type: "content.stop", + event_type: "step.stop", index: 0, event_id: "evt_4", }), }, { - type: "interaction.complete", + type: "interaction.completed", dataShape: extractShape({ - event_type: "interaction.complete", + event_type: "interaction.completed", interaction: { id: "int_abc123", status: "completed", @@ -1664,48 +1664,51 @@ export function geminiInteractionsStreamEventShapes(): SSEEventShape[] { export function geminiInteractionsToolCallStreamEventShapes(): SSEEventShape[] { return [ { - type: "interaction.start", + type: "interaction.created", dataShape: extractShape({ - event_type: "interaction.start", + event_type: "interaction.created", interaction: { id: "int_abc123", status: "in_progress" }, event_id: "evt_1", }), }, { - type: "content.start", + type: "step.start", dataShape: extractShape({ - event_type: "content.start", + event_type: "step.start", index: 0, - content: { type: "function_call" }, + step: { + type: "function_call", + id: "call_abc123", + name: "get_weather", + arguments: {}, + }, event_id: "evt_2", }), }, { - type: "content.delta", + type: "step.delta", dataShape: extractShape({ - event_type: "content.delta", + event_type: "step.delta", index: 0, delta: { - type: "function_call", - id: "call_abc123", - name: "get_weather", - arguments: { city: "Paris" }, + type: "arguments_delta", + arguments: '{"city":"Paris"}', }, event_id: "evt_3", }), }, { - type: "content.stop", + type: "step.stop", dataShape: extractShape({ - event_type: "content.stop", + event_type: "step.stop", index: 0, event_id: "evt_4", }), }, { - type: "interaction.complete", + type: "interaction.completed", dataShape: extractShape({ - event_type: "interaction.complete", + event_type: "interaction.completed", interaction: { id: "int_abc123", status: "requires_action", diff --git a/src/__tests__/gemini-interactions.test.ts b/src/__tests__/gemini-interactions.test.ts index af940861..4a330d0b 100644 --- a/src/__tests__/gemini-interactions.test.ts +++ b/src/__tests__/gemini-interactions.test.ts @@ -830,19 +830,20 @@ describe("SSE event builders", () => { resetEventIdCounter(); }); - it("builds correct text SSE event sequence", () => { + it("builds correct text SSE event sequence (SDK 2.x)", () => { const events = buildInteractionsTextSSEEvents("Hello!", "aimock-int-0", 100); - expect(events[0].event_type).toBe("interaction.start"); - expect(events[1].event_type).toBe("content.start"); + expect(events[0].event_type).toBe("interaction.created"); + expect(events[1].event_type).toBe("step.start"); expect(events[1].index).toBe(0); - expect(events[2].event_type).toBe("content.delta"); + expect((events[1].step as Record).type).toBe("model_output"); + expect(events[2].event_type).toBe("step.delta"); expect((events[2].delta as Record).type).toBe("text"); expect((events[2].delta as Record).text).toBe("Hello!"); - expect(events[3].event_type).toBe("content.stop"); - expect(events[4].event_type).toBe("interaction.complete"); + expect(events[3].event_type).toBe("step.stop"); + expect(events[4].event_type).toBe("interaction.completed"); }); - it("builds correct tool call SSE event sequence", () => { + it("builds correct tool call SSE event sequence (SDK 2.x)", () => { const events = buildInteractionsToolCallSSEEvents( [{ name: "get_weather", arguments: '{"city":"NYC"}', id: "call_1" }], "aimock-int-0", @@ -850,19 +851,26 @@ describe("SSE event builders", () => { ); const eventTypes = events.map((e) => e.event_type); expect(eventTypes).toEqual([ - "interaction.start", - "content.start", - "content.delta", - "content.stop", - "interaction.complete", + "interaction.created", + "step.start", + "step.delta", + "step.stop", + "interaction.completed", ]); + // Identity (id/name) lives on step.start; arguments are an empty placeholder. + const step = events[1].step as Record; + expect(step.type).toBe("function_call"); + expect(step.id).toBe("call_1"); + expect(step.name).toBe("get_weather"); + expect(step.arguments).toEqual({}); + // Arguments stream as a JSON-string fragment in an arguments_delta. const delta = events[2].delta as Record; - expect(delta.type).toBe("function_call"); - expect(delta.name).toBe("get_weather"); - expect(delta.arguments).toEqual({ city: "NYC" }); + expect(delta.type).toBe("arguments_delta"); + expect(delta.arguments).toBe('{"city":"NYC"}'); + expect(JSON.parse(delta.arguments as string)).toEqual({ city: "NYC" }); }); - it("builds content+tools SSE with correct indices", () => { + it("builds content+tools SSE with correct indices (SDK 2.x)", () => { const events = buildInteractionsContentWithToolCallsSSEEvents( "Text", [{ name: "fn", arguments: '{"a":1}', id: "call_1" }], @@ -870,13 +878,14 @@ describe("SSE event builders", () => { 100, logger, ); - // Find content.start events — should have indices 0 and 1 - const contentStarts = events.filter((e) => e.event_type === "content.start"); - expect(contentStarts).toHaveLength(2); - expect(contentStarts[0].index).toBe(0); // text - expect((contentStarts[0].content as Record).type).toBe("text"); - expect(contentStarts[1].index).toBe(1); // function_call - expect((contentStarts[1].content as Record).type).toBe("function_call"); + // Find step.start events — should have indices 0 (text) and 1 (function_call) + const stepStarts = events.filter((e) => e.event_type === "step.start"); + expect(stepStarts).toHaveLength(2); + expect(stepStarts[0].index).toBe(0); // text + expect((stepStarts[0].step as Record).type).toBe("model_output"); + expect(stepStarts[1].index).toBe(1); // function_call + expect((stepStarts[1].step as Record).type).toBe("function_call"); + expect((stepStarts[1].step as Record).name).toBe("fn"); }); it("increments event_id correctly", () => { @@ -885,11 +894,11 @@ describe("SSE event builders", () => { expect(ids).toEqual(["evt_1", "evt_2", "evt_3", "evt_4", "evt_5"]); }); - it("includes usage in interaction.complete event", () => { + it("includes usage in interaction.completed event", () => { const events = buildInteractionsTextSSEEvents("Hi", "aimock-int-0", 100, { usage: { input_tokens: 10, output_tokens: 5 }, }); - const completeEvent = events.find((e) => e.event_type === "interaction.complete")!; + const completeEvent = events.find((e) => e.event_type === "interaction.completed")!; const interaction = completeEvent.interaction as Record; expect(interaction.usage).toEqual({ total_input_tokens: 10, @@ -900,7 +909,7 @@ describe("SSE event builders", () => { it("chunks text by chunkSize", () => { const events = buildInteractionsTextSSEEvents("ABCDEFGH", "aimock-int-0", 3); - const deltas = events.filter((e) => e.event_type === "content.delta"); + const deltas = events.filter((e) => e.event_type === "step.delta"); expect(deltas).toHaveLength(3); // ABC, DEF, GH expect((deltas[0].delta as Record).text).toBe("ABC"); expect((deltas[1].delta as Record).text).toBe("DEF"); @@ -1071,11 +1080,11 @@ describe("Gemini Interactions — streaming", () => { expect(events.length).toBeGreaterThanOrEqual(5); const eventTypes = (events as Array>).map((e) => e.event_type); - expect(eventTypes[0]).toBe("interaction.start"); - expect(eventTypes[1]).toBe("content.start"); - expect(eventTypes).toContain("content.delta"); - expect(eventTypes).toContain("content.stop"); - expect(eventTypes[eventTypes.length - 1]).toBe("interaction.complete"); + expect(eventTypes[0]).toBe("interaction.created"); + expect(eventTypes[1]).toBe("step.start"); + expect(eventTypes).toContain("step.delta"); + expect(eventTypes).toContain("step.stop"); + expect(eventTypes[eventTypes.length - 1]).toBe("interaction.completed"); }); it("accumulates content from text deltas", async () => { @@ -1093,14 +1102,13 @@ describe("Gemini Interactions — streaming", () => { }); const events = parseInteractionsSSEEvents(res.body) as Array>; const textDeltas = events.filter( - (e) => - e.event_type === "content.delta" && (e.delta as Record).type === "text", + (e) => e.event_type === "step.delta" && (e.delta as Record).type === "text", ); const accumulated = textDeltas.map((e) => (e.delta as Record).text).join(""); expect(accumulated).toBe("ABCDEFGHIJ"); }); - it("streams tool call deltas", async () => { + it("streams tool call as step.start identity + arguments_delta", async () => { instance = await createServer([...allFixtures]); const res = await post(`${instance.url}/v1beta/interactions`, { model: "gemini-2.5-flash", @@ -1108,15 +1116,23 @@ describe("Gemini Interactions — streaming", () => { stream: true, }); const events = parseInteractionsSSEEvents(res.body) as Array>; - const funcDeltas = events.filter( + const stepStart = events.find( + (e) => + e.event_type === "step.start" && + (e.step as Record).type === "function_call", + )!; + const step = stepStart.step as Record; + expect(step.name).toBe("get_weather"); + expect(step.id).toBe("call_1"); + + const argDeltas = events.filter( (e) => - e.event_type === "content.delta" && - (e.delta as Record).type === "function_call", + e.event_type === "step.delta" && + (e.delta as Record).type === "arguments_delta", ); - expect(funcDeltas).toHaveLength(1); - const delta = funcDeltas[0].delta as Record; - expect(delta.name).toBe("get_weather"); - expect(delta.arguments).toEqual({ city: "NYC" }); + expect(argDeltas).toHaveLength(1); + const argsStr = (argDeltas[0].delta as Record).arguments as string; + expect(JSON.parse(argsStr)).toEqual({ city: "NYC" }); }); it("assigns correct indices for content+tools stream", async () => { @@ -1130,16 +1146,15 @@ describe("Gemini Interactions — streaming", () => { // Text at index 0, tool call at index 1 const textDelta = events.find( - (e) => - e.event_type === "content.delta" && (e.delta as Record).type === "text", + (e) => e.event_type === "step.delta" && (e.delta as Record).type === "text", ); - const toolDelta = events.find( + const toolStart = events.find( (e) => - e.event_type === "content.delta" && - (e.delta as Record).type === "function_call", + e.event_type === "step.start" && + (e.step as Record).type === "function_call", ); expect(textDelta?.index).toBe(0); - expect(toolDelta?.index).toBe(1); + expect(toolStart?.index).toBe(1); }); it("includes interactionId in lifecycle events", async () => { @@ -1151,8 +1166,8 @@ describe("Gemini Interactions — streaming", () => { }); const events = parseInteractionsSSEEvents(res.body) as Array>; - const startEvent = events.find((e) => e.event_type === "interaction.start")!; - const completeEvent = events.find((e) => e.event_type === "interaction.complete")!; + const startEvent = events.find((e) => e.event_type === "interaction.created")!; + const completeEvent = events.find((e) => e.event_type === "interaction.completed")!; const startInteraction = startEvent.interaction as Record; const completeInteraction = completeEvent.interaction as Record; @@ -1303,69 +1318,97 @@ describe("Gemini Interactions — fixture matching", () => { // ─── Stream collapse ──────────────────────────────────────────────────── describe("collapseGeminiInteractionsSSE", () => { - it("collapses text deltas", () => { + it("collapses text deltas (SDK 2.x)", () => { const sse = [ - 'data: {"event_type":"interaction.start","interaction":{"id":"int-0","status":"in_progress"},"event_id":"evt_1"}', - 'data: {"event_type":"content.start","index":0,"content":{"type":"text"},"event_id":"evt_2"}', - 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"Hello "},"event_id":"evt_3"}', - 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"World"},"event_id":"evt_4"}', - 'data: {"event_type":"content.stop","index":0,"event_id":"evt_5"}', - 'data: {"event_type":"interaction.complete","interaction":{"id":"int-0","status":"completed","usage":{"total_input_tokens":10,"total_output_tokens":5,"total_tokens":15}},"event_id":"evt_6"}', + 'data: {"event_type":"interaction.created","interaction":{"id":"int-0","status":"in_progress"},"event_id":"evt_1"}', + 'data: {"event_type":"step.start","index":0,"step":{"type":"model_output"},"event_id":"evt_2"}', + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"text","text":"Hello "},"event_id":"evt_3"}', + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"text","text":"World"},"event_id":"evt_4"}', + 'data: {"event_type":"step.stop","index":0,"event_id":"evt_5"}', + 'data: {"event_type":"interaction.completed","interaction":{"id":"int-0","status":"completed","usage":{"total_input_tokens":10,"total_output_tokens":5,"total_tokens":15}},"event_id":"evt_6"}', ].join("\n\n"); const result = collapseGeminiInteractionsSSE(sse); expect(result.content).toBe("Hello World"); expect(result.toolCalls).toBeUndefined(); }); - it("collapses tool call deltas", () => { + it("collapses tool call via step.start identity + arguments_delta (SDK 2.x)", () => { const sse = [ - 'data: {"event_type":"interaction.start","interaction":{"id":"int-0"},"event_id":"evt_1"}', - 'data: {"event_type":"content.start","index":0,"content":{"type":"function_call"},"event_id":"evt_2"}', - 'data: {"event_type":"content.delta","index":0,"delta":{"type":"function_call","id":"call_1","name":"get_weather","arguments":{"city":"NYC"}},"event_id":"evt_3"}', - 'data: {"event_type":"content.stop","index":0,"event_id":"evt_4"}', - 'data: {"event_type":"interaction.complete","interaction":{"id":"int-0","status":"requires_action"},"event_id":"evt_5"}', + 'data: {"event_type":"interaction.created","interaction":{"id":"int-0"},"event_id":"evt_1"}', + 'data: {"event_type":"step.start","index":0,"step":{"type":"function_call","id":"call_1","name":"get_weather","arguments":{}},"event_id":"evt_2"}', + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":"{\\"city\\":"},"event_id":"evt_3"}', + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":"\\"NYC\\"}"},"event_id":"evt_4"}', + 'data: {"event_type":"step.stop","index":0,"event_id":"evt_5"}', + 'data: {"event_type":"interaction.completed","interaction":{"id":"int-0","status":"requires_action"},"event_id":"evt_6"}', ].join("\n\n"); const result = collapseGeminiInteractionsSSE(sse); expect(result.toolCalls).toHaveLength(1); expect(result.toolCalls![0].name).toBe("get_weather"); + // Fragments concatenate into valid JSON by step.stop. expect(result.toolCalls![0].arguments).toBe('{"city":"NYC"}'); expect(result.toolCalls![0].id).toBe("call_1"); }); - it("collapses content + tool calls", () => { + it("uses step.start arguments object when no arguments_delta streams (SDK 2.x)", () => { + const sse = [ + 'data: {"event_type":"step.start","index":0,"step":{"type":"function_call","id":"call_1","name":"fn","arguments":{"x":1}},"event_id":"evt_1"}', + 'data: {"event_type":"step.stop","index":0,"event_id":"evt_2"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].arguments).toBe('{"x":1}'); + }); + + it("collapses content + tool calls (SDK 2.x)", () => { const sse = [ - 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"Help"},"event_id":"evt_1"}', - 'data: {"event_type":"content.delta","index":1,"delta":{"type":"function_call","id":"c1","name":"fn","arguments":{"x":1}},"event_id":"evt_2"}', + 'data: {"event_type":"step.start","index":0,"step":{"type":"model_output"},"event_id":"evt_1"}', + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"text","text":"Help"},"event_id":"evt_2"}', + 'data: {"event_type":"step.stop","index":0,"event_id":"evt_3"}', + 'data: {"event_type":"step.start","index":1,"step":{"type":"function_call","id":"c1","name":"fn","arguments":{}},"event_id":"evt_4"}', + 'data: {"event_type":"step.delta","index":1,"delta":{"type":"arguments_delta","arguments":"{\\"x\\":1}"},"event_id":"evt_5"}', + 'data: {"event_type":"step.stop","index":1,"event_id":"evt_6"}', ].join("\n\n"); const result = collapseGeminiInteractionsSSE(sse); expect(result.content).toBe("Help"); expect(result.toolCalls).toHaveLength(1); expect(result.toolCalls![0].name).toBe("fn"); + expect(result.toolCalls![0].arguments).toBe('{"x":1}'); }); - it("collapses thought_summary deltas as reasoning", () => { + it("collapses thought_summary deltas as reasoning (SDK 2.x nested content)", () => { const sse = [ - 'data: {"event_type":"content.delta","index":0,"delta":{"type":"thought_summary","text":"Thinking..."},"event_id":"evt_1"}', - 'data: {"event_type":"content.delta","index":1,"delta":{"type":"text","text":"Answer"},"event_id":"evt_2"}', + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"thought_summary","content":{"text":"Thinking..."}},"event_id":"evt_1"}', + 'data: {"event_type":"step.delta","index":1,"delta":{"type":"text","text":"Answer"},"event_id":"evt_2"}', ].join("\n\n"); const result = collapseGeminiInteractionsSSE(sse); expect(result.reasoning).toBe("Thinking..."); expect(result.content).toBe("Answer"); }); + it("drops arguments_delta with no correlating step.start", () => { + const sse = [ + 'data: {"event_type":"step.delta","index":4,"delta":{"type":"arguments_delta","arguments":"{}"},"event_id":"evt_1"}', + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"text","text":"ok"},"event_id":"evt_2"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.content).toBe("ok"); + expect(result.toolCalls).toBeUndefined(); + expect(result.droppedChunks).toBe(1); + }); + it("handles malformed chunks gracefully", () => { const sse = [ "data: not-json", - 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"ok"},"event_id":"evt_1"}', + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"text","text":"ok"},"event_id":"evt_1"}', ].join("\n\n"); const result = collapseGeminiInteractionsSSE(sse); expect(result.content).toBe("ok"); expect(result.droppedChunks).toBe(1); }); - it("handles incomplete stream (no interaction.complete)", () => { + it("handles incomplete stream (no interaction.completed)", () => { const sse = [ - 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"partial"},"event_id":"evt_1"}', + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"text","text":"partial"},"event_id":"evt_1"}', ].join("\n\n"); const result = collapseGeminiInteractionsSSE(sse); expect(result.content).toBe("partial"); @@ -1375,6 +1418,54 @@ describe("collapseGeminiInteractionsSSE", () => { const result = collapseGeminiInteractionsSSE(""); expect(result.content).toBe(""); }); + + it("round-trips emitter output back through the collapser", () => { + resetEventIdCounter(); + const events = buildInteractionsContentWithToolCallsSSEEvents( + "Let me check", + [{ name: "get_weather", arguments: '{"city":"NYC"}', id: "call_1" }], + "aimock-int-0", + 100, + new Logger("silent"), + ); + const sse = events.map((e) => `data: ${JSON.stringify(e)}`).join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.content).toBe("Let me check"); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("get_weather"); + expect(result.toolCalls![0].arguments).toBe('{"city":"NYC"}'); + expect(result.toolCalls![0].id).toBe("call_1"); + }); + + // ─── Backward compatibility: legacy SDK 1.x recorded fixtures ──────────── + + it("still collapses legacy 1.x content.delta text", () => { + const sse = [ + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"Hello "},"event_id":"evt_1"}', + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"World"},"event_id":"evt_2"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.content).toBe("Hello World"); + }); + + it("still collapses legacy 1.x content.delta function_call", () => { + const sse = [ + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"function_call","id":"call_1","name":"get_weather","arguments":{"city":"NYC"}},"event_id":"evt_1"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("get_weather"); + expect(result.toolCalls![0].arguments).toBe('{"city":"NYC"}'); + expect(result.toolCalls![0].id).toBe("call_1"); + }); + + it("still collapses legacy 1.x flat thought_summary text", () => { + const sse = [ + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"thought_summary","text":"Thinking..."},"event_id":"evt_1"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.reasoning).toBe("Thinking..."); + }); }); // ─── CORS ─────────────────────────────────────────────────────────────── @@ -1450,7 +1541,7 @@ describe("Gemini Interactions — edge cases", () => { stream: true, }); const events = parseInteractionsSSEEvents(res.body) as Array>; - const deltas = events.filter((e) => e.event_type === "content.delta"); + const deltas = events.filter((e) => e.event_type === "step.delta"); expect(deltas).toHaveLength(1); expect((deltas[0].delta as Record).text).toBe(""); }); diff --git a/src/gemini-interactions.ts b/src/gemini-interactions.ts index 46434f68..be8a0888 100644 --- a/src/gemini-interactions.ts +++ b/src/gemini-interactions.ts @@ -446,25 +446,25 @@ export function buildInteractionsTextSSEEvents( ): InteractionsSSEEvent[] { const events: InteractionsSSEEvent[] = []; - // interaction.start + // interaction.created events.push({ - event_type: "interaction.start", + event_type: "interaction.created", interaction: { id: interactionId, status: "in_progress" }, event_id: nextEventId(), }); - // content.start + // step.start — text step is the model_output events.push({ - event_type: "content.start", + event_type: "step.start", index: 0, - content: { type: "text" }, + step: { type: "model_output" }, event_id: nextEventId(), }); - // content.delta(s) + // step.delta(s) — inner delta shape is unchanged ({ type: "text", text }) if (content.length === 0) { events.push({ - event_type: "content.delta", + event_type: "step.delta", index: 0, delta: { type: "text", text: "" }, event_id: nextEventId(), @@ -473,7 +473,7 @@ export function buildInteractionsTextSSEEvents( for (let i = 0; i < content.length; i += chunkSize) { const slice = content.slice(i, i + chunkSize); events.push({ - event_type: "content.delta", + event_type: "step.delta", index: 0, delta: { type: "text", text: slice }, event_id: nextEventId(), @@ -481,16 +481,16 @@ export function buildInteractionsTextSSEEvents( } } - // content.stop + // step.stop events.push({ - event_type: "content.stop", + event_type: "step.stop", index: 0, event_id: nextEventId(), }); - // interaction.complete + // interaction.completed events.push({ - event_type: "interaction.complete", + event_type: "interaction.completed", interaction: { id: interactionId, status: "completed", @@ -510,14 +510,17 @@ export function buildInteractionsToolCallSSEEvents( ): InteractionsSSEEvent[] { const events: InteractionsSSEEvent[] = []; - // interaction.start + // interaction.created events.push({ - event_type: "interaction.start", + event_type: "interaction.created", interaction: { id: interactionId, status: "in_progress" }, event_id: nextEventId(), }); - // Each tool call gets its own content.start/delta/stop bracket + // Each tool call gets its own step.start/delta/stop bracket. In SDK 2.x the + // call identity (id, name) lives on step.start; the arguments stream as a + // dedicated `arguments_delta` carrying a JSON-string fragment, and step.start + // carries an empty `arguments: {}` placeholder. for (let idx = 0; idx < toolCalls.length; idx++) { const tc = toolCalls[idx]; let argsObj: unknown; @@ -531,34 +534,39 @@ export function buildInteractionsToolCallSSEEvents( } events.push({ - event_type: "content.start", + event_type: "step.start", index: idx, - content: { type: "function_call" }, + step: { + type: "function_call", + id: tc.id || generateToolCallId(), + name: tc.name, + arguments: {}, + }, event_id: nextEventId(), }); + // arguments_delta.arguments is a string fragment; emitting the full + // serialized object as one fragment keeps the concatenation valid JSON. events.push({ - event_type: "content.delta", + event_type: "step.delta", index: idx, delta: { - type: "function_call", - id: tc.id || generateToolCallId(), - name: tc.name, - arguments: argsObj, + type: "arguments_delta", + arguments: JSON.stringify(argsObj), }, event_id: nextEventId(), }); events.push({ - event_type: "content.stop", + event_type: "step.stop", index: idx, event_id: nextEventId(), }); } - // interaction.complete + // interaction.completed events.push({ - event_type: "interaction.complete", + event_type: "interaction.completed", interaction: { id: interactionId, status: "requires_action", @@ -580,24 +588,24 @@ export function buildInteractionsContentWithToolCallsSSEEvents( ): InteractionsSSEEvent[] { const events: InteractionsSSEEvent[] = []; - // interaction.start + // interaction.created events.push({ - event_type: "interaction.start", + event_type: "interaction.created", interaction: { id: interactionId, status: "in_progress" }, event_id: nextEventId(), }); - // Text content at index 0 + // Text content at index 0 (model_output step) events.push({ - event_type: "content.start", + event_type: "step.start", index: 0, - content: { type: "text" }, + step: { type: "model_output" }, event_id: nextEventId(), }); if (content.length === 0) { events.push({ - event_type: "content.delta", + event_type: "step.delta", index: 0, delta: { type: "text", text: "" }, event_id: nextEventId(), @@ -606,7 +614,7 @@ export function buildInteractionsContentWithToolCallsSSEEvents( for (let i = 0; i < content.length; i += chunkSize) { const slice = content.slice(i, i + chunkSize); events.push({ - event_type: "content.delta", + event_type: "step.delta", index: 0, delta: { type: "text", text: slice }, event_id: nextEventId(), @@ -615,12 +623,12 @@ export function buildInteractionsContentWithToolCallsSSEEvents( } events.push({ - event_type: "content.stop", + event_type: "step.stop", index: 0, event_id: nextEventId(), }); - // Tool calls at index 1+ + // Tool calls at index 1+ (identity on step.start, args as arguments_delta) for (let i = 0; i < toolCalls.length; i++) { const tc = toolCalls[i]; const idx = i + 1; // offset by 1 because text is index 0 @@ -635,34 +643,37 @@ export function buildInteractionsContentWithToolCallsSSEEvents( } events.push({ - event_type: "content.start", + event_type: "step.start", index: idx, - content: { type: "function_call" }, + step: { + type: "function_call", + id: tc.id || generateToolCallId(), + name: tc.name, + arguments: {}, + }, event_id: nextEventId(), }); events.push({ - event_type: "content.delta", + event_type: "step.delta", index: idx, delta: { - type: "function_call", - id: tc.id || generateToolCallId(), - name: tc.name, - arguments: argsObj, + type: "arguments_delta", + arguments: JSON.stringify(argsObj), }, event_id: nextEventId(), }); events.push({ - event_type: "content.stop", + event_type: "step.stop", index: idx, event_id: nextEventId(), }); } - // interaction.complete + // interaction.completed events.push({ - event_type: "interaction.complete", + event_type: "interaction.completed", interaction: { id: interactionId, status: "requires_action", @@ -711,10 +722,10 @@ export async function writeGeminiInteractionsSSEStream( if (res.writableEnded) return true; // Data-only SSE (no event: prefix, no [DONE]) res.write(`data: ${JSON.stringify(event)}\n\n`); - // Only count content deltas for truncateAfterChunks — framing events - // (interaction.start, content.start, content.stop, interaction.complete) + // Only count step deltas for truncateAfterChunks — framing events + // (interaction.created, step.start, step.stop, interaction.completed) // should not consume chunk budget or trigger the chunk-sent callback. - if (event.event_type === "content.delta") { + if (event.event_type === "step.delta") { onChunkSent?.(); chunkIndex++; } diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index a67a3b0d..3a610aa5 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -1190,9 +1190,16 @@ export function collapseBedrockEventStream(body: Buffer): CollapseResult { /** * Collapse Gemini Interactions SSE stream into a single response. * - * Format (data-only, event_type inside JSON): - * data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"Hello"}}\n\n - * data: {"event_type":"interaction.complete","interaction":{"id":"...","usage":{...}}}\n\n + * Handles the SDK 2.x event protocol (the "Interactions breaking changes, + * May 2026" shapes): + * data: {"event_type":"step.start","index":1,"step":{"type":"function_call","id":"call_1","name":"fn","arguments":{}}} + * data: {"event_type":"step.delta","index":0,"delta":{"type":"text","text":"Hello"}} + * data: {"event_type":"step.delta","index":1,"delta":{"type":"arguments_delta","arguments":"{\"x\":1}"}} + * data: {"event_type":"interaction.completed","interaction":{"id":"...","usage":{...}}} + * + * The legacy SDK 1.x shapes (`content.delta` with an inline `function_call` + * delta) are still accepted for backward compatibility with previously + * recorded fixtures. */ export function collapseGeminiInteractionsSSE(body: string): CollapseResult { const lines = splitSSEEvents(body); @@ -1200,7 +1207,14 @@ export function collapseGeminiInteractionsSSE(body: string): CollapseResult { let reasoning = ""; let droppedChunks = 0; let firstDroppedSample: string | undefined; + // Legacy 1.x tool calls arrive fully formed in a single content.delta. const toolCalls: ToolCall[] = []; + // 2.x tool calls are assembled across step.start (identity) + arguments_delta + // (string fragments), keyed by step index. + const stepToolCalls = new Map< + number, + { id?: string; name: string; argsObj?: unknown; argsStr: string } + >(); for (const line of lines) { const data = extractSSEData(splitSSELines(line)); @@ -1223,13 +1237,45 @@ export function collapseGeminiInteractionsSSE(body: string): CollapseResult { const eventType = parsed.event_type as string | undefined; if (!eventType) continue; - if (eventType === "content.delta") { + const index = typeof parsed.index === "number" ? parsed.index : undefined; + + if (eventType === "step.start") { + // 2.x — tool-call identity lives on step.start, not in a delta. + const step = parsed.step as Record | undefined; + if (step && step.type === "function_call" && index !== undefined) { + stepToolCalls.set(index, { + id: step.id ? String(step.id) : undefined, + name: String(step.name ?? ""), + // step.start may carry a fully-populated `arguments` object (non- + // streamed calls) or an empty `{}` placeholder (streamed calls). + argsObj: step.arguments, + argsStr: "", + }); + } + } else if (eventType === "step.delta" || eventType === "content.delta") { const delta = parsed.delta as Record | undefined; if (!delta) continue; if (delta.type === "text" && typeof delta.text === "string") { content += delta.text; + } else if (delta.type === "arguments_delta") { + // 2.x — argument fragment (a JSON string) keyed by step index. + const entry = index !== undefined ? stepToolCalls.get(index) : undefined; + if (entry) { + if (typeof delta.arguments === "string") { + entry.argsStr += delta.arguments; + } + } else { + droppedChunks++; + if (droppedChunks === 1) { + firstDroppedSample = `arguments_delta with no correlating step.start: ${surrogateSafeSlice( + payload, + 200, + )}`; + } + } } else if (delta.type === "function_call") { + // Legacy 1.x — full tool call inline in a content.delta. toolCalls.push({ name: String(delta.name ?? ""), arguments: @@ -1238,12 +1284,29 @@ export function collapseGeminiInteractionsSSE(body: string): CollapseResult { : JSON.stringify(delta.arguments ?? {}), ...(delta.id ? { id: String(delta.id) } : {}), }); - } else if (delta.type === "thought_summary" && typeof delta.text === "string") { - reasoning += delta.text; + } else if (delta.type === "thought_summary") { + // 2.x nests the text under `content.text`; 1.x used a flat `text`. + const summaryContent = delta.content as Record | undefined; + if (summaryContent && typeof summaryContent.text === "string") { + reasoning += summaryContent.text; + } else if (typeof delta.text === "string") { + reasoning += delta.text; + } } } } + // Finalize 2.x tool calls in step-index order. + for (const [, tc] of Array.from(stepToolCalls.entries()).sort(([a], [b]) => a - b)) { + const args = + tc.argsStr !== "" + ? tc.argsStr + : typeof tc.argsObj === "string" + ? tc.argsObj + : JSON.stringify(tc.argsObj ?? {}); + toolCalls.push({ name: tc.name, arguments: args, ...(tc.id ? { id: tc.id } : {}) }); + } + if (toolCalls.length > 0) { return { ...(content ? { content } : {}), From 78d00b61860a94a06be5b221cb9941f9aeae74b8 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:30:05 +1000 Subject: [PATCH 2/3] fix: harden Interactions collapser tool-call assembly from PR review Address review findings on the SDK 2.x migration: - Flag assembled arguments_delta fragments that don't concatenate into valid JSON by step.stop via droppedChunks/firstDroppedSample, instead of silently writing a corrupt tool call into a recorded fixture. - Preserve the identity of a function_call step.start that arrives without an index by minting a synthetic key (matches the sibling collapsers) rather than dropping it silently. - Reword the emitter's arguments_delta comment: the mock emits one whole fragment (a valid degenerate case), it does not itself concatenate. - Add tests: multiple/interleaved tool calls collapse in step-index order, emitter assigns sequential step indices, invalid-JSON assembly is flagged, and an index-less function_call step.start keeps its identity. --- src/__tests__/gemini-interactions.test.ts | 74 +++++++++++++++++++++++ src/gemini-interactions.ts | 6 +- src/stream-collapse.ts | 39 +++++++++--- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/__tests__/gemini-interactions.test.ts b/src/__tests__/gemini-interactions.test.ts index 4a330d0b..8d7ed639 100644 --- a/src/__tests__/gemini-interactions.test.ts +++ b/src/__tests__/gemini-interactions.test.ts @@ -870,6 +870,28 @@ describe("SSE event builders", () => { expect(JSON.parse(delta.arguments as string)).toEqual({ city: "NYC" }); }); + it("assigns sequential step indices for multiple tool calls (SDK 2.x)", () => { + const events = buildInteractionsToolCallSSEEvents( + [ + { name: "get_weather", arguments: '{"city":"NYC"}', id: "call_1" }, + { name: "get_time", arguments: '{"tz":"UTC"}', id: "call_2" }, + ], + "aimock-int-0", + logger, + ); + const stepStarts = events.filter((e) => e.event_type === "step.start"); + expect(stepStarts.map((e) => e.index)).toEqual([0, 1]); + expect((stepStarts[0].step as Record).name).toBe("get_weather"); + expect((stepStarts[1].step as Record).name).toBe("get_time"); + // Each call's arguments_delta is keyed to its own index. + const argDeltas = events.filter( + (e) => + e.event_type === "step.delta" && + (e.delta as Record).type === "arguments_delta", + ); + expect(argDeltas.map((e) => e.index)).toEqual([0, 1]); + }); + it("builds content+tools SSE with correct indices (SDK 2.x)", () => { const events = buildInteractionsContentWithToolCallsSSEEvents( "Text", @@ -1349,6 +1371,58 @@ describe("collapseGeminiInteractionsSSE", () => { expect(result.toolCalls![0].id).toBe("call_1"); }); + it("collapses multiple interleaved tool calls in step-index order (SDK 2.x)", () => { + const sse = [ + 'data: {"event_type":"step.start","index":1,"step":{"type":"function_call","id":"call_a","name":"get_weather","arguments":{}},"event_id":"evt_1"}', + 'data: {"event_type":"step.start","index":2,"step":{"type":"function_call","id":"call_b","name":"get_time","arguments":{}},"event_id":"evt_2"}', + // Interleaved argument fragments for both calls. + 'data: {"event_type":"step.delta","index":2,"delta":{"type":"arguments_delta","arguments":"{\\"tz\\":"},"event_id":"evt_3"}', + 'data: {"event_type":"step.delta","index":1,"delta":{"type":"arguments_delta","arguments":"{\\"city\\":\\"NYC\\"}"},"event_id":"evt_4"}', + 'data: {"event_type":"step.delta","index":2,"delta":{"type":"arguments_delta","arguments":"\\"UTC\\"}"},"event_id":"evt_5"}', + 'data: {"event_type":"step.stop","index":1,"event_id":"evt_6"}', + 'data: {"event_type":"step.stop","index":2,"event_id":"evt_7"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.toolCalls).toHaveLength(2); + // Sorted by step index regardless of step.stop / delta arrival order. + expect(result.toolCalls![0]).toEqual({ + name: "get_weather", + arguments: '{"city":"NYC"}', + id: "call_a", + }); + expect(result.toolCalls![1]).toEqual({ + name: "get_time", + arguments: '{"tz":"UTC"}', + id: "call_b", + }); + }); + + it("flags assembled arguments_delta that is not valid JSON by step.stop", () => { + const sse = [ + 'data: {"event_type":"step.start","index":0,"step":{"type":"function_call","id":"call_1","name":"fn","arguments":{}},"event_id":"evt_1"}', + // Truncated/interrupted stream — fragment never closes into valid JSON. + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":"{\\"city\\":\\"NY"},"event_id":"evt_2"}', + 'data: {"event_type":"step.stop","index":0,"event_id":"evt_3"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + // The (corrupt) call is still surfaced, but flagged so the recorder warns. + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].arguments).toBe('{"city":"NY'); + expect(result.droppedChunks).toBe(1); + expect(result.firstDroppedSample).toMatch(/not valid JSON/); + }); + + it("preserves identity of a function_call step.start that arrives without an index", () => { + const sse = [ + 'data: {"event_type":"step.start","step":{"type":"function_call","id":"call_1","name":"fn","arguments":{"x":1}},"event_id":"evt_1"}', + 'data: {"event_type":"step.stop","event_id":"evt_2"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("fn"); + expect(result.toolCalls![0].arguments).toBe('{"x":1}'); + }); + it("uses step.start arguments object when no arguments_delta streams (SDK 2.x)", () => { const sse = [ 'data: {"event_type":"step.start","index":0,"step":{"type":"function_call","id":"call_1","name":"fn","arguments":{"x":1}},"event_id":"evt_1"}', diff --git a/src/gemini-interactions.ts b/src/gemini-interactions.ts index be8a0888..0bccb0ab 100644 --- a/src/gemini-interactions.ts +++ b/src/gemini-interactions.ts @@ -545,8 +545,10 @@ export function buildInteractionsToolCallSSEEvents( event_id: nextEventId(), }); - // arguments_delta.arguments is a string fragment; emitting the full - // serialized object as one fragment keeps the concatenation valid JSON. + // arguments_delta.arguments is a string fragment. The real SDK may split + // the args across several fragments that concatenate into valid JSON; the + // mock emits the whole serialized object as one fragment (a valid + // degenerate case the collapser handles identically). events.push({ event_type: "step.delta", index: idx, diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index 3a610aa5..7b3486bd 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -1215,6 +1215,10 @@ export function collapseGeminiInteractionsSSE(body: string): CollapseResult { number, { id?: string; name: string; argsObj?: unknown; argsStr: string } >(); + // Synthetic keys for function_call step.start events that arrive without an + // index (matches the sibling collapsers); seeded high to avoid colliding with + // real step indices. + let nextSyntheticStepIndex = 1_000_000; for (const line of lines) { const data = extractSSEData(splitSSELines(line)); @@ -1242,8 +1246,12 @@ export function collapseGeminiInteractionsSSE(body: string): CollapseResult { if (eventType === "step.start") { // 2.x — tool-call identity lives on step.start, not in a delta. const step = parsed.step as Record | undefined; - if (step && step.type === "function_call" && index !== undefined) { - stepToolCalls.set(index, { + if (step && step.type === "function_call") { + // An index-less start can't correlate later arguments_delta fragments, + // but minting a synthetic key preserves the call's identity instead of + // dropping it silently. + const key = index ?? nextSyntheticStepIndex++; + stepToolCalls.set(key, { id: step.id ? String(step.id) : undefined, name: String(step.name ?? ""), // step.start may carry a fully-populated `arguments` object (non- @@ -1298,12 +1306,27 @@ export function collapseGeminiInteractionsSSE(body: string): CollapseResult { // Finalize 2.x tool calls in step-index order. for (const [, tc] of Array.from(stepToolCalls.entries()).sort(([a], [b]) => a - b)) { - const args = - tc.argsStr !== "" - ? tc.argsStr - : typeof tc.argsObj === "string" - ? tc.argsObj - : JSON.stringify(tc.argsObj ?? {}); + let args: string; + if (tc.argsStr !== "") { + args = tc.argsStr; + // The arguments_delta fragments must concatenate into valid JSON by + // step.stop. A truncated/interrupted stream can leave them malformed; + // surface that via droppedChunks rather than writing a corrupt fixture + // silently (mirrors the per-chunk parse guard above). + try { + JSON.parse(args); + } catch { + droppedChunks++; + if (droppedChunks === 1) { + firstDroppedSample = `assembled arguments_delta not valid JSON for "${tc.name}": ${surrogateSafeSlice( + args, + 200, + )}`; + } + } + } else { + args = typeof tc.argsObj === "string" ? tc.argsObj : JSON.stringify(tc.argsObj ?? {}); + } toolCalls.push({ name: tc.name, arguments: args, ...(tc.id ? { id: tc.id } : {}) }); } From 24de47feff7cef7afab52610386de9a9019bef2e Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:33:22 +1000 Subject: [PATCH 3/3] test: close remaining Interactions coverage gaps from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Streaming builders: malformed tool-call arguments degrade to a valid '{}' arguments_delta fragment and emit a warning. - Tool-call and content+tools streams: assert usage propagates and the terminal status is requires_action on interaction.completed (unit + e2e). - Collapser: pin the ordering behavior when an arguments_delta arrives before its step.start — the early fragment is flagged via droppedChunks rather than mis-attributed, and the call still surfaces with empty args. --- src/__tests__/gemini-interactions.test.ts | 80 ++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/__tests__/gemini-interactions.test.ts b/src/__tests__/gemini-interactions.test.ts index 8d7ed639..73e808fe 100644 --- a/src/__tests__/gemini-interactions.test.ts +++ b/src/__tests__/gemini-interactions.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import * as http from "node:http"; import type { Fixture } from "../types.js"; import { createServer, type ServerInstance } from "../server.js"; @@ -937,6 +937,63 @@ describe("SSE event builders", () => { expect((deltas[1].delta as Record).text).toBe("DEF"); expect((deltas[2].delta as Record).text).toBe("GH"); }); + + it("falls back to empty args and warns on malformed tool-call arguments (streaming)", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const events = buildInteractionsToolCallSSEEvents( + [{ name: "fn", arguments: "not-json", id: "call_1" }], + "aimock-int-0", + new Logger("warn"), + ); + const argDelta = events.find( + (e) => + e.event_type === "step.delta" && + (e.delta as Record).type === "arguments_delta", + )!; + // Malformed args degrade to a valid empty-object fragment, not garbage. + expect((argDelta.delta as Record).arguments).toBe("{}"); + expect(warnSpy).toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it("emits usage and requires_action status on interaction.completed for tool calls", () => { + const events = buildInteractionsToolCallSSEEvents( + [{ name: "fn", arguments: '{"a":1}', id: "call_1" }], + "aimock-int-0", + logger, + { usage: { input_tokens: 3, output_tokens: 7 } }, + ); + const completed = events.find((e) => e.event_type === "interaction.completed")!; + const interaction = completed.interaction as Record; + expect(interaction.status).toBe("requires_action"); + expect(interaction.usage).toEqual({ + total_input_tokens: 3, + total_output_tokens: 7, + total_tokens: 10, + }); + }); + + it("emits usage and requires_action status on interaction.completed for content+tools", () => { + const events = buildInteractionsContentWithToolCallsSSEEvents( + "Text", + [{ name: "fn", arguments: "{}", id: "call_1" }], + "aimock-int-0", + 100, + logger, + { usage: { input_tokens: 2, output_tokens: 4 } }, + ); + const completed = events.find((e) => e.event_type === "interaction.completed")!; + const interaction = completed.interaction as Record; + expect(interaction.status).toBe("requires_action"); + expect(interaction.usage).toEqual({ + total_input_tokens: 2, + total_output_tokens: 4, + total_tokens: 6, + }); + }); }); // ─── Integration tests: non-streaming ─────────────────────────────────── @@ -1155,6 +1212,10 @@ describe("Gemini Interactions — streaming", () => { expect(argDeltas).toHaveLength(1); const argsStr = (argDeltas[0].delta as Record).arguments as string; expect(JSON.parse(argsStr)).toEqual({ city: "NYC" }); + + // The streamed interaction terminates in requires_action for a tool call. + const completed = events.find((e) => e.event_type === "interaction.completed")!; + expect((completed.interaction as Record).status).toBe("requires_action"); }); it("assigns correct indices for content+tools stream", async () => { @@ -1412,6 +1473,23 @@ describe("collapseGeminiInteractionsSSE", () => { expect(result.firstDroppedSample).toMatch(/not valid JSON/); }); + it("drops an arguments_delta that arrives before its step.start (ordering)", () => { + // The SDK emits step.start before its arguments_delta fragments; a fragment + // arriving first can't correlate, so it is flagged rather than mis-attributed. + const sse = [ + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":"{\\"x\\":1}"},"event_id":"evt_1"}', + 'data: {"event_type":"step.start","index":0,"step":{"type":"function_call","id":"call_1","name":"fn","arguments":{}},"event_id":"evt_2"}', + 'data: {"event_type":"step.stop","index":0,"event_id":"evt_3"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.droppedChunks).toBe(1); + expect(result.firstDroppedSample).toMatch(/no correlating step\.start/); + // The call still surfaces (identity preserved), opening with empty args. + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("fn"); + expect(result.toolCalls![0].arguments).toBe("{}"); + }); + it("preserves identity of a function_call step.start that arrives without an index", () => { const sse = [ 'data: {"event_type":"step.start","step":{"type":"function_call","id":"call_1","name":"fn","arguments":{"x":1}},"event_id":"evt_1"}',