fix: migrate Gemini Interactions emitter to SDK 2.x event protocol (#277) - #279
Merged
Conversation
The Interactions mock emitter produced the SDK 1.x streamed event shapes (interaction.start/complete, content.start/delta/stop), which have zero event_type overlap with the SDK 2.x adapter (@google/genai v2 'Interactions breaking changes, May 2026'). A v2 consumer hit its switch default for every event and rendered an empty assistant message. - Rewrite the three SSE builders to emit interaction.created/completed and step.start/delta/stop. Tool-call identity (id/name) now lives on step.start with an empty arguments placeholder; arguments stream as a dedicated arguments_delta carrying a JSON-string fragment (valid JSON by step.stop). - Count step.delta (not content.delta) for truncateAfterChunks budget. - Teach collapseGeminiInteractionsSSE the 2.x shapes (step.start identity + arguments_delta assembly, nested thought_summary), keeping 1.x parsing for backward compatibility with previously recorded fixtures. - Update unit/integration/collapse tests and drift SDK shapes to 2.x; add round-trip and legacy backward-compat coverage. Closes CopilotKit#277
Address review findings on the SDK 2.x migration: - Flag assembled arguments_delta fragments that don't concatenate into valid JSON by step.stop via droppedChunks/firstDroppedSample, instead of silently writing a corrupt tool call into a recorded fixture. - Preserve the identity of a function_call step.start that arrives without an index by minting a synthetic key (matches the sibling collapsers) rather than dropping it silently. - Reword the emitter's arguments_delta comment: the mock emits one whole fragment (a valid degenerate case), it does not itself concatenate. - Add tests: multiple/interleaved tool calls collapse in step-index order, emitter assigns sequential step indices, invalid-JSON assembly is flagged, and an index-less function_call step.start keeps its identity.
- Streaming builders: malformed tool-call arguments degrade to a valid '{}'
arguments_delta fragment and emit a warning.
- Tool-call and content+tools streams: assert usage propagates and the
terminal status is requires_action on interaction.completed (unit + e2e).
- Collapser: pin the ordering behavior when an arguments_delta arrives before
its step.start — the early fragment is flagged via droppedChunks rather than
mis-attributed, and the call still surfaces with empty args.
commit: |
The streamed SSE emitter was migrated to SDK 2.x in this PR, but the
non-streaming (unary) JSON response builders still emitted the SDK 1.x
{ ..., outputs: [...] } envelope. The @google/genai v2 Interactions
consumer reads non-streaming content from interaction.steps (model_output
content[] for text, function_call steps for tools) with an output_text
fast-path — never from outputs — so a v2 consumer doing a non-streaming
completion got a silently empty result, the same failure class the
streaming half already fixed.
- buildInteractionsTextResponse: emit output_text + a single model_output
step wrapping the text part.
- buildInteractionsToolCallResponse: emit function_call steps (id/name/
parsed arguments) instead of outputs; preserve the malformed-args
try/catch + logger.warn guard via a shared buildFunctionCallStep helper.
- buildInteractionsContentWithToolCallsResponse: emit output_text + a
model_output text step followed by function_call steps.
- Flip the drift SDK shapes and the non-streaming unit/integration tests
to assert the v2 steps/output_text shape (red-green proven).
Merged
jpr5
added a commit
that referenced
this pull request
Jun 24, 2026
Release **v1.34.0** — bundles the two grouped PRs now on `main`. ## Included - **#276** (`Changed`) — content-anchored fixture matching; `turnIndex` is a non-fatal disambiguator, not a hard reject gate. New `turnIndexRelaxed` diagnostic + one-shot warn; `AIMOCK_STRICT_TURN_INDEX=1` restores strict replay. Record path stays strict. - **#279** (`Fixed`) — Gemini Interactions mock now emits the SDK 2.x event protocol on both streamed SSE and non-streaming paths; legacy 1.x recorded fixtures still parse. ## Mechanics - `package.json` → `1.34.0`; CHANGELOG `[Unreleased]` finalized under `## [1.34.0] - 2026-06-24`. - Merging to `main` triggers `publish-release.yml` → `npm publish` + tag `v1.34.0`. - Local gate green: prettier/eslint/build clean, 4055 tests pass. Minor bump (not v2): both changes ship with back-compat/opt-out.
This was referenced Jun 24, 2026
jpr5
added a commit
that referenced
this pull request
Jun 24, 2026
Follow-up to the v1.34.0 release (#280): the release commit bumped `package.json` + `CHANGELOG.md` but missed the other 4 version surfaces, leaving them drifted at 1.33.0. Bumps to 1.34.0: - `.claude-plugin/plugin.json` - `.claude-plugin/marketplace.json` (`^1.34.0`) - `charts/aimock/Chart.yaml` (`appVersion`) - `packages/aimock-pytest/src/aimock_pytest/_version.py` (`AIMOCK_VERSION`) — functional: pytest default-download users would otherwise get 1.33.0 (old SDK 1.x Gemini Interactions shapes) and fail against the v2 migration in #279. No republish — `package.json` is already 1.34.0 on npm; this only realigns downstream surfaces.
jpr5
added a commit
that referenced
this pull request
Jul 15, 2026
jpr5
added a commit
that referenced
this pull request
Jul 15, 2026
) (#279) ## Summary Migrates the Gemini **Interactions** mock from the SDK 1.x event/response format to SDK 2.x (the "Interactions breaking changes, May 2026" shapes in `@google/genai` v2) — covering **both** the streamed SSE emitter **and** the non-streaming (unary) JSON response builders. Previously the streamed path produced `interaction.start`/`interaction.complete` + `content.start`/`content.delta`/`content.stop` (**zero `event_type` overlap** with the v2 adapter — a migrated consumer hit its `switch` default for every event and rendered an empty assistant message), and the non-streaming path emitted a 1.x `outputs` envelope the v2 consumer never reads. Both paths now speak v2. Closes #277. ## What changed — streamed SSE **`src/gemini-interactions.ts`** — rewrote the three SSE builders: - Lifecycle: `interaction.start`/`complete` → `interaction.created`/`completed` (id stays populated on both; status text→`completed`, tool calls→`requires_action`). - Text: `content.start/delta/stop` → `step.start { step: { type: "model_output" } }` / `step.delta { delta: { type: "text", text } }` / `step.stop`. - Tool calls: call identity (`id`, `name`) now lives on `step.start` with an `arguments: {}` placeholder; arguments stream as `step.delta { delta: { type: "arguments_delta", arguments: "<json-string fragment>" } }`, valid JSON by `step.stop`. - `writeGeminiInteractionsSSEStream` now counts `step.delta` (not `content.delta`) for the `truncateAfterChunks` budget. **`src/stream-collapse.ts`** — `collapseGeminiInteractionsSSE` now parses 2.x events (assemble tool calls from `step.start` identity + `arguments_delta` string fragments keyed by index; nested `thought_summary.content.text`), while keeping 1.x parsing for previously recorded fixtures. Hardened against silent data loss: malformed assembled args and index-less / uncorrelated `step.start`/`arguments_delta` are flagged via `droppedChunks`/`firstDroppedSample` rather than written silently. ## What changed — non-streaming (unary) responses The `@google/genai` v2 Interactions consumer (`extractTextFromInteraction` in `packages/ai-gemini/src/experimental/text-interactions/adapter.ts`, TanStack/ai#781) reads a non-streaming interaction from `interaction.output_text` (string fast-path) then walks `interaction.steps` — `model_output` steps' `content[]` text parts and `function_call` steps — and **never** reads `outputs`. So a v2 consumer doing a non-streaming completion got a silently empty result: the same failure class the streamed path fixed. These builders are reachable (`if (!streaming)` routing in `src/gemini-interactions.ts`). Migrated `outputs → steps`: - `buildInteractionsTextResponse` → `{ id, status: "completed", model, role: "model", output_text: content, steps: [{ type: "model_output", content: [{ type: "text", text: content }] }], usage }`. - `buildInteractionsToolCallResponse` → `steps: toolCalls.map(... { type: "function_call", id, name, arguments: <parsed> })`, status `requires_action`; preserves the malformed-args `try/catch` + `logger.warn` guard (extracted into a shared `buildFunctionCallStep` helper). - `buildInteractionsContentWithToolCallsResponse` → `output_text` + a `model_output` text step followed by `function_call` steps. ## Tests + drift baseline Updated unit/integration/collapse tests and `src/__tests__/drift/sdk-shapes.ts` (`geminiInteractionsStreamEventShapes`, `geminiInteractionsResponseShape`, `geminiInteractionsToolCallResponseShape`) to 2.x; added round-trip, multiple/interleaved tool calls, ordering, malformed-args, usage/status, and 1.x backward-compat coverage; flipped the non-streaming unit/integration assertions to the v2 `steps`/`output_text` shape. ## Verification ```bash grep -E 'event_type: "(step|interaction\.created|interaction\.completed)' dist/gemini-interactions.js ``` matches after build (0 legacy shapes remain). Full suite: **3982 passing / 44 skipped**, prettier + eslint clean, build OK. Red → green proof for the non-streaming migration (assertions run against the real builder output / real server JSON, not a re-implementation): ```text # RED (against the old `outputs` source, before the builder change) × response builders > builds text response → expected undefined to be 'Hello!' // Object.is equality × Gemini Interactions — non-streaming > returns tool call response → Target cannot be null or undefined. Tests 2 failed | 97 skipped (99) # (full file pre-fix: 15 failed | 84 passed (99)) # GREEN (after migrating the builders to `steps`) ✓ src/__tests__/gemini-interactions.test.ts (99 tests | 97 skipped) Tests 2 passed | 97 skipped (99) # (full file: 99 passed (99); full suite: 3982 passed | 44 skipped; eslint + prettier clean; build OK) ``` This unblocks re-enabling the `stateful-interactions` e2e test in TanStack/ai#781 once aimock is bumped/released. ## Notes / scope - One ambiguous case is deliberately not signalled: a streamed `function_call` whose `step.start` carries an empty `{}` placeholder but never receives an `arguments_delta` finalizes to `"{}"`. On the wire that is byte-identical to a legitimately empty-args call, so flagging it would false-positive on every legitimate empty-args call. - Empty-text non-streaming responses emit `output_text: ""` and a single `model_output` step whose text part is `""` (mirrors the streamed empty-content behavior). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jpr5
added a commit
that referenced
this pull request
Jul 15, 2026
Release **v1.34.0** — bundles the two grouped PRs now on `main`. ## Included - **#276** (`Changed`) — content-anchored fixture matching; `turnIndex` is a non-fatal disambiguator, not a hard reject gate. New `turnIndexRelaxed` diagnostic + one-shot warn; `AIMOCK_STRICT_TURN_INDEX=1` restores strict replay. Record path stays strict. - **#279** (`Fixed`) — Gemini Interactions mock now emits the SDK 2.x event protocol on both streamed SSE and non-streaming paths; legacy 1.x recorded fixtures still parse. ## Mechanics - `package.json` → `1.34.0`; CHANGELOG `[Unreleased]` finalized under `## [1.34.0] - 2026-06-24`. - Merging to `main` triggers `publish-release.yml` → `npm publish` + tag `v1.34.0`. - Local gate green: prettier/eslint/build clean, 4055 tests pass. Minor bump (not v2): both changes ship with back-compat/opt-out.
jpr5
added a commit
that referenced
this pull request
Jul 15, 2026
Follow-up to the v1.34.0 release (#280): the release commit bumped `package.json` + `CHANGELOG.md` but missed the other 4 version surfaces, leaving them drifted at 1.33.0. Bumps to 1.34.0: - `.claude-plugin/plugin.json` - `.claude-plugin/marketplace.json` (`^1.34.0`) - `charts/aimock/Chart.yaml` (`appVersion`) - `packages/aimock-pytest/src/aimock_pytest/_version.py` (`AIMOCK_VERSION`) — functional: pytest default-download users would otherwise get 1.33.0 (old SDK 1.x Gemini Interactions shapes) and fail against the v2 migration in #279. No republish — `package.json` is already 1.34.0 on npm; this only realigns downstream surfaces.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrates the Gemini Interactions mock from the SDK 1.x event/response format to SDK 2.x (the "Interactions breaking changes, May 2026" shapes in
@google/genaiv2) — covering both the streamed SSE emitter and the non-streaming (unary) JSON response builders. Previously the streamed path producedinteraction.start/interaction.complete+content.start/content.delta/content.stop(zeroevent_typeoverlap with the v2 adapter — a migrated consumer hit itsswitchdefault for every event and rendered an empty assistant message), and the non-streaming path emitted a 1.xoutputsenvelope the v2 consumer never reads. Both paths now speak v2.Closes #277.
What changed — streamed SSE
src/gemini-interactions.ts— rewrote the three SSE builders:interaction.start/complete→interaction.created/completed(id stays populated on both; status text→completed, tool calls→requires_action).content.start/delta/stop→step.start { step: { type: "model_output" } }/step.delta { delta: { type: "text", text } }/step.stop.id,name) now lives onstep.startwith anarguments: {}placeholder; arguments stream asstep.delta { delta: { type: "arguments_delta", arguments: "<json-string fragment>" } }, valid JSON bystep.stop.writeGeminiInteractionsSSEStreamnow countsstep.delta(notcontent.delta) for thetruncateAfterChunksbudget.src/stream-collapse.ts—collapseGeminiInteractionsSSEnow parses 2.x events (assemble tool calls fromstep.startidentity +arguments_deltastring fragments keyed by index; nestedthought_summary.content.text), while keeping 1.x parsing for previously recorded fixtures. Hardened against silent data loss: malformed assembled args and index-less / uncorrelatedstep.start/arguments_deltaare flagged viadroppedChunks/firstDroppedSamplerather than written silently.What changed — non-streaming (unary) responses
The
@google/genaiv2 Interactions consumer (extractTextFromInteractioninpackages/ai-gemini/src/experimental/text-interactions/adapter.ts, TanStack/ai#781) reads a non-streaming interaction frominteraction.output_text(string fast-path) then walksinteraction.steps—model_outputsteps'content[]text parts andfunction_callsteps — and never readsoutputs. So a v2 consumer doing a non-streaming completion got a silently empty result: the same failure class the streamed path fixed. These builders are reachable (if (!streaming)routing insrc/gemini-interactions.ts).Migrated
outputs → steps:buildInteractionsTextResponse→{ id, status: "completed", model, role: "model", output_text: content, steps: [{ type: "model_output", content: [{ type: "text", text: content }] }], usage }.buildInteractionsToolCallResponse→steps: toolCalls.map(... { type: "function_call", id, name, arguments: <parsed> }), statusrequires_action; preserves the malformed-argstry/catch+logger.warnguard (extracted into a sharedbuildFunctionCallStephelper).buildInteractionsContentWithToolCallsResponse→output_text+ amodel_outputtext step followed byfunction_callsteps.Tests + drift baseline
Updated unit/integration/collapse tests and
src/__tests__/drift/sdk-shapes.ts(geminiInteractionsStreamEventShapes,geminiInteractionsResponseShape,geminiInteractionsToolCallResponseShape) to 2.x; added round-trip, multiple/interleaved tool calls, ordering, malformed-args, usage/status, and 1.x backward-compat coverage; flipped the non-streaming unit/integration assertions to the v2steps/output_textshape.Verification
grep -E 'event_type: "(step|interaction\.created|interaction\.completed)' dist/gemini-interactions.jsmatches after build (0 legacy shapes remain). Full suite: 3982 passing / 44 skipped, prettier + eslint clean, build OK.
Red → green proof for the non-streaming migration (assertions run against the real builder output / real server JSON, not a re-implementation):
This unblocks re-enabling the
stateful-interactionse2e test in TanStack/ai#781 once aimock is bumped/released.Notes / scope
function_callwhosestep.startcarries an empty{}placeholder but never receives anarguments_deltafinalizes to"{}". On the wire that is byte-identical to a legitimately empty-args call, so flagging it would false-positive on every legitimate empty-args call.output_text: ""and a singlemodel_outputstep whose text part is""(mirrors the streamed empty-content behavior).🤖 Generated with Claude Code