You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[bug] Muting while a turn is transcribing deletes the words — the bubble is cleared and the in-flight transcript is then dropped or sent by an unrelated 800ms echo timer #420
In hands-free voice: finish speaking → the pending bubble shows the words with status Transcribing… → press Mute (or say "mute") → the words vanish from the screen immediately, and depending on timing they are either never sent at all or sent silently a second later with no bubble on screen while it happens.
Expected: muting stops the mic and the agent. It should not cost the user the sentence they already finished saying. Every other path that destroys a pending utterance in this codebase hands the words back to the composer first — mute is the only one that deletes them.
Where it is
packages/sdk/src/voice/use-voice.ts:797-807 — muteFromCommand, reached by both mute channels (the voice command, and the on-screen button via toggleMute at :1644, wired at store/console/src/pages/InstanceDetail.tsx:1263):
constmuteFromCommand=useCallback(()=>{mutedRef.current=true;setMuted(true);sttRef.current?.stop();ttsRef.current?.cancel();// stop the agent mid-sentence + drop the queuesetSpeaking(false);speakEndedAtRef.current=Date.now();// ← leg 2stopAudioMonitor();setMicOn(false);clearVoiceText();// ← leg 1},[stopAudioMonitor,clearVoiceText]);
The mechanism — two individually-correct decisions
Leg 1 — clearVoiceText() deletes a transcribing utterance whose text has not arrived yet
clearVoiceText() dispatches {type:"clear"}, and reduceDictation (machine.ts:301) returns null. At that moment the dictation is {status:"transcribing", text, heard} — the user has finished speaking and the clip is mid-upload, so the words exist but their final text does not.
The codebase already knows this exact state is worth saving. prepareConversationSwitch (machine.ts:~211-225) singles it out by name:
"A transcribing utterance is the sharpest form of it — they finished speaking and the clip is mid-upload, so the speech exists but its text does not yet. That is recovered to the composer rather than sent (the #175 contract)."
leaveForSwitch (use-voice.ts:1405-1427) does the recover then the clear. muteFromCommand does only the clear.
Blame makes this a two-commit composition, not a single mistake:
Leg 2 — the surviving audio's fate is decided by an unrelated 800 ms timer
Mute deliberately stops the recorder with stop(), not stopDiscard() — that is #228's promise, so "run the tests, mute" still sends the request. So the clip uploads and lands in handleResult, where classifyResult (use-voice.ts:951-955) judges it against readGuard() — which now carries speakEndedAt = <the moment Mute was pressed>. shouldIgnoreResult (machine.ts:52) is isEchoing || paused, and ECHO_GUARD_MS is 800 (machine.ts:18).
Measured against the real modules (packages/sdk/dist/voice/machine.js, node, guard state as muteFromCommand leaves it):
bubble at mute time : {"text":"file the issue about masters games","status":"transcribing", ...}
bubble after mute : null
switch would recover: "file the issue about masters games"
ECHO_GUARD_MS = 800
transcript lands 200ms after Mute -> ignore
transcript lands 700ms after Mute -> ignore
transcript lands 799ms after Mute -> ignore
transcript lands 800ms after Mute -> accept
transcript lands 1200ms after Mute -> accept
transcript lands 2000ms after Mute -> accept
≥ 800 ms → accept → finalize → emitSend. planFinalizedTurn takes muted (turn.ts:89) but only for command matching, never to suppress the send (turn.ts:119-120). The message is sent to the agent after the user muted, with nothing on screen during the gap.
Whisper latency is ~1-2 s (the code's own estimate, :1121), so both branches are routinely reachable. The 800 ms line has nothing to do with mute: it exists to protect against a cancelled TTS tail.
Leg 3 (related, same root) — mute does not silence the next reply
maybeSpeakResponse (:766-775) has no muted check. When leg 2 sends a turn after mute, the agent's reply is spoken aloud while the UI says "Muted". ADR 0001 M2: "Mute silences both directions at once… Muting an agent that keeps talking is not mute." Today mute cancels only the speech in flight at that instant; it is not a state that suppresses speech that starts afterwards.
Why the console logs show nothing
Checked production for this account: GET /v1/admin/errors?q=voice returns 4 rows total — one noise/kept, three AudioContext suspended. Nothing corresponding to a dropped turn. That is the finding, not the absence of one: the ignore branch is the one silent drop path in the pipeline, so a lost turn leaves no trace anywhere by construction.
What to do — cheapest first
1. One line — stop arming the echo tail when nothing was speaking. Read speakingbeforecancel() and arm conditionally:
The line was added in #153 to cover a cancelled TTS tail. Mute also closes the mic, so when TTS was not speaking the guard protects nothing and its only remaining effect is the 800 ms lottery above. This alone makes the outcome deterministic and preserves the control listener's raised bar during a real cancelled utterance (ADR 0001 M3 — a higher bar, not a closed door).
2. Mute must resolve the pending utterance, not delete it. Give muteFromCommand the leaveForSwitch treatment (:1412-1418) — same helpers, already tested:
onRecoveredText is already wired in both consumers (InstanceDetail.tsx:287, CodingTab.tsx:195). Worth extracting the recoverText derivation out of prepareConversationSwitch into a named pendingUtterance(dictation) so mute isn't calling a function named "switch".
3. Make the in-flight transcript's destination explicit, not timing-derived. With (1) applied, a post-mute transcript always reaches accept and is sent. Set a flag at mute time that handleResult consults so the arriving transcript is classified recover (→ composer) rather than accept (→ sent). Carve-out for #228: the trailing-command sites (finalize's plan.command === "mute" at :1078, and the gate's onInterim at :350) must keep sending — "run the tests, mute" is a request plus a request for quiet, and #228 exists because latching there threw the request away. Cleanest shape: a parameter — muteFromCommand({ pendingTurn: "send" }) at those two sites, "recover" everywhere else.
4. Make muted suppress TTS that starts later — maybeSpeakResponse: if (mutedRef.current) { setPaused(false); return; }.
Keep clearing, but auto-send whatever arrives. Simplest, and it is what ≥800 ms already does. Rejected: mute is what a user reaches for when something has gone wrong (ADR 0001's own framing). Firing an agent action — with spend, and a spoken reply — off a turn they just interrupted is acting on an instruction they withdrew. The composer is visible, editable and one tap from sending; being wrong there costs one tap, being wrong the other way costs an unwanted agent run.
After a standalone mute during transcribing, should the finished turn be sent or recovered to the composer? I would recover (reasons above, and it keeps leg 3 from mattering). The counter-argument is that the user had finished the sentence and mute was only about what happens next. Worth a decision because it is the one place fixes (3) and (4) could be simplified away.
Acceptance criteria
Mute while a transcribing utterance is pending: the words appear in the composer (or are sent, per the decision above) — never nothing.
The outcome is identical whether the transcript lands 200 ms or 2000 ms after the mute; no dependence on ECHO_GUARD_MS.
No turn is dropped without a durable log row — the verdict === "ignore" branch at :955 gets a reportClientError like every sibling drop path.
While muted, an arriving reply is not spoken aloud.
Mute still closes the mic and cancels in-flight + queued TTS immediately (ADR 0001 M1/M2/M4 unchanged; the control listener is untouched, so M3 is unchanged).
Regression risk
(1) could let a real cancelled-TTS tail be transcribed — only if the mic were open at cancel time, which the conditional preserves exactly. Guard: a test asserting the tail is armed when ttsSpeaking was true at mute and not when it was false.
(4) risk of silencing a reply the user wanted. Contained: "repeat" re-speaks it after unmute.
Nothing here re-applies a guard to the control path or closes a mute channel, so ADR 0001 M1–M4 all hold; (1) and (4) strengthen M2. Note for whoever ships this: ADR 0001's M2 paragraph says "(Today: muteFromCommand … clears the pending capture.)" — that parenthetical is descriptive of the buggy line, not normative, and should be corrected in the same PR so the next reader doesn't take it as a requirement.
Reproduction status
Mechanism verified by reading + a measurement against the real machine.js decision functions (output above). Not reproduced end-to-end in a browser — it needs a live microphone and a mid-upload button press, which Playwright cannot drive. Recorded as such.
Related: #228 (mute must not eat the words), #281 (the pending utterance; the commit that introduced this), #377 (a rejected turn erased with nothing logged — same class, different path), #175 (the recover-to-composer contract), #153/#388/ADR 0001 (mute reachability).
What the user saw
In hands-free voice: finish speaking → the pending bubble shows the words with status Transcribing… → press Mute (or say "mute") → the words vanish from the screen immediately, and depending on timing they are either never sent at all or sent silently a second later with no bubble on screen while it happens.
Expected: muting stops the mic and the agent. It should not cost the user the sentence they already finished saying. Every other path that destroys a pending utterance in this codebase hands the words back to the composer first — mute is the only one that deletes them.
Where it is
packages/sdk/src/voice/use-voice.ts:797-807—muteFromCommand, reached by both mute channels (the voice command, and the on-screen button viatoggleMuteat:1644, wired atstore/console/src/pages/InstanceDetail.tsx:1263):The mechanism — two individually-correct decisions
Leg 1 —
clearVoiceText()deletes atranscribingutterance whose text has not arrived yetclearVoiceText()dispatches{type:"clear"}, andreduceDictation(machine.ts:301) returnsnull. At that moment the dictation is{status:"transcribing", text, heard}— the user has finished speaking and the clip is mid-upload, so the words exist but their final text does not.The codebase already knows this exact state is worth saving.
prepareConversationSwitch(machine.ts:~211-225) singles it out by name:leaveForSwitch(use-voice.ts:1405-1427) does the recover then the clear.muteFromCommanddoes only the clear.Blame makes this a two-commit composition, not a single mistake:
79f94d6(Voice: mute command only active during active recording — must work at all times (during TTS, agent processing, co-pilot chat) #153, 2026-08-03) addedspeakEndedAtRef.current = Date.now()— correct: mute cancels TTS, so arm the echo tail.56ca1ae([bug] Spoken words vanish between end-of-turn and the reply — dictation should land in the thread as a pending message with a transcribing status #281, 2026-08-06) rewrotesetInterim("")→clearVoiceText()— a mechanical rename during the pending-utterance work. ButsetInterim("")cleared a live partial string;clearVoiceText()destroys a first-classDictationthat can be intranscribingstatus. The same commit taught the switch path to recover a transcribing dictation and the mute path to destroy one.Leg 2 — the surviving audio's fate is decided by an unrelated 800 ms timer
Mute deliberately stops the recorder with
stop(), notstopDiscard()— that is #228's promise, so "run the tests, mute" still sends the request. So the clip uploads and lands inhandleResult, whereclassifyResult(use-voice.ts:951-955) judges it againstreadGuard()— which now carriesspeakEndedAt = <the moment Mute was pressed>.shouldIgnoreResult(machine.ts:52) isisEchoing || paused, andECHO_GUARD_MSis 800 (machine.ts:18).Measured against the real modules (
packages/sdk/dist/voice/machine.js, node, guard state asmuteFromCommandleaves it):So:
ignore.if (verdict === "ignore") return;(:955) is the only drop path in the voice pipeline with noreportClientError— compare:976,:1124,:531,:1416. The turn evaporates with no message, no bubble, no error row. This is exactly the shape [bug] A voice turn rejected as noise is erased along with the live capture — nothing survives, nothing is logged #377 was filed and closed for, on a path [bug] A voice turn rejected as noise is erased along with the live capture — nothing survives, nothing is logged #377 did not cover.accept→finalize→emitSend.planFinalizedTurntakesmuted(turn.ts:89) but only for command matching, never to suppress the send (turn.ts:119-120). The message is sent to the agent after the user muted, with nothing on screen during the gap.Whisper latency is ~1-2 s (the code's own estimate,
:1121), so both branches are routinely reachable. The 800 ms line has nothing to do with mute: it exists to protect against a cancelled TTS tail.Leg 3 (related, same root) — mute does not silence the next reply
maybeSpeakResponse(:766-775) has nomutedcheck. When leg 2 sends a turn after mute, the agent's reply is spoken aloud while the UI says "Muted". ADR 0001 M2: "Mute silences both directions at once… Muting an agent that keeps talking is not mute." Today mute cancels only the speech in flight at that instant; it is not a state that suppresses speech that starts afterwards.Why the console logs show nothing
Checked production for this account:
GET /v1/admin/errors?q=voicereturns 4 rows total — onenoise/kept, threeAudioContext suspended. Nothing corresponding to a dropped turn. That is the finding, not the absence of one: theignorebranch is the one silent drop path in the pipeline, so a lost turn leaves no trace anywhere by construction.What to do — cheapest first
1. One line — stop arming the echo tail when nothing was speaking. Read
speakingbeforecancel()and arm conditionally:The line was added in #153 to cover a cancelled TTS tail. Mute also closes the mic, so when TTS was not speaking the guard protects nothing and its only remaining effect is the 800 ms lottery above. This alone makes the outcome deterministic and preserves the control listener's raised bar during a real cancelled utterance (ADR 0001 M3 — a higher bar, not a closed door).
2. Mute must resolve the pending utterance, not delete it. Give
muteFromCommandtheleaveForSwitchtreatment (:1412-1418) — same helpers, already tested:onRecoveredTextis already wired in both consumers (InstanceDetail.tsx:287,CodingTab.tsx:195). Worth extracting therecoverTextderivation out ofprepareConversationSwitchinto a namedpendingUtterance(dictation)so mute isn't calling a function named "switch".3. Make the in-flight transcript's destination explicit, not timing-derived. With (1) applied, a post-mute transcript always reaches
acceptand is sent. Set a flag at mute time thathandleResultconsults so the arriving transcript is classifiedrecover(→ composer) rather thanaccept(→ sent). Carve-out for #228: the trailing-command sites (finalize'splan.command === "mute"at:1078, and the gate'sonInterimat:350) must keep sending — "run the tests, mute" is a request plus a request for quiet, and #228 exists because latching there threw the request away. Cleanest shape: a parameter —muteFromCommand({ pendingTurn: "send" })at those two sites,"recover"everywhere else.4. Make
mutedsuppress TTS that starts later —maybeSpeakResponse:if (mutedRef.current) { setPaused(false); return; }.Alternatives considered and rejected
stopDiscard()on mute instead ofstop(). Kills the upload, so nothing can be lost. Rejected: regresses Voice: mute is unreachable while the user is speaking in Whisper STT mode — works in dictation, silently not in OpenAI #228 — its fix deliberately does not latch the utterance so "run the tests, mute" still sends, and this would throw the request away, which is the exact defect Voice: mute is unreachable while the user is speaking in Whisper STT mode — works in dictation, silently not in OpenAI #228 closed.mutedtoshouldIgnoreResult. One line, drops every post-mute transcript. Rejected: that is the silent-loss defect promoted to a rule, and it regresses Voice: mute is unreachable while the user is speaking in Whisper STT mode — works in dictation, silently not in OpenAI #228.transcribingforever if the upload never lands, which is the [bug] Hands-free gives up after four failed mic restarts and says nothing — no notice, no error-log row, indistinguishable from a crash #387 "indistinguishable from a crash" shape.Open question for the owner
After a standalone mute during
transcribing, should the finished turn be sent or recovered to the composer? I would recover (reasons above, and it keeps leg 3 from mattering). The counter-argument is that the user had finished the sentence and mute was only about what happens next. Worth a decision because it is the one place fixes (3) and (4) could be simplified away.Acceptance criteria
transcribingutterance is pending: the words appear in the composer (or are sent, per the decision above) — never nothing.ECHO_GUARD_MS.verdict === "ignore"branch at:955gets areportClientErrorlike every sibling drop path.Regression risk
ttsSpeakingwas true at mute and not when it was false.muteFromCommand… clears the pending capture.)" — that parenthetical is descriptive of the buggy line, not normative, and should be corrected in the same PR so the next reader doesn't take it as a requirement.Reproduction status
Mechanism verified by reading + a measurement against the real
machine.jsdecision functions (output above). Not reproduced end-to-end in a browser — it needs a live microphone and a mid-upload button press, which Playwright cannot drive. Recorded as such.Related: #228 (mute must not eat the words), #281 (the pending utterance; the commit that introduced this), #377 (a rejected turn erased with nothing logged — same class, different path), #175 (the recover-to-composer contract), #153/#388/ADR 0001 (mute reachability).