From 8bf821284671d184897d4d39b5321e0f75a806ee Mon Sep 17 00:00:00 2001 From: Max Hsu Date: Thu, 11 Jun 2026 00:29:22 +0800 Subject: [PATCH] fix(chat): copy only the displayed reply from the message copy buttons (#3731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI-message copy buttons copied dataset.raw, which is the full accumulated model output — still containing the reasoning block and any tool-call markup that the renderer strips for display. Pasting therefore leaked the model's thinking, and the first heading after lost its markdown formatting because it was glued to the closing tag. Add chatRenderer.copyMessageText(), which mirrors the display pipeline (stripToolBlocks then extractThinkingBlocks) and falls back to the raw text when stripping leaves nothing (thinking-only turns), and route both copy handlers — the message footer and the slash-reply footer — through it. The interrupted-turn Continue flow intentionally keeps reading dataset.raw. Fixes #3722 Co-authored-by: Claude Fable 5 --- static/js/chatRenderer.js | 17 +- static/js/slashCommands.js | 2 +- tests/test_copy_message_strips_thinking_js.py | 160 ++++++++++++++++++ 3 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 tests/test_copy_message_strips_thinking_js.py diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 9a5c6f78b..7c6ecd096 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -862,6 +862,20 @@ export function stripToolBlocks(text) { return cleaned.trim(); } +/** + * Plain-text payload for the message copy buttons: the reply as the renderer + * displays it — tool blocks and reasoning stripped. dataset.raw keeps + * the full model output (chat.js even embeds the elapsed time into the + * tag for reload persistence), so copying it verbatim leaks the + * thinking block (#3722). Falls back to the raw text when stripping leaves + * nothing (e.g. turns interrupted mid-thinking). + */ +export function copyMessageText(msgElement) { + const raw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || ''; + const { content } = markdownModule.extractThinkingBlocks(stripToolBlocks(raw)); + return content || raw; +} + /** * Build a collapsible sources box (used by both research and web search). */ @@ -1372,7 +1386,7 @@ export function createMsgFooter(msgElement) { { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) { e.stopPropagation(); const btn = e.currentTarget; - uiModule.copyToClipboard(msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || ''); + uiModule.copyToClipboard(copyMessageText(msgElement)); btn.innerHTML = CHECK_ICON; setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500); }}, @@ -2444,6 +2458,7 @@ const chatRenderer = { updateSessionCostUI, roleTimestamp, stripToolBlocks, + copyMessageText, safeToolScreenshotSrc, safeDisplayImageSrc, buildSourcesBox, diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js index 6a32cb89e..79b037cf4 100644 --- a/static/js/slashCommands.js +++ b/static/js/slashCommands.js @@ -380,7 +380,7 @@ function _slashFooter(msgEl) { copyBtn.innerHTML = _copySvg; copyBtn.onclick = (e) => { e.stopPropagation(); - uiModule.copyToClipboard(msgEl.dataset.raw || msgEl.querySelector('.body')?.textContent || ''); + uiModule.copyToClipboard(chatRenderer.copyMessageText(msgEl)); copyBtn.innerHTML = _checkSvg; setTimeout(() => { copyBtn.innerHTML = _copySvg; }, 1500); }; diff --git a/tests/test_copy_message_strips_thinking_js.py b/tests/test_copy_message_strips_thinking_js.py new file mode 100644 index 000000000..4c88bb6d4 --- /dev/null +++ b/tests/test_copy_message_strips_thinking_js.py @@ -0,0 +1,160 @@ +"""Regression coverage for issue #3722 — the message copy button copied the +full raw model output (``dataset.raw``), which still contains the +``...`` reasoning block that the renderer strips for +display. Pasting therefore leaked the model's thinking, and the first heading +after ```` lost its markdown formatting because it was glued to the +closing tag. + +The fix adds chatRenderer.copyMessageText(), which mirrors the display +pipeline (``stripToolBlocks()`` then ``extractThinkingBlocks()``), and routes +both AI-message copy buttons (createMsgFooter and the slash-reply footer) +through it. extractThinkingBlocks() behavior is pinned here under node +(including on the payload from the issue report); the helper and handler +wiring are guarded at the source level because chatRenderer.js pulls in +browser globals and can't be imported under node (same approach as +test_new_chat_clears_input.py). +""" + +import json +import re +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_HAS_NODE = shutil.which("node") is not None + + +@pytest.fixture(scope="module") +def node_available(): + if not _HAS_NODE: + pytest.skip("node binary not on PATH") + + +def _extract_thinking_blocks(text: str) -> dict: + """Run markdown.js extractThinkingBlocks(text) under node.""" + script = textwrap.dedent( + r""" + import fs from 'node:fs'; + + globalThis.window = { location: { origin: 'http://localhost' }, katex: null }; + globalThis.document = { + readyState: 'loading', + addEventListener() {}, + createElement(tag) { + if (tag !== 'template') throw new Error(`unsupported element: ${tag}`); + return { + _html: '', + content: { querySelectorAll() { return []; } }, + set innerHTML(value) { this._html = value; }, + get innerHTML() { return this._html; }, + }; + }, + }; + globalThis.MutationObserver = class { observe() {} }; + + let source = fs.readFileSync('./static/js/markdown.js', 'utf8'); + source = source.replace( + /import uiModule from ['"]\.\/ui\.js['"];/, + '' + ); + source = source.replace( + /import \{ splitTableRow \} from ['"]\.\/markdown\/tableRow\.js['"];/, + `function splitTableRow(row) { + return (row || '').replace(/^\\s*\\|/, '').replace(/\\|\\s*$/, '').split('|').map(c => c.trim()); + }` + ); + const emojiSource = fs.readFileSync('./static/js/emojiShortcodes.js', 'utf8') + .replace(/^export default .*$/m, '') + .replace(/export const /g, 'const ') + .replace(/export function /g, 'function '); + source = source.replace( + /import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from ['"]\.\/emojiShortcodes\.js['"];/, + () => emojiSource + ); + source = source.replace( + /var escapeHtml = uiModule\.esc;/, + `var escapeHtml = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''');` + ); + + const moduleUrl = 'data:text/javascript;base64,' + Buffer.from(source).toString('base64'); + const mod = await import(moduleUrl); + const input = JSON.parse(process.argv[1]); + console.log(JSON.stringify({ out: mod.extractThinkingBlocks(input) })); + """ + ) + result = subprocess.run( + ["node", "--input-type=module", "-e", script, json.dumps(text)], + cwd=_REPO, + capture_output=True, + timeout=15, + text=True, + ) + if result.returncode != 0: + raise AssertionError(f"node failed:\nSTDERR:\n{result.stderr}\nSTDOUT:\n{result.stdout}") + return json.loads(result.stdout.splitlines()[-1])["out"] + + +def test_issue_payload_copy_text_excludes_thinking(node_available): + # Shape reported in #3722: timed think block glued to the reply heading. + raw = ( + '\n' + "Here's a thinking process that leads to the desired summary:\n\n" + "6. **Generate the Output.** (This matches the final provided response.)" + "### Juxtaposition: Interweaving Cultural Norms in Lesson Design\n" + "The most effective lesson structure is created by deliberately juxtaposing." + ) + out = _extract_thinking_blocks(raw) + + assert out["content"].startswith("### Juxtaposition:"), out["content"] + assert "thinking process" not in out["content"] + assert "only reasoning, no reply yet") + assert out["content"] == "" + + +def _function_body(text: str, marker: str) -> str: + start = text.index(marker) + rest = text[start + len(marker):] + m = re.search(r"\nexport function |\nfunction ", rest) + return rest[: m.start()] if m else rest + + +def test_copy_message_text_mirrors_display_pipeline(): + text = (_REPO / "static/js/chatRenderer.js").read_text(encoding="utf-8") + body = _function_body(text, "export function copyMessageText") + # Mirrors the display path: tool blocks stripped, then thinking extracted. + assert "extractThinkingBlocks" in body + assert "stripToolBlocks" in body + assert "dataset.raw" in body + + +def test_copy_handlers_route_through_copy_message_text(): + for path, count in (("static/js/chatRenderer.js", 1), ("static/js/slashCommands.js", 1)): + text = (_REPO / path).read_text(encoding="utf-8") + assert text.count("copyToClipboard(copyMessageText(") + text.count( + "copyToClipboard(chatRenderer.copyMessageText(" + ) == count, path + # The old behavior passed dataset.raw straight to the clipboard. + assert "copyToClipboard(msgElement.dataset.raw" not in text, path + assert "copyToClipboard(msgEl.dataset.raw" not in text, path