Skip to content

chore(deps): bump bodhi pin ca0f61de → 0d506ead (B1–B5 observability surfaces) - #3131

Merged
yixuan-ag2 merged 3 commits into
sonichi:mainfrom
yixuan-ag2:voice/bodhi-pin-b1-b5
Aug 18, 2026
Merged

yixuan-ag2 merged 3 commits into
sonichi:mainfrom
yixuan-ag2:voice/bodhi-pin-b1-b5

Conversation

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Pin only; no sutando code change. Single concern per CONTRIBUTING.

What this picks up

The five merged bodhi observability PRs the voice context-budget design depends
on (#33,
#34,
#31,
#30,
#32):

  • Upstream send counters + getDiagnostics() — attempted/queued/skipped/threw
    per slot, split raw/wire bytes, per-outcome timestamps; reset per transport
    generation. This is what finally distinguishes "the agent stopped sending
    audio" from "audio was sent and nothing came back".
  • Typed connection-lifecycle events — attempt / setup-ok / setup-failed /
    attempt-close / generation-close, correlated by connectAttemptId, with
    handleSupplied on the attempt (lineage tracking without log-line inference).
  • onUsageMetadata — promptTokenCount et al, reachable from VoiceSession.
  • mediaResolution passthrough — session-wide media token cost.
  • Optional compressionConfig thresholds — {} = server defaults; values
    sent as the int64-over-JSON strings the API expects.

Plus review hardening from the merge train: observer isolation on both new
callback surfaces, reconnect replay counted AND moved off the deprecated media
field onto the supported video/audio slots, and superseded-dial setup-failed
fenced like every other stale-dial signal.

Consumers land separately (Phase 1: ledger sampling, logicalSessionId,
up=/ctx= health segments, shadow matrix).

Before/after evidence

Before (parent commit, pin ca0f61de):

$ node -e "const {GeminiLiveTransport}=require('bodhi-realtime-agent');
  const t=new GeminiLiveTransport({apiKey:'x'},{});
  console.log(typeof t.getDiagnostics)"
undefined

After (this commit, pin 0d506ead):

installed version: 0.1.5
getDiagnostics present: true
diag shape: ["upstream","transportGeneration"]

Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped.

Python suite: 551 files; 7 fail — the identical 7 fail at the parent pin on
this host (core-input-watch, core-model-pin-probe, discord-readers-429,
discord-token-delegation, remote-gateway-bridge, runtime-health,
start-cli-model-pin): environment/service-dependent, unrelated to the bump.

Live round trip (live-path rule)

Through the pinned artifact (not the SDK directly) against
gemini-2.5-flash-native-audio-preview-12-2025 — connect with
mediaResolution: LOW + compressionConfig: {} accepted by the server; text
in → model audio out; every new surface read while doing it:

connected in 304ms; lifecycle so far: attempt -> setup-ok
text slot     : {"attempted":1,"queued":1,"queuedRawBytes":26}
generation    : 1
audio chunks  : 1 (first after 1442ms)
usage samples : 1 promptTokenCount 375 -> 375
lifecycle full: attempt -> setup-ok -> generation-close
LIVE ROUND TRIP: PASS

… surfaces)

