diff --git a/.changeset/gemini-genai-v2-upgrade.md b/.changeset/gemini-genai-v2-upgrade.md new file mode 100644 index 0000000000..1eae869db5 --- /dev/null +++ b/.changeset/gemini-genai-v2-upgrade.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-gemini': minor +--- + +Upgrade `@google/genai` to v2 and migrate the experimental text-interactions adapter to the SDK 2.x step-based streaming API (`step.start` / `step.delta` / `step.stop`), replacing the prior `content.*` events. Streamed function-call arguments are now accumulated from `arguments_delta` fragments and parsed leniently with `partial-json`, so an incomplete or truncated buffer keeps the last good arguments instead of resetting them and no longer logs a parse error per fragment. Exported built-in-tool CUSTOM event payloads now carry `Interactions.*Step` values, and structured output uses the polymorphic `response_format` request shape. diff --git a/packages/ai-gemini/package.json b/packages/ai-gemini/package.json index f982daf14f..73b7f0a824 100644 --- a/packages/ai-gemini/package.json +++ b/packages/ai-gemini/package.json @@ -64,8 +64,9 @@ "text-to-speech" ], "dependencies": { - "@google/genai": "^1.43.0", - "@tanstack/ai-utils": "workspace:*" + "@google/genai": "^2.8.0", + "@tanstack/ai-utils": "workspace:*", + "partial-json": "^0.1.7" }, "peerDependencies": { "@tanstack/ai": "workspace:^" diff --git a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts index 2ce2e666a1..16c1249d36 100644 --- a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts +++ b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts @@ -1,5 +1,6 @@ import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { parse as parsePartialJSON } from 'partial-json' import { createGeminiClient, generateId, @@ -377,10 +378,17 @@ export class GeminiTextInteractionsAdapter< }, }) + // SDK 2.x: `response_mime_type` has been removed and `response_format` + // is now polymorphic — each entry has a `type` discriminator and the + // mime type lives inside the entry. See: + // https://ai.google.dev/gemini-api/docs/interactions-breaking-changes-may-2026 const request: GeminiInteractionsRequestBody = { ...baseRequest, - response_mime_type: 'application/json', - response_format: outputSchema, + response_format: { + type: 'text', + mime_type: 'application/json', + schema: outputSchema, + }, } try { @@ -490,7 +498,6 @@ function buildInteractionsRequest( background: modelOpts?.background, response_modalities: modelOpts?.response_modalities, response_format: modelOpts?.response_format, - response_mime_type: modelOpts?.response_mime_type, } } @@ -638,24 +645,24 @@ function messagesAfterLastAssistant( return messages } -function safeParseToolArguments( - raw: string | undefined, - logger: InternalLogger, -): Record { - if (!raw) return {} +// Leniently parse the *accumulated* streamed tool-call argument buffer. +// Streamed `arguments_delta` fragments are individually incomplete JSON, so +// a strict `JSON.parse` would throw (and log noise) on every fragment until +// the final one. `partial-json` recovers a best-effort object from a +// truncated buffer instead. Returns `undefined` when nothing usable could be +// parsed, so callers can keep the last good value rather than clobber +// previously-merged args with `{}`. +function parsePartialToolArguments( + raw: string, +): Record | undefined { + if (!raw) return undefined try { - const parsed = JSON.parse(raw) - return parsed && typeof parsed === 'object' ? parsed : {} - } catch (error) { - logger.errors( - 'gemini-text-interactions.safeParseToolArguments parse failed', - { - error, - raw, - source: 'gemini-text-interactions.chatStream', - }, - ) - return {} + const parsed = parsePartialJSON(raw) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : undefined + } catch { + return undefined } } @@ -924,6 +931,17 @@ async function* translateInteractionEvents( let thinkingAccumulated = '' let reasoningMessageId: string | null = null let hasClosedReasoning = false + // SDK 2.x routes events by step `index`, not by content id. We need to + // map the index of an in-flight `function_call` step back to the tool + // call id so subsequent `step.delta` (arguments_delta) and `step.stop` + // events can update / close the right TOOL_CALL_*. + const indexToToolCallId = new Map() + // Function-call arguments now stream as partial JSON fragments + // (`StepDelta.ArgumentsDelta.arguments`) rather than as pre-parsed + // object deltas. Buffer the raw strings per tool call so we can + // attempt one JSON parse per delta and recover gracefully if the + // fragment isn't yet syntactically complete. + const argStringByToolCallId = new Map() const closeReasoningIfNeeded = function* (): Generator { if (reasoningMessageId && !hasClosedReasoning) { @@ -997,86 +1015,146 @@ async function* translateInteractionEvents( for await (const event of stream) { logger.provider(`provider=gemini-text-interactions`, { event }) switch (event.event_type) { - case 'interaction.start': { + case 'interaction.created': { interactionId = event.interaction.id yield* emitRunStartedIfNeeded() break } - case 'content.start': { - yield* emitRunStartedIfNeeded() - break - } - - case 'content.delta': { + case 'step.start': { yield* emitRunStartedIfNeeded() - const delta = event.delta - switch (delta.type) { - case 'text': { + const step = event.step + const index = event.index + switch (step.type) { + case 'function_call': { yield* closeReasoningIfNeeded() - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - model, - timestamp, - role: 'assistant', - } + sawFunctionCall = true + const toolCallId = step.id + indexToToolCallId.set(index, toolCallId) + // `step.arguments` is required on FunctionCallStep but may + // be an empty `{}` placeholder when streaming, where the + // real args arrive as `arguments_delta` events. Treat both + // uniformly: stash whatever we got, stringify once. + const initialArgs = step.arguments + const state: ToolCallState = { + name: step.name, + args: { ...initialArgs }, + index: nextToolIndex++, + started: true, + ended: false, } - textAccumulated += delta.text + toolCalls.set(toolCallId, state) + argStringByToolCallId.set( + toolCallId, + Object.keys(initialArgs).length > 0 + ? JSON.stringify(initialArgs) + : '', + ) yield { - type: EventType.TEXT_MESSAGE_CONTENT, - messageId, + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: state.name, + toolName: state.name, + // Bind the tool call to the same assistant message id the + // eventual TEXT_MESSAGE_START uses so the message id stays + // stable when a function_call arrives before any text (#477). + parentMessageId: messageId, model, timestamp, - delta: delta.text, - content: textAccumulated, + index: state.index, + } + if (Object.keys(initialArgs).length > 0) { + const argsJson = JSON.stringify(initialArgs) + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + model, + timestamp, + delta: argsJson, + args: argsJson, + } } break } - case 'function_call': { - yield* closeReasoningIfNeeded() - sawFunctionCall = true - const toolCallId = delta.id - const deltaArgs: Record = - typeof delta.arguments === 'string' - ? safeParseToolArguments(delta.arguments, logger) - : delta.arguments - let state = toolCalls.get(toolCallId) - if (!state) { - state = { - name: delta.name, - args: { ...deltaArgs }, - index: nextToolIndex++, - started: false, - ended: false, + case 'thought': { + // Open the reasoning block lazily — content lands here via + // `step.delta { thought_summary }` events. If the server + // ships a non-empty `summary` array up-front (rare, unary + // responses), surface it immediately. + if (thinkingStepId === null || reasoningMessageId === null) { + thinkingStepId = generateId(adapterName) + reasoningMessageId = generateId(adapterName) + yield { + type: EventType.REASONING_START, + messageId: reasoningMessageId, + model, + timestamp, + } + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningMessageId, + role: 'reasoning', + model, + timestamp, + } + yield { + type: EventType.STEP_STARTED, + stepName: thinkingStepId, + stepId: thinkingStepId, + model, + timestamp, + stepType: 'thinking', } - toolCalls.set(toolCallId, state) - } else { - state.args = { ...state.args, ...deltaArgs } - if (delta.name) state.name = delta.name } - if (!state.started) { - state.started = true + for (const part of step.summary ?? []) { + if (part.type !== 'text' || !part.text) continue + thinkingAccumulated += part.text yield { - type: EventType.TOOL_CALL_START, - toolCallId, - toolCallName: state.name, - toolName: state.name, - parentMessageId: messageId, + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: part.text, model, timestamp, - index: state.index, + } + yield { + type: EventType.STEP_FINISHED, + stepName: thinkingStepId, + stepId: thinkingStepId, + model, + timestamp, + delta: part.text, + content: thinkingAccumulated, } } - yield { - type: EventType.TOOL_CALL_ARGS, - toolCallId, - model, - timestamp, - delta: JSON.stringify(deltaArgs), - args: JSON.stringify(state.args), + break + } + case 'model_output': { + yield* closeReasoningIfNeeded() + // Some servers ship an initial `content` array on + // `step.start` (notably non-streaming and ahead-of-stream + // unary completions). Treat any prefilled text content the + // same way a `text` step.delta would. + for (const part of step.content ?? []) { + if (part.type !== 'text' || !part.text) continue + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + model, + timestamp, + role: 'assistant', + } + } + textAccumulated += part.text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + model, + timestamp, + delta: part.text, + content: textAccumulated, + } } break } @@ -1085,7 +1163,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.googleSearchCall', - value: delta, + value: step, model, timestamp, } @@ -1096,7 +1174,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.googleSearchResult', - value: delta, + value: step, model, timestamp, } @@ -1107,7 +1185,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.codeExecutionCall', - value: delta, + value: step, model, timestamp, } @@ -1118,7 +1196,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.codeExecutionResult', - value: delta, + value: step, model, timestamp, } @@ -1129,7 +1207,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.urlContextCall', - value: delta, + value: step, model, timestamp, } @@ -1140,7 +1218,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.urlContextResult', - value: delta, + value: step, model, timestamp, } @@ -1151,7 +1229,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.fileSearchCall', - value: delta, + value: step, model, timestamp, } @@ -1162,12 +1240,94 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.fileSearchResult', - value: delta, + value: step, model, timestamp, } break } + // Unhandled step types (user_input on GET timelines, + // mcp_server_*, google_maps_*, function_result) fall through + // to the observability default so SDK drift is visible. + case 'user_input': + case 'mcp_server_tool_call': + case 'mcp_server_tool_result': + case 'google_maps_call': + case 'google_maps_result': + case 'function_result': + default: + logger.provider(`gemini-text-interactions unhandled step.start`, { + step, + }) + break + } + break + } + + case 'step.delta': { + yield* emitRunStartedIfNeeded() + const delta = event.delta + const index = event.index + switch (delta.type) { + case 'text': { + yield* closeReasoningIfNeeded() + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + model, + timestamp, + role: 'assistant', + } + } + textAccumulated += delta.text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + model, + timestamp, + delta: delta.text, + content: textAccumulated, + } + break + } + case 'arguments_delta': { + // Streamed function-call arguments. Identity (id, name) was + // delivered on the matching `step.start` and recorded in + // indexToToolCallId. + const toolCallId = indexToToolCallId.get(index) + if (!toolCallId) { + logger.provider( + `gemini-text-interactions arguments_delta for unknown step index`, + { index, delta }, + ) + break + } + const state = toolCalls.get(toolCallId) + if (!state) break + const fragment = delta.arguments ?? '' + const buffer = + (argStringByToolCallId.get(toolCallId) ?? '') + fragment + argStringByToolCallId.set(toolCallId, buffer) + // Parse the accumulated buffer leniently: streamed arg fragments + // are individually incomplete JSON, so use a partial-JSON parser + // that tolerates truncation rather than logging a parse error per + // fragment. Only overwrite `state.args` when we actually recovered + // an object, so a momentarily-unparseable fragment can't reset + // previously-merged args back to `{}`. + const parsed = parsePartialToolArguments(buffer) + if (parsed) state.args = parsed + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + model, + timestamp, + delta: fragment, + args: buffer, + } + break + } case 'thought_summary': { const thoughtText = delta.content && 'text' in delta.content ? delta.content.text : '' @@ -1216,22 +1376,33 @@ async function* translateInteractionEvents( } break } - // The following delta types are valid per the SDK type union - // but aren't yet translated by this adapter (output modalities - // text-only adapter shouldn't see, response-side function_result - // / mcp_server_*, thought_signature). Falling through to the - // observability default so SDK drift is visible. + // The remaining StepDelta variants (image/audio/video/document + // for output modalities a text adapter shouldn't see, tool + // call/result deltas which are surfaced via step.start in this + // adapter, thought_signature, annotation deltas, mcp/google + // maps variants) fall through to the observability default. case 'image': case 'audio': case 'video': case 'document': - case 'function_result': + case 'thought_signature': + case 'text_annotation_delta': + case 'code_execution_call': + case 'code_execution_result': + case 'url_context_call': + case 'url_context_result': + case 'google_search_call': + case 'google_search_result': + case 'file_search_call': + case 'file_search_result': case 'mcp_server_tool_call': case 'mcp_server_tool_result': - case 'thought_signature': + case 'google_maps_call': + case 'google_maps_result': + case 'function_result': default: logger.provider( - `gemini-text-interactions unhandled content.delta type`, + `gemini-text-interactions unhandled step.delta type`, { delta }, ) break @@ -1239,12 +1410,34 @@ async function* translateInteractionEvents( break } - case 'content.stop': + case 'step.stop': { + // Close any open function_call so downstream consumers get the + // matching TOOL_CALL_END once the arguments are complete. Other + // step types don't carry adapter-level open state. + const toolCallId = indexToToolCallId.get(event.index) + if (toolCallId) { + const state = toolCalls.get(toolCallId) + if (state && !state.ended) { + state.ended = true + yield { + type: EventType.TOOL_CALL_END, + toolCallId, + toolName: state.name, + model, + timestamp, + input: state.args, + } + } + indexToToolCallId.delete(event.index) + } + break + } + case 'interaction.status_update': { break } - case 'interaction.complete': { + case 'interaction.completed': { if (event.interaction.id) { interactionId = event.interaction.id } @@ -1348,10 +1541,21 @@ async function* translateInteractionEvents( } function extractTextFromInteraction(interaction: Interaction): string { + // SDK 2.x: the response carries a `steps` array; `output_text` is a + // convenience the SDK derives from the last model output. Prefer the + // SDK sugar when it's populated, then fall back to walking + // model_output steps for adapters / responses that don't get the + // sugar (e.g. older SDK builds). + if (typeof interaction.output_text === 'string' && interaction.output_text) { + return interaction.output_text + } let text = '' - for (const output of interaction.outputs ?? []) { - if (output.type === 'text') { - text += output.text + for (const step of interaction.steps) { + if (step.type !== 'model_output' || !step.content) continue + for (const part of step.content) { + if (part.type === 'text') { + text += part.text + } } } return text diff --git a/packages/ai-gemini/src/experimental/text-interactions/events.ts b/packages/ai-gemini/src/experimental/text-interactions/events.ts index 7aa6b36683..6035cebad5 100644 --- a/packages/ai-gemini/src/experimental/text-interactions/events.ts +++ b/packages/ai-gemini/src/experimental/text-interactions/events.ts @@ -15,55 +15,60 @@ export interface GeminiInteractionIdEvent extends Omit< } /** - * `CUSTOM` event carrying a raw `google_search_call` content delta from the + * `CUSTOM` event carrying a raw `google_search_call` Step from the * Interactions API. Payload shape is owned by `@google/genai`. + * + * SDK 2.x: server-side tool activity is now first-class `Step`s (emitted + * via `step.start`) rather than content deltas, so each variant below + * carries the full Step (with its `id` / `call_id`) instead of a delta + * fragment. */ export interface GeminiGoogleSearchCallEvent extends Omit< CustomEvent, 'name' | 'value' > { name: 'gemini.googleSearchCall' - value: Interactions.ContentDelta.GoogleSearchCallDelta + value: Interactions.GoogleSearchCallStep } /** - * `CUSTOM` event carrying a raw `google_search_result` content delta from - * the Interactions API. + * `CUSTOM` event carrying a raw `google_search_result` Step from the + * Interactions API. */ export interface GeminiGoogleSearchResultEvent extends Omit< CustomEvent, 'name' | 'value' > { name: 'gemini.googleSearchResult' - value: Interactions.ContentDelta.GoogleSearchResultDelta + value: Interactions.GoogleSearchResultStep } /** - * `CUSTOM` event carrying a raw `code_execution_call` content delta from - * the Interactions API. + * `CUSTOM` event carrying a raw `code_execution_call` Step from the + * Interactions API. */ export interface GeminiCodeExecutionCallEvent extends Omit< CustomEvent, 'name' | 'value' > { name: 'gemini.codeExecutionCall' - value: Interactions.ContentDelta.CodeExecutionCallDelta + value: Interactions.CodeExecutionCallStep } /** - * `CUSTOM` event carrying a raw `code_execution_result` content delta from - * the Interactions API. + * `CUSTOM` event carrying a raw `code_execution_result` Step from the + * Interactions API. */ export interface GeminiCodeExecutionResultEvent extends Omit< CustomEvent, 'name' | 'value' > { name: 'gemini.codeExecutionResult' - value: Interactions.ContentDelta.CodeExecutionResultDelta + value: Interactions.CodeExecutionResultStep } /** - * `CUSTOM` event carrying a raw `url_context_call` content delta from the + * `CUSTOM` event carrying a raw `url_context_call` Step from the * Interactions API. */ export interface GeminiUrlContextCallEvent extends Omit< @@ -71,11 +76,11 @@ export interface GeminiUrlContextCallEvent extends Omit< 'name' | 'value' > { name: 'gemini.urlContextCall' - value: Interactions.ContentDelta.URLContextCallDelta + value: Interactions.URLContextCallStep } /** - * `CUSTOM` event carrying a raw `url_context_result` content delta from the + * `CUSTOM` event carrying a raw `url_context_result` Step from the * Interactions API. */ export interface GeminiUrlContextResultEvent extends Omit< @@ -83,11 +88,11 @@ export interface GeminiUrlContextResultEvent extends Omit< 'name' | 'value' > { name: 'gemini.urlContextResult' - value: Interactions.ContentDelta.URLContextResultDelta + value: Interactions.URLContextResultStep } /** - * `CUSTOM` event carrying a raw `file_search_call` content delta from the + * `CUSTOM` event carrying a raw `file_search_call` Step from the * Interactions API. */ export interface GeminiFileSearchCallEvent extends Omit< @@ -95,11 +100,11 @@ export interface GeminiFileSearchCallEvent extends Omit< 'name' | 'value' > { name: 'gemini.fileSearchCall' - value: Interactions.ContentDelta.FileSearchCallDelta + value: Interactions.FileSearchCallStep } /** - * `CUSTOM` event carrying a raw `file_search_result` content delta from the + * `CUSTOM` event carrying a raw `file_search_result` Step from the * Interactions API. */ export interface GeminiFileSearchResultEvent extends Omit< @@ -107,7 +112,7 @@ export interface GeminiFileSearchResultEvent extends Omit< 'name' | 'value' > { name: 'gemini.fileSearchResult' - value: Interactions.ContentDelta.FileSearchResultDelta + value: Interactions.FileSearchResultStep } /** @@ -127,12 +132,13 @@ export interface GeminiFileSearchResultEvent extends Omit< * } * ``` * - * The four tool-delta variant pairs (`google_search`, `code_execution`, + * The four tool variant pairs (`google_search`, `code_execution`, * `url_context`, `file_search`) forward the raw - * `Interactions.ContentDelta.*Delta` payload from `@google/genai` so - * consumers stay in sync with SDK shape changes automatically. + * `Interactions.*CallStep` / `Interactions.*ResultStep` payload from + * `@google/genai`. SDK 2.x emits these as full Steps via `step.start` + * (carrying the call's `id` / `call_id`) rather than as content deltas. * `computer_use` is accepted as a request tool but the API does not - * stream per-delta CUSTOM events for it. + * surface a dedicated CUSTOM event for it. */ export type GeminiInteractionsCustomEvent = | GeminiInteractionIdEvent diff --git a/packages/ai-gemini/src/experimental/text-interactions/provider-options.ts b/packages/ai-gemini/src/experimental/text-interactions/provider-options.ts index 657cf8e69a..54549c9926 100644 --- a/packages/ai-gemini/src/experimental/text-interactions/provider-options.ts +++ b/packages/ai-gemini/src/experimental/text-interactions/provider-options.ts @@ -21,6 +21,5 @@ export type ExternalTextInteractionsProviderOptions = Pick< | 'system_instruction' | 'response_modalities' | 'response_format' - | 'response_mime_type' | 'generation_config' > diff --git a/packages/ai-gemini/tests/text-interactions-adapter.test.ts b/packages/ai-gemini/tests/text-interactions-adapter.test.ts index 547a9e461b..c043efb63b 100644 --- a/packages/ai-gemini/tests/text-interactions-adapter.test.ts +++ b/packages/ai-gemini/tests/text-interactions-adapter.test.ts @@ -67,27 +67,27 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_1', status: 'in_progress' }, }, { - event_type: 'content.start', + event_type: 'step.start', index: 0, - content: { type: 'text', text: '' }, + step: { type: 'model_output' }, }, { - event_type: 'content.delta', + event_type: 'step.delta', index: 0, delta: { type: 'text', text: 'Hello' }, }, { - event_type: 'content.delta', + event_type: 'step.delta', index: 0, delta: { type: 'text', text: ', world!' }, }, - { event_type: 'content.stop', index: 0 }, + { event_type: 'step.stop', index: 0 }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_1', status: 'completed', @@ -150,11 +150,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_3', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_3', status: 'completed' }, }, ]), @@ -185,11 +185,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_2', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_2', status: 'completed' }, }, ]), @@ -233,11 +233,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_gen', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_gen', status: 'completed' }, }, ]), @@ -273,11 +273,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_followup', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_followup', status: 'completed' }, }, ]), @@ -355,11 +355,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_fresh', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_fresh', status: 'completed' }, }, ]), @@ -384,27 +384,22 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_tool', status: 'in_progress' }, }, { - event_type: 'content.start', + event_type: 'step.start', index: 0, - content: { type: 'function_call' }, - }, - { - event_type: 'content.delta', - index: 0, - delta: { + step: { type: 'function_call', id: 'call_1', name: 'lookup_weather', arguments: { location: 'Madrid' }, }, }, - { event_type: 'content.stop', index: 0 }, + { event_type: 'step.stop', index: 0 }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_tool', status: 'completed' }, }, ]), @@ -448,6 +443,201 @@ describe('GeminiTextInteractionsAdapter', () => { expect(finished.finishReason).toBe('tool_calls') }) + it('accumulates multi-fragment arguments_delta into the final tool input', async () => { + // Streaming function calls deliver identity on step.start (with an empty + // `{}` arguments placeholder) and the JSON arguments as a sequence of + // individually-incomplete `arguments_delta` fragments. + mocks.interactionsCreateSpy.mockResolvedValue( + mkStream([ + { + event_type: 'interaction.created', + interaction: { id: 'int_frag', status: 'in_progress' }, + }, + { + event_type: 'step.start', + index: 0, + step: { + type: 'function_call', + id: 'call_frag', + name: 'lookup_weather', + arguments: {}, + }, + }, + { + event_type: 'step.delta', + index: 0, + delta: { type: 'arguments_delta', arguments: '{"location":' }, + }, + { + event_type: 'step.delta', + index: 0, + delta: { type: 'arguments_delta', arguments: '"Berlin","unit":' }, + }, + { + event_type: 'step.delta', + index: 0, + delta: { type: 'arguments_delta', arguments: '"celsius"}' }, + }, + { event_type: 'step.stop', index: 0 }, + { + event_type: 'interaction.completed', + interaction: { id: 'int_frag', status: 'completed' }, + }, + ]), + ) + + const adapter = createAdapter() + const chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'Weather in Berlin?' }], + tools: [{ name: 'lookup_weather', description: 'Return the weather' }], + }), + ) + + const startEvent = chunks.find((c) => c.type === 'TOOL_CALL_START') as any + expect(startEvent.toolCallId).toBe('call_frag') + expect(startEvent.toolName).toBe('lookup_weather') + + // The last TOOL_CALL_ARGS event carries the fully-accumulated buffer. + const argsEvents = chunks.filter( + (c) => c.type === 'TOOL_CALL_ARGS', + ) as Array + expect(argsEvents.length).toBeGreaterThan(1) + expect(argsEvents.at(-1)!.args).toBe( + '{"location":"Berlin","unit":"celsius"}', + ) + + const endEvent = chunks.find((c) => c.type === 'TOOL_CALL_END') as any + expect(endEvent.input).toEqual({ location: 'Berlin', unit: 'celsius' }) + }) + + it('preserves the last good args when the arguments stream truncates mid-fragment', async () => { + // If the stream ends after an incomplete fragment, the tool input must + // retain whatever was parsed so far rather than being reset to `{}` — a + // strict JSON.parse would throw on the partial buffer and lose the + // already-merged keys. + mocks.interactionsCreateSpy.mockResolvedValue( + mkStream([ + { + event_type: 'interaction.created', + interaction: { id: 'int_trunc', status: 'in_progress' }, + }, + { + event_type: 'step.start', + index: 0, + step: { + type: 'function_call', + id: 'call_trunc', + name: 'lookup_weather', + arguments: {}, + }, + }, + { + event_type: 'step.delta', + index: 0, + // Complete `city` key, then a dangling `unit` key the stream never + // finishes. + delta: { + type: 'arguments_delta', + arguments: '{"city":"London","unit":', + }, + }, + { event_type: 'step.stop', index: 0 }, + { + event_type: 'interaction.completed', + interaction: { id: 'int_trunc', status: 'completed' }, + }, + ]), + ) + + const adapter = createAdapter() + const chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'Weather in London?' }], + tools: [{ name: 'lookup_weather', description: 'Return the weather' }], + }), + ) + + // No parse-failure RUN_ERROR, and the completed key survives truncation. + expect(chunks.find((c) => c.type === 'RUN_ERROR')).toBeUndefined() + const endEvent = chunks.find((c) => c.type === 'TOOL_CALL_END') as any + expect(endEvent.input).toEqual({ city: 'London' }) + }) + + it('translates thought_summary deltas into REASONING events', async () => { + mocks.interactionsCreateSpy.mockResolvedValue( + mkStream([ + { + event_type: 'interaction.created', + interaction: { id: 'int_think', status: 'in_progress' }, + }, + { + event_type: 'step.start', + index: 0, + step: { type: 'thought', id: 'thought_1' }, + }, + { + event_type: 'step.delta', + index: 0, + delta: { + type: 'thought_summary', + content: { text: 'First I consider the question. ' }, + }, + }, + { + event_type: 'step.delta', + index: 0, + delta: { + type: 'thought_summary', + content: { text: 'Then I answer.' }, + }, + }, + { event_type: 'step.stop', index: 0 }, + { + event_type: 'step.delta', + index: 1, + delta: { type: 'text', text: 'It is sunny.' }, + }, + { + event_type: 'interaction.completed', + interaction: { id: 'int_think', status: 'completed' }, + }, + ]), + ) + + const adapter = createAdapter() + const chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'Think, then answer.' }], + }), + ) + + expect(chunks.find((c) => c.type === 'REASONING_START')).toBeDefined() + expect( + chunks.find((c) => c.type === 'REASONING_MESSAGE_START'), + ).toBeDefined() + + const reasoningDeltas = chunks.filter( + (c) => c.type === 'REASONING_MESSAGE_CONTENT', + ) as Array + expect(reasoningDeltas.map((c) => c.delta)).toEqual([ + 'First I consider the question. ', + 'Then I answer.', + ]) + + // Reasoning is distinct from the final assistant text. + const textContent = chunks.find( + (c) => c.type === 'TEXT_MESSAGE_CONTENT', + ) as any + expect(textContent.delta).toBe('It is sunny.') + + const finished = chunks.find((c) => c.type === 'RUN_FINISHED') as any + expect(finished.finishReason).toBe('stop') + }) + it('emits parentMessageId on tool-first tool calls matching the assistant message id', async () => { // The function_call content arrives before any text. parentMessageId must // bind the tool call to the same assistant message id the eventual @@ -455,38 +645,27 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_tf', status: 'in_progress' }, }, { - event_type: 'content.start', - index: 0, - content: { type: 'function_call' }, - }, - { - event_type: 'content.delta', + event_type: 'step.start', index: 0, - delta: { + step: { type: 'function_call', id: 'call_tf', name: 'lookup_weather', arguments: { location: 'Berlin' }, }, }, - { event_type: 'content.stop', index: 0 }, - { - event_type: 'content.start', - index: 1, - content: { type: 'text', text: '' }, - }, + { event_type: 'step.stop', index: 0 }, { - event_type: 'content.delta', + event_type: 'step.delta', index: 1, delta: { type: 'text', text: 'It is sunny.' }, }, - { event_type: 'content.stop', index: 1 }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_tf', status: 'completed' }, }, ]), @@ -523,11 +702,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_followup', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_followup', status: 'completed' }, }, ]), @@ -614,11 +793,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_builtins', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_builtins', status: 'completed' }, }, ]), @@ -653,11 +832,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_fs', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_fs', status: 'completed' }, }, ]), @@ -701,11 +880,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_strip', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_strip', status: 'completed' }, }, ]), @@ -765,40 +944,42 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_search', status: 'in_progress' }, }, { - event_type: 'content.start', + event_type: 'step.start', index: 0, - content: { type: 'google_search_call' }, - }, - { - event_type: 'content.delta', - index: 0, - delta: { + step: { type: 'google_search_call', id: 'call_gs_1', arguments: { queries: ['weather madrid'] }, }, }, + { event_type: 'step.stop', index: 0 }, { - event_type: 'content.delta', - index: 0, - delta: { + event_type: 'step.start', + index: 1, + step: { type: 'google_search_result', call_id: 'call_gs_1', - result: [{ title: 'Madrid weather', uri: 'https://example.com' }], + result: [{ search_suggestions: '
Madrid weather
' }], }, }, - { event_type: 'content.stop', index: 0 }, + { event_type: 'step.stop', index: 1 }, { - event_type: 'content.delta', - index: 0, + event_type: 'step.start', + index: 2, + step: { type: 'model_output' }, + }, + { + event_type: 'step.delta', + index: 2, delta: { type: 'text', text: 'It is sunny.' }, }, + { event_type: 'step.stop', index: 2 }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_search', status: 'completed' }, }, ]), @@ -879,7 +1060,7 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_err', status: 'in_progress' }, }, { @@ -910,7 +1091,12 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce({ id: 'int_structured', status: 'completed', - outputs: [{ type: 'text', text: '{"foo":"bar"}' }], + steps: [ + { + type: 'model_output', + content: [{ type: 'text', text: '{"foo":"bar"}' }], + }, + ], }) const adapter = createAdapter() @@ -924,8 +1110,12 @@ describe('GeminiTextInteractionsAdapter', () => { expect(mocks.interactionsCreateSpy).toHaveBeenCalledTimes(1) const structuredPayload = mocks.interactionsCreateSpy.mock.calls[0]![0] - expect(structuredPayload.response_mime_type).toBe('application/json') - expect(structuredPayload.response_format).toBeDefined() + // SDK 2.x: `response_mime_type` was removed; mime + schema now live + // inside `response_format`. + expect(structuredPayload.response_format).toMatchObject({ + type: 'text', + mime_type: 'application/json', + }) expect(structuredPayload.stream).toBeUndefined() }) @@ -950,21 +1140,22 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_tool_chain', status: 'in_progress' }, }, { - event_type: 'content.delta', + event_type: 'step.start', index: 0, - delta: { + step: { type: 'function_call', id: 'call_x', name: 'lookup', arguments: { q: 'madrid' }, }, }, + { event_type: 'step.stop', index: 0 }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_tool_chain', status: 'requires_action', @@ -977,11 +1168,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_after_tool', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_after_tool', status: 'completed' }, }, ]), @@ -1026,16 +1217,22 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_done', status: 'in_progress' }, }, { - event_type: 'content.delta', + event_type: 'step.start', + index: 0, + step: { type: 'model_output' }, + }, + { + event_type: 'step.delta', index: 0, delta: { type: 'text', text: 'done' }, }, + { event_type: 'step.stop', index: 0 }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_done', status: 'completed' }, }, ]), @@ -1043,11 +1240,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_fresh', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_fresh', status: 'completed' }, }, ]), @@ -1077,7 +1274,7 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_err', status: 'in_progress' }, }, { event_type: 'error', error: { code: 500, message: 'boom' } }, @@ -1086,11 +1283,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_recovery', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_recovery', status: 'completed' }, }, ]), @@ -1125,35 +1322,40 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_trunc', status: 'in_progress' }, }, { - event_type: 'content.delta', + event_type: 'step.start', index: 0, - delta: { type: 'text', text: 'partial' }, + step: { type: 'model_output' }, }, { - event_type: 'content.delta', + event_type: 'step.delta', index: 0, - delta: { + delta: { type: 'text', text: 'partial' }, + }, + { + event_type: 'step.start', + index: 1, + step: { type: 'function_call', id: 'call_trunc', name: 'lookup', arguments: { q: 'x' }, }, }, - // (no interaction.complete, no error) + // (no interaction.complete, no error — also no step.stop for index 1) ]), ) mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_after_trunc', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_after_trunc', status: 'completed' }, }, ]), @@ -1212,17 +1414,22 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_abandon', status: 'in_progress' }, }, { - event_type: 'content.delta', + event_type: 'step.start', + index: 0, + step: { type: 'model_output' }, + }, + { + event_type: 'step.delta', index: 0, delta: { type: 'text', text: 'first chunk' }, }, // (never reached — consumer breaks before this) { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_abandon', status: 'completed' }, }, ]), @@ -1230,11 +1437,11 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_fresh_after_abandon', status: 'in_progress' }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_fresh_after_abandon', status: 'completed', @@ -1280,16 +1487,22 @@ describe('GeminiTextInteractionsAdapter', () => { .mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_seed', status: 'in_progress' }, }, { - event_type: 'content.delta', + event_type: 'step.start', + index: 0, + step: { type: 'model_output' }, + }, + { + event_type: 'step.delta', index: 0, delta: { type: 'text', text: 'ack' }, }, + { event_type: 'step.stop', index: 0 }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_seed', status: 'completed' }, }, ]), @@ -1297,7 +1510,12 @@ describe('GeminiTextInteractionsAdapter', () => { .mockResolvedValueOnce({ id: 'int_struct', status: 'completed', - outputs: [{ type: 'text', text: '{"answer":"42"}' }], + steps: [ + { + type: 'model_output', + content: [{ type: 'text', text: '{"answer":"42"}' }], + }, + ], }) const adapter = createAdapter() @@ -1338,7 +1556,10 @@ describe('GeminiTextInteractionsAdapter', () => { expect(result).toEqual({ answer: '42' }) const structuredPayload = mocks.interactionsCreateSpy.mock.calls[1]![0] expect(structuredPayload.previous_interaction_id).toBe('int_seed') - expect(structuredPayload.response_mime_type).toBe('application/json') + expect(structuredPayload.response_format).toMatchObject({ + type: 'text', + mime_type: 'application/json', + }) }) it('evicts on consumer abandonment AFTER RUN_FINISHED(tool_calls) — the tool-iteration that never happens', async () => { @@ -1350,21 +1571,22 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_tool_abandon', status: 'in_progress' }, }, { - event_type: 'content.delta', + event_type: 'step.start', index: 0, - delta: { + step: { type: 'function_call', id: 'call_y', name: 'lookup', arguments: { q: 'x' }, }, }, + { event_type: 'step.stop', index: 0 }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_tool_abandon', status: 'requires_action', @@ -1375,14 +1597,14 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValueOnce( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_recovery_after_tool_abandon', status: 'in_progress', }, }, { - event_type: 'interaction.complete', + event_type: 'interaction.completed', interaction: { id: 'int_recovery_after_tool_abandon', status: 'completed', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d1578c154..40f5181e4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1366,11 +1366,14 @@ importers: packages/ai-gemini: dependencies: '@google/genai': - specifier: ^1.43.0 - version: 1.43.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) + specifier: ^2.8.0 + version: 2.8.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) '@tanstack/ai-utils': specifier: workspace:* version: link:../ai-utils + partial-json: + specifier: ^0.1.7 + version: 0.1.7 devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1949,8 +1952,8 @@ importers: testing/e2e: dependencies: '@copilotkit/aimock': - specifier: ^1.28.1 - version: 1.29.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + specifier: ^1.34.0 + version: 1.34.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.3.6) @@ -3080,8 +3083,8 @@ packages: '@cloudflare/workers-types@4.20260317.1': resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==} - '@copilotkit/aimock@1.29.0': - resolution: {integrity: sha512-xNMHMUDX7zPSc56dm2ZXbttoLk6x72oEBHwWCAakVWNO85zZepLkB8Poc/x1cJrY69FI9frN0gavI/zVuZq/9A==} + '@copilotkit/aimock@1.34.0': + resolution: {integrity: sha512-r60WxGA/qQCNtULVzpAZo72LqP6wjJF4a2o9zlT++AgltbAw0W4Z0Tsv6fPK7Q+SNSknIjZbmNwunUFRFGOtGw==} engines: {node: '>=20.15.0'} hasBin: true peerDependencies: @@ -4190,8 +4193,8 @@ packages: '@gerrit0/mini-shiki@3.19.0': resolution: {integrity: sha512-ZSlWfLvr8Nl0T4iA3FF/8VH8HivYF82xQts2DY0tJxZd4wtXJ8AA0nmdW9lmO4hlrh3f9xNwEPtOgqETPqKwDA==} - '@google/genai@1.43.0': - resolution: {integrity: sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==} + '@google/genai@2.8.0': + resolution: {integrity: sha512-pc2ayxqO5+O7AvnHBqpNHIk7PAZkHZgL31tbyx0gJZBSS9qPYiQoqwK7oYOw/ePmG6QY4EMSu+304vD5QlhXAw==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -16148,7 +16151,7 @@ snapshots: '@cloudflare/workers-types@4.20260317.1': {} - '@copilotkit/aimock@1.29.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@copilotkit/aimock@1.34.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': optionalDependencies: vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -17114,12 +17117,12 @@ snapshots: '@shikijs/types': 3.20.0 '@shikijs/vscode-textmate': 10.0.2 - '@google/genai@1.43.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': + '@google/genai@2.8.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': dependencies: google-auth-library: 10.5.0 p-retry: 4.6.2 protobufjs: 7.5.4 - ws: 8.18.3 + ws: 8.20.1 optionalDependencies: '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index adb07c43e3..25219fcb36 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,9 @@ minimumReleaseAge: 1440 minimumReleaseAgeExclude: - 'kiira@0.5.0' # root dev dependency already locked on main - 'kiira-core@0.5.0' # transitive dependency of kiira@0.5.0 + # Working closely with the aimock team; the e2e suite often needs fresh + # releases of this test-only mock as it tracks provider SDK changes. + - '@copilotkit/aimock' trustPolicyExclude: - 'chokidar@4.0.3' # existing transitive from sass/nitro; latest v4 with v5 available @@ -33,7 +36,9 @@ allowBuilds: # Vite + every transitive bundler-step needs the platform-specific binary. esbuild: true # @google/genai (used by @tanstack/ai-gemini in CI) loads files generated by - # protobufjs's postinstall. + # protobufjs's postinstall. Its own `preinstall` is a no-op echo and + # `prepare` only runs when developing the SDK from a git checkout, so it + # has no runtime effect on consumers. protobufjs: true # Expo/Metro reaches this through msgpackr and can use the native extractor. msgpackr-extract: true @@ -49,4 +54,5 @@ allowBuilds: sharp: false unrs-resolver: false workerd: false + '@google/genai': false lmdb: true diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 68381f527a..a2af416a8d 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -11,7 +11,7 @@ "postinstall": "playwright install chromium" }, "dependencies": { - "@copilotkit/aimock": "^1.28.1", + "@copilotkit/aimock": "^1.34.0", "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/sdk": "0.12.35", "@opentelemetry/api": "^1.9.0", diff --git a/testing/e2e/tests/stateful-interactions.spec.ts b/testing/e2e/tests/stateful-interactions.spec.ts index 8f4d46a045..d7cee5b7f6 100644 --- a/testing/e2e/tests/stateful-interactions.spec.ts +++ b/testing/e2e/tests/stateful-interactions.spec.ts @@ -11,7 +11,7 @@ import { providersFor } from './test-matrix' // // Two-turn flow: // 1. Issue a first chat call. The aimock fixture returns a server-assigned -// interactionId in interaction.start. The adapter surfaces it via the +// interactionId in interaction.created. The adapter surfaces it via the // `gemini.interactionId` CUSTOM event, which the route stores in state // and renders into a hidden `gemini-interaction-id` element so this // spec can read it.