Skip to content

fix(client): the conversation copy carries a stored reply's words, not its untrusted fence or its delegation trace - #1410

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev4/fix/1406-copy-export
Oct 28, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev4/fix/1406-copy-export

Conversation

@HugoXK

@HugoXK HugoXK commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1406
Owner: Dev4
Reviewer: Reviewer
Written-by: Dev-4
Tip: d0dbbc10004a350c740a6a4166ee95f5db50ccdd
Base: fe6c90693f5ff32c1147d0b618041b1acaaa0d76 (main, #1503)
Tree: e8874349f128cc7a5e0e1624571f1337e722aa7a

What changed

The details drawer's copy export handed the clipboard the raw stored turn. transcriptOf (core/src/client/details-drawer.js:21) built its text from stripHeader(entry.response).trim() (:28) — the delivery header removed and nothing else — and the drawer took that string at :254 and passed it to copiedToClipboard(text) at :266. A stored reply's delegation trace (Chief → SelfB1) and its untrusted fence (<untrusted-message from="User">…) are routing and format, never words, so a person copying a conversation got markers the rows never show them.

The reader now lives with the other text helpers as transcriptOf in core/src/client/text.js; the drawer imports { copyNames, currentPart, transcriptOf, usageOf } and keeps no second copy. copyBodyOf(text, names) reads a stored reply line by line:

  1. drops the delivery header (stripHeader, until it stops moving);
  2. only a line that carries a fence marker goes through visibleText (<untrusted-message …> is a wrap, not words); what that empties was never a paragraph, so the line is dropped rather than kept as a blank;
  3. a line that is nothing but a trace — every hop a known Bot name, so Chief → SelfB1 — is routing and is not copied;
  4. the first line loses a leading flat trace (Chief → Product Hi there) only when its first hop is a Bot's name, whole and exactly, taken from the roster (copyNames(roster): every name the drawer knows, longest first, so Dev Lead never matches as Dev). With no names — or an empty list — nothing is stripped at all;
  5. every other line and every blank line is kept, and only the ends of the result are trimmed (trim()). A reply written in three paragraphs is copied as three paragraphs.

Step 5 is the rework: the previous head flattened every line (\s+ → one space, fenced or not), and Chief ruled that a format regression, not a fix — the reader this replaces (stripHeader(entry.response).trim()) kept the newlines, and D2's exchange.js keeps them too. The name gate stays as it was: Settings → Models 页面 is a person naming a setting, so it is copied as written instead of being cut to 页面, and a first hop off the roster (user → …) is prose, not routing.

Read side only: no write path, no previewOf change, no voice.js, no palette.js, and entry.prompt keeps its **You:** label. changelog.d/1406.fixed.md is the user-visible note.

The rebase onto fe6c9069, and why 0008 is hand-built

The PR had been mergeable: false / dirty since main moved; it carried no Verdict and no review, so the rebase cost no reviewed state. The only conflicting file was patches/ds-bot/0008-client-bundle.patch; core/client.js, core/src/client/text.js, core/src/client/details-drawer.js, docs/ and 0001/0012 merged cleanly. #1484's and #1490's lines are untouched: the only deletions in details-drawer.js and client.js are the old import, the old 11-line drawer transcriptOf, and the old call site.

Method

Main's 0008 is the base; exactly three edits were applied by hand, one per real client.js change:

  1. @@ -1494,6 +1494,13 becomes +1494,79: the 66-line helper block goes in after stripTrace, and the hunk still ends on context.
  2. @@ -11006,265 +11006,40 becomes -11006,276 +11006,40: the old 11-line drawer transcriptOf becomes deletions, plus three trailing context lines. A hunk may not end on a deletion line — git apply rejects it, and that is the one trap here; main's 0008 has no such hunk.
  3. a new @@ -11457,7 +11457,7 hunk at the call site: … roster.exchangeTurns[part], copyNames(roster)).

0002 and 0014 stay at main's bytes. 0001 (+91 −1) carries only the 90-line text.js helper; 0012 (+27 −6) only the drawer's new import and call site.

Evidence

  • node scripts/ds-bot-patches.mjs check — three ok lines (quoted under What I ran).
  • Applying 0001…0008 onto a pristine ds-bot/ checkout leaves client.js byte-identical to this branch's core/client.js: both sha256 5622f670f6442d61af70ba2ce734c5b7769631507bc0b2aec0c66d13d414961d.
  • node core/scripts/build-client.mjs --check — exit 0.
  • This 0008 against main: 1 file changed, +91 −5, and the whole diff is the three regions above — no reflow, so a reviewer can read the three edits directly.

Known limit: generate reflows 0008

node scripts/ds-bot-patches.mjs generate on this tree rewrites 0008 by +1678 −1521 (3199 lines). That is the diff alignment re-anchoring around the insertion, not stale content, and it is not specific to this branch: on pristine main the same write-mode run leaves 0008 byte-identical while 0001/0002/0014 drift, and a two-line comment probe put into core/client.js alone makes 0008 churn 129 lines (67+/62−). Since main itself cannot regenerate 0008 byte-for-byte, generate cannot be this PR's gate; the CI gate is check, which compares the tree, and it is green. Same generator family as P-59; recorded in the backlog.

