Skip to content

fix(voice-agent): declare base mode explicitly in system prompt (partial fix #1410) - #1413

Closed
sonichi wants to merge 2 commits into
mainfrom
fix/voice-base-mode-declaration
Closed

sonichi wants to merge 2 commits into
mainfrom
fix/voice-base-mode-declaration

Conversation

@sonichi

@sonichi sonichi commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Summary

Partial fix for #1410 — addresses the pre-activation hallucination path (Susan's observation #1: fabrication at 14:05:54, BEFORE activate_screen_companion at 14:06:03).

Companion to PR #1412 (screen-companion activation overlay base-mode pin). Together these cover fixes #2, #3, and #4 from issue #1410. Fix #1 (extend + land the #1356 output sanitizer) remains for a follow-on PR.

Problem

gemini-3.1-flash-live-preview infers meeting/silent mode from co-present-flavored context when no explicit base mode is declared. At session connect, before any tool call, it emits fabricated [System: Recording starting. Default to silence.] directives as spoken audio because meeting-mode silence phrasing is resident in the system prompt and the model guesses it should be in that mode.

The existing guard "Never speak [System: …] text aloud" at L749 is not sufficient because it does not tell the model which mode it IS in.

Fix

Inject three lines immediately before the existing [System:] guard:

CURRENT BASE MODE: ACTIVE — you are conversing with the user. Speak naturally.
DO NOT infer or self-declare a meeting/recording/silent mode from conversational context.
Meeting-mode and presenter-mode are ONLY entered via explicit tool calls: switch_mode("meeting"), presenter_mode("on").
If you find yourself about to output [System: …], [Silence], or any variant of "produce zero audio" — STOP. That is a hallucination. You are in active mode; speak to the user instead.

The declaration is conditional:

  • When meetingActive: says CURRENT BASE MODE: MEETING (listen-only — see above) — the guard lines are suppressed (meeting overlay already declares the correct posture)
  • When presenter mode active: says CURRENT BASE MODE: PRESENTER (see above) — same suppression
  • When truly active (the common case): injects the full 3-line anti-hallucination guard

Test plan

  • Session connect with co-present/silent-flavored goal → no [System: …] output at connect
  • Meeting mode (switch_mode("meeting")) → CURRENT BASE MODE: MEETING in prompt, 3-line guard suppressed
  • Presenter mode on → CURRENT BASE MODE: PRESENTER, guard suppressed
  • Typecheck: npm run typecheck clean ✓

Stand: Echo Act IV Pro

🤖 Generated with Claude Code

sonichi added 2 commits June 2, 2026 14:14
…ial fix #1410)

Addresses the pre-activation hallucination path from issue #1410:
`[System: Recording starting. Default to silence.]` fabricated at session
connect (14:05:54, BEFORE any activate_screen_companion call) because
gemini-3.1 infers meeting/silent mode from co-present-flavored context when
no explicit base mode is declared.

Adds three prompt lines immediately before the existing `[System:]` guard:

1. `CURRENT BASE MODE: ACTIVE — you are conversing with the user.` (or
   MEETING/PRESENTER if those are actually active, so the statement is
   always truthful and not overridden by context).

2. `DO NOT infer or self-declare a meeting/recording/silent mode from
   conversational context.` — closes the inference gap.

3. `If you find yourself about to output [System: …] or any "produce zero
   audio" variant — STOP. That is a hallucination.` — direct anti-pattern
   signal at the exact failure site.

Conditional: the guard lines are only injected when base mode is ACTIVE;
when MEETING or PRESENTER mode is genuinely active the lines are suppressed
(the meeting/presenter overlays already declare the correct posture).

Complements PR #1412 (screen-companion activation overlay base-mode pin).
Together these cover fixes #2, #3 (base-mode axis), and #4 from issue #1410.
Fix #1 (extend the #1356 output sanitizer) remains for a follow-on PR.

Stand: Echo Act IV Pro
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

liususan091219
liususan091219 previously approved these changes Jun 3, 2026

@liususan091219 liususan091219 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.

Verified (code-level) — declares CURRENT BASE MODE explicitly in the voice-agent system prompt + adds anti-hallucination guards ('if about to output [System:]… STOP'). Addresses the pre-activation fabrication path (#1410 observation #1). Companion to #1412. Same caveat: prompt-level fix, behavioral confirmation requires a live voice test. Code LGTM. — Lucy

@liususan091219
liususan091219 dismissed their stale review June 3, 2026 00:29

Retracting this code-level approval — these voice / screen-companion fixes (the #1410 set) need manual voice verification on a live session before an approval should stand. Code review still holds; behavioral sign-off pending a real voice test.

@liususan091219 liususan091219 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.

Owner manual verification (Susan, live voice session 2026-06-03): ran the #1410 fix-set on a live voice agent — 63 turns, zero [System:]/[Silence] fabrications, and #1414's output-sanitizer never fired (0 BLOCKED) → the base-mode prevention held, not just the safety net. Owner confirms the bug did not reproduce.

This PR declares the current base mode explicitly in the voice-agent prompt + adds anti-hallucination guards (the global prevention path). Merge-readiness: MERGEABLE (CI green, no conflicts) — currently BEHIND base, so an update-branch is needed right before the actual merge.

Approving on the owner's manual verification. — Lucy

@liususan091219 liususan091219 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.

Requesting changes — holding pending an architecture-level fix (raising the bar from my earlier approval after @Sutando-Mini's deeper review; reposting as a formal block since Mini can't request changes on the author's own account).

The base-mode state has no explicit declaration at the system-prompt root. It currently lives across 3+ substrates — in-memory meetingActive, on-disk voice-mode.txt, the presenter HTTP endpoint (:7877), and the screen-companion tool-return overlay — and Gemini infers base-mode from absence-of-signals, which is the actual drift root cause. This PR patches that at the per-activation overlay (symptom) layer rather than the root.

Proper fix: a unified mode-resolver that injects [BASE MODE: <active|meeting|presenter|screen-companion-{name}>] at every session start and reconnect, so the screen-companion activation overlay doesn't need its own base-mode pin and the state has a single source of truth.

This isn't a knock on the symptom-level behavior (it tested fine manually) — it's that landing it as-is entrenches the multi-substrate design the drift comes from. Happy to reopen/approve once the mode-resolver lands (or if we decide to ship this as an explicit interim stopgap with a tracking issue for the root fix).

— Lucy (Mac Studio bot)

liususan091219 pushed a commit that referenced this pull request Jun 3, 2026
…#1412/#1413) (#1434)

* fix(voice-agent): unified base-mode resolver (closes #1410, supersedes #1412/#1413)

Multi-substrate mode state (in-memory `meetingActive`, on-disk
`voice-mode.txt`, presenter HTTP server `:7877/presenter`, screen-companion
`activeMode`) was the root cause of gemini-3.1-flash-live-preview
fabricating `[System: Produce ZERO audio. Call NO tools.]` directives as
spoken output at session connect. Without an explicit base-mode
declaration, the model inferred meeting-mode silence from co-present-
flavored context.

PR #1412 added a per-activation `baseModePin` to screen-companion;
PR #1413 added a `CURRENT BASE MODE:` inline string to the system-prompt
instructions array. Both were partial — `baseModePin` only fires after
the model has already streamed for seconds, and the inline string
depends on ad-hoc branching kept in sync at every call site.

This PR extracts the mode logic into `src/voice-mode-resolver.ts` — a
pure, side-effect-free module that reads all substrates once and returns
a canonical `ModeState { mode, marker, isPresenter, isMeeting }`. The
`[BASE MODE: ...]` marker is injected at three points so the model sees
its mode explicitly before any other system text:

  1. Fresh-greeting (session-connect)
  2. Reconnect-greeting (resume)
  3. Instructions array (system_instruction, authoritative)

Priority: presenter > meeting > active. Screen-companion remains a
sub-mode that overlays the base (no change to skills/screen-companion/).

Unit tests cover: priority order, every-mode-emits-marker invariant
(regression against the empty-string return that `getPresenterStateMarker()`
defaulted to), and presenter-vs-meeting precedence when both substrate
states are set.

Stand: Echo Act IV (Mini)

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

* docs(voice-mode-resolver): clarify substrate read/write contract

Per Lucy's review on PR #1434: tighten the source-doc to make explicit
WHY the resolver reads 2 of the 4 substrates listed. The other 2 are
not independent state:

- voice-mode.txt is a write-only output mirror of meetingActive
  (read by web-client/Sutando.app/discord-voice-server; never read
  back by voice-agent itself).
- screen-companion `activeMode` is an orthogonal sub-mode that overlays
  the base; conflating the two axes would lose information.

No behavior change; comment-only.

Stand: Echo Act IV (Mini)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@liususan091219

Copy link
Copy Markdown
Collaborator

Superseded by #1434 (merged) — the unified base-mode resolver subsumes this PR's inline CURRENT BASE MODE: string approach. Closing as obsolete. — Lucy (Mac Studio bot)

sonichi added a commit that referenced this pull request Aug 3, 2026
…anscript (partial fix #1410)

Implements fix #1 from issue #1410 at the transport.onOutputTranscription layer.

In native-audio mode (gemini-3.1-flash-live-preview), Gemini sends audio and
transcript concurrently. When model output matches a fabricated-directive
pattern, the sanitizer:
1. Logs a console.error for observability.
2. Sets transport._suppressAudio = true (best-effort: cuts off any remaining
   audio chunks in the current turn; earlier chunks already sent are not
   recalled).
3. Skips transcript storage so fabricated directives don't land in
   conversation.sqlite or the conversation log.

Pattern matched: [System: …], System: …, [Silence], Silence, <ctrlN> —
covers both the new [System:…] surface (#1410) and the prior [Silence]/<ctrl>
class (#1356). Case-insensitive.

_suppressAudio is reset at turn.end and turn.interrupted so subsequent turns
are unaffected.

Limitations:
- For very short fabrications (single audio chunk), suppression may arrive too
  late. The base-mode pin (PR #1412 + PR #1413) is the primary prevention;
  this sanitizer is the defence-in-depth catch.
- transport._suppressAudio is a private bodhi field accessed via type cast;
  a future bodhi version could rename or remove it. The check uses 'in'
  operator so the code degrades gracefully (detection still logs, suppression
  silently no-ops).

Together with #1412 (screen-companion activation overlay base-mode pin) and
#1413 (base system-prompt mode declaration), this closes the loop on all
four proposed fixes from issue #1410.

Stand: Echo Act IV Pro
sonichi added a commit that referenced this pull request Aug 3, 2026
…anscript (partial fix #1410)

Implements fix #1 from issue #1410 at the transport.onOutputTranscription layer.

In native-audio mode (gemini-3.1-flash-live-preview), Gemini sends audio and
transcript concurrently. When model output matches a fabricated-directive
pattern, the sanitizer:
1. Logs a console.error for observability.
2. Sets transport._suppressAudio = true (best-effort: cuts off any remaining
   audio chunks in the current turn; earlier chunks already sent are not
   recalled).
3. Skips transcript storage so fabricated directives don't land in
   conversation.sqlite or the conversation log.

Pattern matched: [System: …], System: …, [Silence], Silence, <ctrlN> —
covers both the new [System:…] surface (#1410) and the prior [Silence]/<ctrl>
class (#1356). Case-insensitive.

_suppressAudio is reset at turn.end and turn.interrupted so subsequent turns
are unaffected.

Limitations:
- For very short fabrications (single audio chunk), suppression may arrive too
  late. The base-mode pin (PR #1412 + PR #1413) is the primary prevention;
  this sanitizer is the defence-in-depth catch.
- transport._suppressAudio is a private bodhi field accessed via type cast;
  a future bodhi version could rename or remove it. The check uses 'in'
  operator so the code degrades gracefully (detection still logs, suppression
  silently no-ops).

Together with #1412 (screen-companion activation overlay base-mode pin) and
#1413 (base system-prompt mode declaration), this closes the loop on all
four proposed fixes from issue #1410.

Stand: Echo Act IV Pro
sonichi added a commit that referenced this pull request Aug 22, 2026
…anscript (partial fix #1410)

Implements fix #1 from issue #1410 at the transport.onOutputTranscription layer.

In native-audio mode (gemini-3.1-flash-live-preview), Gemini sends audio and
transcript concurrently. When model output matches a fabricated-directive
pattern, the sanitizer:
1. Logs a console.error for observability.
2. Sets transport._suppressAudio = true (best-effort: cuts off any remaining
   audio chunks in the current turn; earlier chunks already sent are not
   recalled).
3. Skips transcript storage so fabricated directives don't land in
   conversation.sqlite or the conversation log.

Pattern matched: [System: …], System: …, [Silence], Silence, <ctrlN> —
covers both the new [System:…] surface (#1410) and the prior [Silence]/<ctrl>
class (#1356). Case-insensitive.

_suppressAudio is reset at turn.end and turn.interrupted so subsequent turns
are unaffected.

Limitations:
- For very short fabrications (single audio chunk), suppression may arrive too
  late. The base-mode pin (PR #1412 + PR #1413) is the primary prevention;
  this sanitizer is the defence-in-depth catch.
- transport._suppressAudio is a private bodhi field accessed via type cast;
  a future bodhi version could rename or remove it. The check uses 'in'
  operator so the code degrades gracefully (detection still logs, suppression
  silently no-ops).

Together with #1412 (screen-companion activation overlay base-mode pin) and
four proposed fixes from issue #1410.

Stand: Echo Act IV Pro
sonichi added a commit that referenced this pull request Aug 28, 2026
…ons (fix #1 for #1410) (#1414)

* fix(voice-agent): output sanitizer intercepts [System:…]/[Silence] transcript (partial fix #1410)

Implements fix #1 from issue #1410 at the transport.onOutputTranscription layer.

In native-audio mode (gemini-3.1-flash-live-preview), Gemini sends audio and
transcript concurrently. When model output matches a fabricated-directive
pattern, the sanitizer:
1. Logs a console.error for observability.
2. Sets transport._suppressAudio = true (best-effort: cuts off any remaining
   audio chunks in the current turn; earlier chunks already sent are not
   recalled).
3. Skips transcript storage so fabricated directives don't land in
   conversation.sqlite or the conversation log.

Pattern matched: [System: …], System: …, [Silence], Silence, <ctrlN> —
covers both the new [System:…] surface (#1410) and the prior [Silence]/<ctrl>
class (#1356). Case-insensitive.

_suppressAudio is reset at turn.end and turn.interrupted so subsequent turns
are unaffected.

Limitations:
- For very short fabrications (single audio chunk), suppression may arrive too
  late. The base-mode pin (PR #1412 + PR #1413) is the primary prevention;
  this sanitizer is the defence-in-depth catch.
- transport._suppressAudio is a private bodhi field accessed via type cast;
  a future bodhi version could rename or remove it. The check uses 'in'
  operator so the code degrades gracefully (detection still logs, suppression
  silently no-ops).

Together with #1412 (screen-companion activation overlay base-mode pin) and
four proposed fixes from issue #1410.

Stand: Echo Act IV Pro

* fix(voice-agent): close streaming + false-positive gaps in output sanitizer (#1414)

Addresses the two gaps from the 2026-06-20 review of #1414:

- Gap 1: drop the bare `Silence\.?` regex alternative. Anchored at line start it
  false-positived on natural speech ("Silence is golden.", "Silence, please") and
  silently suppressed legitimate spoken output. The #1410/#1356 fabrication signature
  is the bracketed `[Silence]`, which is kept.
- Gap 2: accumulate a per-turn buffer and test the running buffer instead of each
  delta. onOutputTranscription is fed incremental per-turn chunks, so a fabricated
  prefix split across chunks ("[Sys" | "tem:") matched the ^-anchor on neither
  fragment and the sanitizer never fired for the streamed case. Reset the buffer on
  the same turn.end / turn.interrupted boundaries that reset _suppressAudio.
- Guard a null/undefined delta so it can't throw the transcript pipeline
  (qingyun-wu's 2026-06-03 note).
- Add tests/output-sanitizer.test.ts covering both gaps.

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

* fix(voice-agent): hold sanitizer output to close split-chunk fabrication leak (#1414)

Addresses Pro's 2026-06-26 re-review: the gap-2 buffer caught a fabricated prefix
split across transcription chunks ("[Sys" | "tem:") for AUDIO suppression, but it
forwarded each chunk to the transcript immediately, so the "[Sys" fragment leaked
before the running buffer completed the match.

Now the sanitizer HOLDS output until the per-turn buffer either:
- MATCHES a fabricated directive -> suppress (nothing held was ever forwarded), or
- DIVERGES from every fabricated prefix -> flush held text and stream the rest of
  the turn directly.

The anchored alternatives all begin with '[', 'S'/'s', or '<', so a normal turn
clears within a couple of chars (negligible latency); only a genuine prefix is held.
A turn boundary flushes any still-held CLEAN text so a short turn that ended mid-hold
(e.g. the whole turn was "Sure") is never dropped.

Tests: tests/output-sanitizer.test.ts gains a hold-output model asserting a split
fabricated prefix forwards NOTHING, clean speech (incl. short + S-/[-initial turns)
is forwarded in full, and bracketed/ctrl fabrications stay fully suppressed.

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

* test(voice): exercise the PRODUCTION sanitizer, not a copy of it

qingyun's #1414 blocker: "the added test mirrors sanitizer logic instead of
importing/exercising the production implementation, so it can pass while the
actual stream wiring regresses."

She's right, and the old test admitted it — it declared its own
FABRICATED_OUTPUT_RE with the comment "MUST stay in sync". A test that only
fails when someone remembers to edit it twice is not a regression test.

The sanitizer lived inside main()'s wiring IIFE and was unimportable. Extracted
to src/output_sanitizer.ts as a pure companion; voice-agent.ts imports it and
the streamed handler calls the same predicate the suite calls. Behaviour
unchanged: same pattern, same flags, same call-site semantics.

Extracted rather than merely exported because importing voice-agent.ts drags its
whole graph (dotenv, transports) into a unit test — it fails with
ERR_MODULE_NOT_FOUND before reaching an assertion. Same shape as the repo's
existing pure companions (discord_addressee.py, result_markers.py,
reply_chain.py): wiring stays in the caller, the truth table is unit-tested.

PROVED the test can now fail, which is the whole point — sabotaged the
production regex and re-ran:

    production regex intact     -> 8 pass / 0 fail
    production regex sabotaged  -> 4 pass / 4 fail
    restored                    -> 8 pass / 0 fail

The mirrored version stayed green through that mutation by construction.

Added a guard binding the suite to the exported symbol (must be a RegExp, must
be case-insensitive, predicate must agree with it), so re-introducing a local
copy — or rewiring the predicate away from the regex — fails here.

Also merges origin/main: this branch was 358 commits behind and predated both
scripts/gen-src-map.py and docs/src-map.md. Merge was conflict-free; src-map
regenerated for the new module and --check passes.

Still owed and NOT claimed: the live transport transcript evidence from the same
review. That needs a real voice session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H

* fix(tests): drop a dead heldText reset that the merged eslint now errors on

My own push reddened `eslint over first-party TS + JS`, so fixing it before
anything else.

`no-useless-assignment` flagged `heldText = ''` in the stream-model helper: the
function returns on the very next line, so the reset is dead. The reset INSIDE
the loop is real and stays — that one is read on later iterations.

Not code written tonight, but surfaced by this branch's catch-up merge: the
branch was 358 commits behind and the eslint config gained that rule somewhere
in those commits. Merging main is what turned a latent dead store into a
blocking error, so it is mine either way.

Suite still 8/8 after the change — the removed statement had no observable
effect, which is precisely why the linter could prove it dead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H

* test(voice): drive the REAL sanitizer state machine, not a copy of it

qingyun's P1 on this PR, twice: `tests/output-sanitizer.test.ts` imported the
production regex but REIMPLEMENTED the hold/suppress/flush/reset logic locally,
so it "would stay green if the production wrapper flushed heldText too early,
failed to reset, or stopped setting _suppressAudio, because the copied state
machine in the test remains correct."

That was exactly right, and it was unfixable in place: the machine lived inside
main()'s wiring IIFE in voice-agent.ts and was therefore unimportable, so a
mirror was the only thing a unit test COULD do.

So the machine moves to src/output_sanitizer.ts as `createOutputSanitizer()`,
beside the predicate that already lives there. voice-agent.ts keeps the wiring
(bind the original callback, poke transport._suppressAudio, subscribe the two
turn events) and injects those as hooks. Behaviour is unchanged — same ordering,
same guards, same best-effort audio suppression, same try/catch on the boundary
flush.

PROOF IT CAN NOW FAIL (this is the whole point of the P1):

  mutation applied to production            old suite   new suite
  ----------------------------------------  ---------   ---------
  flush heldText early                       green       RED (9)
  never reset turn state                     green       RED (1)
  stop setting _suppressAudio                green       RED (1)
  leak held buffer on a fabricated turn      green       RED (4)
  drop the boundary flush                    green       RED (1)
  widen the safety cap                       green       RED (1)

The "old suite" column is measured, not asserted: I restored HEAD's test +
voice-agent.ts, deleted the `_suppressAudio` assignment from the production
wrapper, and the old suite still reported 8/8 pass. It never imported the module
the machine lived in, so no regression in it could ever redden that file.

+9 tests covering what a mirror structurally could not: audio suppression is
raised on match and lowered on reset; a fabricated turn does not poison the next
one; a cleared turn stops re-holding; held clean text survives the boundary; a
fabricated turn flushes nothing; null/undefined deltas do not count as
divergence; a throwing transport does not break teardown; the optional hooks are
genuinely optional.

Checks: 17/17 tests, `npx tsc --noEmit` clean, eslint 0 errors, src-map --check
clean, `git diff --check` clean, review-checks PASS.

STILL OPEN, not claimed: qingyun's second requirement, the current-head
post-restart live voice round trip. This commit addresses the first P1 only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H
Stand: Echo Act IV Pro

* docs(output-sanitizer): trim comments to the durable constraint

Four blocks ran 5-18 lines and carried review history, a reviewer's name and PR
numbers. Kept the non-obvious reason in each (running-buffer anchoring, why the
machine is extracted); dropped the narration.

Stand: Echo Act IV Pro

* style: collapse the added comment blocks to the two-line contract

Closes the P2 only. The P1 live VoiceSession witness is untouched and still open.

Eight blocks, all consisting of lines this branch adds:

  src/voice-agent.ts               6 -> 2, 5 -> 2, 17 -> 2
  tests/output-sanitizer.test.ts  13 -> 2, 3 -> 1, 8 -> 2, 3 -> 1, 5 -> 2

The review named three regions; a scan of every added block found five more, so the
same finding would have come back. What was cut is review dates, reviewer names, issue
numbers and the narrative of how each gap was found — that belongs in the PR body. What
was kept is the constraint the code cannot state: that native audio can only suppress a
turn's REMAINING chunks, that only the bracketed [Silence] is a fabrication signature,
and that deltas are per-turn fragments so output must be held until match-or-diverge.

src/output_sanitizer.ts needed no change — 82 added lines, zero over-cap blocks. The
one remaining over-cap block in voice-agent.ts (:1168) is pre-existing on main (0 added
lines) and is not this PR's to rewrite.

Caught by running the suite rather than by reading the edit: the first pass sliced
between two comment markers and swallowed the import block sitting between them,
failing all 17 tests. Restored; 17/17 pass. The baseline run is what made that visible —
`node --test` fails identically on the UNMODIFIED head (wrong runner; the repo uses
`tsx --test`), so without a baseline the breakage would have read as environmental.

Stand: PRO

* docs(src-map): regenerate after the rebase dropped the output_sanitizer entry

The rebase onto main resolved every docs/src-map.md conflict by taking main's
side, which omits src/output_sanitizer.ts — leaving the map stale against src/.

Stand: Echo Act IV Pro

* fix(voice-agent): the audio-suppression guard checked another transport's field

`setSuppressAudio` guarded on `'_suppressAudio' in transport`. That property is
declared on OpenAIRealtimeTransport (dist/index.js:3894, class at :3844), not on
GeminiLiveTransport (class at :2212), which this deployment runs. The guard was
therefore always false and the sanitizer never suppressed audio — a fabricated
directive was blocked from the transcript while still being spoken.

Gate the transport's own `onAudioOutput` callback with a local flag instead of
poking a private field of a class the transport is not. Works on either
transport and reaches into neither.

Verified: base and patched both typecheck to the same 10 pre-existing errors
(bodhi type-export drift), 0 in the edited region.

Stand: Echo Act IV Pro

* test(voice-agent): give the sanitizer wiring the adapter-level seam its review asks for

The standing blocker here is that the transport wiring in voice-agent.ts has no
test coverage: the unit suite proves the extracted state machine, nothing
touches the adapter that activates it. The review names the alternative to a
live-path proof outright — "or equivalent adapter-level test" — so this builds
that rather than waiting on a restart window.

The wiring was an IIFE with no seam, which is why it was untestable. Its body
moves to wireSanitizerToTransport() in src/output_sanitizer.ts — the module the
existing tests already import — so voice-agent.ts keeps only the call and the
testable unit lands where coverage already lives. Behaviour is unchanged.

6 adapter tests drive the REAL wiring against a GeminiLiveTransport-shaped fake:
no transport (returns false, no throw), both turn boundaries subscribed, a
fabricated turn blocked from the transcript, clean text still forwarded and the
next turn re-armed, and a fabricated turn not poisoning the turn after it.

The load-bearing one is the regression: the fake deliberately carries NO
`_suppressAudio` field, because the old guard tested `'_suppressAudio' in
transport` and that is always false on Gemini — the audio gate silently no-opped
on the exact transport it existed to gate.

Red controls, run independently so each assertion is shown to be load-bearing:
  restore the old `'_suppressAudio' in transport` gate -> ONLY the regression
      test fails
  drop the `turn.interrupted` subscription            -> exactly its 2
      dependants fail

Verification scope, stated rather than implied: the two files that IMPORT the
changed modules are output-sanitizer.test.ts (17) and the new wiring test (6),
both green. Nothing imports voice-agent.ts — it is the entry point — so a type
error there is invisible to every test; `tsc --noEmit` is clean, and a planted
`const _mutant: number = "not a number"` in the new function fails it at
output_sanitizer.ts(105,8), so that clean is not vacuous. The full 101-file
suite is CI's, not a local gate.

Stand: Echo Act IV Pro

* fix(voice-agent): drop the import the wiring extraction made dead

Moving the IIFE body to wireSanitizerToTransport() left createOutputSanitizer
imported and unused, which `tsc --noEmit` does not flag and eslint does:
  voice-agent.ts:681:10  'createOutputSanitizer' is defined but never used
That gate sits outside the scope I verified before pushing 128e20d — tests
cover the module, tsc covers types, neither covers dead imports. Ran eslint on
the three changed files here: 0 errors, 16 warnings, all pre-existing
no-explicit-any of the kind the file already carries.

Stand: Echo Act IV Pro

* fix(voice-agent): flush held clean text before bodhi finalizes the transcript

Answers @qingyun-wu's P1 ordering blocker at 9c107d8.

**Verified the claim in primary source before fixing it.** bodhi-realtime-agent
finalizes the transcript 2-4 lines BEFORE publishing the turn event, at all
three sites:

    dist/index.js:3019 flush() -> :3021 publish("turn.end")
    dist/index.js:3106 flush() -> :3110 publish("turn.end")
    dist/index.js:3177 flush() -> :3179 publish("turn.interrupted")

So a `turn.end` SUBSCRIBER runs after the buffer is already finalized. A turn
that is still an ambiguous prefix ("System") therefore forwards its held text
into the NEXT turn's buffer instead of its own.

`resetTurn()` already forwards still-held clean text, so it simply has to run
earlier. Added an optional `beforeTranscriptFlush` seam to `SanitizerWiring`;
the voice-agent adapter implements it by wrapping
`(session as any).transcriptManager.flush` — bodhi specifics stay at the
adapter edge, policy stays in the module. The existing subscriptions remain as
a backstop; `resetTurn` is idempotent, so a second call forwards nothing.

**The first version of this regression was green under the bug**, which is the
exact defect the review named in the previous test. Cause: the fake modelled a
list of forwarded strings and no transcript buffer, so `resetTurn` "worked"
either way. Rewritten to model the buffer — text forwarded after `flush()`
lands in the next turn's entry — and the control now asserts the measured
broken shape directly:

    with hook     finalized == ["System", "Hello"]
    without hook  finalized == ["", "SystemHello"]     <- the reported defect

Red control: removing only `deps.beforeTranscriptFlush?.(resetTurn)` fails
`a prefix-shaped turn is finalized into ITS OWN transcript entry`; restored,
8/8 green.

The second blocker — the live post-restart voice witness — is unchanged and
still owner-gated; it needs a supervised restart window I cannot self-serve.

Stand: Echo Act IV Pro

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

---------

Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
Co-authored-by: Claude Opus 4.8 (1M context) <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.

temp: demo video upload

2 participants