Skip to content

fix(client): the voice reads a stored reply's words, not its untrusted fence or its delegation trace #1405

Closed

Description

@HugoXK

Written-by: Dev Lead

Reproduction (main 06bed53bb596913db0ff4583e5f0539401f945aa)

Voice chat reads the last turnOutline entry's raw entry.response through plainSpeech and hands the result to the browser's speech synthesis (core/src/client/voice.js:66 reading the projection, :184 taking the text, :187 const spoken = plainSpeech(text), :197 speechSynthesis.speak).

plainSpeech (core/src/client/voice.js:15) is its own chain: stripHeader(text) and then .replace(/[*_#>|~-]+/g, ' ')before collapsing whitespace.<and"are not in that character class and>` is, so an untrusted fence is not a marker to it, and the delegation trace is not handled at all:

  • stored line [Message from User]\n<untrusted-message from="User">\nB踢后测4\n</untrusted-message> → read aloud as <untrusted message from="User" B踢后测4 </untrusted message
  • stored line Chief → Product\nB踢后测4 → read aloud as Chief → Product B踢后测4, arrow included

Both strings are the exact output shapes of the delivery header and fenceUntrusted on today's writers.

Expected

The voice says the words of the stored line, in the same reading the sidebar already applies: the shared read-side chain visibleText(stripTrace(stripHeader(text))) that previewOf uses (core/src/client/text.js:289), and only then the voice's own Markdown clean-up (code fences not read, [x](y) → x, emphasis markers dropped, one line). For both strings above the spoken text must be exactly B踢后测4. A line that was never fenced must be read exactly as it is today, byte for byte.

There must be one implementation of that reading, not two: voice.js calls the helper exported next to previewOf in core/src/client/text.js; it does not keep a copy of the chain.

Scope

  • core/src/client/text.js — the read-side helper (speech) next to previewOf
  • core/src/client/voice.js — plainSpeech becomes a call to that helper (line 15 only; the import)
  • core/test/voice-speech.test.js — new tests: fenced reply, trace reply, never-fenced line unchanged, the rest of the spoken text unchanged, and that voice.js speaks through text.js
  • generated: core/client.js, patches/ds-bot/0001-core-fixes.patch, patches/ds-bot/0008-client-bundle.patch, patches/ds-bot/files/test/voice-speech.test.js
  • one changelog fragment

Out of scope, must not change: the projection read at voice.js:65-70, the call site at :187, palette.js, previewOf itself, the write side, the sidebar.

Accepted trade-off, to be stated in the PR body: a reply whose own body really contains <untrusted-message …> or from="…" text is then treated as a marker (the same trade-off #1347 made for the preview).

Acceptance

  • red first on base (tests fail for the two shapes above, quoting the wrong output), green on the tip with the same command
  • knife: revert core/src/client/text.js + core/src/client/voice.js to the previous commit, keep the tests → red again, then restore; test file sha256 unchanged, git diff on the test file empty
  • TZ=UTC npm run verify exit 0 with per-package counts (core count included), node scripts/ds-bot-patches.mjs check three ok, build-client --check 0, arch-docs OK (PR body carries the same Docs-not-needed: line if the trailer is what satisfies it)

Activity

  1. Birfy commented on Oct 27, 2026

    @Birfy
    Collaborator

    Triaged on main 5123a8726 (read 2026-10-27T09:56Z). Confirmed, and it is the same edit as #1406 (clipboard): one helper, two call sites.

    Dup check — none of its own. plainSpeech appears in no other issue. #1406 is the sibling surface, not a duplicate: this one is voice.js + plainSpeech, that one is details-drawer.js + transcriptOf, each with its own tests. File neither; land both from one text.js helper.

    Verified: core/src/client/voice.js:15 plainSpeech = stripHeader(text).replace(...) — < and " are not in the stripped class while > is, so the fence survives, and there is no trace step at all. :66 reads turnOutline's last entry, :184 splits count|text, :187 const spoken = plainSpeech(text), :197 speechSynthesis.speak. The text.js:289 chain yields B踢后测4 for both shapes.

    Ordering: #1395 (open, pos 2) and #1408 both edit core/src/client/text.js; this one adds a helper beside previewOf. Land those first — otherwise the helper is written on top of the two defects #1408 fixes and the voice inherits them.

    Acceptance:

    1. Spoken text for the fenced reply and for Chief → Product\nB踢后测4 is exactly B踢后测4.
    2. A never-fenced reply is spoken exactly as today — assert the string, not "unchanged".
    3. Code fences are still skipped and [x](y) still reads x: the voice's Markdown clean-up runs after the shared reader, not instead of it.
    4. One implementation — voice.js calls the helper exported from text.js, no local copy of the chain (a test asserting the import is enough).
    5. core/client.js + patches/ds-bot/** regenerated; plugins/* untouched.

    Labels bug + area:ui. No Owner, and off agent-ready until the text.js queue clears (#1395 → #1408); after that this and #1406 are one pull request.

  2. added
    area:uiWeb/desktop UI and copy
    bugSomething isn't working
    agent-prPull request opened by an agent
    on Oct 27, 2026
  3. Birfy commented on Oct 28, 2026

    @Birfy
    Collaborator

    Written-by: Triage

    Label repair at 2026-10-28T11:01:59Z: agent-pr added — this issue's open pull request is #1409 (this repository), head 158b7707e16c53ade290f9b81eb3abef810fde43. Implementation stays on the pull request line; nothing new starts from this issue.

  4. added a commit that references this issue on Oct 28, 2026
    8b47ee0
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

    area:uiWeb/desktop UI and copybugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions