Skip to content

fix: migrate Gemini Interactions emitter to SDK 2.x event protocol (#277) - #279

Merged
jpr5 merged 5 commits into
CopilotKit:mainfrom
tombeckenham:277-gemini-interactions-emitter-still-produces-sdk-1-x-events-migrate-to-sdk-2-x-step-interaction-created-completed
Jun 24, 2026
Merged

fix: migrate Gemini Interactions emitter to SDK 2.x event protocol (#277)#279
jpr5 merged 5 commits into
CopilotKit:mainfrom
tombeckenham:277-gemini-interactions-emitter-still-produces-sdk-1-x-events-migrate-to-sdk-2-x-step-interaction-created-completed

Conversation

@tombeckenham

@tombeckenham tombeckenham commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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/completeinteraction.created/completed (id stays populated on both; status text→completed, tool calls→requires_action).
  • Text: content.start/delta/stopstep.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.tscollapseGeminiInteractionsSSE 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.stepsmodel_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 }.
  • buildInteractionsToolCallResponsesteps: 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).
  • buildInteractionsContentWithToolCallsResponseoutput_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

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):

# 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

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.
@pkg-pr-new

pkg-pr-new Bot commented Jun 24, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@279

commit: ef3a3fc

jpr5 added 2 commits June 24, 2026 14:09
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).
@jpr5
jpr5 merged commit 3f8e654 into CopilotKit:main Jun 24, 2026
23 checks passed
@jpr5 jpr5 mentioned this pull request Jun 24, 2026
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gemini-interactions: emitter still produces SDK 1.x events; migrate to SDK 2.x step.* / interaction.created|completed

2 participants