Skip to content

[bug] A voice turn rejected as noise is erased along with the live capture — nothing survives, nothing is logged #377

Description

@serge-ivo

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:

  1. They speak. The gate emits interim text; the bubble shows their words. ✅
  2. End of turn; the bubble holds the words and moves to transcribing. ✅
  3. The clip returns something isNoiseTranscript rejects.
  4. 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).

Activity

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