Skip to content

[invariant] Mute must be reachable at every moment — ADR 0001, and the tests that keep it true #388

Closed

Description

@serge-ivo

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 while muted. 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 — matchVoiceCommand returns mute
    for a configured mute phrase with ttsSpeaking: true, and unmute with muted: 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 dispatches mute. 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, ensureControlStt returns null and 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

  1. added
    documentationImprovements or additions to documentation
    voiceVoice / STT / TTS
    on Aug 7, 2026
  2. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    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

    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.

    readAdr parses 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationvoiceVoice / STT / TTS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions