From 91d7f4e4fad9cc02b65ca64f61ca951f8da3a206 Mon Sep 17 00:00:00 2001 From: Baldwinzc <56501736+Baldwinzc@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:27:23 +0800 Subject: [PATCH] fix(frontend): keep inline-code pairs in content and restore copy for reasoning-only turns (#4647) Two related fixes in the reasoning extraction path of core/messages/utils.ts: 1. splitInlineReasoning's first pass stripped every closed ... pair unconditionally, so a message that discusses the tag literally in markdown inline code (e.g. "Wrap your reasoning in `...`") had its code span hollowed out and the inner text shipped to the Reasoning panel. The streaming pass already guards backtick-adjacent openers; apply the same guard to the closed-pair pass so both passes agree on what counts as literal tag talk. 2. getAssistantTurnCopyData fell back to reasoning via `content ?? reasoning`, but extractContentFromMessage never returns null, so the fallback was dead code and a reasoning-only turn (e.g. stopped mid-thinking) rendered no copy button at all - inconsistent with getMessageCopyData, which does copy reasoning in that case. Use the same empty-string check it uses. --- frontend/src/core/messages/utils.ts | 32 ++++++++++----- .../tests/unit/core/messages/utils.test.ts | 40 +++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 85ebed41c..2e8663b20 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -431,8 +431,13 @@ export function getAssistantTurnCopyData( .reverse() .filter((message) => message.type === "ai") .map((message) => { + // extractContentFromMessage never returns null, so fall back to + // reasoning on empty text (same rule as getMessageCopyData) — + // otherwise a reasoning-only turn loses its copy button entirely. const content = extractContentFromMessage(message); - return content ?? extractReasoningContentFromMessage(message) ?? ""; + return content.length > 0 + ? content + : (extractReasoningContentFromMessage(message) ?? ""); }) .find((content) => content.length > 0) ?? null ); @@ -483,14 +488,23 @@ function splitInlineReasoning(content: string): InlineReasoningSplit { const reasoningParts: string[] = []; // First pass: strip every fully closed `...` pair and - // collect its body as reasoning. - let cleaned = content.replace(THINK_TAG_RE, (_, reasoning: string) => { - const normalized = reasoning.trim(); - if (normalized) { - reasoningParts.push(normalized); - } - return ""; - }); + // collect its body as reasoning. A pair whose opener sits right after a + // backtick is the model talking about the tag literally inside markdown + // inline code (same guard as the streaming pass below) — leave it in the + // rendered content instead of hollowing out the code span. + let cleaned = content.replace( + THINK_TAG_RE, + (match: string, reasoning: string, offset: number) => { + if (content[offset - 1] === "`") { + return match; + } + const normalized = reasoning.trim(); + if (normalized) { + reasoningParts.push(normalized); + } + return ""; + }, + ); // Streaming-safe pass: a `` opener whose `` has not arrived // yet means the rest of the chunk is reasoning in flight. Route it into the diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts index 027352b42..2a9b2d685 100644 --- a/frontend/tests/unit/core/messages/utils.test.ts +++ b/frontend/tests/unit/core/messages/utils.test.ts @@ -456,6 +456,30 @@ describe("inline tag splitting", () => { expect(hasReasoning(message)).toBe(false); }); + test("a closed pair inside markdown inline code stays in content", () => { + // The model talking about the tag literally, e.g. when explaining prompt + // engineering. Hollowing the code span out into the reasoning panel + // corrupts the visible answer. + const message = aiMessage( + "Wrap your reasoning in `your reasoning` before answering.", + ); + expect(extractContentFromMessage(message)).toBe( + "Wrap your reasoning in `your reasoning` before answering.", + ); + expect(extractReasoningContentFromMessage(message)).toBeNull(); + expect(hasReasoning(message)).toBe(false); + }); + + test("a closed pair outside code is still stripped when another sits in inline code", () => { + const message = aiMessage( + "real reasoningUse `text` markers.", + ); + expect(extractContentFromMessage(message)).toBe( + "Use `text` markers.", + ); + expect(extractReasoningContentFromMessage(message)).toBe("real reasoning"); + }); + test("a backtick-prefixed mid-stream is not split into reasoning", () => { // Simulates the moment the model has emitted the opening backtick and // `` for a literal documentation reference, before the closing @@ -713,6 +737,22 @@ test("hides assistant copy data while that turn is streaming", () => { expect(getAssistantTurnCopyData(messages, { isStreaming: true })).toBeNull(); }); +test("falls back to reasoning for a reasoning-only assistant turn's copy data", () => { + // A turn can end with reasoning but no answer text (e.g. stopped during + // thinking). getMessageCopyData already copies the reasoning in that case; + // the turn-level copy button must not disappear instead. + const messages = [ + { + id: "ai-1", + type: "ai", + content: "", + additional_kwargs: { reasoning_content: "the actual reasoning" }, + }, + ] as Message[]; + + expect(getAssistantTurnCopyData(messages)).toBe("the actual reasoning"); +}); + test("marks the latest assistant message as streaming", () => { const messages = [ {