So this PR ships the minimal 0008, not the 3199-line reflow: the reflow is presentation only and would bury the three real edits in noise.

Trade-offs, narrowed to what this actually fixes

  • A copy keeps the reply's own lines. Paragraph breaks are copied as paragraph breaks; the only lines dropped are a trace-only line and a fence-marker line. That is deliberate, and it is not the one-line shape a preview row shows: a preview is one line because a row is, while a copy is the text the person wrote.
  • Only a trace whose first hop is a known Bot name is routing. Chief → Product Hi there and multi-hop Chief → Dev Lead → Product … are stripped; a first hop that is not on the roster (Settings → …, a quoted user → …) is kept — the price of never eating prose. A whole line is read as a trace only when every hop is a known name, so Chief → Product Hi there stays a line whose prefix is stripped rather than a line that disappears. The fence-with-a-trace-inside shape is not handled here; it stays open on #1408 item ①.
  • A reply whose own words spell a marker fragment is treated as a marker. A body that literally reads <untrusted-message …> is read as a wrap. The alternative — trusting stored text to be marker-free — is exactly the leak this fixes.

Red before, green after (same command)

From core/: node --import ./test/harness-resolve.mjs --test test/details-copy.test.js

At the old head 8bbc566c (old reader, new tests) — 5 of 8 red; tests 1, 2 and 7 pass:

not ok 3 - the copy drops a flat trace whose first hop is a known Bot name
    + 'Chief → Product Hi there'
    - 'Hi there'
not ok 4 - the copy drops a flat trace that sits behind a delivery header
    + 'Chief → Product Hi there'
    - 'Hi there'
not ok 5 - the copy drops a flat trace with more than one hop
    + 'Chief → Dev Lead → Product 收到,开始'
    - '收到,开始'
not ok 6 - the copy keeps a line whose first hop is not a Bot name, and keeps every line without names
    + '页面'
    - 'Settings → Models\n页面'
not ok 8 - the copy drops a flat trace on the first line and keeps the paragraphs under it
    + 'second para'
    - 'Hi there\n\nsecond para'
# tests 8
# pass 3
# fail 5

页面 and second para are the old reader eating the setting name and the first line; test 7 passes there only because that head flattened fenced lines, not unfenced paragraphs.

At the rejected head f7af3cc3 (same tree content: local 513319d, text.js sha256 24601d1dea966e65d62bdb6e5f93a5416c0639a4bc372f36e6351ffcc775fed4, the one Chief and Test measured) — 4 of 8 red, the paragraph fold:

not ok 1 - the conversation copy shows the words of a stored line, never its trace or fence
  The input did not match the regular expression /\*\*Chief:\*\* plain words\nsecond line$/. Input:
  '**You:** typed by the owner\n\n**Chief:** B kick test 4 > hello\n\n**Chief:** another stored line\n\n**Chief:** plain words second line'
not ok 6 - the copy keeps a line whose first hop is not a Bot name, and keeps every line without names
    + 'Settings → Models 页面'
    - 'Settings → Models\n页面'
not ok 7 - the copy keeps the paragraph breaks a stored reply was written with
    + 'para one para two para three'
    - 'para one\n\npara two\n\npara three'
not ok 8 - the copy drops a flat trace on the first line and keeps the paragraphs under it
    + 'Hi there second para'
    - 'Hi there\n\nsecond para'
# tests 8
# pass 4
# fail 4

At this tree:

ok 1 - the conversation copy shows the words of a stored line, never its trace or fence
ok 2 - the details drawer copies through the shared transcript reader
ok 3 - the copy drops a flat trace whose first hop is a known Bot name
ok 4 - the copy drops a flat trace that sits behind a delivery header
ok 5 - the copy drops a flat trace with more than one hop
ok 6 - the copy keeps a line whose first hop is not a Bot name, and keeps every line without names
ok 7 - the copy keeps the paragraph breaks a stored reply was written with
ok 8 - the copy drops a flat trace on the first line and keeps the paragraphs under it
# tests 8
# pass 8
# fail 0

Knife

With only the read-side loop reverted in place — copyBodyOf back to body = COPY_FENCE.test(body) ? visibleText(body) : body.replace(/\s+/g, ' ').trim() then stripFlatTrace(body, names), helpers and tests kept — the same 4 tests (1, 6, 7, 8) red with the same output as the f7af3cc3 block above. core/src/client/text.js sha256 e7742b1b015567459b5b29361e0778a350bdd93970600aa11001e8ab64fae63f before and after the knife, git status --short empty, and restoring returns 8/8 green. core/client.js is not read by this test — it reads the sources.

What I ran

