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
isNoiseTranscript rejects.
- 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 of clear, 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).
Reported
The path that erases a turn with no trace
packages/sdk/src/voice/use-voice.ts:1046:When the finished transcript is judged noise, the turn is cleared and abandoned.
clearVoiceTextdispatches
{ type: "clear" }, whichreduceDictationdocuments 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:
transcribing. ✅isNoiseTranscriptrejects.Nothing is written anywhere. No message, no
chat.in, no error-log row, no trace event. The words theuser 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
reduceDictationhas afailedstatus whose entire purpose is this case(
voice/machine.ts:232-234):and its top-level contract is stated as an invariant:
The noise path honours the letter of that (it does call
clear) while defeating its intent. #319went 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
isNoiseTranscriptis 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 byendOfTurnAction— then something was said, and the transcriber failed to render it. That is afailedturn, 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:{ type: "failed", note: … }instead ofclear, so thebubble 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
recoveredpath (:918,:1308both drop text viaisNoiseTranscriptwith no surviving artefact).Log it either way. Even a genuine noise discard should leave a
client:voicebreadcrumb. Theconsole 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_traceor 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).