Skip to content

fix(drift): attribute a WS refusal, close the CLI signal race, and probe a modality Gemini serves - #374

Merged
jpr5 merged 9 commits into
mainfrom
land/drift-followups
Aug 12, 2026
Merged

fix(drift): attribute a WS refusal, close the CLI signal race, and probe a modality Gemini serves#374
jpr5 merged 9 commits into
mainfrom
land/drift-followups

Conversation

@jpr5

@jpr5 jpr5 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Follow-ups to #371. Three fixes: the drift collector could not attribute a WS refusal and stopped for manual triage; the CLI could be killed ungracefully by a signal arriving as it announced readiness; and the Gemini Live probe requested a modality its model does not serve.

1. A WS refusal now names its provider instead of stopping the run

After #371, drift-live-pr still exited 5 — the message moved from "could not be parsed as drift reports" to "could not be mapped to a provider". The classification worked; attribution did not, and the manual-triage stop remained.

From run 31571018005's own artifact, the two failures are not timeouts:

WSClosedError code=1007
reason="The requested combination of response modalities (TEXT) is not supported
        by the model. models/gemini-3.1-flash-live-preview"

1007 is an RFC 6455 refusal, so the correct outcome is attributed critical drift at exit 2, not the hang-up lane.

Attribution keyed on a *.drift.ts stack frame, and there is no such frame — every frame is ws-providers.ts or a node internal, because the throw is raised from a socket callback the probe's frame does not survive. It is absent, not deeper, so no frame pattern recovers it. The suite title carries the answer, so the frame is tried first (still load-bearing) and the title is the fallback, anchored at the start against the same closed WS_HANDSHAKE_PROBES table via each surface's registry label.

Red reproduces CI byte-identically at REAL_RC=5. Green gives REAL_RC=2 with provider=Gemini Live, builderFile=src/ws-gemini-live.ts, id=ws-close:1007, and Google's own reason preserved into the finding. Exit 2 is tolerated on the base leg, so the stop clears.

Refusing to guess an owner is retained deliberately — a confidently wrong owner fails open. Unregistered suite with no frame still quarantines at 5; a label appearing mid-title still quarantines; a non-refusal code still routes to the hang-up lane. All three probes attribute on both the refusal and handshake lanes, with each probe's real describe title bound to its registry label, because a table that works for one provider is how the previously hardcoded openai-realtime bug survived.

An unfailable guard was deleted: a longest-label-first sort that no input could distinguish, since no WS label is a prefix of another. It is replaced by a test pinning that prefix invariant, so the assumption fails loudly if it ever stops holding.

2. The CLI installs signal handlers before announcing readiness

aimock server listening on … was printed before process.on("SIGTERM") was registered. A signal landing in that window hit Node's default handler, which re-raises — the process died code=null, signal=SIGTERM and never ran its shutdown. Anyone sending SIGTERM to aimock in a container could hit it.

The pre-fix artifact fails 35/40 attempts idle and 54/60 under load. Post-fix: 0 failures in 100 attempts. Reverting the reorder reds the guard 10/10.

The guard also fails loudly with run pnpm build when dist/cli.js predates src/cli.ts. pnpm test never builds and these tests exercise dist/, so a stale artifact previously made the guard silently test the wrong binary — which happened during this work and produced a false conclusion.

Scope stated honestly: a SIGTERM arriving before readiness is announced is still fatal (20/20). That window is not closed, and the test name and comment say so rather than implying coverage the code does not provide.

3. The Gemini Live probe requests a modality its model serves

Google's reason string blames the modality request, but the cause is one layer back: fetchLiveCapableTextModels selected models with !name.includes("native-audio")a name heuristic standing in for a capability check. gemini-3.1-flash-live-preview is a native-audio model whose name omits that substring, so it was silently misclassified as text-capable and the probe asked it for TEXT.

Selection now keys on the declared bidiGenerateContent capability. The probe requests ["AUDIO"] — native-audio Live models support only AUDIO, one modality per session, per Google's capabilities guide, which names this model. The SDK shape becomes inlineData + turnComplete, the drift server gains an audio fixture (the mock already implemented the audio shape), and both legs send identical generationConfig.

An expect(true).toBe(true) canary was deleted rather than kept.

Red replays Google's frame locally: code=1007, 6 failures. Green: 12 pass. Eleven mutations all red, including restoring the name heuristic, flipping the fixture to text, and conflating audio with text in the summary.

Verification

Full suite 5280 passing, test:drift, lint, format:check, build, commitlint all clean. Tests-inclusive typecheck at the 115 baseline with zero errors in every touched file, harness mutation-tested so the zero is a measurement rather than an artifact of a program that compiled nothing. Version-neutral: package.json, CHANGELOG.md, charts/** and .claude-plugin/** untouched.

Unproven, and two findings not fixed here

Only a live run proves the Gemini endpoint accepts ["AUDIO"] and emits this sequence; there are no Google credentials in the environment where this was verified. Only a real CI run proves the cron clears.

aimock ignores generationConfig entirely — removing it from the mock leg fails nothing. It is sent for parity, and that parity is unguarded.

src/ws-gemini-live.ts:468-505 — the audio branch emits inlineData + turnComplete and returns, dropping the toolCalls / content companions that types.ts:398-406 documents as preserved "so the tool call / content / reasoning are not silently discarded."

jpr5 added 9 commits August 12, 2026 11:24
The CLI logged 'aimock server listening on <url>' and only then called
process.on('SIGTERM'). Writes to a pipe are synchronous on Linux, so a
supervisor that reacts to that line can deliver SIGTERM while the process is
still mid-statement. In that window the signal reaches Node's default
disposition, which re-raises it: the process dies with a null exit code and
never runs its own shutdown. Registering first closes the window.

Observed on linux/node20, freezing the child at the instant it announces
readiness: 55/60 iterations died with code=null signal=SIGTERM before the
change, 0/60 after.
The --watch reload test waited for 'listening on' and then asserted that
'Watching' was ALREADY in stdout. The watcher is started after the readiness
line is logged, so the assertion raced the child; it failed 2 of 30 full-suite
runs in a CI-shaped container (4 cpu / 16 GB, linux, node 20). Wait for the
line that actually marks the watcher as up.
… by its test name

Run 31571018005 quarantined two Gemini Live legs as "could not be mapped to a
provider" (exit 5 — the manual-triage stop). Google had refused the session with
RFC 6455 code 1007 and stated the cause, and the collector recognized the close;
what failed was attribution. WS attribution keyed only off a `*.drift.ts` stack
frame, and the failure is raised by the SHARED client (ws-providers.ts) from a
socket callback, so the probe frame does not survive the async boundary — it is
absent from the stack, not merely deeper, and no frame pattern can recover it.

The failing test's name still carries the answer ("Gemini Live WS drift > …"),
so attribution now reads the stack frame FIRST and falls back to the suite title,
anchored at the start and matched against the SAME closed WS_HANDSHAKE_PROBES
table via each surface's registry provider label. Refusing to guess is unchanged:
a title matching no registered label yields null and the failure still
quarantines at exit 5.

The two production legs now attribute to Gemini Live / src/ws-gemini-live.ts as
critical drift (exit 2, non-fatal on the base leg) instead of hard-failing it.
…efix invariant

The longest-label-first sort could not be made to fail: no registered WS probe
label is a prefix of another, so no input distinguished the ordering. An ordering
rule nothing can exercise is coverage that does not exist, so it is gone and the
property it relied on is asserted instead — registering a prefix-colliding probe
now turns a test RED rather than resolving by table order.
… stale build

The CLI tests exercise dist/cli.js and `pnpm test` does not build, so a suite run
right after a source change aims this guard at the previous build. It then reports
the exact ungraceful-exit symptom it exists to detect, from a tree where the code
is already correct. Assert the build is not older than src/cli.ts and say which
command fixes it.

Also widen the attempt loop from 3 to 12 — one attempt catches the unfixed ordering
a little under half the time from inside the running suite, so three attempts miss
it in roughly 18% of runs — and state in the name and the comment that the covered
window starts at the readiness announcement: a SIGTERM delivered before then is
still fatal.
The probe opened every Live session with responseModalities: ["TEXT"].
Every model exposing bidiGenerateContent is a native-audio model that
supports only AUDIO, so Google refused the session out-of-band with an
RFC 6455 close frame — code=1007, "The requested combination of response
modalities (TEXT) is not supported by the model.
models/gemini-3.1-flash-live-preview" — on every drift run since the
listing began resolving to that model. A 1007 is a refusal, so the leg
reported provider noise where drift should have been.

Root cause was model SELECTION, not just the request: discovery required
that the model name NOT contain "native-audio", using the name as a proxy
for text-capability. gemini-3.1-flash-live-preview is a native-audio
model whose name omits that substring, so the heuristic silently
mis-classified it as text-capable and the probe asked it for TEXT.

Selection now keys only on the listing's declared bidiGenerateContent
support, and the leg drives AUDIO — the modality every Live model serves
— grading the audio event sequence (inlineData parts + turnComplete)
plus the modality-independent toolCall. Both sides of the three-way
comparison send the same generationConfig, and the mock side is driven by
an audio fixture so a real AUDIO turn is no longer compared against a
mock TEXT one. The TEXT serverContent shape is not expressible against
any live Live model; it stays covered mock-only by ws-gemini-live.test.ts.

Drops the availability canary, whose body was expect(true).toBe(true) —
it could not fail, so it reported health it never checked, and its
premise (waiting for a text-capable model) is obsolete.

ws-gemini-live-modality.test.ts drives the real probe against a local TLS
server that enforces Google's modality rule, reproducing the 1007 frame
verbatim from the drift-report artifact and running the whole three-way
comparison locally, so the mock side is verified without live credentials.
@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 558baeb

@jpr5

jpr5 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Merging with drift-live-pr red, same structural reason as #371. The failing step runs the collector from a base-main worktree checked out from origin/main — visible in the paths it reports (/home/runner/work/aimock/base-main/…) — so it executes the unfixed code and cannot pass until this lands. It fails on the exact fault this PR fixes.

25 other checks green; local suite 5280 passing; version-neutral.

@jpr5
jpr5 merged commit 0d305d8 into main Aug 12, 2026
27 of 28 checks passed
@jpr5
jpr5 deleted the land/drift-followups branch August 12, 2026 20:06
contextablemark added a commit that referenced this pull request Aug 19, 2026
…the release (#381)

Adds the four user-facing `[Unreleased]` CHANGELOG entries that landed
since the `v1.38.0` tag but had no entry, so the release notes are
complete before the version is cut.

Docs-only: `git diff --name-only origin/main..HEAD` is `CHANGELOG.md`
and nothing else. Version-neutral — no bump, so it does not trigger
`publish-release.yml`. Merge it before the release bump, or lift the
text into the release commit; either works.

Each entry was written from the actual commit diff, not the subject
line:

- **AG-UI `usage` on `RUN_FINISHED`/`RUN_ERROR`** — `aa68b26` (#378):
the new optional `usage?: AGUITokenUsage[]`, typed and emitted,
numeric-only, emitted only when supplied.
- **Responses-API function-call collapse → `toolCalls`** — `23a8c3c` /
#380: a tool-call-only Responses turn previously collapsed to empty
content; now accumulates into `toolCalls` keyed by `output_index`,
capturing `call_id`.
- **Gemini Live speak-and-call ordering** — `a7d9b64` (#378): a
`serverContent` (audio + text companion) strictly before the `toolCall`,
then `turnComplete`; audio turns keep their companions.
- **CLI signal handlers before readiness** — `1079b46` (#374):
`SIGINT`/`SIGTERM` registered before the readiness log, closing the
window where a supervisor's `SIGTERM` killed the process ungracefully
(55/60 → 0/60). The residual — a signal strictly before readiness — is
stated in the entry, drawn from code placement rather than a documented
commit note.

Verified: prettier clean, commitlint RC 0, no version-bearing file
touched.
@jpr5 jpr5 mentioned this pull request Aug 19, 2026
contextablemark added a commit that referenced this pull request Aug 19, 2026
Release cut for v1.39.0. No source changes.

- Bumps root `package.json` version 1.38.0 → 1.39.0.
- Converts the merged `[Unreleased]` material into a dated `## [1.39.0]
- 2026-08-18` entry and opens a fresh empty `[Unreleased]`. All entries
preserved in order (Added / Changed / Deprecated / Fixed), covering
#382, #380, #378, #374, and the reset-canonicalization work (#358).

Follow-up (not in this PR):
`packages/aimock-pytest/src/aimock_pytest/_version.py` still pins
`AIMOCK_VERSION = "1.38.0"`; bump it on the aimock-pytest release
cadence per the changelog note.
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.

1 participant