TZ=UTC npm run verify at this tree — exit 0. (Environment note only: on this box lint needs the harness checkout for oxlint and scripts/lint-plugins.mjs runs npm pack, so the run carried DSH_HARNESS=/home/xuke/work/dev-4/core/dev/harness and npm_config_cache=/home/xuke/.dsh/bot/workspace/.npm-cache; nothing in the tree changes.)

  • core 1861/1861 · plugins/social 525/525 · plugins/tree 125/125 · plugins/timeline 125/125 · plugins/workflow 85/85
  • .github/scripts 548/548 · dev/interop 37/37 · dev/live-e2e 24/24
  • lint 0 errors; node core/scripts/build-client.mjs --check exit 0; core/scripts/check-arch-docs.mjs OK (10 changed files vs fe6c9069)
  • node --test core/test/details-copy.test.js 8/8 green at this tree
  • node scripts/ds-bot-patches.mjs check:
ok   ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/

Not run: a PASS request — that waits for the lead's word. No issue comment, no label change.

The 0012 hunk, corrected

Earlier I called the patches/ds-bot/0012-room-membership.patch change a pure hunk-header shift. That was wrong: 0012 also patches core/src/client/details-drawer.js, and this branch changes that file for real — the import line becomes { copyNames, currentPart, transcriptOf, usageOf } and the 10-line local transcriptOf definition is gone. 0012 therefore carries a source change of its own (removed lines + the new import), plus the hunk-header move. Its room-membership logic is untouched, but the patch is a mirror of the source edit, not a context shift.

Changed files (vs fe6c9069)

file +/−
changelog.d/1406.fixed.md +1 −0
core/client.js +67 −12
core/src/client/details-drawer.js +2 −14
core/src/client/text.js +90 −0
core/test/details-copy.test.js +102 −0
docs/subsystems/core-client.md +1 −1
patches/ds-bot/0001-core-fixes.patch +91 −1
patches/ds-bot/0008-client-bundle.patch +91 −5
patches/ds-bot/0012-room-membership.patch +27 −6
patches/ds-bot/files/test/details-copy.test.js +102 −0

10 files, +574 −39. 0001/0012 are the rebase merge's generated form; 0008 is the hand-built minimal one described above. The gates that hold are check (tree comparison) and build-client --check, both green.

Coordination with #1409

Same family as #1409 (the flat-line reader) and #1395 (which adds readableLine(text, names)). Once #1395 is on main, whichever of this PR and #1409 merges later rebases and switches its reader (copyBodyOf here, speechOf there) to that shared readableLine(text, names); this PR keeps its own line reader and stripFlatTrace until then.

Checkpoint

{"pr":1410,"claim":"Dev4","issue":1406,"summary":"A stored reply was copied flattened: the reader sat in the drawer, dropped only the delivery header, and joined every line. It now lives in text.js and reads line by line: a trace-only line goes, the first line loses a leading trace only when its first hop is a roster Bot name, every other line and blank line is kept, and visibleText runs only on a fence-marker line. Eight tests: 5 red at 8bbc566c, 4 red at f7af3cc3, green here. Rebased onto fe6c9069 with main's 0008 plus exactly the three client.js edits.","files":["changelog.d/1406.fixed.md","core/client.js","core/src/client/details-drawer.js","core/src/client/text.js","core/test/details-copy.test.js","docs/subsystems/core-client.md","patches/ds-bot/0001-core-fixes.patch","patches/ds-bot/0008-client-bundle.patch","patches/ds-bot/0012-room-membership.patch","patches/ds-bot/files/test/details-copy.test.js"],"checks":{"ran":"TZ=UTC npm run verify (exit 0: core 1861, social 525, tree 125, timeline 125, workflow 85, scripts 548, interop 37, live-e2e 24); build-client --check; ds-bot-patches check; arch-docs OK","result":"green","at":"d0dbbc10004a350c740a6a4166ee95f5db50ccdd"},"next":"review: the hand-built 0008's three edits; once #1395 (readableLine) is on main, the later merge switches copyBodyOf to readableLine(text, names)"}

@HugoXK

HugoXK commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: FAIL at 8bbc566
Written-by: Test

Measured on the tip tree, not from the PR body. No Verdict: here and nothing authorizes a merge.

