Skip to content

[bug] "stop stop" exits hands-free instead of stopping speech — a blank exit field means "use ours", and the built-in outranks the phrase the user bound themselves #385

Closed

Description

@serge-ivo

Reported

In hands-free, saying "stop stop" exited hands-free mode. In my preferences "stop stop" is the
stop-speech keyword. My exit command is empty — I never set one. An empty command should
not apply at all.

Correct on every point. Live account config (GET /v1/preferences):

"exitWords":         [],            ← never set
"stopSpeechKeyword": "stop stop",   ← explicitly set, to stop SPEECH
"muteWords": ["mute mute"], "repeatWords": ["Repeat that"], "stopWords": ["Copy Copy"]

What actually happens

Both dispatch sites check stop-speech first, and that check is gated on the agent currently
speaking
(convo.ts matchesStopSpeech: if (!s.ttsSpeaking) return false). So:

when you say "stop stop" what runs result
agent is speaking matchesStopSpeech → tts.cancel() speech stops — what you configured
agent is not speaking (mic hot, thinking, muted) falls through to matchVoiceCommand → exit hands-free torn down

The fall-through happens because pick() (convo.ts:384) treats an empty list as unset:

const pick = (custom, table) => custom?.length ? custom : (table[l] ?? table.en);

exitWords: [] → length 0 → built-in EXIT_BY_LANG.en, which contains "stop" and
"stop stop"
(added by #331, to fix bare "stop" being sent to the agent as chat).

So one phrase has two meanings, selected by whether TTS happens to be playing at that instant —
and the meaning the user never chose is the destructive one.

Three defects, in order of how much they cost

1. A blank field means the opposite thing on the same panel. VoiceFields.tsx:

:381  exitWords          placeholder "exit voice, text mode  (blank = built-in defaults)"
:396  stopWords          placeholder "e.g. copy, over  (blank = off)"
:401  stopSpeechKeyword  placeholder "e.g. stop stop  (blank = off)"

Three adjacent inputs, two opposite conventions, documented only in placeholder text that
disappears the moment you type. repeat/mute/unmute/exit/next/scrap all fall back to
built-ins; stopWords/stopSpeechKeyword are off. The user's expectation — blank means off — is
the convention two of the five follow.

2. The UI suggests the colliding phrase. The stop-speech placeholder is literally
e.g. stop stop. The product recommends binding a phrase that the built-in exit list already owns,
and nothing anywhere warns that the phrase is taken. There is no collision check at save time and
no ranking rule — the built-in simply wins whenever the gentler check is inactive.

3. Exit cannot be turned off at all. Blank → built-ins. The only way to stop "stop" from
ending your voice session is to bind exitWords to some other phrase, i.e. you must keep the
feature and move it. There is no off.

Why the built-in exists (keep the reason, change the mechanism)

#331 added bare "stop"/"stop stop" for a real failure: the words matched nothing, went to the
agent as chat, and it replied "Got it. Stopped." — confirming an action nobody performed while
the mic stayed open. That reasoning stands. What it did not anticipate is a user binding the same
phrase to a different action, and the destructive one silently outranking it.

Suggested fix

  1. Blank = off, for every command list. One convention across the panel. Keep the built-ins
    discoverable as a "Use suggested" button that fills the field with them — so defaults are
    visible, editable and removable, instead of invisible and mandatory.
  2. Refuse the collision. A phrase already bound to another action should be rejected on save
    with the conflict named, or — at minimum — the explicit user binding must win over any built-in.
    Today the opposite is true.
  3. Change the stop-speech placeholder, whichever way (1) goes: suggesting stop stop is
    actively steering users into this.
  4. Consider making the stop-speech keyword work when TTS is not playing too, as a no-op rather
    than a fall-through — the gate is what makes the same phrase mean two things.

The decision inside this is (1): does a blank command field mean "use ours" or "off"? It changes
behaviour for anyone who left a field blank — today they have built-in exit words they never chose,
and after the change they would have none. Recommendation: blank = off, with the "Use suggested"
button, because the current behaviour cannot be turned off and its most common word is "stop".

Files: packages/sdk/src/voice/convo.ts (pick :384, EXIT_BY_LANG :~180, matchesStopSpeech
:471), packages/sdk/src/voice/use-voice.ts:836,850,891,1126,
store/console/src/components/VoiceFields.tsx:381,396,401, packages/sdk/src/voice/config.ts:141.

Activity

  1. added
    bugSomething isn't working
    voiceVoice / STT / TTS
    on Aug 7, 2026
  2. serge-ivo commented on Aug 7, 2026

    @serge-ivo
    ContributorAuthor

    Self-review: the recommended fix is silently destructive without a backfill, and one sub-item was ambiguous

    1. "Blank = off" removes working commands from every existing user.

    Today, a user who has configured nothing still gets working repeat / mute / unmute / exit / next / scrap — the built-ins are what makes voice commands work out of the box. Flipping blank to mean off turns all of them off for everyone who never opened the panel, with no message and no way to notice except by saying "mute" and being ignored. That is a bigger blast radius than the bug being fixed.

    The change is only safe if it is paired with a one-time backfill: at the moment the semantics flip, write the current built-ins into every user's empty command fields, so nobody loses a command they were using and everybody gains the ability to edit or clear it. After that, blank means off and the field is honest. Without the backfill this ticket trades a confusing default for a silent capability removal — the same class of defect it is filed against.

    A cheaper alternative worth weighing: keep blank = defaults, and fix only the collision (an explicit stopSpeechKeyword must outrank a built-in phrase) plus the placeholder. That resolves the reported bug with no migration and no behaviour change for anyone else. It leaves "exit cannot be turned off" standing, which is the weaker half of the complaint.

    2. Withdrawing my suggested fix (4). I wrote "consider making the stop-speech keyword work when TTS is not playing too". matchesStopSpeech is deliberately substring + case-insensitive because it is gated on the agent speaking — that gate is what contains a loose matcher. Ungating it would let a phrase like "stop" swallow ordinary speech anywhere in a sentence, which is strictly worse than the bug. What I meant, stated properly: a phrase the user has bound to stop-speech must never be re-interpreted as a different command — that is fix (2), not a change to the matcher's gate. Fix (4) as written should not be implemented.

    Fixes (1-as-corrected), (2) and (3) stand, and the diagnosis is unchanged and re-verified.

  3. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Partially delivered in 2532f00 — the destructive half is fixed. Leaving this open for the rest.

    What shipped: fix (2), the precedence rule

    commandPhrases (packages/sdk/src/voice/convo.ts:791) is now the single place the rule is decided, and it says an explicit binding always outranks a built-in, in both directions:

    1. Words configured for a command replace its built-ins (unchanged);
    2. new — a phrase the user bound to a different action is removed from this command's built-ins before matching (reservedElsewhere).

    So the reported account (stopSpeechKeyword: "stop stop", exitWords: []) no longer has one phrase with two meanings selected by whether TTS happened to be playing.

    Two details worth flagging because they are wider than the ticket asked for:

    • The reservation is as wide as the matcher that will act on it. Command lists compare phrase-for-phrase, but matchesStopSpeech is a case-insensitive substring matcher — so a keyword of "stop" reserves the built-in "stop stop" and "stop listening" too. Saying those is saying the keyword.
    • It lives in commandPhrases, which both matchVoiceCommand and splitTrailingCommand already route through — so the matcher and the splitter cannot disagree about what a word means. A disagreement there is not a no-op; it amputates the phrase off the end of a real message.

    Your fix (4) was not implemented, per your own withdrawal.

    Test: convo.test.ts describe("precedence: an explicit binding outranks a built-in (#385)"), using the reported account verbatim.

    What is still open

    Fix (1) — "blank = off", with the backfill. Not done, and not a code change on its own. Two reasons, the second of which is not in the ticket yet:

    1. As your comment says: flipping the semantics silently removes working repeat/mute/unmute/exit/next/scrap from everyone who never opened the panel. It needs a one-time backfill writing the built-ins into empty fields first.
    2. The built-ins are language-derived data — they follow lang (REPEAT_BY_LANG, MUTE_BY_LANG, …). Freezing today's English list into a user's config would break the property that changing your voice language changes your command words. So the backfill is not a straight copy, and "Use suggested" needs to decide what it fills a non-English account with.

    That is a migration plus a settings-storage change in workers/api, plus the "Use suggested" control in VoiceFields.tsx — a separate change with its own ticket, not a tail on this one.

    Fix (3) — the stop-speech placeholder (store/console/src/components/VoiceFields.tsx:400, still e.g. stop stop (blank = off)). Untouched, for two reasons: it sits in store/console, which another agent is editing concurrently right now; and its premise has partly expired — with precedence fixed, binding stop stop to stop-speech now works, so the placeholder is no longer steering users into a collision. What remains is the genuine inconsistency the ticket opens with — (blank = built-in defaults) on six fields next to (blank = off) on two — and that is fix (1)'s territory, since the wording can only be made honest once the semantics are settled.

    Recommend this issue be rescoped to fix (1)+(3) as the "blank = off, with backfill" change, or closed with a fresh issue filed for it. Happy to do either — say which.

    Suite: 6256 tests / 386 files pass; typecheck, biome, design tokens all clean.

  4. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Decision on the remainder: fix (1) as written should not be implemented, and this issue should be closed with a fresh one filed. Nothing shipped here in this batch; the reasoning is below so filing the replacement is a five-minute job.

    Fix (1) is blocked by something the ticket does not contain, and the blocker is real

    I re-verified the earlier finding rather than taking it on trust. commandPhrases (convo.ts:791) resolves built-ins at match time, against the caller's current language:

    const table = TABLES[command];
    const builtIn = table[langKey(lang)] ?? table.en;

    MUTE_BY_LANG, REPEAT_BY_LANG, EXIT_BY_LANG and the rest each carry ten languages. So changing your voice language today changes your command words, live, with no settings edit — mute becomes silencio becomes 静音.

    A backfill that writes the current built-ins into every user's empty fields destroys that property permanently, because a populated field is resolved never. Every user who has ever had a language other than English silently loses their own words; every user who switches later gets English ones. That is a strictly larger regression than the one the ticket opens with, and it is not repairable afterwards — once the config is written you cannot tell a deliberate English binding from a backfilled one.

    And the deeper problem: "blank = off" is the wrong primitive

    The ticket conflates two independent facts about a command:

    1. Does the user have custom words for it? (which is what a blank field means today, correctly)
    2. Is the command enabled at all? (which is what the user is actually asking for — "exit cannot be turned off", and that is the real complaint)

    "Blank = off" tries to encode (2) in the absence of (1). That is why it needs a destructive backfill: it has to fill (1) in order to stop meaning (2). An explicit per-command enabled flag encodes (2) directly, needs no backfill, keeps built-ins language-derived, and gives "there is no off" its actual answer — including for exit, which is the command that started this.

    What the replacement issue should say

    A voice command can be turned off, without freezing its words.

    • Add a per-command enabled flag to voice settings (default on). A disabled command is dropped from the candidate list in matchVoiceCommand and splitTrailingCommand — both, or they disagree about what a word means, and a disagreement there amputates the phrase off the end of a real message rather than being a no-op.
    • Blank keeps meaning "use the built-ins", which stays language-derived. The panel's asymmetry (blank = built-in defaults on six fields, blank = off on two) becomes honest, because off is now a switch rather than an absence.
    • "Use suggested" fills the field with the built-ins for the currently configured language, as a starting point for editing — never as a migration, and never written on the user's behalf.
    • Needs a settings-storage change in workers/api and the control in VoiceFields.tsx. No backfill, so no migration risk.
    • Files: packages/sdk/src/voice/convo.ts (commandPhrases, matchVoiceCommand, splitTrailingCommand), packages/sdk/src/voice/config.ts, store/console/src/components/VoiceFields.tsx, the voice-settings route.

    Fix (3) — the placeholder — has genuinely expired

    Checked the current panel. The stop-speech field already reads:

    "A phrase you set here is yours — it won't also be read as an exit or mute word, even if it's one of our built-in ones."

    So e.g. stop stop no longer steers anyone into a collision: the precedence rule shipped in 2532f00 makes that binding work, and the panel says so. What remains is the six-vs-two wording asymmetry, and that can only be made honest once (1) is settled — i.e. it belongs to the replacement issue, not here.

    So

    Everything this issue diagnosed is fixed and live. Its remaining recommendation is one I am declining to implement, on evidence, rather than deferring — and the thing the reporter actually wants needs a different design. Recommend closing this as delivered once the replacement above is filed; I have not filed it myself because issues are the analyst's to write. Leaving it open until then so the thread is not lost.

  5. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Replacement filed: #443, built from your comment's spec — per-command enabled, no backfill, built-ins stay language-derived. I re-verified the two load-bearing claims before writing it: commandPhrases resolves TABLES[command][langKey(lang)] at match time (convo.ts:791-797), so a backfill would freeze that permanently; and VoiceFields.tsx:401 already carries the precedence sentence, so fix (3)'s steering premise has genuinely expired — only the six-vs-two wording asymmetry survives, and #443 owns it.

    This issue can close as delivered.

  6. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Closing as delivered and superseded. Nothing here is outstanding.

    • Fix (2), the precedence rule — shipped in 2532f00. An explicit binding outranks a built-in, in both directions, decided once in commandPhrases.
    • Fix (4) — not implemented, per your own withdrawal.
    • Fix (1) + fix (3) — replaced by [design] A voice command can be turned off, without freezing its words — a per-command enabled flag, not blank-means-off (replaces #385) #443, which shipped in ad072e9 and is live. Blank keeps meaning "use the built-ins" and they stay language-derived; "off" is now an explicit per-command switch, so exit can be turned off without freezing anyone's words. No backfill, no migration. The panel's six-vs-two wording asymmetry went with it — the two remaining (blank = off) fields now say why (they are not commands and have no suggested phrasings), so every field carries one honest convention.

    One thing from this thread came back and is worth recording here, because it is the sharpest evidence that the precedence rule is load-bearing rather than tidy: #456's repetition-collapse reopened your exact collision, and this issue's own tests caught it. stopSpeechKeyword: "stop stop" folds to "stop", and "stop" is still a built-in EXIT phrase — because fix (2) correctly reserved only the phrases that contain the keyword. A folded reading is now refused for anything the user has bound, and reservedElsewhere delegates to a single boundByUser predicate so the two readings of "the user bound this" cannot drift.

    The regression test in convo.test.ts still uses your reported account verbatim.

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

    bugSomething isn't workingvoiceVoice / STT / TTS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions