fix(frontend): keep inline-code <think> 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
   <think>...</think> pair unconditionally, so a message that discusses
   the tag literally in markdown inline code (e.g. "Wrap your reasoning
   in `<think>...</think>`") 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.
This commit is contained in:
Baldwinzc 2026-08-03 08:27:23 +08:00 committed by GitHub
parent da282811b8
commit 91d7f4e4fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 63 additions and 9 deletions

View File

@ -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 `<think>...</think>` 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 `<think>` opener whose `</think>` has not arrived
// yet means the rest of the chunk is reasoning in flight. Route it into the

View File

@ -456,6 +456,30 @@ describe("inline <think> tag splitting", () => {
expect(hasReasoning(message)).toBe(false);
});
test("a closed <think> 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 `<think>your reasoning</think>` before answering.",
);
expect(extractContentFromMessage(message)).toBe(
"Wrap your reasoning in `<think>your reasoning</think>` before answering.",
);
expect(extractReasoningContentFromMessage(message)).toBeNull();
expect(hasReasoning(message)).toBe(false);
});
test("a closed <think> pair outside code is still stripped when another sits in inline code", () => {
const message = aiMessage(
"<think>real reasoning</think>Use `<think>text</think>` markers.",
);
expect(extractContentFromMessage(message)).toBe(
"Use `<think>text</think>` markers.",
);
expect(extractReasoningContentFromMessage(message)).toBe("real reasoning");
});
test("a backtick-prefixed <think> mid-stream is not split into reasoning", () => {
// Simulates the moment the model has emitted the opening backtick and
// `<think>` 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 = [
{