Repository navigation
Description
Reported
"In hands-free mode it wasn't sending the messages. Messages started to get transcribed, and then
they just… were disappearing. Make sure that messages never disappear — at least the dictation
version stays even if transcribing fails."
The path that erases a turn with no trace
packages/sdk/src/voice/use-voice.ts:1046:
if (isNoiseTranscript(t, biasPrompt())) { flushSync(() => clearVoiceText()); return; }When the finished transcript is judged noise, the turn is cleared and abandoned. clearVoiceText
dispatches { type: "clear" }, which reduceDictation documents as "the turn is over (sent,
abandoned, recovered, or discarded as noise)" — and clear is the only event that removes words.
So the sequence the user describes is exactly what the code does:
- They speak. The gate emits interim text; the bubble shows their words. ✅
- End of turn; the bubble holds the words and moves to
transcribing. ✅ - The clip returns something
isNoiseTranscriptrejects. - The whole turn is erased — the bubble, the live capture, everything.
Nothing is written anywhere. No message, no chat.in, no error-log row, no trace event. The words the
user watched appear on screen a second earlier are gone with no record that a turn ever happened.
The machinery to prevent this already exists and this path skips it
reduceDictation has a failed status whose entire purpose is this case
(voice/machine.ts:232-234):
failed= it did not come back, and the words we DID hear must survive so nothing is silently
lost.
and its top-level contract is stated as an invariant:
words are only ever removed by an explicit
clear. A status change never costs the user their
speech.
The noise path honours the letter of that (it does call clear) while defeating its intent. #319
went to real trouble to persist the live capture so "is anything missing from what I said?" could be
answered — and this branch throws that same capture away before it can be stored.
Why the noise check is right to exist, and still wrong here
Rejecting noise is not the bug. Uploading silence makes Whisper hallucinate a turn (#332), and
isNoiseTranscript is the guard that stops a phantom message being sent. That should stay.
The flaw is what it does with a rejection: it treats "the transcript is noise" as "nothing was
said". Those are different claims, and the gate already knows which one is true. If the browser gate
heard real words this turn — heardSpeech() is exactly that signal, and it is already consulted by
endOfTurnAction — then something was said, and the transcriber failed to render it. That is a
failed turn, not an empty one.
Fix — the invariant the report asks for
A turn where the gate heard real words must never disappear. Concretely, at :1046:
- if the gate did not hear speech → keep today's behaviour, clear silently (it really was noise);
- if the gate did hear speech → dispatch
{ type: "failed", note: … }instead ofclear, so the
bubble survives carrying the live capture, with a reason and the existing Dismiss affordance.
From there the user can send what was heard, edit it, or dismiss it — a decision they can only make
if the words are still on screen. This also closes the gap where the same rejection happens on the
recovered path (:918, :1308 both drop text via isNoiseTranscript with no surviving artefact).
Log it either way. Even a genuine noise discard should leave a client:voice breadcrumb. The
console already reports STT/TTS failures there; a turn that was heard and then dropped is at least as
worth recording, and its absence is why this report could not be confirmed from the data — there is
nothing in agent_trace or the error log for a turn that never became a message.
Related
#371 (the same thread's mangled-but-surviving turns — this is the strictly worse case where nothing
survives), #319 (the stored live capture this path discards), #332 (why the noise guard exists and
should stay), #364 (the composer showed nothing during all of this, so the bubble was the only
surface — and it is the thing being erased).
Activity
fix(voice): a turn the gate heard is never erased by the noise filter (…
ci(size): raise the use-voice pin #377 grew past (#377)
fix(voice): a phrase you bound is yours, the agent cannot command its…
serge-ivo commentedon Aug 8, 2026
Delivered in f38367f (+ f8ea51a raising the use-voice size pin). On
main, deployed.Verified in the code, not from the commit message:
planNoiseRejection(packages/sdk/src/voice/turn.ts:170) splits the two claims the old code conflated: "this text is junk" vs "nothing was said". It returnspass/keep/discard, and every rejection carries areportstring.keep; no gate or no speech heard →discard. Same trust ruleendOfTurnActionalready used.use-voice.ts:1119), and both recover paths (:975,:1415). Akeepdispatches{type:"failed", note}so the bubble survives with the live capture and the existing Dismiss; the recover paths hand the words to the composer instead.reportClientError("voice", …)fires on discard and on keep, with the transcript in the context. Fixed message strings, becausereportClientErrorde-dups on source+message and a burst must collapse to one row.One deliberate narrowing worth naming: where there is no gate at all (Whisper mode, iOS Safari) a rejection still discards. There is no live capture in that mode, so "keeping the words" would leave a bubble reading "(nothing was captured)" — a dead affordance over a turn nobody took. It is logged, so it is no longer invisible.
Tests:
turn.test.tsdescribe("planNoiseRejection")covers pass/keep/discard, plus a guard asserting the hook makes no noise decision without it.Live check: the
Came back as noise…note string is present in the deployed console bundle at https://proagentstore.online/console/.Suite: 6256 tests / 386 files pass; typecheck, biome, design tokens all clean.
fix(agent): a fabrication in stored history is withheld from the mode…
fix(voice): a hands-free send hands the bubble to the message in one …
fix(voice): every path that throws away a recording now says so, with…