Skip to content

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

Closed

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

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

    @serge-ivo
    ContributorAuthor

    Delivered in f38367f (+ f8ea51a raising the use-voice size pin). On main, deployed.

    Verified in the code, not from the commit message:

    • New pure verdict 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 returns pass / keep / discard, and every rejection carries a report string.
    • The gate decides which: a proven-alive gate that heard speech → keep; no gate or no speech heard → discard. Same trust rule endOfTurnAction already used.
    • All three hook sites now go through it — the hands-free send path (use-voice.ts:1119), and both recover paths (:975, :1415). A keep dispatches {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.
    • Logged either way, as the issue asked: reportClientError("voice", …) fires on discard and on keep, with the transcript in the context. Fixed message strings, because reportClientError de-dups on source+message and a burst must collapse to one row.
    • The noise filter itself ([bug] Silence is transcribed as the agent's own name — proper nouns in the Whisper bias list come back verbatim #332) is unchanged — only what a rejection costs.

    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.ts describe("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.

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