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 = [ {