Skip to content

fix(client): the Bot exchange sheet renders and copies a stored message's fence and its trace - #1426

Open
HugoXK wants to merge 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev4/fix/d2-exchange
Open

HugoXK wants to merge 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev4/fix/d2-exchange

Conversation

@HugoXK

@HugoXK HugoXK commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1423
Owner: Dev4
Reviewer: Reviewer
Written-by: Dev-4
Tip: d595d20
Base: 26657c8
Tree: 9d01171ab2ed323d79bd82b0490bb449bb39a9d9
Base note: main moved from 7a4fa61e to 26657c8a (#1411 workflow reap) while this branch was in review; rebased onto it, re-ran build + generate (no drift) and every check below on the new base. #1411 touches only plugins/workflow/** and docs/subsystems/workflow.md, no file of this PR.

What was wrong

core/src/client/exchange.js drew a stored turn with h(Bubbles, { texts: [entry.text] }) and handed the same raw string to MessageTools. A stored line carries its routing, not only its words: a leading Chief → 助手 delegation trace and, for a remote writer, an <untrusted-message …> fence. So the exchange sheet showed and copied the envelope as if the writer had typed it.

Fix (read side only)

exchangeText(text) is the one reader, and both the bubble and the message tools' Copy receive its result:

  1. unwrap a complete <untrusted-message …>…</untrusted-message> (repeatedly, so a nested wrap converges), keeping the inner text with its line breaks and any prose that stood before or after the wrap;
  2. drop the leading whitespace and run stripTrace — this also covers a trace that sits inside the fence, right after the fence's newline;
  3. run stripFence to take the leftover fragments (from="…">, half a tag) and trim only the ends.

ExchangeLine (new, exported) passes that single string to Bubbles and to actions, so the displayed text and the copied text cannot drift. visibleText is not used anywhere on this path: it folds \s+, which would destroy a multi-paragraph reply. The write side, the other tabs, and entry.text in storage are untouched.

Two read-side limits, stated plainly:

  • A line whose prose is itself shaped like <untrusted-message …> (someone quoting a fence in their reply) is treated as a marker and loses the marker text — the same trade-off stripFence already makes elsewhere in the client.
  • Only the leading trace is routing. A trace in the middle of a sentence stays, because it reads as prose there (stripTrace semantics, shared with the sidebar).

Tests

New core/test/exchange-read.test.js (2 tests, 6 stored shapes plus a real render): a standalone leading trace, a complete wrap, a from="…"> fragment, a half tag, a three-paragraph reply, and ordinary prose byte-for-byte unchanged. The render test mounts ExchangeLine, records what Bubbles is given through a MarkdownText stub, opens More message actions → Copy, and asserts the navigator.clipboard.writeText string equals the displayed one.

Red/green, one command (core/):

node --import ./test/harness-resolve.mjs --test test/exchange-read.test.js

On base 7a4fa61e (test file only, source unfixed) — 0/2, the read side does not exist yet:

not ok 1 - exchangeText reads a stored line: no trace, no fence, and the paragraph breaks stay
  error: |-
    <stdin>:1:9: ERROR: No matching export in "src/client/exchange.js" for import "ExchangeLine"
    <stdin>:1:23: ERROR: No matching export in "src/client/exchange.js" for import "exchangeText"
not ok 2 - the row shows the words, and the copy path copies exactly what it shows
# tests 2
# pass 0
# fail 2

At this tip — 2/2:

ok 1 - exchangeText reads a stored line: no trace, no fence, and the paragraph breaks stay
ok 2 - the row shows the words, and the copy path copies exactly what it shows
# tests 2
# pass 2
# fail 0

Two knives, both returned to this tip afterwards (core/src/client/exchange.js sha256 a2696b4b3f7e927ed4f28ad50abafa7cd0def7c8e500eeec9a8eaa01370e4e04 before and after, git status --short empty):

  • Knife A — texts: [entry.text] (the reported defect). Red 1/2 with the stored line verbatim:

    + '<untrusted-message from="User">\n' +
    +   'Chief → 助手\n' +
    +   '\n' +
    +   'first para\n' +
    +   '\n' +
    +   'second para\n' +
    +   '</untrusted-message>'
    - 'first para\n\nsecond para'
    
  • Knife B — visibleText(stripFence(stripTrace(…))), i.e. the fold this PR refuses. Red 2/2 with the paragraphs collapsed:

    actual: 'first second'   expected: 'first\n\nsecond'
    actual: 'first para second para'   expected: 'first para\n\nsecond para'
    

What I ran

  • TZ=UTC npm run verify — exit 0: core 1825 · social 525 · tree 125 · timeline 125 · workflow 31 · scripts 545 · interop 37 · live-e2e 24; lint 0 errors; arch-docs OK (8).
  • node scripts/ds-bot-patches.mjs check — three lines ok (ds-bot/ tree match, 10 patches apply in order, core/ equals ds-bot/ + patches/ds-bot/).
  • The checks ran on this head's tree 9d01171a: the pushed head is d595d205 on top of 26657c8a, same tree as the rebased local tip.
  • Did not run: no live instance (no instance access), no browser beyond the jsdom-free render harness in the test.

Files (8, +1932 −1387)

File +/−
core/src/client/exchange.js +34 −5
core/test/exchange-read.test.js +114 −0
docs/subsystems/core-client.md +1 −0
core/client.js +24 −7
patches/ds-bot/0003-flybot-api-client.patch +65 −0
patches/ds-bot/0008-client-bundle.patch +1579 −1375
patches/ds-bot/files/test/exchange-read.test.js +114 −0
patches/ds-bot/topics.json +1 −0

Generated, not hand-merged: core/client.js from npm --prefix core run build, the four patches/ds-bot/* from node scripts/ds-bot-patches.mjs generate (replaying build + generate on the tip tree leaves git status empty; 0008 is the bundle mirror of core/client.js). One metadata edit: topics.json maps src/client/exchange.js to 0003-flybot-api-client — this is the first time we change that upstream file, and generate refuses an edited upstream file with no topic.

Docs: docs/subsystems/core-client.md gains the core/src/client/exchange.js row.

{"pr":1426,"claim":"Dev4","issue":1423,"summary":"The exchange sheet drew and copied a stored turn verbatim, so a leading delegation trace and an untrusted-message fence reached the reader. A new exported exchangeText() unwraps the fence, strips the leading trace and the fence fragments, and keeps paragraph breaks; visibleText is not used because it folds whitespace. ExchangeLine hands that one string to the bubble and to MessageTools, so what is shown and what is copied are the same text. Read side only: the write side, the other tabs and the stored entry.text are untouched.","files":["core/src/client/exchange.js","core/test/exchange-read.test.js","docs/subsystems/core-client.md","core/client.js","patches/ds-bot/0003-flybot-api-client.patch","patches/ds-bot/0008-client-bundle.patch","patches/ds-bot/files/test/exchange-read.test.js","patches/ds-bot/topics.json"],"checks":{"ran":"TZ=UTC npm run verify (exit 0; core 1825, social 525, tree 125, timeline 125, workflow 31, scripts 545, interop 37, live-e2e 24); ds-bot-patches check (3x ok)","result":"green","at":"d595d20572fa73da9e93033185a6d2da1ff7eefa"},"next":"review: exchangeText against the sheet and the copy path; then Test"}

…routing

The Bot exchange drew `entry.text` as stored: a leading delegation trace and
an untrusted fence reached the bubble, and the message tools' copy carried the
same markers. `exchangeText` now reads the line once — unwrap a complete
`<untrusted-message …>` wrap, drop a leading trace, strip leftover fragments —
and `ExchangeLine` gives that one string to the bubble and to the copy. The
paragraph breaks stay: a reply is not a sidebar row and is never folded.
@github-actions github-actions Bot added the risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff label Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(client): the Bot exchange sheet renders and copies a stored message's fence and its trace

1 participant