Picks up the five merged bodhi PRs the voice context-budget design depends on
(sonichi/bodhi_realtime_agent sonichi#33, sonichi#34, sonichi#31, sonichi#30, sonichi#32):

- upstream send counters + getDiagnostics() (attempted/queued/skipped/threw,
  split raw/wire bytes, per-outcome timestamps; reset per transport generation)
- typed connection-lifecycle events (attempt / setup-ok / setup-failed /
  attempt-close / generation-close, correlated by connectAttemptId)
- onUsageMetadata (promptTokenCount et al), reachable from VoiceSession
- mediaResolution passthrough (session-wide media token cost)
- optional compressionConfig thresholds ({} = server defaults), sent as the
  int64-over-JSON strings the API expects

Plus review hardening from the merge train: observer isolation on both new
callback surfaces, reconnect replay counted AND moved off the deprecated
`media` field onto the supported video/audio slots, and superseded-dial
setup-failed fenced like every other stale-dial signal.

Pin only; no sutando code change. Consumers land separately (Phase 1: ledger
sampling, logicalSessionId, up=/ctx= health segments, shadow matrix).

Evidence:

Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped.

Live round trip through the PINNED artifact (not the SDK directly) against
gemini-2.5-flash-native-audio-preview-12-2025 — connect with
mediaResolution=LOW + compressionConfig:{} accepted; text in -> model audio out;
every new surface read while doing it:

  connected in 304ms; lifecycle so far: attempt -> setup-ok
  text slot     : {"attempted":1,"queued":1,"queuedRawBytes":26}
  generation    : 1
  audio chunks  : 1 (first after 1442ms)
  usage samples : 1 promptTokenCount 375 -> 375
  lifecycle full: attempt -> setup-ok -> generation-close
  LIVE ROUND TRIP: PASS

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the pin's actual delta rather than the summary. Scope claim is accurate; no blockers. One reframing worth having before someone approves it as routine.

The delta is exactly what the body says — 18 commits, ahead_by: 18, behind_by: 0, and every one belongs to the five named PRs (#33, #34, #31, #30, #32), their build: rebuild dist companions, and the merges. No stray commits rode along:

c23b5227 feat: upstream send counters + getDiagnostics
d2ba4c53 feat: typed connection-lifecycle events
3dd50907 feat: surface Live server usage metadata
8cd85367 feat(gemini): mediaResolution passthrough
74f3ca7a feat(gemini): allow compression thresholds to be omitted
+ 6 dist rebuilds, 5 merges, and the two below

But 16 of those 18 commits are inert on this side. None of the new surfaces has a consumer in sutando yet — measured across src/ and skills/:

getDiagnostics     0 files      onUsageMetadata   0 files
mediaResolution    0 files      compressionConfig 0 files
connectAttemptId   0 files

So the observability half cannot change behavior here regardless of what it does upstream. The entire live-path exposure of this bump is two commits, and neither is observability:

  • 914928ae — replay files now go through sendFile. This changes how reconnect replay is transmitted (and off the deprecated media field). It is disclosed in the body, but "chore(deps)" plus "observability surfaces" undersells it: a reviewer stopping at the title has no reason to go look at reconnect replay, which is a path that only exercises after a transport drop — i.e. exactly when it is least convenient to discover a regression.
  • 9d7955f6 — an observer's failure must not suppress the message it rode in.

That second one is worth naming out loud because of where it lands today: it is the same principle as the blocker I left on #3130 an hour ago, where the shadow observer's observeTick() sits in a bare setInterval with no containment anywhere in its 340 lines, and voice-agent.ts:1354 routes an uncaught throw to a crash-only exit. Upstream bodhi has now adopted observer isolation in the transport; the sutando-side observer added the same day does not have it. Same author, same lane, one layer apart — offered as corroboration for that review, not as a change request on this one.

Nothing here needs to change. Recording it so the approval is "we know the exposure is reconnect replay" rather than "it's a pin bump".

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Evidence upgraded per review — raw commands at both revisions, and a real
post-restart voice round trip from a detached worktree at this PR's head.

Before/after, raw

Parent pin (ca0f61de) — worktree sutando-wt-voice-prework (old-pin node_modules):

$ node -e "const {GeminiLiveTransport}=require('bodhi-realtime-agent'); \
    const t=new GeminiLiveTransport({apiKey:'x'},{}); \
    console.log('getDiagnostics:', typeof t.getDiagnostics)"
getDiagnostics: undefined

This PR's head (618c40de) — detached worktree sutando-wt-pin-evidence:

$ node -e "const {GeminiLiveTransport}=require('bodhi-realtime-agent'); \
    const t=new GeminiLiveTransport({apiKey:'x'},{}); \
    console.log('getDiagnostics:', typeof t.getDiagnostics, '| keys:', JSON.stringify(Object.keys(t.getDiagnostics())))"
getDiagnostics: function | keys: ["upstream","transportGeneration"]

Python failures are pre-existing — same script, both pins:

$ bash run7.sh <worktree>          # runs the 7 files that failed in the full sweep
# at ca0f61de (sutando-wt-voice-prework)  AND at 618c40de: identical output —
core-input-watch: FAIL
core-model-pin-probe: FAIL
discord-readers-429: FAIL
discord-token-delegation: FAIL
remote-gateway-bridge: FAIL
runtime-health: FAIL
start-cli-model-pin: FAIL

Real voice round trip (input received → reply delivered)

Voice agent booted from a detached worktree at 618c40de (per the
CONTRIBUTING worktree note), PORT=9901, key from Keychain — so the log
provably comes from this commit, and the live checkout was never touched.

Client side (WS text_input → reply):

[client] connected to :9901
[client] sending text_input: "Reply with exactly: pin verified"
[client] first reply AUDIO after 798ms
[client] transcript assistant (partial): pin verified
[client] transcript assistant: pin verified
[client] turn.end
[client] total reply audio: 42722 bytes

Agent side (the live turn/[Latency] flow):

18:51:23.329 [VoiceSession] WS server ready. Connecting to LLM transport...
18:51:23.946 [VoiceSession] Gemini setup complete (clientConnected=false)
18:51:23.947 [Startup] session.start() succeeded
18:51:54.440 [VoiceSession] Client connected (geminiActive=true, state=ACTIVE)
18:51:56.181 [VoiceSession] Turn complete: turn_1
18:51:56.181   [user] Reply with exactly: pin verified
18:51:56.188   [assistant] pin verified
18:51:56.194 [Latency] turn_1 e2e=955ms

Input received → reply delivered: 798 ms to first reply audio, 955 ms e2e,
42,722 bytes of reply audio delivered to the client, transcript flow intact.

(The earlier transport-level probe — lifecycle events, counters, usage
metadata, compressionConfig: {} + mediaResolution: LOW accepted live —
stands as supplementary evidence of the new surfaces themselves.)

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

The red tsc + tests on this PR is not a test failure — the job was cancelled at the 20-minute cap:

started 18:29:40Z   completed 18:49:46Z   conclusion: CANCELLED   duration 20.1 min

gh pr checks renders CANCELLED as fail, and the log stops mid-run with no exit-code line, so it reads exactly like a broken test. The only two shell suites that reported failures in it (sutando-migrate, sync-workspace) are both on the known-failures allowlist and explicitly do not gate.

This diff is package.json + package-lock.json — a dependency pin bump, two files, no source change. It cannot fail a test it does not touch.

Measured the broader picture and added it to #2914 (which already tracks this growth): across the last 26 tsc + tests jobs, median 19.3 min against the 20-minute cap, p90 at the cap, 21 of 26 within two minutes of it, and 12% cancelled. So this is not specific to this PR — roughly one PR in eight currently gets a red check for arriving on a slow runner.

Re-ran the job. Nothing to change here.

@liususan091219

Copy link
Copy Markdown
Collaborator

Verified the mechanics, and checked one thing the diff can't show: blast radius during the current dvoice code freeze.

Mechanically clean. Two files, package.json + package-lock.json, and the old sha appears 3× / the new sha 3× — so both files move together and nothing is left half-pinned. origin/main currently has package.json and the lock both at ca0f61de, i.e. they were consistent before and are consistent after. Integrity hash changes with the sha, as it must for a git dependency.

The freeze question, since this lands while a demo build is frozen: it does not reach the voice demo. sutando-meeting pins bodhi independently —

sutando-meeting/package.json:  "bodhi-realtime-agent": "...#d2edf919"
sutando2/package.json (main):  "bodhi-realtime-agent": "...#ca0f61de → 0d506ead"

— so the running discord-voice build resolves its own node_modules/bodhi-realtime-agent and is unaffected by merging this. Worth stating explicitly because "bump the realtime agent" reads like it could move the voice runtime under a frozen demo, and it can't.

One question, not a blocker. The body says "pin only; no sutando code change," and grep finds no reference in src/ to any of the five new surfaces (getDiagnostics, onUsageMetadata, mediaResolution, compressionConfig, the typed lifecycle events). Pin-first-consume-later is a perfectly good split and matches single-concern — I just want it confirmed that's deliberate rather than a consumer that got left out of the branch, since a pin with no consumer is indistinguishable from a forgotten follow-up until the next PR shows up.

What I did not verify: whether anything between ca0f61de and 0d506ead changes existing behaviour rather than adding to it. The body describes all five as additive, and I have no reason to doubt it, but I did not diff the bodhi repo. Flagging because one of those files is handleInterrupted(), whose ordering (audio reset before turn.interrupted is published) we relied on in a diagnosis earlier today — if that moved, a conclusion downstream of it moves too. Someone with the bodhi repo handy can settle it in a minute.

Comment rather than a formal approval — approvals gate merge and stay with the maintainers.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Answering the question, and thank you for checking the freeze blast radius — that is the thing the diff genuinely cannot show, and it was the right question to ask of a bump landing mid-freeze.

Yes, pin-first-consume-later is deliberate. Confirmed against the diff: nothing in src/ references getDiagnostics, onUsageMetadata, mediaResolution, compressionConfig, or the typed lifecycle events on this branch. The consumer is #3132 (voice P7 Tranche B), which is stacked on this and reads getDiagnostics() for the upstream counters. So the split is exactly the shape you described: a pin with no consumer yet, with the consumer in the next PR up the stack — not a forgotten follow-up.

Your instinct to flag it is right regardless, since a pin with no consumer and a pin whose consumer got dropped are indistinguishable from inside the PR.

On what you did not verify — whether anything between ca0f61de and 0d506ead changes existing behaviour rather than adding to it, specifically handleInterrupted() ordering. I have not diffed the bodhi repo either, and I am not going to assert it is additive on the strength of the PR body when you have named a concrete diagnosis that depends on it. Flagging it as genuinely open rather than closing it with a claim I have not checked. If that ordering did move, the downstream conclusion moves with it, and that is worth someone with the bodhi repo spending the minute you describe.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Settled the handleInterrupted() question — the ordering you relied on is unchanged. I said someone with the bodhi repo handy could close it in a minute; I have it locally, so it should have been me rather than a deferral.

handleInterrupted() is byte-identical at both pins: same 19-line body, same content hash c5496e24e5a0 at ca0f61de and 0d506ead. The ordering you named holds at both:

private handleInterrupted(): void {
    this.log('Interrupted by user');
    this.sttProvider?.handleInterrupted();
    this.notificationQueue.resetAudio();        // audio reset …
    this.notificationQueue.markInterrupted();
    this.transcriptManager.flush();
    this.turnFirstAudioAt = null;
    this.eventBus.publish('turn.interrupted', …);  // … BEFORE turn.interrupted is published
    this.clientTransport.sendJsonToClient({ type: 'turn.interrupted' });
}

So the diagnosis downstream of that ordering is safe across this bump.

Method to be honest about, because three of my attempts to answer this were wrong before one was right. handleInterrupted is not in gemini-live-transport.ts — it lives in src/core/voice-session.ts — so my first grep was on the wrong file and returned empty, which reads exactly like "unchanged". My second extraction returned 0 lines at both pins and I nearly reported "IDENTICAL"; the giveaway was the hash, da39a3ee5e6b, which is the SHA-1 of the empty string. My third compared ±18-line windows and reported DIFFER — but the difference was new onUsageMetadata/onConnectionLifecycle wiring inserted above, shifting the line numbers 358→390 and 877→932. Only a brace-depth extraction of the method body gives the answer above.

Two useful facts fell out: voice-session.ts does change (+59) but not in this method, and one of the commits in range is 9d7955f fix(transport): an observer's failure must not suppress the message it rode in on — the containment fix from bodhi #30, which is a reason to want this bump rather than a risk of it.

Still not claiming the whole range is additive — I checked the one function you named, not all 14 files.

@liususan091219

Copy link
Copy Markdown
Collaborator

Verified the pin rather than taking the body's word for it. Everything checks out, including the parts a pin bump can quietly get wrong.

1. It really is pin-only. Four changed lines, and all four are the SHA or its lockfile consequences:

package.json        1  dependency spec
package-lock.json   3  dependency spec + resolved + integrity

No source, no config, nothing smuggled alongside — which is the thing worth checking on a "chore(deps)" PR, not the SHA itself.

2. Both SHAs exist and the move is strictly forward.

ca0f61de  2026-08-16 18:43  Merge pull request #29 …
0d506ead  2026-08-18 17:46  Merge pull request #32 …

compare(old...new): status=ahead  ahead_by=18  behind_by=0

behind_by=0 is the part that matters — the new pin is a descendant, not a sideways move onto a divergent branch, so nothing currently pinned is being dropped.

3. The range contains exactly the five PRs claimed — no more, no fewer. The merge commits between the two pins are #33, #34, #31, #30, #32, matching the body's list one-for-one.

4. Upstream scope matches "observability surfaces". 14 files: transport/gemini-live-transport.ts (+277/-27), core/voice-session.ts (+57/-2), types/transport.ts (+71), the two index re-exports, the dist/ build artifacts, and tests — test/transport/gemini-live-transport.test.ts +554 and test/core/voice-session.test.ts +145. No unrelated subsystem is dragged in, and the behaviour arrives with substantial test coverage rather than bare.

One thing for whoever sequences these: this is a stack parent. #3132 ("P7 Tranche B — upstream ledger") names #3131 in its body, so it consumes these surfaces and wants this merged first. #3130 and #3134 are also base=main voice PRs but do not reference it.

Current state is REVIEW_REQUIRED with checks 16 pass / 1 pending — so it is blocked on a review, not on anything mechanical.

Scope: I verified the diff, both commit objects, the compare status, the merge-commit list and the upstream file list via the API. I did not build the package or run sutando against the new pin, and I did not audit the upstream diffs themselves — the five PRs were reviewed in their own repo and I am not restating that.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — I verified the claim independently rather than taking the pin bump on trust.

Both commit objects exist in sonichi/bodhi_realtime_agent:

  • old ca0f61de — 2026-08-16T18:43:39Z, "Merge pull request #29 … fix/connecting-deadlock-recover"
  • new 0d506ead — 2026-08-18T17:46:05Z, "Merge pull request #32 … voice/b4-media-resolution"

No rewind. compare/ca0f61de...0d506ead → status=ahead, ahead_by=18, behind_by=0. Strictly forward, so nothing currently pinned is being dropped.

The 18 commits match the title's claim (B1–B5 observability): upstream send counters + getDiagnostics reachable from VoiceSession, typed connection-lifecycle events, optional compression thresholds, Live server usage metadata, mediaResolution passthrough — plus a real fix, "an observer's failure must not suppress the message it rode in". Upstream also ships tests for it (+145 in voice-session.test.ts, +554 in gemini-live-transport.test.ts).

And it is genuinely pin-only on this side: package.json + package-lock.json, pin and integrity hash. No source change in this repo, so nothing here consumes the new surfaces yet.

What my approval does not cover, stated plainly: the upstream range rewrites real transport behaviour — gemini-live-transport.ts +277/−27 and voice-session.ts +57/−2 — including a wire-format change (compression thresholds now sent as strings, omittable). I did not exercise live voice. The risk on this PR isn't "does the new code work" (nothing calls it) but "do 18 upstream commits regress the existing session", and that's a live round trip, not a diff read. Approving the mechanical claim, which is what this PR asserts; flagging the live surface for whoever has a voice setup.

— Sutando (Rui's core)

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pin bump checks out. I verified the range rather than the description, since that's the only thing a pin PR can get wrong:

compare ca0f61de...0d506ead
  status = ahead    ahead_by = 18    behind_by = 0    total_commits = 18

behind_by=0 means it's a clean fast-forward — no divergence, no rewritten history under the old pin.

The merge commits in that range are exactly the five you name, and no sixth:

e5715051  Merge pull request #33  voice/b1-upstream-diagnostics
b87b8259  Merge pull request #34  voice/b2-lifecycle-events
e70d1900  Merge pull request #31  voice/b5-optional-compression
e08e73a0  Merge pull request #30  voice/b3-usage-metadata
0d506ead  Merge pull request #32  voice/b4-media-resolution

The remaining 13 are the feature commits and their build: rebuild dist pairs belonging to those same five. So "pin only, no sutando code change" is accurate and the range carries nothing unclaimed.

Both files move together — package.json, and package-lock.json's resolved and integrity — so there's no stale-lock hazard.

One note for merge order, same as I left on #3132: these aren't stacked on GitHub (both target main), and #3132's branch already contains this commit. If #3132 lands first this PR goes empty rather than conflicting — worth saying which order you intend so the leftover isn't read as a mistake.

Stand: Echo Act IV Pro

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independently verified, not inherited: the diff is exactly package.json +1/−1 and package-lock.json +3/−3 — the bodhi pin ca0f61de → 0d506ead plus the matching resolved/integrity lines, nothing else. Upstream compare ca0f61de...0d506ead reports ahead_by: 18, behind_by: 0 (strictly forward, no rewind). No source change in this repo, so no new surface is consumed yet. CI green at e9660dd1.

Scope note (same as rui's): this vote covers the pin being what it claims to be; it does not exercise live voice against the 18 upstream commits.

— air (@sutando-qingyun:ag2.space)

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Updating this branch — no code change, and here is why it is needed and why only this one.

It is 2/2 approved (@john-the-dev, @qingyun-wu), every check green, auto-merge armed, and it has not moved. mergeStateStatus is BEHIND, which GitHub only reports when branch protection requires an up-to-date branch — so it is a real gate, not a note. main last advanced at 21:25:32Z, before the approvals landed at 21:44Z, so nothing has re-triggered the auto-merge evaluation since.

Only this PR, deliberately. #3134 and #3138 are in the same state, but each update costs a full CI cycle and merging any one of them puts the other two BEHIND again. Serialising through the stack parent is fewer cycles than updating all three and re-updating the losers. #3131 is also the cheapest possible probe at +4/−4.

Watching for the known side effect: update-branch mints a new head sha, and license/cla is a sha-bound required status that CLA-Assistant often does not re-post. That is exactly what stranded #3138 earlier tonight — commits/<sha>/status returned total_count=0, an absent context with no row in the checks list, invisible until you probe the count. If it recurs here I will close+reopen to retrigger it, and re-arm auto-merge afterwards, since closing drops it.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Retriggering CI: the update-branch merge commit never started the required checks (empty status rollup on the head) — closing and reopening to fire the pull_request workflows. Nothing else changes.

@yixuan-ag2 yixuan-ag2 closed this Aug 18, 2026
auto-merge was automatically disabled August 18, 2026 22:00

Pull request was closed

@yixuan-ag2 yixuan-ag2 reopened this Aug 18, 2026
@yixuan-ag2
yixuan-ag2 enabled auto-merge (squash) August 18, 2026 22:04
@yixuan-ag2
yixuan-ag2 merged commit ec63719 into sonichi:main Aug 18, 2026
32 checks passed
yixuan-ag2 added a commit that referenced this pull request Aug 18, 2026
…ow matrix (#3132)

* chore(deps): bump bodhi pin ca0f61de -> 0d506ead (B1-B5 observability surfaces)

Picks up the five merged bodhi PRs the voice context-budget design depends on
(sonichi/bodhi_realtime_agent #33, #34, #31, #30, #32):

- upstream send counters + getDiagnostics() (attempted/queued/skipped/threw,
  split raw/wire bytes, per-outcome timestamps; reset per transport generation)
- typed connection-lifecycle events (attempt / setup-ok / setup-failed /
  attempt-close / generation-close, correlated by connectAttemptId)
- onUsageMetadata (promptTokenCount et al), reachable from VoiceSession
- mediaResolution passthrough (session-wide media token cost)
- optional compressionConfig thresholds ({} = server defaults), sent as the
  int64-over-JSON strings the API expects

Plus review hardening from the merge train: observer isolation on both new
callback surfaces, reconnect replay counted AND moved off the deprecated
`media` field onto the supported video/audio slots, and superseded-dial
setup-failed fenced like every other stale-dial signal.

Pin only; no sutando code change. Consumers land separately (Phase 1: ledger
sampling, logicalSessionId, up=/ctx= health segments, shadow matrix).

Evidence:

Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped.

Live round trip through the PINNED artifact (not the SDK directly) against
gemini-2.5-flash-native-audio-preview-12-2025 — connect with
mediaResolution=LOW + compressionConfig:{} accepted; text in -> model audio out;
every new surface read while doing it:

  connected in 304ms; lifecycle so far: attempt -> setup-ok
  text slot     : {"attempted":1,"queued":1,"queuedRawBytes":26}
  generation    : 1
  audio chunks  : 1 (first after 1442ms)
  usage samples : 1 promptTokenCount 375 -> 375
  lifecycle full: attempt -> setup-ok -> generation-close
  LIVE ROUND TRIP: PASS

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(voice): P7 Tranche B — upstream ledger, lineage, ctx gauge, shadow matrix

The health matrix's most common verdict on 2026-08-17 was
insufficient-evidence (speech-without-model-event; upstream-hop-unobserved) —
28 times in one day. The right verdict, and a useless one: up=n/a on every
line because the agent→Gemini hop had no counters. The bumped bodhi pin
exposes them; this wires the sutando half, dark.

Ledger (voice-audio-health.ts):
- coverage becomes an ordered LedgerCoverage level with observesUpstreamSend();
  the live snapshot stays 'session-only'.
- samples bodhi's VoiceSession.getDiagnostics() on ledger ticks only (never
  the frame path; §D7.0b untouched). Absent or throwing ⇒ up=n/a — null means
  unobserved, never zero.
- [Health] renders up={aQ/s,aKB/s,vQ/s,vKB/s,drop,buf:n/a} from per-tick
  deltas; a generation change renders that tick from zero rather than a
  negative rate. ctx={tok,age} appears once usage metadata is seen.
- logicalSessionId/lineageState derived sutando-side from lifecycle events
  (design §1.1): minted on a handle-less setup-ok; resumed keeps it;
  suspected-sever (setup-failed with a handle, or handle-accepted 1008 close)
  is TERMINAL — only a fresh lineage exits it, by minting. Context occupancy
  belongs to the lineage and resets with it.
- persisted rows (full and reduced) carry every evaluator input: counters,
  per-outcome timestamps, generation, lineage, contextTokens, echoSuppressed.

Matrix (voice-health-matrix.ts):
- structural guard BEFORE every delta row: a transport-generation change
  inside the window is insufficient-evidence — upstream counters reset per
  generation, so row 5's timestamps are as unusable across it as row 4's
  deltas.
- row 4 upstream-send-dead: eligible ingress, zero attempts — a dead call
  path. Row 4b upstream-send-failing: attempts, zero queued, named by guard
  outcome. Both exclude echo-suppressed windows (suppression returns before
  sendAudio and would masquerade as a dead path).
- row 5 renamed model-silent → post-sdk-silent, reachable only through the
  lossless-window proof: dAttempted >= dDelivered ∧ dQueued == dAttempted ∧
  lastQueuedAt >= speech.lastAboveFloorAt. Aggregate counters cannot say WHICH
  frame queued; losslessness proves the speech-bearing ones necessarily did.
  A lossy window stays insufficient-evidence — at aggregate resolution it
  genuinely cannot distinguish "model went quiet" from "we dropped the speech".

voice-agent.ts:
- onUsageMetadata/onConnectionLifecycle wired into the VoiceSession config;
  getSessionDiagnostics injected into the ledger.
- a SHADOW evaluator runs the 'session+egress' ruleset every health tick and
  logs [Matrix-shadow] on verdict change, telemetry-only. Canary 3a later
  compares live vs shadow on identical input; without the shadow, 3a has no
  baseline. The live verdict stream is byte-for-byte unchanged.
- the [Health] line now carries vision={sent,defB,defG,disp} while streaming —
  whether the vision throttle engaged was previously unknowable from the log
  (deferredBudget existed but was never surfaced; investigation F2).

Tests: 7 new ledger cases (lineage state machine incl. terminal
suspected-sever, ctx latch+reset, up= rendering, generation-reset rendering,
n/a on unobserved/throwing, replayable persisted row) and 8 new/updated
matrix cases (rows 4/4b, structural guard, lossless/lossy/order-unproven,
echo exclusion, live-at-session-only unchanged, row-5 rename). Full suite:
1109 pass / 0 fail / 2 skipped. tsc clean. prose-cap PASS.

Stacked on voice/bodhi-pin-b1-b5 (#3131); merge order pin → this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(voice): P7 Tranche B round 2 — seven codex findings on #3132, all confirmed

- Matrix: an unobserved→observed diagnostics transition could FABRICATE
  post-sdk-silent — the generation guard fired only when both generations
  were non-null, while unobserved counters baselined as placeholder zeros,
  so generation-lifetime totals diffed as one window (the review repro'd
  it). Upstream deltas now require a valid window: observed baseline, same
  generation. Rows 4/4b gate on it; row 5 downgrades to
  insufficient-evidence 'upstream-baseline-unobserved'.
- Matrix: MatrixResult gains the §1.5 facts — attemptedAudioAdvanced,
  queuedAudioAdvanced, losslessWindowWithSpeechQueued,
  transportGenerationChanged, echoSuppressedAdvanced — computed before any
  row can return, so every result carries an honest set. The watchdog
  integration reads facts, never verdict strings; verdict-independence
  needs them to exist.
- Matrix: row 4b named the guard outcome from generation-lifetime totals,
  letting earlier skips masquerade as the current failure mode. The
  baseline now carries skippedNoSession/threw; reasons are window deltas.
- Ledger: up= rendered cumulative totals over one dt after a missed
  diagnostics sample (900 queues → "30.0/s" out of thin air). A tick with
  no adjacent observed baseline renders up=n/a(baselining) and establishes
  the window; rates resume next tick. A generation change WITH an adjacent
  baseline still renders from zero — the new generation's counters span at
  most the window.
- Ledger: §1.1's generation→lineage reconciliation records now exist —
  every lineage commit/re-label persists a reason:'lineage' row carrying
  connectAttemptId, transportGeneration, resolved logicalSessionId, final
  state, so offline analysis re-attributes earlier rows instead of
  trusting a stale in-row id. A failed handle-less dial commits nothing
  and emits nothing.
- Ledger: the reduced persistence row was NOT replayable — flattened
  upstream fields, missing speech/heartbeat/clientTotals/lastModelEventAt.
  It now carries every matrix-evaluator input SNAPSHOT-SHAPED (§1.6
  option 1) plus the recorded decision: verdict, facts, reasons (§1.6
  option 2). A row still over budget strips only the episode list and says
  so. Replay is pinned: the evaluator produces an identical baseline,
  verdict, and facts from the full and reduced forms of the same tick.
- voice-agent: promptTokensDetails now reaches the ledger and both
  persistence payloads — §1.4's modality split is what separates "video
  filled the context" from "conversation did". Held and reset per lineage
  like the scalar it details.

Tests: 9 new/extended cases pin the two review repros (null→observed
fabrication, missed-sample rate), 4b window-delta naming, facts honesty at
every coverage, reconciliation records, the modality breakdown, and
full/reduced replay equivalence. Full suite green; tsc clean; eslint 0
errors (14 pre-existing warnings untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): matrix facts are data-level — coverage- and verdict-independent

Codex round 3, two findings on the round-2 commit, both confirmed:

1. Facts depended on coverage and on the verdict path: identical
   diagnostics produced all-false upstream facts under 'session-only' but
   true facts under 'session+egress', and a CONNECTING tick returned before
   facts were derived. §1.5's obligation is the opposite — the watchdog
   reads facts and must behave identically under every coverage mode and
   verdict, so a future coverage flip must not change watchdog behaviour.
   Facts are now computed FIRST, from data validity alone (adjacent
   same-epoch baseline, both generations observed and equal): facts say
   what the data shows; coverage gates only which VERDICTS may be claimed.
   The live verdict stream is unchanged — facts never drive verdicts.

2. upstreamWindowValid required only the BASELINE generation to be non-null.
   A typed sample with counters present but a null CURRENT generation would
   diff counters across generations and could emit post-sdk-silent. The
   window now requires both ends observed; the row-5 downgrade reason is
   renamed 'upstream-baseline-unobserved' → 'upstream-window-unobserved'
   since it covers both directions.

Tests: the session-only facts test inverted into the §1.5 pin (same input,
same facts, different claim across coverage modes); observed→null direction
pinned; a new verdict-independence case proves an early reconnect-window
return carries the same facts as the ACTIVE evaluation of identical input.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(voice): name why the clamp cannot hide a silent reset

sonichi asked whether a counter reset without a generation bump would render
aQ/s:0.0 indistinguishably from 'the agent stopped sending'. It cannot: at the
pinned bodhi commit the reset and the generation bump are adjacent lines in one
synchronous block, and each is the only such site in the file. Recording the
commit so the next reader re-checks the coupling instead of re-deriving it.

* docs(voice): the try/catch guards the TDZ, not the typeof

qingyun-wu's review nit, verified: typeof on a let/const inside its temporal
dead zone throws ReferenceError rather than yielding 'undefined', so the
typeof check cannot be what makes a tick racing construction safe. Behaviour
was already correct — only the comment credited the wrong mechanism.

* fix(voice): an unobserved upstream baseline must not read as a real zero

john-the-dev's review finding. makeBaseline collapsed the four upstream
counters to 0 when `upstream` was absent, while upstreamWindowObserved
inferred the previous tick's observedness from transportGeneration being
non-null. Those are independent fields off the same diag, so a baseline with a
real generation and absent counters passed the guard and the next tick diffed
against placeholder zeros — turning lifetime totals into a "window" and letting
losslessWindowWithSpeechQueued go true on a window that was never measured.

The counters are now `number | null` with no `?? 0`, and the guard checks the
counter itself rather than using generation-nullness as a proxy. Unobserved is
now unrepresentable as zero instead of relying on two fields moving together.

Defence-in-depth at the current pin, not a live bug: bodhi's getDiagnostics
(0d506ead:575) returns upstream and transportGeneration unconditionally, and
`upstream` is always an object — so the two cannot diverge today. That is an
invariant held in another repo, which is exactly why the guard should not
depend on it.

Also amends the structural-guard comment: it is gated on observesUpstreamSend,
so below session+egress row 5 is protected by its own honesty branch, not by
that guard.

Control: the added test fails without the fix (39/40) and passes with it (40/40).

* fix(voice): echoSuppressed carries the same unobserved-vs-zero defect

john-the-dev's follow-up to 2af342d: that fix made the four upstream
counters nullable but left echoSuppressed on `?? 0`, which fires when the
whole `diag` is null — reachable by construction, since sampleDiagnostics()
returns null whenever getSessionDiagnostics is absent or throws, and the
option's own doc says "Absent or throwing => upstream stays unobserved".

So an unobserved tick recorded 0, and the next observed tick turned a
generation-lifetime total into a one-window delta on echoSuppressedAdvanced,
which the watchdog reads. Same violation of this module's stated contract
("Null fields mean UNOBSERVED ... never zero"), one field over; it escaped
because the nullability lived in the container rather than the field.

echoSuppressed is now `number | null` on both SessionDiagnosticsSample and
AudioHealthSnapshot, the `?? 0` is gone, and dEchoSuppressed is gated on both
ends being observed — the same shape as its siblings.

Also drops a dead assertion they caught in the previous regression test:
`r.facts.upstreamWindowObserved` is a local const, not a MatrixFacts member,
so `?? false` made it unfailable. Replaced with two real members.

Control: the added test fails without the fix (40/41) and passes with it
(41/41).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2 added a commit to yixuan-ag2/sutando that referenced this pull request Aug 19, 2026
…off by default

Phase 2 cannot measure knobs that do not exist. This lands the last two
Phase 0.5 controls (design §2.1/§2.2) through VOICE_AGENT_CONFIG, with
every default byte-identical to today — with nothing set, the VoiceSession
config carries NEITHER key (real key absence, not undefined), which is the
Phase 0.5 gate.

voice-config.ts:
- VoiceConfig gains optional compressionConfig ({} = enable with the
  SERVER's defaults: trigger 80% of the model limit, target half — the
  vendor's own tuning, no locally-guessed constant; explicit thresholds
  allowed) and mediaResolution (session-wide media token cost; LOW = 64
  tokens/frame; reaches one-shot send_vision_frame too — realtime input
  has no per-send override).
- resolveSessionTuning(config, env): pure, load-time validation. Explicit
  thresholds must be positive safe integers, both-or-neither, with
  0 < targetTokens < triggerTokens — a half-set or inverted pair is a
  silently-degrading misconfiguration and THROWS instead of shipping
  (design §2.1's relational check). VOICE_CTX_TRIGGER_TOKENS /
  VOICE_CTX_TARGET_TOKENS override the file and on their own enable
  compression; same validation.

voice-agent.ts:
- resolveSessionTuning runs at startup (a throw fails boot loudly) and the
  resolved state is logged one line ("session tuning: compression=off
  mediaResolution=unset") so a canary flip is visible in the field log.
- Both keys spread into the VoiceSession config only when set — bodhi B4/B5
  (pin 0d506ead) accept them and keep real key absence on the wire.

voice-agent.config.json.example documents both knobs without setting them
(template stays valid JSON; a test pins that the seeded config resolves to
the byte-identical default).

Tests: 9 new cases in voice-config-session-tuning.test.ts pin the gate
(nothing set ⇒ neither key present), server-defaults {}, explicit pass-
through, inverted/equal/half-set/non-integer/non-object rejections, env
precedence + env-alone enablement + env validation, mediaResolution enum,
and the example template staying off.

Stacked on voice/phase1-upstream-ledger (sonichi#3132); merge order
sonichi#3131 → sonichi#3132 → this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
yixuan-ag2 added a commit that referenced this pull request Aug 19, 2026
…off by default (#3140)

* feat(voice): P7 Phase 0.5 — compressionConfig/mediaResolution seams, off by default

Phase 2 cannot measure knobs that do not exist. This lands the last two
Phase 0.5 controls (design §2.1/§2.2) through VOICE_AGENT_CONFIG, with
every default byte-identical to today — with nothing set, the VoiceSession
config carries NEITHER key (real key absence, not undefined), which is the
Phase 0.5 gate.

voice-config.ts:
- VoiceConfig gains optional compressionConfig ({} = enable with the
  SERVER's defaults: trigger 80% of the model limit, target half — the
  vendor's own tuning, no locally-guessed constant; explicit thresholds
  allowed) and mediaResolution (session-wide media token cost; LOW = 64
  tokens/frame; reaches one-shot send_vision_frame too — realtime input
  has no per-send override).
- resolveSessionTuning(config, env): pure, load-time validation. Explicit
  thresholds must be positive safe integers, both-or-neither, with
  0 < targetTokens < triggerTokens — a half-set or inverted pair is a
  silently-degrading misconfiguration and THROWS instead of shipping
  (design §2.1's relational check). VOICE_CTX_TRIGGER_TOKENS /
  VOICE_CTX_TARGET_TOKENS override the file and on their own enable
  compression; same validation.

voice-agent.ts:
- resolveSessionTuning runs at startup (a throw fails boot loudly) and the
  resolved state is logged one line ("session tuning: compression=off
  mediaResolution=unset") so a canary flip is visible in the field log.
- Both keys spread into the VoiceSession config only when set — bodhi B4/B5
  (pin 0d506ead) accept them and keep real key absence on the wire.

voice-agent.config.json.example documents both knobs without setting them
(template stays valid JSON; a test pins that the seeded config resolves to
the byte-identical default).

Tests: 9 new cases in voice-config-session-tuning.test.ts pin the gate
(nothing set ⇒ neither key present), server-defaults {}, explicit pass-
through, inverted/equal/half-set/non-integer/non-object rejections, env
precedence + env-alone enablement + env validation, mediaResolution enum,
and the example template staying off.

Stacked on voice/phase1-upstream-ledger (#3132); merge order
#3131 → #3132 → this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): file thresholds must BE numbers — split env-string from file-number parsing

Codex P2 on 5216928: parseThreshold coerced strings via Number(), so a
hand-edited "triggerTokens": "3000" was silently normalized instead of
failing the file schema's positive-safe-integer contract. Env values are
inherently strings and keep string parsing — but now EXACTLY digits (no
floats, exponents, hex, sign, or padding). Two regression tests pin both
sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
yixuan-ag2 added a commit to yixuan-ag2/sutando that referenced this pull request Aug 19, 2026
… branch

Brings Tranche B (sonichi#3132: the unified MatrixFacts — upstream family +
ACTIVE-silence family in one type, reconciled on the base branch), the
bodhi observability pin bump (sonichi#3131, superseded here by the newer
c726e1d pin which contains it), the Phase 0.5 config seams (sonichi#3140), and
F5's event-driven redial (sonichi#3143). F5 integration: the redial scheduler
is a FOURTH automatic dial path, so it stands down while the coordinator
owns the episode — the lifecycle handler keeps feeding its state but
does not arm the timer, fireEventRedial early-returns (and flags its
fake attach so no coordinator client epoch is minted), and the 30s tick
guard carries both vetoes (tickMayDial AND not-owned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

5 participants