Repository navigation
Description
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
- 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. - 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. - Change the stop-speech placeholder, whichever way (1) goes: suggesting
stop stopis
actively steering users into this. - 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
serge-ivo commentedon Aug 7, 2026
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
stopSpeechKeywordmust 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".
matchesStopSpeechis 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.
fix(voice): a phrase you bound is yours, the agent cannot command its…
serge-ivo commentedon Aug 8, 2026
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: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:
matchesStopSpeechis 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.commandPhrases, which bothmatchVoiceCommandandsplitTrailingCommandalready 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.tsdescribe("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:
repeat/mute/unmute/exit/next/scrapfrom everyone who never opened the panel. It needs a one-time backfill writing the built-ins into empty fields first.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 inVoiceFields.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, stille.g. stop stop (blank = off)). Untouched, for two reasons: it sits instore/console, which another agent is editing concurrently right now; and its premise has partly expired — with precedence fixed, bindingstop stopto 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.
serge-ivo commentedon Aug 8, 2026
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:MUTE_BY_LANG,REPEAT_BY_LANG,EXIT_BY_LANGand the rest each carry ten languages. So changing your voice language today changes your command words, live, with no settings edit —mutebecomessilenciobecomes静音.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:
"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
Fix (3) — the placeholder — has genuinely expired
Checked the current panel. The stop-speech field already reads:
So
e.g. stop stopno 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.
serge-ivo commentedon Aug 8, 2026
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:commandPhrasesresolvesTABLES[command][langKey(lang)]at match time (convo.ts:791-797), so a backfill would freeze that permanently; andVoiceFields.tsx:401already 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.
fix(voice): a repeated command word is the command it obviously is, n…
serge-ivo commentedon Aug 8, 2026
Closing as delivered and superseded. Nothing here is outstanding.
2532f00. An explicit binding outranks a built-in, in both directions, decided once incommandPhrases.ad072e9and is live. Blank keeps meaning "use the built-ins" and they stay language-derived; "off" is now an explicit per-command switch, soexitcan 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, andreservedElsewheredelegates to a singleboundByUserpredicate so the two readings of "the user bound this" cannot drift.The regression test in
convo.test.tsstill uses your reported account verbatim.fix(voice): every listener sees the same command word set, so a phras…
feat(voice): split command-vs-content classification into fire/park/n…