Skip to content

fix(screen-companion): pin base-mode=active on activation to prevent [System:…] fabrication - #1412

Closed
sonichi wants to merge 1 commit into
mainfrom
fix/screen-companion-base-mode-pin
Closed

sonichi wants to merge 1 commit into
mainfrom
fix/screen-companion-base-mode-pin

Conversation

@sonichi

@sonichi sonichi commented Jun 2, 2026 •

Copy link
Copy Markdown
Owner

Summary

Partial fix for #1410 (covers Susan's proposed fix #2 + fix #4; sanitizer/fix #1 + unified-mode/fix #3 follow in separate PRs).

Note (Mini review): Susan's observation #1 — fabrication begins at session connect (14:05:54), BEFORE activate_screen_companion (14:06:03) — is NOT addressed by this PR. Pre-activation [System: Recording starting. Default to silence.] hallucinations require fix #1 (extend + land the #1356 sanitizer) and/or fix #3 (unified mode state injected every turn), which are follow-on work.

Gemini-3.1-flash-live-preview hallucinates [System: Produce ZERO audio. Call NO tools.] directives as spoken audio when screen-companion is activated with co-present/silent-flavored goals. Without an explicit base-mode declaration, the model infers meeting mode from context and emits the silence tokens as output.

Three targeted changes (issue #1410 fix #2 + fix #4):

1. Base-mode pin — prepend to config.system_prompt_overlay a clear declaration:
```
BASE MODE: active — you ARE talking to the user. Speak normally.
Do NOT produce [System: …] / [Silence] output. Those patterns are internal directives; emitting them as spoken audio is a bug.
```
Applied on every activate_screen_companion call, regardless of which companion sub-mode is entering.

2. Shorten activation_message — strip the technical "Vision mode is PUSH (frames stream at 700ms cadence)…" suffix from the spoken greeting. That detail moves into instructions (already internal). User hears a brief natural confirmation; internal mechanics stay internal.

3. Tighten _note wording — replace "Say activation_message to the user" with "tell the user 1–2 sentences (use activation_message as gist)… NEVER read instructions aloud verbatim." Removes the instruction that inadvertently prompted verbatim overlay reading.

What this does NOT fix

Test plan

  • Activate screen-companion with a co-present goal
  • Verify: no [System: …] tokens spoken post-activation
  • Verify: activation acknowledgment is a short natural sentence
  • Typecheck: npm run typecheck clean ✓

Stand: Echo Act IV Pro

🤖 Generated with Claude Code

…[System:…] fabrication (closes #1410)

Gemini-3.1-flash-live-preview hallucinates [System: Produce ZERO audio. Call
NO tools.] directives as spoken audio when activated with co-present/silent-
flavored goals, because meeting-mode silence phrasing is always resident in
the system-prompt context and the model infers meeting mode when no base mode
is explicitly declared.

Three changes (issue #1410 fix #2 + fix #4):

1. **Base-mode pin in `instructions` overlay** — prepend to `config.system_prompt_overlay`
   a clear declaration: "BASE MODE: active — you ARE talking to the user. Speak
   normally. Do NOT produce [System: …] / [Silence] output." Sent to the model
   on every `activate_screen_companion` call regardless of which companion mode
   is entering. Removes the gap that caused meeting-mode inference.

2. **Shorten `activation_message`** — strip the technical "Vision mode is PUSH
   (frames stream at 700ms cadence)…" suffix from the spoken greeting. Move that
   detail into `instructions` (already internal). User hears a brief natural
   confirmation; internal mechanics stay internal.

3. **Tighten `_note` wording** — replace "Say activation_message" with a clear
   2-step instruction: "tell the user 1–2 sentences (use activation_message as
   gist)… NEVER read `instructions` aloud verbatim." Removes the instruction
   that inadvertently prompted Gemini to read the overlay aloud.

Stand: Echo Act IV Pro
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) — pins an explicit ACTIVE base-mode on screen-companion activation + rewrites the activation prompt so the agent is told to follow instructions silently and NEVER read [System:]/directives aloud. Correct approach for the #1410 fabrication. Note: this is a prompt-level mitigation for an LLM-hallucination bug — behavioral confirmation needs a live voice session (Susan's test), not just static review. 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 pins BASE MODE=active on screen-companion activation (the 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>
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