From 083cedfd2c48d310f3ff99c2795ac10a7ae75abc Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Thu, 16 Apr 2026 10:40:14 -0700 Subject: [PATCH 01/13] chore: update project agent configuration --- .agentsroom/.gitignore | 4 ++++ .agentsroom/agents.json | 1 + .agentsroom/prompts.json | 4 ++++ 3 files changed, 9 insertions(+) create mode 100644 .agentsroom/.gitignore create mode 100644 .agentsroom/agents.json create mode 100644 .agentsroom/prompts.json diff --git a/.agentsroom/.gitignore b/.agentsroom/.gitignore new file mode 100644 index 000000000..1acd1a387 --- /dev/null +++ b/.agentsroom/.gitignore @@ -0,0 +1,4 @@ +# AgentsRoom: personal files (not committed to git) +*-personal.json +agents-local.json +sessions/ diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/.agentsroom/agents.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.agentsroom/prompts.json b/.agentsroom/prompts.json new file mode 100644 index 000000000..f4455d843 --- /dev/null +++ b/.agentsroom/prompts.json @@ -0,0 +1,4 @@ +{ + "folders": [], + "prompts": [] +} \ No newline at end of file From 57a8abdd5e9efa2c5e7a7c4044e5a56ec77fedf5 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Thu, 16 Apr 2026 10:40:44 -0700 Subject: [PATCH 02/13] chore: update project agent configuration --- .agentsroom/agents.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json index 0637a088a..61f6b9421 100644 --- a/.agentsroom/agents.json +++ b/.agentsroom/agents.json @@ -1 +1,9 @@ -[] \ No newline at end of file +[ + { + "role": "fullstack", + "model": "opus", + "customName": "Full-Stack Developer", + "isPersonal": false, + "id": "agent-1776361243376-3sekdc" + } +] \ No newline at end of file From 922d64d80750a32aa9652bc74d47a7ea74adc757 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Thu, 16 Apr 2026 10:40:59 -0700 Subject: [PATCH 03/13] chore: update project agent configuration --- .agentsroom/agents.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json index 61f6b9421..e9a83e418 100644 --- a/.agentsroom/agents.json +++ b/.agentsroom/agents.json @@ -4,6 +4,7 @@ "model": "opus", "customName": "Full-Stack Developer", "isPersonal": false, - "id": "agent-1776361243376-3sekdc" + "id": "agent-1776361243376-3sekdc", + "claudeSessionId": "96773a93-be2a-45a9-a732-ceb224d3d0e5" } ] \ No newline at end of file From 8b6ce43ba92b6ab58fad24aa45c2e3f596432352 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 15 Jun 2026 19:48:18 -0700 Subject: [PATCH 04/13] feat(ai-gemini): upgrade @google/genai to v2 and migrate Interactions API Bump @google/genai from ^1.43.0 to ^2.8.0. The v2 SDK replaces the legacy flat Interactions schema with the new step-based model, so migrate the experimental text-interactions adapter to the renamed SSE events (interaction.created / step.start / step.delta / step.stop / interaction.completed) and the Step data model, switch structured output to the polymorphic response_format, and drop the deprecated response_mime_type. Update the 25 text-interactions tests to the new event/step schema and set allowBuilds['@google/genai'] to false (its install scripts are no-ops for consumers). Co-authored-by: Cursor --- packages/ai-gemini/package.json | 2 +- .../experimental/text-interactions/adapter.ts | 363 ++++++++++++++---- .../experimental/text-interactions/events.ts | 52 +-- .../text-interactions/provider-options.ts | 1 - .../tests/text-interactions-adapter.test.ts | 224 ++++++----- pnpm-lock.yaml | 12 +- pnpm-workspace.yaml | 5 +- 7 files changed, 452 insertions(+), 207 deletions(-) diff --git a/packages/ai-gemini/package.json b/packages/ai-gemini/package.json index 6780301d3..be4ebe322 100644 --- a/packages/ai-gemini/package.json +++ b/packages/ai-gemini/package.json @@ -56,7 +56,7 @@ "text-to-speech" ], "dependencies": { - "@google/genai": "^1.43.0", + "@google/genai": "^2.8.0", "@tanstack/ai-utils": "workspace:*" }, "peerDependencies": { diff --git a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts index b27feaa1a..9bb779aa0 100644 --- a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts +++ b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts @@ -377,10 +377,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 +497,6 @@ function buildInteractionsRequest( background: modelOpts?.background, response_modalities: modelOpts?.response_modalities, response_format: modelOpts?.response_format, - response_mime_type: modelOpts?.response_mime_type, } } @@ -924,6 +930,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,85 +1014,142 @@ 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 as Record + 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, 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, + 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 } @@ -1084,7 +1158,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.googleSearchCall', - value: delta, + value: step, model, timestamp, } @@ -1095,7 +1169,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.googleSearchResult', - value: delta, + value: step, model, timestamp, } @@ -1106,7 +1180,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.codeExecutionCall', - value: delta, + value: step, model, timestamp, } @@ -1117,7 +1191,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.codeExecutionResult', - value: delta, + value: step, model, timestamp, } @@ -1128,7 +1202,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.urlContextCall', - value: delta, + value: step, model, timestamp, } @@ -1139,7 +1213,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.urlContextResult', - value: delta, + value: step, model, timestamp, } @@ -1150,7 +1224,7 @@ async function* translateInteractionEvents( yield { type: EventType.CUSTOM, name: 'gemini.fileSearchCall', - value: delta, + value: step, model, timestamp, } @@ -1161,9 +1235,90 @@ 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) + // Try to parse the accumulated buffer; if it's not yet a + // complete JSON object, keep `state.args` whatever the last + // parse produced. The wire-level args fragments don't have + // to be individually-parseable, but the *full* string at + // step.stop will be. + const parsed = safeParseToolArguments(buffer, logger) + state.args = parsed + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + model, + timestamp, + delta: fragment, + args: buffer, } break } @@ -1215,22 +1370,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 @@ -1238,12 +1404,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 } @@ -1347,10 +1535,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 7aa6b3668..6035cebad 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 657cf8e69..54549c992 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 1bc235610..bbf3396a8 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' }, }, ]), @@ -452,11 +447,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' }, }, ]), @@ -543,11 +538,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' }, }, ]), @@ -582,11 +577,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' }, }, ]), @@ -630,11 +625,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' }, }, ]), @@ -694,40 +689,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', - index: 0, - content: { type: 'google_search_call' }, - }, - { - event_type: 'content.delta', + event_type: 'step.start', 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' }, }, ]), @@ -808,7 +805,7 @@ describe('GeminiTextInteractionsAdapter', () => { mocks.interactionsCreateSpy.mockResolvedValue( mkStream([ { - event_type: 'interaction.start', + event_type: 'interaction.created', interaction: { id: 'int_err', status: 'in_progress' }, }, { @@ -839,7 +836,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() @@ -853,8 +855,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() }) @@ -879,21 +885,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', @@ -906,11 +913,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' }, }, ]), @@ -955,16 +962,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' }, }, ]), @@ -972,11 +985,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' }, }, ]), @@ -1006,7 +1019,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' } }, @@ -1015,11 +1028,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' }, }, ]), @@ -1054,35 +1067,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' }, }, ]), @@ -1141,17 +1159,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' }, }, ]), @@ -1159,11 +1182,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', @@ -1209,16 +1232,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' }, }, ]), @@ -1226,7 +1255,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() @@ -1267,7 +1301,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 () => { @@ -1279,21 +1316,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', @@ -1304,14 +1342,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 3fe7276d9..01e09e391 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1247,8 +1247,8 @@ 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 @@ -3914,8 +3914,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 @@ -15812,12 +15812,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 dc784eb75..989d30ece 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,7 +23,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 @@ -39,3 +41,4 @@ allowBuilds: sharp: false unrs-resolver: false workerd: false + '@google/genai': false From c1e5b1bbea81fb1f14f36b915918b471d54fb52e Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Thu, 16 Apr 2026 10:40:14 -0700 Subject: [PATCH 05/13] chore: update project agent configuration --- .agentsroom/.gitignore | 4 ++++ .agentsroom/agents.json | 1 + .agentsroom/prompts.json | 4 ++++ 3 files changed, 9 insertions(+) create mode 100644 .agentsroom/.gitignore create mode 100644 .agentsroom/agents.json create mode 100644 .agentsroom/prompts.json diff --git a/.agentsroom/.gitignore b/.agentsroom/.gitignore new file mode 100644 index 000000000..1acd1a387 --- /dev/null +++ b/.agentsroom/.gitignore @@ -0,0 +1,4 @@ +# AgentsRoom: personal files (not committed to git) +*-personal.json +agents-local.json +sessions/ diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/.agentsroom/agents.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.agentsroom/prompts.json b/.agentsroom/prompts.json new file mode 100644 index 000000000..f4455d843 --- /dev/null +++ b/.agentsroom/prompts.json @@ -0,0 +1,4 @@ +{ + "folders": [], + "prompts": [] +} \ No newline at end of file From 45518bf114e5c9433b65d754522388a81605f9f0 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Thu, 16 Apr 2026 10:40:44 -0700 Subject: [PATCH 06/13] chore: update project agent configuration --- .agentsroom/agents.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json index 0637a088a..61f6b9421 100644 --- a/.agentsroom/agents.json +++ b/.agentsroom/agents.json @@ -1 +1,9 @@ -[] \ No newline at end of file +[ + { + "role": "fullstack", + "model": "opus", + "customName": "Full-Stack Developer", + "isPersonal": false, + "id": "agent-1776361243376-3sekdc" + } +] \ No newline at end of file From 88f018e20a8513933d35ce1e0f82b048535be42b Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Thu, 16 Apr 2026 10:40:59 -0700 Subject: [PATCH 07/13] chore: update project agent configuration --- .agentsroom/agents.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json index 61f6b9421..e9a83e418 100644 --- a/.agentsroom/agents.json +++ b/.agentsroom/agents.json @@ -4,6 +4,7 @@ "model": "opus", "customName": "Full-Stack Developer", "isPersonal": false, - "id": "agent-1776361243376-3sekdc" + "id": "agent-1776361243376-3sekdc", + "claudeSessionId": "96773a93-be2a-45a9-a732-ceb224d3d0e5" } ] \ No newline at end of file From 875c68df34f616fbb383ae406667047ee375e3e6 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 17 Jun 2026 10:13:40 -0700 Subject: [PATCH 08/13] test(e2e): quarantine gemini stateful-interactions pending aimock SDK 2.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @google/genai v2 migration moved geminiTextInteractions onto the SDK 2.x Interactions event protocol (step.start/step.delta/step.stop, interaction.created/completed). All published @copilotkit/aimock versions (≤1.31.0) still emit the SDK 1.x shapes (content.* / interaction.start / interaction.complete), so the e2e mock and adapter no longer agree on the wire format and the assistant message comes back empty. Mark the test test.fixme (skipped, not failing) and document the exact event shapes aimock must emit to re-enable it. SDK 2.x adapter behaviour stays covered by unit tests in packages/ai-gemini/tests/text-interactions-adapter.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../stateful-interactions.AIMOCK-TODO.md | 152 ++++++++++++++++++ .../e2e/tests/stateful-interactions.spec.ts | 16 +- 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md diff --git a/testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md b/testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md new file mode 100644 index 000000000..a0c5bc89a --- /dev/null +++ b/testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md @@ -0,0 +1,152 @@ +# aimock TODO — Gemini Interactions **SDK 2.x** event format + +**Status:** `tests/stateful-interactions.spec.ts` is marked `test.fixme` and will +not run until [`@copilotkit/aimock`](https://github.com/CopilotKit/aimock) emits +the **SDK 2.x** Gemini Interactions event format. + +This is **not** a bug in the adapter. The `gemini-upgrade` branch +(`feat(ai-gemini): upgrade @google/genai to v2 and migrate Interactions API`) +migrated `geminiTextInteractions` to the **SDK 2.x** Interactions event protocol +(the "Interactions breaking changes, May 2026"). The adapter is correct for the +live API. aimock has **not** migrated — every published version through **1.31.0** +(latest as of 2026-06-17) still emits the **SDK 1.x** event shapes — so the mock +and the adapter now disagree on the wire format, and the adapter consumes none of +the mock's text/tool deltas (empty assistant message → test fails). + +Unit coverage for the 2.x adapter is comprehensive and green +(`packages/ai-gemini/tests/text-interactions-adapter.test.ts`, 25 tests) — those +feed hand-written 2.x `step.*` events. The fixme'd e2e test is the *only* gap, and +it is purely a mock-vs-adapter format mismatch. + +--- + +## Root cause: zero event-type overlap + +| Concern | aimock emits today (**SDK 1.x**) | adapter expects (**SDK 2.x**) | +| --- | --- | --- | +| interaction opened | `interaction.start` | `interaction.created` | +| content block opened | `content.start` | `step.start` | +| streamed delta | `content.delta` | `step.delta` | +| content block closed | `content.stop` | `step.stop` | +| interaction finished | `interaction.complete` | `interaction.completed` | + +Because **no `event_type` matches**, the adapter's `switch` (in +`translateInteractionEvents`, `packages/ai-gemini/src/experimental/text-interactions/adapter.ts`) +hits its observability `default` for every event, emits nothing, and the +assistant message comes back empty. + +The adapter handles exactly these top-level `event_type`s: +`interaction.created`, `step.start`, `step.delta`, `step.stop`, +`interaction.status_update`, `interaction.completed`, `error`. + +--- + +## What aimock needs to change + +Source: `dist/gemini-interactions.js` (and its `src` equivalent) in the aimock +repo. Two builders need rewriting: +`buildInteractionsTextSSEEvents` and `buildInteractionsToolCallSSEEvents`. + +### 1. Lifecycle events (both builders) + +Rename and reshape the envelope. The `interaction` object keeps `id`, `status`, +and `usage` — only the `event_type` names change. + +```diff +- { event_type: "interaction.start", interaction: { id, status: "in_progress" } } ++ { event_type: "interaction.created", interaction: { id, status: "in_progress" } } + +- { event_type: "interaction.complete", interaction: { id, status, usage } } ++ { event_type: "interaction.completed", interaction: { id, status, usage } } +``` + +> The adapter reads `interaction.id` from **both** `interaction.created` and +> `interaction.completed` and surfaces it as the `gemini.interactionId` CUSTOM +> event. The e2e test reads that via the `gemini-interaction-id` element, so the +> `id` field must remain populated on these events. + +Status values map unchanged: text → `completed`; tool calls → `requires_action`. +`statusToFinishReason` / `statusIsError` interpret them the same way. + +### 2. Text streaming — `buildInteractionsTextSSEEvents` + +The **inner delta shape is identical** (`{ type: "text", text }`). Only the +envelope `event_type` changes, plus an optional `step.start`/`step.stop` wrapper. + +```diff +- { event_type: "content.start", index: 0, content: { type: "text" } } ++ { event_type: "step.start", index: 0, step: { type: "model_output" } } + +- { event_type: "content.delta", index: 0, delta: { type: "text", text: slice } } ++ { event_type: "step.delta", index: 0, delta: { type: "text", text: slice } } + +- { event_type: "content.stop", index: 0 } ++ { event_type: "step.stop", index: 0 } +``` + +Minimum viable fix for *this* test: the adapter lazily opens the assistant +message on the first `step.delta { type: "text" }`, so renaming +`content.delta` → `step.delta` alone is enough to make text render. Emitting the +`step.start { type: "model_output" }` / `step.stop` wrapper is the +spec-faithful shape and is recommended. + +### 3. Tool calls — `buildInteractionsToolCallSSEEvents` + +This is the larger change. In 2.x the call **identity (`id`, `name`, +`arguments`) lives on `step.start`**, not in a delta, and streamed argument +fragments use a dedicated `arguments_delta` variant carrying a **string** +fragment (not a parsed object). + +```diff + // per tool call, at step index `idx`: +- { event_type: "content.start", index: idx, content: { type: "function_call" } } +- { event_type: "content.delta", index: idx, +- delta: { type: "function_call", id, name, arguments: argsObj } } +- { event_type: "content.stop", index: idx } ++ { event_type: "step.start", index: idx, ++ step: { type: "function_call", id, name, arguments: {} } } ++ // optional: stream args as JSON-string fragments ++ { event_type: "step.delta", index: idx, ++ delta: { type: "arguments_delta", arguments: "" } } ++ { event_type: "step.stop", index: idx } +``` + +Adapter contract for tool calls: +- `step.start.step.id` is the tool-call id; the adapter maps `index → id` so + later `arguments_delta` / `step.stop` at the same `index` resolve correctly. +- `step.start.step.arguments` may be `{}` (placeholder) when streaming; the real + args then arrive as `arguments_delta` string fragments. It may also be a fully + populated object for non-streamed calls — both are accepted. +- `arguments_delta.arguments` is a **string** fragment; the concatenation across + all fragments for a given index must be valid JSON by `step.stop`. + +### 4. Reasoning (`thought`) — not exercised by the current spec + +For completeness, 2.x reasoning is: +`step.start { step: { type: "thought", summary?: [{ type: "text", text }] } }` +then `step.delta { delta: { type: "thought_summary", content: { text } } }`. + +--- + +## How to verify once aimock ships 2.x + +1. Bump `@copilotkit/aimock` in `testing/e2e/package.json`, `pnpm install`. +2. Confirm the new emitter produces `step.*` events: + ```bash + grep -E 'event_type: "(step|interaction\.created|interaction\.completed)' \ + node_modules/.pnpm/@copilotkit+aimock@*/node_modules/@copilotkit/aimock/dist/gemini-interactions.js + ``` +3. Un-`fixme` the test (`test.fixme` → `test`) in + `tests/stateful-interactions.spec.ts`. +4. ```bash + pnpm --filter @tanstack/ai-e2e test:e2e -- --grep "stateful" + ``` + +## References + +- Adapter event translator: `packages/ai-gemini/src/experimental/text-interactions/adapter.ts` + (`translateInteractionEvents`, the `switch (event.event_type)`). +- 2.x unit fixtures (canonical event shapes): `packages/ai-gemini/tests/text-interactions-adapter.test.ts`. +- aimock current emitter: `@copilotkit/aimock/dist/gemini-interactions.js` + (`buildInteractionsTextSSEEvents`, `buildInteractionsToolCallSSEEvents`). +- aimock repo: https://github.com/CopilotKit/aimock diff --git a/testing/e2e/tests/stateful-interactions.spec.ts b/testing/e2e/tests/stateful-interactions.spec.ts index 8f4d46a04..2b84fc4b5 100644 --- a/testing/e2e/tests/stateful-interactions.spec.ts +++ b/testing/e2e/tests/stateful-interactions.spec.ts @@ -35,7 +35,21 @@ import { providersFor } from './test-matrix' // tests in `packages/ai-gemini/tests/text-interactions-adapter.test.ts`. for (const provider of providersFor('stateful-interactions')) { test.describe(`${provider} — stateful-interactions`, () => { - test('two-turn conversation chained via previous_interaction_id', async ({ + // DISABLED — blocked on aimock. The gemini-upgrade branch migrated + // `geminiTextInteractions` to the @google/genai v2 (SDK 2.x) Interactions + // event protocol, which streams `step.start` / `step.delta` / `step.stop` + // and `interaction.created` / `interaction.completed`. Every published + // `@copilotkit/aimock` (≤1.31.0) still emits the SDK 1.x shapes + // (`content.*` / `interaction.start` / `interaction.complete`), so the + // adapter consumes none of the mock's deltas and the assistant message + // comes back empty. This is a mock/adapter format mismatch, not an adapter + // bug — 2.x behaviour is covered by unit tests in + // packages/ai-gemini/tests/text-interactions-adapter.test.ts. + // + // Re-enable (test.fixme → test) once aimock emits SDK 2.x events. The exact + // required event shapes and migration steps are documented in + // ./stateful-interactions.AIMOCK-TODO.md + test.fixme('two-turn conversation chained via previous_interaction_id', async ({ page, testId, aimockPort, From 396b73119ce0deedc24b484a83e3d7be2f37011c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:15:22 +0000 Subject: [PATCH 09/13] ci: apply automated fixes --- .agentsroom/agents.json | 2 +- .agentsroom/prompts.json | 2 +- .../experimental/text-interactions/adapter.ts | 4 ++-- .../stateful-interactions.AIMOCK-TODO.md | 19 ++++++++++--------- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json index e9a83e418..87e40d148 100644 --- a/.agentsroom/agents.json +++ b/.agentsroom/agents.json @@ -7,4 +7,4 @@ "id": "agent-1776361243376-3sekdc", "claudeSessionId": "96773a93-be2a-45a9-a732-ceb224d3d0e5" } -] \ No newline at end of file +] diff --git a/.agentsroom/prompts.json b/.agentsroom/prompts.json index f4455d843..f4cf36c15 100644 --- a/.agentsroom/prompts.json +++ b/.agentsroom/prompts.json @@ -1,4 +1,4 @@ { "folders": [], "prompts": [] -} \ No newline at end of file +} diff --git a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts index 9bb779aa0..68d349457 100644 --- a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts +++ b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts @@ -1302,8 +1302,8 @@ async function* translateInteractionEvents( const state = toolCalls.get(toolCallId) if (!state) break const fragment = delta.arguments ?? '' - const buffer = (argStringByToolCallId.get(toolCallId) ?? '') + - fragment + const buffer = + (argStringByToolCallId.get(toolCallId) ?? '') + fragment argStringByToolCallId.set(toolCallId, buffer) // Try to parse the accumulated buffer; if it's not yet a // complete JSON object, keep `state.args` whatever the last diff --git a/testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md b/testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md index a0c5bc89a..33c03bcb9 100644 --- a/testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md +++ b/testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md @@ -15,20 +15,20 @@ the mock's text/tool deltas (empty assistant message → test fails). Unit coverage for the 2.x adapter is comprehensive and green (`packages/ai-gemini/tests/text-interactions-adapter.test.ts`, 25 tests) — those -feed hand-written 2.x `step.*` events. The fixme'd e2e test is the *only* gap, and +feed hand-written 2.x `step.*` events. The fixme'd e2e test is the _only_ gap, and it is purely a mock-vs-adapter format mismatch. --- ## Root cause: zero event-type overlap -| Concern | aimock emits today (**SDK 1.x**) | adapter expects (**SDK 2.x**) | -| --- | --- | --- | -| interaction opened | `interaction.start` | `interaction.created` | -| content block opened | `content.start` | `step.start` | -| streamed delta | `content.delta` | `step.delta` | -| content block closed | `content.stop` | `step.stop` | -| interaction finished | `interaction.complete` | `interaction.completed` | +| Concern | aimock emits today (**SDK 1.x**) | adapter expects (**SDK 2.x**) | +| -------------------- | -------------------------------- | ----------------------------- | +| interaction opened | `interaction.start` | `interaction.created` | +| content block opened | `content.start` | `step.start` | +| streamed delta | `content.delta` | `step.delta` | +| content block closed | `content.stop` | `step.stop` | +| interaction finished | `interaction.complete` | `interaction.completed` | Because **no `event_type` matches**, the adapter's `switch` (in `translateInteractionEvents`, `packages/ai-gemini/src/experimental/text-interactions/adapter.ts`) @@ -84,7 +84,7 @@ envelope `event_type` changes, plus an optional `step.start`/`step.stop` wrapper + { event_type: "step.stop", index: 0 } ``` -Minimum viable fix for *this* test: the adapter lazily opens the assistant +Minimum viable fix for _this_ test: the adapter lazily opens the assistant message on the first `step.delta { type: "text" }`, so renaming `content.delta` → `step.delta` alone is enough to make text render. Emitting the `step.start { type: "model_output" }` / `step.stop` wrapper is the @@ -112,6 +112,7 @@ fragment (not a parsed object). ``` Adapter contract for tool calls: + - `step.start.step.id` is the tool-call id; the adapter maps `index → id` so later `arguments_delta` / `step.stop` at the same `index` resolve correctly. - `step.start.step.arguments` may be `{}` (placeholder) when streaming; the real From 9feeca7a5f6aeaead02de7b41ae34abb1323540c Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 23 Jun 2026 16:37:12 -0700 Subject: [PATCH 10/13] fix(ai-gemini): address review feedback on Interactions v2 migration - Stream function-call args via partial-json instead of strict JSON.parse: incomplete `arguments_delta` fragments no longer log a parse error each, and a truncated buffer keeps the last good args instead of resetting to {} (tombeckenham, CodeRabbit). - Drop the unnecessary `as Record` cast on step.arguments; FunctionCallStep.arguments is already an index-signature object (tombeckenham). - Remove accidentally-committed `.agentsroom/` tooling (incl. a personal claudeSessionId), unrelated to this change (CodeRabbit). - Add unit tests: multi-fragment arguments_delta accumulation, args-stream truncation preserving last good args, and thought_summary -> REASONING_*. - Add changeset for the @google/genai v2 upgrade. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agentsroom/.gitignore | 4 - .agentsroom/agents.json | 10 - .agentsroom/prompts.json | 4 - .changeset/gemini-genai-v2-upgrade.md | 5 + packages/ai-gemini/package.json | 3 +- .../experimental/text-interactions/adapter.ts | 52 ++--- .../tests/text-interactions-adapter.test.ts | 194 ++++++++++++++++++ pnpm-lock.yaml | 3 + 8 files changed, 231 insertions(+), 44 deletions(-) delete mode 100644 .agentsroom/.gitignore delete mode 100644 .agentsroom/agents.json delete mode 100644 .agentsroom/prompts.json create mode 100644 .changeset/gemini-genai-v2-upgrade.md diff --git a/.agentsroom/.gitignore b/.agentsroom/.gitignore deleted file mode 100644 index 1acd1a387..000000000 --- a/.agentsroom/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# AgentsRoom: personal files (not committed to git) -*-personal.json -agents-local.json -sessions/ diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json deleted file mode 100644 index 87e40d148..000000000 --- a/.agentsroom/agents.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "role": "fullstack", - "model": "opus", - "customName": "Full-Stack Developer", - "isPersonal": false, - "id": "agent-1776361243376-3sekdc", - "claudeSessionId": "96773a93-be2a-45a9-a732-ceb224d3d0e5" - } -] diff --git a/.agentsroom/prompts.json b/.agentsroom/prompts.json deleted file mode 100644 index f4cf36c15..000000000 --- a/.agentsroom/prompts.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "folders": [], - "prompts": [] -} diff --git a/.changeset/gemini-genai-v2-upgrade.md b/.changeset/gemini-genai-v2-upgrade.md new file mode 100644 index 000000000..1eae869db --- /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 279a38071..43b1b5933 100644 --- a/packages/ai-gemini/package.json +++ b/packages/ai-gemini/package.json @@ -65,7 +65,8 @@ ], "dependencies": { "@google/genai": "^2.8.0", - "@tanstack/ai-utils": "workspace:*" + "@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 4086eb186..16c1249d3 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, @@ -644,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 } } @@ -1034,7 +1035,7 @@ async function* translateInteractionEvents( // 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 as Record + const initialArgs = step.arguments const state: ToolCallState = { name: step.name, args: { ...initialArgs }, @@ -1309,13 +1310,14 @@ async function* translateInteractionEvents( const buffer = (argStringByToolCallId.get(toolCallId) ?? '') + fragment argStringByToolCallId.set(toolCallId, buffer) - // Try to parse the accumulated buffer; if it's not yet a - // complete JSON object, keep `state.args` whatever the last - // parse produced. The wire-level args fragments don't have - // to be individually-parseable, but the *full* string at - // step.stop will be. - const parsed = safeParseToolArguments(buffer, logger) - state.args = parsed + // 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, diff --git a/packages/ai-gemini/tests/text-interactions-adapter.test.ts b/packages/ai-gemini/tests/text-interactions-adapter.test.ts index 2012a4409..fc3ce9f41 100644 --- a/packages/ai-gemini/tests/text-interactions-adapter.test.ts +++ b/packages/ai-gemini/tests/text-interactions-adapter.test.ts @@ -443,6 +443,200 @@ 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 297c24d09..044b3fdbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1371,6 +1371,9 @@ importers: '@tanstack/ai-utils': specifier: workspace:* version: link:../ai-utils + partial-json: + specifier: ^0.1.7 + version: 0.1.7 devDependencies: '@tanstack/ai': specifier: workspace:* From 1d80a2d68ffcea2f947078c994cbfb81446bd5ae Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:38:30 +0000 Subject: [PATCH 11/13] ci: apply automated fixes --- .../tests/text-interactions-adapter.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/ai-gemini/tests/text-interactions-adapter.test.ts b/packages/ai-gemini/tests/text-interactions-adapter.test.ts index fc3ce9f41..c043efb63 100644 --- a/packages/ai-gemini/tests/text-interactions-adapter.test.ts +++ b/packages/ai-gemini/tests/text-interactions-adapter.test.ts @@ -491,9 +491,7 @@ describe('GeminiTextInteractionsAdapter', () => { chat({ adapter, messages: [{ role: 'user', content: 'Weather in Berlin?' }], - tools: [ - { name: 'lookup_weather', description: 'Return the weather' }, - ], + tools: [{ name: 'lookup_weather', description: 'Return the weather' }], }), ) @@ -502,7 +500,9 @@ describe('GeminiTextInteractionsAdapter', () => { 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 + 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"}', @@ -538,7 +538,10 @@ describe('GeminiTextInteractionsAdapter', () => { index: 0, // Complete `city` key, then a dangling `unit` key the stream never // finishes. - delta: { type: 'arguments_delta', arguments: '{"city":"London","unit":' }, + delta: { + type: 'arguments_delta', + arguments: '{"city":"London","unit":', + }, }, { event_type: 'step.stop', index: 0 }, { @@ -553,9 +556,7 @@ describe('GeminiTextInteractionsAdapter', () => { chat({ adapter, messages: [{ role: 'user', content: 'Weather in London?' }], - tools: [ - { name: 'lookup_weather', description: 'Return the weather' }, - ], + tools: [{ name: 'lookup_weather', description: 'Return the weather' }], }), ) From 0b42883f0003d5dd93d0a961d70d298324817e24 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:13:32 +1000 Subject: [PATCH 12/13] test(ai-gemini): enable stateful-interactions e2e on aimock 1.34.0 aimock 1.34.0 emits the SDK 2.x Gemini Interactions event format (interaction.created/completed, step.start/delta/stop, arguments_delta), so the migrated geminiTextInteractions adapter can now be exercised end-to-end. Bump @copilotkit/aimock to ^1.34.0, un-fixme the two-turn stateful-interactions spec, and drop the now-obsolete AIMOCK-TODO doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- pnpm-lock.yaml | 10 +- testing/e2e/package.json | 2 +- .../stateful-interactions.AIMOCK-TODO.md | 153 ------------------ .../e2e/tests/stateful-interactions.spec.ts | 18 +-- 4 files changed, 8 insertions(+), 175 deletions(-) delete mode 100644 testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 044b3fdbf..ffe9d4363 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1958,8 +1958,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) @@ -3088,8 +3088,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: @@ -16168,7 +16168,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)(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)) diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 68381f527..a2af416a8 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.AIMOCK-TODO.md b/testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md deleted file mode 100644 index 33c03bcb9..000000000 --- a/testing/e2e/tests/stateful-interactions.AIMOCK-TODO.md +++ /dev/null @@ -1,153 +0,0 @@ -# aimock TODO — Gemini Interactions **SDK 2.x** event format - -**Status:** `tests/stateful-interactions.spec.ts` is marked `test.fixme` and will -not run until [`@copilotkit/aimock`](https://github.com/CopilotKit/aimock) emits -the **SDK 2.x** Gemini Interactions event format. - -This is **not** a bug in the adapter. The `gemini-upgrade` branch -(`feat(ai-gemini): upgrade @google/genai to v2 and migrate Interactions API`) -migrated `geminiTextInteractions` to the **SDK 2.x** Interactions event protocol -(the "Interactions breaking changes, May 2026"). The adapter is correct for the -live API. aimock has **not** migrated — every published version through **1.31.0** -(latest as of 2026-06-17) still emits the **SDK 1.x** event shapes — so the mock -and the adapter now disagree on the wire format, and the adapter consumes none of -the mock's text/tool deltas (empty assistant message → test fails). - -Unit coverage for the 2.x adapter is comprehensive and green -(`packages/ai-gemini/tests/text-interactions-adapter.test.ts`, 25 tests) — those -feed hand-written 2.x `step.*` events. The fixme'd e2e test is the _only_ gap, and -it is purely a mock-vs-adapter format mismatch. - ---- - -## Root cause: zero event-type overlap - -| Concern | aimock emits today (**SDK 1.x**) | adapter expects (**SDK 2.x**) | -| -------------------- | -------------------------------- | ----------------------------- | -| interaction opened | `interaction.start` | `interaction.created` | -| content block opened | `content.start` | `step.start` | -| streamed delta | `content.delta` | `step.delta` | -| content block closed | `content.stop` | `step.stop` | -| interaction finished | `interaction.complete` | `interaction.completed` | - -Because **no `event_type` matches**, the adapter's `switch` (in -`translateInteractionEvents`, `packages/ai-gemini/src/experimental/text-interactions/adapter.ts`) -hits its observability `default` for every event, emits nothing, and the -assistant message comes back empty. - -The adapter handles exactly these top-level `event_type`s: -`interaction.created`, `step.start`, `step.delta`, `step.stop`, -`interaction.status_update`, `interaction.completed`, `error`. - ---- - -## What aimock needs to change - -Source: `dist/gemini-interactions.js` (and its `src` equivalent) in the aimock -repo. Two builders need rewriting: -`buildInteractionsTextSSEEvents` and `buildInteractionsToolCallSSEEvents`. - -### 1. Lifecycle events (both builders) - -Rename and reshape the envelope. The `interaction` object keeps `id`, `status`, -and `usage` — only the `event_type` names change. - -```diff -- { event_type: "interaction.start", interaction: { id, status: "in_progress" } } -+ { event_type: "interaction.created", interaction: { id, status: "in_progress" } } - -- { event_type: "interaction.complete", interaction: { id, status, usage } } -+ { event_type: "interaction.completed", interaction: { id, status, usage } } -``` - -> The adapter reads `interaction.id` from **both** `interaction.created` and -> `interaction.completed` and surfaces it as the `gemini.interactionId` CUSTOM -> event. The e2e test reads that via the `gemini-interaction-id` element, so the -> `id` field must remain populated on these events. - -Status values map unchanged: text → `completed`; tool calls → `requires_action`. -`statusToFinishReason` / `statusIsError` interpret them the same way. - -### 2. Text streaming — `buildInteractionsTextSSEEvents` - -The **inner delta shape is identical** (`{ type: "text", text }`). Only the -envelope `event_type` changes, plus an optional `step.start`/`step.stop` wrapper. - -```diff -- { event_type: "content.start", index: 0, content: { type: "text" } } -+ { event_type: "step.start", index: 0, step: { type: "model_output" } } - -- { event_type: "content.delta", index: 0, delta: { type: "text", text: slice } } -+ { event_type: "step.delta", index: 0, delta: { type: "text", text: slice } } - -- { event_type: "content.stop", index: 0 } -+ { event_type: "step.stop", index: 0 } -``` - -Minimum viable fix for _this_ test: the adapter lazily opens the assistant -message on the first `step.delta { type: "text" }`, so renaming -`content.delta` → `step.delta` alone is enough to make text render. Emitting the -`step.start { type: "model_output" }` / `step.stop` wrapper is the -spec-faithful shape and is recommended. - -### 3. Tool calls — `buildInteractionsToolCallSSEEvents` - -This is the larger change. In 2.x the call **identity (`id`, `name`, -`arguments`) lives on `step.start`**, not in a delta, and streamed argument -fragments use a dedicated `arguments_delta` variant carrying a **string** -fragment (not a parsed object). - -```diff - // per tool call, at step index `idx`: -- { event_type: "content.start", index: idx, content: { type: "function_call" } } -- { event_type: "content.delta", index: idx, -- delta: { type: "function_call", id, name, arguments: argsObj } } -- { event_type: "content.stop", index: idx } -+ { event_type: "step.start", index: idx, -+ step: { type: "function_call", id, name, arguments: {} } } -+ // optional: stream args as JSON-string fragments -+ { event_type: "step.delta", index: idx, -+ delta: { type: "arguments_delta", arguments: "" } } -+ { event_type: "step.stop", index: idx } -``` - -Adapter contract for tool calls: - -- `step.start.step.id` is the tool-call id; the adapter maps `index → id` so - later `arguments_delta` / `step.stop` at the same `index` resolve correctly. -- `step.start.step.arguments` may be `{}` (placeholder) when streaming; the real - args then arrive as `arguments_delta` string fragments. It may also be a fully - populated object for non-streamed calls — both are accepted. -- `arguments_delta.arguments` is a **string** fragment; the concatenation across - all fragments for a given index must be valid JSON by `step.stop`. - -### 4. Reasoning (`thought`) — not exercised by the current spec - -For completeness, 2.x reasoning is: -`step.start { step: { type: "thought", summary?: [{ type: "text", text }] } }` -then `step.delta { delta: { type: "thought_summary", content: { text } } }`. - ---- - -## How to verify once aimock ships 2.x - -1. Bump `@copilotkit/aimock` in `testing/e2e/package.json`, `pnpm install`. -2. Confirm the new emitter produces `step.*` events: - ```bash - grep -E 'event_type: "(step|interaction\.created|interaction\.completed)' \ - node_modules/.pnpm/@copilotkit+aimock@*/node_modules/@copilotkit/aimock/dist/gemini-interactions.js - ``` -3. Un-`fixme` the test (`test.fixme` → `test`) in - `tests/stateful-interactions.spec.ts`. -4. ```bash - pnpm --filter @tanstack/ai-e2e test:e2e -- --grep "stateful" - ``` - -## References - -- Adapter event translator: `packages/ai-gemini/src/experimental/text-interactions/adapter.ts` - (`translateInteractionEvents`, the `switch (event.event_type)`). -- 2.x unit fixtures (canonical event shapes): `packages/ai-gemini/tests/text-interactions-adapter.test.ts`. -- aimock current emitter: `@copilotkit/aimock/dist/gemini-interactions.js` - (`buildInteractionsTextSSEEvents`, `buildInteractionsToolCallSSEEvents`). -- aimock repo: https://github.com/CopilotKit/aimock diff --git a/testing/e2e/tests/stateful-interactions.spec.ts b/testing/e2e/tests/stateful-interactions.spec.ts index 2b84fc4b5..d7cee5b7f 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. @@ -35,21 +35,7 @@ import { providersFor } from './test-matrix' // tests in `packages/ai-gemini/tests/text-interactions-adapter.test.ts`. for (const provider of providersFor('stateful-interactions')) { test.describe(`${provider} — stateful-interactions`, () => { - // DISABLED — blocked on aimock. The gemini-upgrade branch migrated - // `geminiTextInteractions` to the @google/genai v2 (SDK 2.x) Interactions - // event protocol, which streams `step.start` / `step.delta` / `step.stop` - // and `interaction.created` / `interaction.completed`. Every published - // `@copilotkit/aimock` (≤1.31.0) still emits the SDK 1.x shapes - // (`content.*` / `interaction.start` / `interaction.complete`), so the - // adapter consumes none of the mock's deltas and the assistant message - // comes back empty. This is a mock/adapter format mismatch, not an adapter - // bug — 2.x behaviour is covered by unit tests in - // packages/ai-gemini/tests/text-interactions-adapter.test.ts. - // - // Re-enable (test.fixme → test) once aimock emits SDK 2.x events. The exact - // required event shapes and migration steps are documented in - // ./stateful-interactions.AIMOCK-TODO.md - test.fixme('two-turn conversation chained via previous_interaction_id', async ({ + test('two-turn conversation chained via previous_interaction_id', async ({ page, testId, aimockPort, From a14339efc53980e4f6d0bcecd648e5a5949b6776 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:23:24 +1000 Subject: [PATCH 13/13] Exlcuding aimock from minimum age restrictions --- pnpm-workspace.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8c8caf60a..7d2b784d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,10 @@ trustPolicy: 'no-downgrade' overrides: abbrev: ^3.0.0 +# We're working closely with the aimock team and often need releases of this package. +minimumReleaseAgeExclude: + - '@copilotkit/aimock' + patchedDependencies: '@changesets/assemble-release-plan@6.0.9': patches/@changesets__assemble-release-plan@6.0.9.patch