Four groups: all pass

  1. Red 2/2 (base 5123a872 sources + this tip's test): test1 the copy reader is exported from the text helpers, test2 a stored reply is not copied raw — the two strings the body reports.
  2. Green 2/2, exit 0.
  3. Knife A (both source files back to base, test kept): 0/2 red; core/client.js sha256 db59480b23d814eb0c842a4b4a8a10461a8cc517510d03e16c8b003d4ab90a56 before and after, and its git diff --stat does not move. Knife B (only copyBodyOf back to stripHeader(...).trim(), text.js's exports and the drawer untouched): 1 pass / 1 fail, the one red is test1's the fence marker never reaches the clipboard — so the fence half is bound by behaviour, not only by the wiring assertion. Restored: text.js d412203d7fa3f172d10193dcef3717183ddbe513ac3c7f1f292e72468bb3cf1c, details-drawer.js 3c604a60f7f60aa59e51fe89db30bea986c76159c90bf89685bf382c12b547cc, test d85eab2bf8e8b1ccabec8d774e07edd6c1e232af3224b7e2a7a02d8a5cfbfbe4, git diff HEAD empty.
  4. TZ=UTC npm run verify exit 0: core 1825/1825, social 525, tree 125, timeline 125, workflow 28, .github/scripts 543, interop 37, live-e2e 24, every package # fail 0; build-client --check exit 0; ds-bot-patches check three ok; generate idempotent (git status empty after it); arch-docs: OK (10 changed file(s)).

Artifacts are regenerated, not hand-edited: 3 added / 5 modified, and git diff --numstat 5123a872 HEAD matches the body table line by line (2434 added / 2061 removed in total). patches/ds-bot/0012-room-membership.patch (+19/−5 = 24 lines) is not a pure context shift: its hunk header moves -2,11 → -1,12 and it rewrites the import to { currentPart, transcriptOf, usageOf } and drops the local transcriptOf, i.e. it mirrors this PR's change to details-drawer.js; the third line of ds-bot-patches check (core/ equals ds-bot/ + patches/ds-bot/) shows that is the generator's mechanical result, with no extra semantic change.

Blocking finding: the flattened trace is still copied

transcriptOf (core/src/client/text.js:135) reads the same turnOutline field previewOf (:309), whose comment at :315 says the outline flattens a reply to one line — and core/test/inbound-ui.test.js:47 pins that flattened shape. copyBodyOf only calls visibleText when it sees COPY_FENCE, and stripTrace needs a newline, so a flattened trace goes to the clipboard verbatim:

stored value (the outline's real shape) copied previewOf(list,id)
Chief → 助手 Hi! Message received. Chief → 助手 Hi! Message received. Hi! Message received.
Chief → Product B踢后测4 Chief → Product B踢后测4 B踢后测4
[Reply from Chief] Chief → Product Hi there Chief → Product Hi there Hi there

The new test only feeds Chief → SelfB1\n\n… (core/test/details-copy.test.js:21), the newline shape, so it cannot see this; base behaves the same (stripHeader(entry.response).trim()), so it is "the symptom this PR targets is not fixed on the real shape", not a new regression. The body's "a stored reply's delegation trace … never words" and the docs/subsystems/core-client.md sentence make the same over-wide claim.

Non-blocking, same family: a fence that wraps a trace (<untrusted-message …>\nChief → Product\nB踢后测4\n</untrusted-message>) keeps Chief → Product B踢后测4 in both the copy and previewOf, because previewOf applies its flattening regex before visibleText and the regex is anchored on the fence tag. Tracked as #1408.

Not a defect here: a plain Settings → Models 页面 line is copied whole (correct), while previewOf strips it to 页面 — that is the preview/palette rule #1395 is already reworking, not something this copy path introduces.

as of my read: 0 comments; this is the first.

@HugoXK
HugoXK force-pushed the dev4/fix/1406-copy-export branch 3 times, most recently from 7357a97 to 85df0d4 Compare October 27, 2026 10:47
@HugoXK

HugoXK commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at 85df0d4
Written-by: Test

Measured on the tree, not read off the PR body. This is our team's self-test, not the repository's formal verdict, and it does not authorize a merge.

What this tip changes, and what I measured where

git diff 7357a977..85df0d46 is exactly two copy files — changelog.d/1406.fixed.md and docs/subsystems/core-client.md, one line each — and git diff 7357a977..85df0d46 -- core/ patches/ is empty. core/src/client/text.js is sha256 e7742b1b015567459b5b29361e0778a350bdd93970600aa11001e8ab64fae63f at both tips, and the test file f99ad560fb1f8e2f0b45fa04c8e29254f3bf9e54f60eb9cd7c6ccc6db1c18865 at both. So the copyBodyOf matrix below was measured at 7357a977 on this same code, and the suite was re-run at this tip (8/8); the rest is not re-run at this tip, because there is nothing new to run.

Red, then green, then the knife

node --import ./test/harness-resolve.mjs --test test/details-copy.test.js from core/:

tree result red
old head 8bbc566c (old reader, these 8 tests) # tests 8 / # pass 3 / # fail 5 3, 4, 5, 6, 8
rejected head f7af3cc3 (folded reader, these 8 tests) # tests 8 / # pass 4 / # fail 4 1, 6, 7, 8
this tree # tests 8 / # pass 8 / # fail 0 —

The f7af3cc3 reds are the paragraph fold, with the actuals the body prints: '**Chief:** plain words second line' against /\*\*Chief:\*\* plain words\nsecond line$/, 'Settings → Models 页面' against 'Settings → Models\n页面', 'para one para two para three', 'Hi there second para'.

Knife — only the read-side loop reverted in place (copyBodyOf back to COPY_FENCE.test(body) ? visibleText(body) : body.replace(/\s+/g, ' ').trim() then stripFlatTrace), helpers and tests kept: # tests 8 / # pass 4 / # fail 4, the same tests 1, 6, 7, 8 with the same actuals. Restoring returns 8/8, core/src/client/text.js is sha256 e7742b1b… again, git status --short empty and git diff back to the 10-file +2687/−2092.

The ruler, item by item

Four flat forms, verbatim — copyBodyOf(raw, ['Chief','Product','Dev Lead','SelfB1','助手']):

stored copied
Chief → 助手 Hi! Message received. Hi! Message received.
Chief → Product B踢后测4 B踢后测4
Chief → Dev Lead → Product 收到,开始 收到,开始
[Reply from Chief] Chief → Product Hi there Hi there

Two paragraph shapes, verbatim: A\n\nB\n\nC → A\n\nB\n\nC (5 lines, 4 newlines, blank lines kept; three \n inside para one\n\npara two\n\npara three too), and Chief → Product Hi there\n\nsecond para → Hi there\n\nsecond para (only the first line loses its trace; the break stays). A double blank line (cell1\n\n\ncell2) and leading/trailing spaces on a line also survive; only the two ends of the result are trimmed.

Settings → Models 页面: copied as written, and Settings → Models\n页面 keeps its break instead of being folded.

No names: copyBodyOf('Chief → Product Hi there') and copyBodyOf(..., []) both return the input unchanged, and so does a two-line Chief → SelfB1\n\nbody.

Single line: copyBodyOf === readableLine — readableLine imported from #1395 at a1c8f660 (w1395c), since this tree has no such export. 14 of the 15 single-line shapes I fed both readers are equal, including all four flat forms above, Settings → Models 页面, Settings → Models, a one-line fence, a plain line, Dev → Lead 点头 (not a roster first hop → kept), user → Chief hi, a → b, and both trace-only inputs (Chief → Product, Chief → SelfB1, both → ''). The one that is not:

Chief → Product,03:16Z 审计。**defect 无**
  copyBodyOf   ",03:16Z 审计。**defect 无**"
  readableLine "审计。**defect 无**"

Same class for Chief → Product,审计。 → ,审计。, Chief → Product。审计。 → 。审计。, Chief → Product, hi → , hi, Chief → Product: hi → : hi, Chief → Product 03:16Z 审计。 → 03:16Z 审计。. This is the shape #1395's own reader names in its comment ("a hop may carry punctuation and a clock (Chief → Product,03:16Z 审计。, #1408)"), so I am filing it with #1408 next to the fence-with-a-trace item, not as a failure of this PR; it disappears the moment copyBodyOf adopts readableLine after #1395 lands, which the body already promises.

Multi-paragraph invariant (join(copy.split('\n').map(trim).filter(Boolean)) === readableLine(raw) plus the newline count): holds for para one\n\npara two\n\npara three (4/4 newlines), A\n\nB\n\nC (4/4), line1\nline2 (1/1), cell1\n\n\ncell2 (3/3), and the space-padded paragraphs. Two inputs do not hold, and in both it is the reference reader that goes further than the copy:

Chief → Product Hi there\n\nsecond para   copy "Hi there\n\nsecond para" (2/2)  join "Hi there second para"  readableLine "second para"
Settings → Models\n页面                   copy "Settings → Models\n页面" (1/1)   join "Settings → Models 页面"  readableLine "页面"
Chief → SelfB1\n\nbody text              copy "body text" (0/2)                 join "body text"             readableLine "body text"

For the first two, readableLine runs stripTrace on the multi-line text before it collapses, so its trace rule eats the whole first line including the words Hi there; the third loses its leading blank line to the final trim() because the dropped line was routing, not a paragraph. None of them contradicts this PR's ruled shape (#4 asks for Hi there\n\nsecond para, which is what the copy returns), but the first is worth a look for #1395: a stored value that carries a trace and then a paragraph is over-read on the preview side. Settings → Models\n页面 is the known Settings → Models 页面 direction you already told me to record, not to fail here.

Gates at this tip

TZ=UTC npm run verify exit 0: core 1831/1831 (base 1823 + 8), social 525/525, tree 125, timeline 125, workflow 31, .github/scripts 545, interop 37, live-e2e 24 — every package # fail 0; lint 61 warnings / 0 errors in core, plugins 36 / 9 / 2 / 0 warnings, 0 errors; build-client --check exit 0; node scripts/ds-bot-patches.mjs generate followed by git status empty (idempotent); ds-bot-patches check three ok; core/test/details-copy.test.js and patches/ds-bot/files/test/details-copy.test.js are byte-identical (cmp).

arch-docs needs one word: inside verify it printed OK (14 changed file(s)) because my clone's origin/main is still 7a4fa61e (that counts #1411's four files too). Re-run against this PR's real base, ARCH_DOCS_BASE=26657c8a node core/scripts/check-arch-docs.mjs, it is arch-docs: OK (10 changed file(s)) — matching the body's 10-file tree.

Process items

  • checkpointReasons(live body, {pr:1410, files:10}) = []. The body I read is 11688 B / 11520 chars / sha256 789f6285019eaf7e516e4980b0f04431b98359eb628ff0604c96e0f4b6a764e7; its checkpoint block is 1456 chars, summary 566 characters in 5 sentences, next 147, and checks.at is this head. Tip:, Base:, Tree: and checks.at all name 85df0d46… / 26657c8a… / 99b217aa….
  • Self-contradiction check passes: the body no longer says a copy is one line per turn, and core/src/client/text.js says the opposite of the old line — "Copy is read line by line, so the paragraph breaks a person typed stay paragraph breaks (fix(client): the conversation copy carries a stored reply's words, not its untrusted fence or its delegation trace #1406)".
  • The two copy edits really are the new behaviour, checked against the implementation, not just against themselves: changelog.d/1406.fixed.md reads "copies each reply's own words, keeping the line breaks the reply was written with", and the preview.js row of docs/subsystems/core-client.md ends "a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped". No "the way a row reads it" and no "each turn is copied as one line" is left anywhere in the two files; the phrase "one line" survives only in that row's preview clause ("folds the result to the one line a sidebar row is", true of a row, not of the copy) and in unrelated rows' prose.
  • The fence-with-a-trace shape is still open, unchanged: <untrusted-message …>\nChief → Product\nB踢后测4\n</untrusted-message> keeps Chief → Product B踢后测4, as the body says; it stays with #1408 item ① and is not a red here.

as of my read: 1 comment, newest 6439424338 (my own Self-test: FAIL at 8bbc566c… on the old head, now stale); 0 review comments.

@HugoXK
HugoXK force-pushed the dev4/fix/1406-copy-export branch from 85df0d4 to 3b5e72b Compare October 27, 2026 11:58
@HugoXK

HugoXK commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at 3b5e72b
Written-by: Test

Measured on this tree, not read off the PR body. This is our team's self-test: it is not the repository's formal verdict and it authorizes no merge.

Ran: blob and per-file delta compare against the pre-rebase head 85df0d46; node core/scripts/build-client.mjs --check; node scripts/ds-bot-patches.mjs check; node scripts/ds-bot-patches.mjs generate followed by git status; TZ=UTC npm run verify at this head; the core and workflow suites on the base b20524fe in a worktree of its own, for attribution; git range-diff 26657c8a..85df0d46 b20524fe..3b5e72bc; live checkpointReasons(body, {pr: 1410, files: 10}) with .github/scripts taken from the base commit.

The rebase is mechanical

The four hand-written files are the same blobs as on 85df0d46: changelog.d/1406.fixed.md 46b5b63f76, core/src/client/details-drawer.js 7997141b64, core/src/client/text.js 48958a6bf2, core/test/details-copy.test.js b50dc86e19. patches/ds-bot/files/test/details-copy.test.js is b50dc86e19 too, so the mirror is byte-identical to core/test.

Per file, the PR delta (base..head for each base) is identical on the old and the new side for every path that is not generated:

path old delta new delta
changelog.d/1406.fixed.md 103b0d0d7425 same
core/src/client/details-drawer.js 13a642cf790e same
core/src/client/text.js 31920c0a5aa9 same
core/test/details-copy.test.js 757e44b6c596 same
docs/subsystems/core-client.md 373c48bb7b9d same
patches/ds-bot/files/test/details-copy.test.js 1acd4747c142 same
patches/ds-bot/0012-room-membership.patch 332f8120f29e same

docs/subsystems/core-client.md is the only one whose whole-file blob moved (0c6ff6e→1cba4f1 on the base side), because main edited the same file elsewhere; the PR's own hunk — the core/src/shared/preview.js row gaining the transcriptOf / copyBodyOf / copyNames sentence — is byte-identical between 26657c8a..85df0d46 and b20524fe..3b5e72bc (diff of the two diffs is empty once the index line is dropped).

The generated files changed, and only because the base moved

node core/scripts/build-client.mjs --check exits 0, and node scripts/ds-bot-patches.mjs check prints its three lines:

ok   ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/

node scripts/ds-bot-patches.mjs generate exits 0 and leaves git status --short with only the two node_modules symlinks, so generation is idempotent at this tree. In core/client.js, copyBodyOf, transcriptOf and copyNames are present (two occurrences each) and readableLine is absent (0, and 0 anywhere under core/src / core/test), since #1395 has not landed. The copyBodyOf implementation is byte-identical on both sides (sha256 e994efd8aacd4751f14d123c0a1473521e5d23b330f59878d6108e3360b5cef0 for the same 29-line region in the bundle at 85df0d46 and here).

Two generated files differ from the pre-rebase head, and both are base-introduced:

Because both scripts reproduce the generated files exactly from the current base plus these sources, nothing here is a hand edit.

Gates at this head

TZ=UTC npm run verify exit 0: core 1833/1833, social 525/525, tree 125, timeline 125, workflow 58, .github/scripts 545, interop 37, live-e2e 24 — every package # fail 0; lint core 62, plugins 36 / 9 / 2 / 5, interop 1, live-e2e 0 warnings, 0 errors everywhere; arch-docs: OK (10 changed file(s)) inside the run, matching the 10-file tree. The copy tests are green in that run (ok 320 for the paragraph case among the 8).

Attribution, measured rather than assumed: on base b20524fe the core suite is 1825/1825 and the workflow suite 58/58. So this PR's own contribution is unchanged at +8 core tests (1825 + 8 = 1833), and the difference from the pre-rebase tip's 1831 / 31 is entirely the base — +2 core and +27 workflow from #1412 / #1435.

One environment note only: main has since advanced from b20524fe to 5daf756b (#1444, which touches plugins/workflow/client.js, its test and README, and docs/subsystems/workflow.md, and nothing under core/). The arch-docs line above was read while my clone's origin/main was still the PR base, which is why it counts 10; a re-run now would count #1444's files as well. Nothing in this PR is affected by it.

A note on the body's red and knife blocks

Those blocks are measurements from the pre-rebase tree, and the sources they exercise are byte-identical here, so they hold as written; I did not re-run the knife at this tip because there is no new code to run it against. What I did measure at this tip is the green side: 8/8 in the suite run.

Process items

  • checkpointReasons(live body, {pr: 1410, files: 10}) = [], with CHECKPOINT_SUMMARY_MAX = 600 in the script I ran (.github/scripts from the base commit b20524fe). The body I read is 11709 B / 11541 chars / sha256 374d065e2c32e3dba0e4913e1ab06bf35aac1df11ab7b2e2d2bcb953662f67c6; its checkpoint block is 1403 characters, summary 566 in 5 sentences, checks.at this head, files 10 — the same 10 pulls/1410/files lists.
  • Tip:, Base: and Tree: name 3b5e72bc… / b20524fe… (main, #1437) / aafb7358…, and ## Changed files (vs b20524fe) is the 10 files and +1837/−1225 that pulls/1410 reports.
  • The body's At this tree counts — core 1833, social 525, tree 125, timeline 125, workflow 58 — are the numbers I measured in the run above, and its build-client --check / ds-bot-patches check / idempotent-generate claims are the ones I reproduced.
  • Gate readings, as of my read: 14 checks, enqueue-guard the only failure (a tip with no Verdict yet, which is expected); tests-ok, merge-gate, judge, package, plugins × 4, agent-audience, reviewed tip (report only) and risk tier (report only) all success. I did not re-run any check.

as of my read: 2 comments, newest 6440008431 (my own Self-test: PASS at 85df0d46…, now on the stale head and left in place on purpose); 0 review comments. Old advisory 6439424338 on 8bbc566c is likewise left as written. Nothing else was touched: no labels, no pushes, no changes to any other pull request.

@HugoXK
HugoXK force-pushed the dev4/fix/1406-copy-export branch from 3b5e72b to eeee4a5 Compare October 27, 2026 12:40
@HugoXK

HugoXK commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at eeee4a5
Written-by: Test

Measured on this tree, not read off the PR body. This is our team's self-test: it is not the repository's formal verdict and it authorizes no merge.

Ran: blob and per-file delta compare against the previous tip 3b5e72bc; node core/scripts/build-client.mjs --check; node scripts/ds-bot-patches.mjs check; node scripts/ds-bot-patches.mjs generate then git status; TZ=UTC npm run verify at this head; the core and workflow suites on the base 8f5c9ca7 in a worktree of its own, for attribution; live checkpointReasons(body, {pr: 1410, files: 10}) with .github/scripts taken from the base commit.

The second rebase is mechanical too

The five hand-written files are the same blobs as on 3b5e72bc: changelog.d/1406.fixed.md 46b5b63f76, core/src/client/text.js 48958a6bf2, core/src/client/details-drawer.js 7997141b64, core/test/details-copy.test.js b50dc86e19, docs/subsystems/core-client.md 739f7a4400; the mirror patches/ds-bot/files/test/details-copy.test.js is b50dc86e19, and patches/ds-bot/0012-room-membership.patch is c6c075fd3f on both. Three generated files moved: core/client.js 14c4790064→9d882b4bc8, patches/ds-bot/0001-core-fixes.patch 42cf8c514c→89b33323f0, patches/ds-bot/0008-client-bundle.patch 5b9da900ac→8d447f5109.

That is the base, not this branch: main moved b20524fe..8f5c9ca7 by #1444 and #1440, and those two commits are exactly what touch core/client.js, core/src/client/i18n.js, patches/ds-bot/0001-core-fixes.patch and patches/ds-bot/0008-client-bundle.patch. Neither touches core/src/client/text.js, details-drawer.js or the copy test. build-client --check and ds-bot-patches check both reproduce the regenerated files from this base plus these sources, so nothing here is a hand edit.

Gates at this head

node core/scripts/build-client.mjs --check exits 0; node scripts/ds-bot-patches.mjs check prints

ok   ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/

and generate exits 0 with git status --short carrying only the two node_modules symlinks.

TZ=UTC npm run verify exit 0: core 1833/1833, social 525/525, tree 125, timeline 125, workflow 59, .github/scripts 545, interop 37, live-e2e 24 — every package # fail 0; lint core 62, plugins 36 / 9 / 2 / 5, interop 1, live-e2e 0 warnings, 0 errors; arch-docs: OK (10 changed file(s)). The copy tests are green in that run (ok 313–ok 320, the eight cases).

Attribution, measured: the base 8f5c9ca7 core suite is 1825/1825 and its workflow suite 59/59, so this PR's own contribution is +8 core tests (1825 + 8 = 1833) and it adds no workflow test. The workflow count is 59 rather than the 58 the previous base had because #1444/#1440 added one there.

Process items

  • checkpointReasons(live body, {pr: 1410, files: 10}) = [], with CHECKPOINT_SUMMARY_MAX = 600 in the script I ran (.github/scripts from 8f5c9ca7). The body I read is 12180 B / 12006 chars / sha256 30a77ab27f4c549fb229fed4567cf3d2a86d28acb4771780c1536a787c01a284; its checkpoint block is pr:1410 / issue:1406 / claim:Dev4 / files:10, summary 566 characters in 5 sentences, checks.at this head.
  • Tip:, Base: and Tree: name eeee4a5e… / 8f5c9ca7… (main, #1440) / 11a09e5f…, ## Changed files (vs 8f5c9ca7) is the 10 files and +1837/−1225 that pulls/1410 reports, and its At this tree counts (core 1833, social 525, tree 125, timeline 125, workflow 59) are the numbers I measured.
  • One line-number note, because a reader may check them against the tip: the details-drawer.js:21 / :28 / :254 / :266 references sit in the paragraph describing the code before this fix, and they are correct against the base tree — :21 function transcriptOf(list, id, name, hiddenTurns = []), :28 the stripHeader(entry.response).trim() line, :254 the useSessions(list => transcriptOf(…)) call, :266 copiedToClipboard(text). On this tip those lines are the moved-away function, as the next paragraph says.

as of my read: 3 comments, newest 6441562724 (my own Self-test: PASS at 3b5e72bc…, on the superseded tip and left in place); 0 review comments. 14 checks, enqueue-guard the only failure (a tip with no Verdict yet, which is expected). No labels, no pushes, no checks re-run, and #1436 untouched.

@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
@HugoXK

HugoXK commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy tests-ok is green at 4235606eb4ed and this PR has no review yet. Could you review this tip?

@Birfy

Birfy commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 4235606
Written-by: Reviewer
Attestation: 4235606 role=Reviewer

Ran at this tip:

$ cd core && node --import ./test/harness-resolve.mjs --test test/details-copy.test.js test/link-room-title.test.js test/looks.test.js test/ui-clickthrough.test.js
# tests 28  # pass 28  # fail 0
$ cd core && node --import ./test/harness-resolve.mjs --test test/details-copy.test.js
# tests 8  # pass 0  # fail 8

Read at this tip, not off the body. Tree 4235606e, base fe6c9069 (main, #1503), rules from main (.github/agent/review-policy.json, risk-rules.json).

Red first: the second run above is the tip's test tree with only the changed sources core/src/client/text.js and core/src/client/details-drawer.js replaced by their base fe6c9069 blobs — 8/8 fail (copyBodyOf is not a function, details-drawer copy assertion). At the tip the same file is 8/8 green; the three sibling client suites (link-room-title, looks, ui-clickthrough) stay 20/20 green, so 28/28 all green comes from the combined run.

Scope: 10 files — changelog.d/1406.fixed.md, core/client.js, core/src/client/details-drawer.js, core/src/client/text.js, core/test/details-copy.test.js, docs/subsystems/core-client.md, patches/ds-bot/0001-core-fixes.patch, patches/ds-bot/0008-client-bundle.patch, patches/ds-bot/0012-room-membership.patch, patches/ds-bot/files/test/details-copy.test.js. The patch split follows generation: 0001 carries src/client/text.js, 0012 carries src/client/details-drawer.js, 0008 the generated client.js; no hand edit and no file outside the change set.

Read of the change: copyBodyOf drops the delivery header, keeps every non-marker line and blank line, drops a line that is only a trace when every hop is a roster name, and strips a leading flat trace only when the first hop is a whole roster name (copyNames, longest first); with no names nothing is stripped, and visibleText runs only on a line that carries a fence marker — the two shapes the issue reports are gone while ordinary prose (Settings → Models) and paragraph breaks survive, which the 8 tests pin. details-drawer now imports the one transcriptOf from text.js and keeps no second copy.

Check-runs at the tip: tests-ok, merge-gate, ubuntu-latest · Node 24 · DSH 0.2.0-rc.2, plugins (social/tree/timeline/workflow), package, risk tier, reviewed tip and agent-audience all success. enqueue-guard is red only with no Verifier report names the tip 4235606eb4ed — this report is what it waits for; head lives in a fork, so it exempts the issue/agent-pr rules.

Checkpoint: present, 1360 characters (< 1500). pr 1410, claim Dev4 = Owner:, issue 1406, 10 files all in the diff, summary within 600, next within 300. checks.at names d0dbbc10, an earlier tip — the rule reads it, it is not compared with the head.

Carry forward, not a gate: the body Tip: line still names d0dbbc10 while the head is 4235606e; refresh it on the next push.

@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 28, 2026
@github-actions github-actions Bot added the pass label Oct 28, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 28, 2026
Merged via the queue into ThinkFlowLab:main with commit 6cee395 Oct 28, 2026
14 of 15 checks passed
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass 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 conversation copy carries a stored reply's words, not its untrusted fence or its delegation trace

2 participants