Repository navigation
Description
Owner decision, recorded as an invariant
"Mute should be available at any time. I should be able to mute at any moment — and unmute
also."
Written up as ADR 0001 — Mute is available at every moment of a voice session
(docs/adr/0001-mute-is-always-available.md), which also establishes the ADR convention for this
repo (docs/adr/README.md). platform-docs/voice.md now documents the invariant, the full command
vocabulary, the settings hierarchy, the hands-free lifecycle, the guards and the tunables, and links
the ADR from the place someone would break it.
This issue is the enforcement half: the invariant currently rests on nothing but the code
happening to be right today, and it has already been one review comment away from being removed.
The four rules (full text in the ADR)
- M1 — mute reachable by voice AND touch during
listening/transcribing/processing/
speaking, and unmute whilemuted. No phase is a dead zone. - M2 — mute silences both directions at once: mic closed, in-flight speech cancelled, queue
dropped. - M3 — no condition may reduce to "not while the agent is speaking". Echo/pause guards must
not be applied wholesale to the control path. - M4 — unmute is symmetric and matched by the one listener that runs while muted.
Why this needs a test and not just a document
The near-miss is on record. #386 is a real defect — the control listener has no echo guard — and
its first proposed fix was "drop control results while isEchoing". That is one line, it reuses an
existing tested helper, it matches three neighbouring call sites, and it deletes mute-during-TTS,
which is the entire reason #153 built that listener. Nothing in the type system, the tests, or the
file being edited would have objected.
Do
- Unit tests in
packages/sdk/src/voice/convo.test.ts—matchVoiceCommandreturnsmute
for a configured mute phrase withttsSpeaking: true, andunmutewithmuted: true. Pure,
cheap, and the first thing to fail on a regression. - A dispatcher test on the control path asserting that whatever echo/precision logic ends up
there, a deliberate mute utterance during TTS still dispatchesmute. This is the test
that would have caught [bug] The agent's own voice can issue commands — the always-on control listener is the only path with no echo guard, and a partial "stop" exits hands-free #386's first draft. - A comment on
handleControlResult(packages/sdk/src/voice/use-voice.ts:834) pointing at
ADR 0001 — one hop from the place the constraint would be broken. - Close the touch half of M1 everywhere hands-free ships. Verify the on-screen mute is
reachable in every phase on the Assistant tab and the Coder Co-pilot, including while the
agent is speaking and on a phone. M1 requires two channels; today the voice channel is the
one that is documented and the touch channel is the one that is assumed. - Decide what to do about the no-Web-Speech hole. Where the browser has no
SpeechRecognition,ensureControlSttreturnsnulland voice-mute during TTS cannot exist —
the invariant then rests entirely on the on-screen control. Options: accept it and make the
touch control unmissable in that case; detect it and say so once in the UI ("voice commands
are not available in this browser") rather than letting configured commands silently do
nothing; or add a non-Web-Speech fallback. This one is a decision, not a task.
Explicitly out of scope
exit and next are not covered by ADR 0001. They end or redirect a session rather than
quieting it, and holding them to a stricter bar (final-only, whole-utterance, or confirmation) is
legitimate — see #385 and #386. Mute is the one that must never be hard to reach, because it is what
a user reaches for when something has already gone wrong.
Related: #153 (built the listener), #386 (fix the echo defect without breaking this),
#385 (blank-field semantics + phrase collisions), #387 (hands-free ending silently).
Activity
docs(voice): ADR 0001 — mute is available at every moment — and a voi…
test(voice): the mute invariant is now something that objects, not on…
test(console): the touch half of the mute invariant, and the defect i…
fix(voice): mute silences the agent without deleting what the user sa…
docs(voice): ADR 0001's known hole, decided — the gap stays and the a…
serge-ivo commentedon Aug 8, 2026
Complete. Closing. The last open item was the decision, and it is made and shipped in 2f33801; the rest of the checklist was already delivered (99959bb, be494a8) and is verified below.
The checklist
matchVoiceCommand—convo.test.ts.mute-invariant.test.tsM3, including the guard table that classifies [bug] The agent's own voice can issue commands — the always-on control listener is the only path with no echo guard, and a partial "stop" exits hands-free #386's first draft as illegal.handleControlResultpointing at the ADR — asserted, so it cannot be deleted quietly.mute-touch-invariant.test.ts(be494a8), which found thetoggleMutedefect on its first run.The decision: the hole stays open, and is announced
Three options were on the table. Two are rejected, with reasons recorded in the ADR's Consequences so this is not re-litigated from scratch:
A non-Web-Speech fallback — rejected. The only other recogniser available is Whisper over the key proxy, so a control channel built on it means continuously uploading the room's audio to OpenAI, billed to the user's own key, for as long as voice is engaged — so that a mute button can be spoken to. It would also run hardest in exactly the phase it exists for (the agent talking), where most of what the microphone hears is the agent. That trade is far larger than the gap it closes.
Silence — rejected, and it was the worst of the three. A user whose configured command words simply did nothing could not tell "this browser cannot do that" from "the app is ignoring me", and would go on saying them into a channel that does not exist.
So: stated once per session, latched, in the same words as a refused microphone (#425) — because from the user's side those are one event, and both leave the on-screen control carrying the whole invariant.
The assertion that the hole still exists is unchanged, deliberately: it is what has to be edited, alongside the ADR, on the day a fallback lands. What sits beside it now is that the hole is announced.
What #420 and #425 added, since you framed them as violations of this invariant
Both were, and both are now guarded rather than only fixed.
voice.toggleMutereference, so a button cannot opt the interrupted turn into being sent.muteFromCommand… clears the pending capture.)" was a description of the defect, not a requirement, and a reader could have taken it as one.mute-invariant.test.tsasserts all three, and asserts the narrowness: the latch may only fire on a permission verdict, becauseaudio-captureis producible by two recognizers contending for one device and latching on it would delete mute-by-voice.readAdrparses M1-M5 out of the document, and both test files assert their coverage maps equal that list — so M5 could not have been added to the ADR without both halves answering for it, which is the property this issue was filed to buy.Verified
6,606 unit tests, the full CI bar, and 249 e2e specs across chromium and webkit — including
mobile — mute is reachable in every phase (ADR 0001 M1, #388), both variants (with and without a Web Speech API), at mobile widths in WebKit. Deployed and confirmed live: the production console bundle carries the new notice text.fix(supervision): the Lead declares transfer_conversation, so the tra…
fix(voice): a hands-free send hands the bubble to the message in one …