fix(frontend): preserve literal think tags in code (#5540)

* fix(frontend): preserve literal think tags in code

* Fix indented continuations of inline code spans

* Respect paragraph boundaries when extracting inline reasoning

* fix: respect block boundaries and escaped backtick runs

* fix(frontend): avoid quadratic reasoning delimiter backtracking

* fix(frontend): track reasoning fences inside list items

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Daoyuan Li 2026-09-20 02:38:20 -07:00 committed by GitHub
parent 0b7cef2e0b
commit 015ebcc88c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 627 additions and 40 deletions

View File

@ -1370,6 +1370,8 @@ The Web UI reports completed task time once per run. This is total wall-clock ti
While a response streams, reasoning-only messages stay in the processing panel, including Anthropic thinking blocks. Once answer content arrives alongside reasoning, it appears in an assistant bubble.
Literal `<think>` tags in fenced, indented, or inline code remain part of the answer and its copied text, rather than being moved into the reasoning disclosure. This includes fences opened on list-item lines: real reasoning after the code is still extracted. Unfinished inline code spans are preserved while streaming within a paragraph, but end at a blank line or an interrupting heading, list, thematic break or fence. Indented paragraph continuations do not start a code block.
In the Web UI, the latest completed user turn can also be edited and rerun from the message toolbar. DeerFlow restores the conversation checkpoint before that user message, submits the edited text as a new user message, and hides the superseded turn once the replay is in progress or succeeds. This is a conversation-state replay only: files, memory updates, and external tool side effects are not undone.
Web UI chat links percent-encode custom thread identifiers before placing them in route segments, so reserved URL characters such as `#` and `?` do not change which conversation is opened.

View File

@ -83,6 +83,8 @@
AI message grouping uses `extractContentFromMessage()` to identify visible answer content. A non-empty content array may contain only Anthropic thinking blocks; keep it in `assistant:processing` until answer content arrives. Cover both streamed snapshots in `tests/unit/core/messages/utils.test.ts`.
Inline `<think>` extraction scans code and reasoning openers in source order. Code tags stay in answer/copy data, including unfinished streaming spans. Blank lines, headings, thematic breaks, interrupting lists and fences end inline spans; ATX spans also end at the heading's newline. Fenced/indented code stays protected; indentation cannot interrupt a paragraph. Fences opened after list markers scan to a matching closer or the end of the list item, with tab-aware container indentation; their closers must not open a new top-level fence. Outside inline code, a backslash escapes one backtick, not the whole run. Real reasoning closes independently of Markdown inside it. Preserve the content-keyed cache and no-tag fast path.
Project moves in `core/threads/hooks.ts` cancel all per-thread metadata query
variants after the write succeeds, merge only `deerflow_project_id`, then
invalidate/refetch that metadata prefix. This fences delayed pre-move reads and

View File

@ -575,55 +575,212 @@ export function extractTextFromMessage(message: Message) {
}
const THINK_OPEN_TAG = "<think>";
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
const THINK_CLOSE_TAG = "</think>";
interface InlineReasoningSplit {
content: string;
reasoning: string | null;
}
function splitInlineReasoning(content: string): InlineReasoningSplit {
const reasoningParts: string[] = [];
// First pass: strip every fully closed `<think>...</think>` pair and
// 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
// reasoning slot instead of letting it render as message content (the
// raw-HTML markdown pipeline would otherwise paint the inner text on
// screen until the closing tag lands).
//
// Skip when the opener sits right after a backtick — that is the model
// talking about `<think>` literally inside markdown inline code, not
// actually streaming reasoning.
const openTagIndex = cleaned.indexOf(THINK_OPEN_TAG);
if (openTagIndex !== -1 && cleaned[openTagIndex - 1] !== "`") {
const tail = cleaned.slice(openTagIndex + THINK_OPEN_TAG.length).trim();
if (tail) {
reasoningParts.push(tail);
}
cleaned = cleaned.slice(0, openTagIndex);
function markdownColumns(prefix: string): number {
let column = 0;
for (const char of prefix) {
column += char === "\t" ? 4 - (column % 4) : 1;
}
return column;
}
function skipListFence(
content: string,
start: number,
marker: string,
listIndent: number,
): number {
let lineStart = start;
while (lineStart < content.length) {
const newline = content.indexOf("\n", lineStart);
const lineEnd = newline === -1 ? content.length : newline;
const line = content.slice(lineStart, lineEnd);
const whitespace = /^[ \t]*/.exec(line)![0];
const indent = markdownColumns(whitespace);
if (line.trim() !== "") {
// A fenced block cannot outlive its containing list item, even when
// the model has not supplied a closing fence yet.
if (indent < listIndent) return lineStart;
const closer = /^(`{3,}|~{3,})[ \t]*\r?$/.exec(
line.slice(whitespace.length),
)?.[1];
if (
indent <= listIndent + 3 &&
closer?.startsWith(marker[0]!) &&
closer.length >= marker.length
) {
return lineEnd;
}
}
lineStart = lineEnd + 1;
}
return content.length;
}
function splitInlineReasoning(content: string): InlineReasoningSplit {
if (!content.includes(THINK_OPEN_TAG)) {
return { content: content.trim(), reasoning: null };
}
const reasoningParts: string[] = [];
const contentParts: string[] = [];
// Scan code delimiters and reasoning openers in source order. Once inside
// real reasoning, jump directly to its closing tag: Markdown in reasoning
// must not change how the following answer is parsed.
// Thematic-break repetitions already consume trailing whitespace. Do not add
// another whitespace repetition after them: near-matches then backtrack quadratically.
const tokens =
/^ {0,3}(`{3,}|~{3,})|^( {4}|\t)|(\r?\n[ \t]*\r?\n)|^ {0,3}(#{1,6})(?=[ \t]|\r?$)|^ {0,3}((?:(?:=+|-+)[ \t]*|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})\r?$)|^ {0,3}((?:[-+*]|\d{1,9}[.)])[ \t]+)(?=\S)|`+|<think>/gm;
let fence: string | null = null;
let inlineDelimiter: string | null = null;
let headingEnd: number | null = null;
let indentedCodeEnd: number | null = null;
let contentStart = 0;
let match: RegExpExecArray | null;
while ((match = tokens.exec(content)) !== null) {
if (headingEnd !== null && match.index >= headingEnd) {
inlineDelimiter = null;
headingEnd = null;
}
if (match[4] || match[5] || match[6]) {
// Headings, thematic breaks and nonempty lists delimit inline spans
// without a blank line. Ordered lists must start at 1 to interrupt.
if (fence === null) {
if (match[6] && !/^(?:[-+*]|1[.)])/.test(match[6])) {
const previousLineStart =
content.lastIndexOf("\n", match.index - 2) + 1;
if (content.slice(previousLineStart, match.index).trim() !== "") {
continue;
}
}
inlineDelimiter = null;
if (match[4]) {
const newline = content.indexOf("\n", tokens.lastIndex);
headingEnd = newline === -1 ? content.length : newline;
}
if (match[6]) {
const newline = content.indexOf("\n", tokens.lastIndex);
const lineEnd = newline === -1 ? content.length : newline;
const line = content.slice(match.index, lineEnd);
const listFence =
/^ {0,3}(?:(?:[-+*]|\d{1,9}[.)])[ \t]{1,4})+(`{3,}|~{3,})/.exec(
line,
);
const marker = listFence?.[1];
if (
listFence &&
marker &&
(marker.startsWith("~") ||
!line.slice(listFence[0].length).includes("`"))
) {
const prefix = listFence[0].slice(0, -marker.length);
tokens.lastIndex = skipListFence(
content,
lineEnd + 1,
marker,
markdownColumns(prefix),
);
}
}
}
continue;
}
if (match[3]) {
// Inline spans cannot cross paragraph boundaries, unlike fenced code.
if (fence === null) inlineDelimiter = null;
continue;
}
if (match[2]) {
// An indented continuation can still close an open inline code span.
// Indented code cannot interrupt an existing paragraph either.
const previousLineStart = content.lastIndexOf("\n", match.index - 2) + 1;
const startsBlock =
content.slice(previousLineStart, match.index).trim() === "";
const continuesBlock =
indentedCodeEnd !== null &&
content.slice(indentedCodeEnd, match.index).trim() === "";
if (inlineDelimiter === null && (startsBlock || continuesBlock)) {
const newline = content.indexOf("\n", tokens.lastIndex);
tokens.lastIndex = newline === -1 ? content.length : newline;
indentedCodeEnd = tokens.lastIndex;
} else {
indentedCodeEnd = null;
}
continue;
}
const marker = match[1];
if (marker) {
const newline = content.indexOf("\n", tokens.lastIndex);
const lineEnd = newline === -1 ? content.length : newline;
const lineTail = content.slice(tokens.lastIndex, lineEnd);
if (fence !== null) {
if (
marker.startsWith(fence.charAt(0)) &&
marker.length >= fence.length &&
lineTail.trim() === ""
) {
fence = null;
}
tokens.lastIndex = lineEnd;
continue;
}
// Backtick fence info strings cannot contain backticks. Such a run
// may instead open or close an inline code span on this line.
if (marker.startsWith("~") || !lineTail.includes("`")) {
// Fenced blocks also interrupt paragraphs, including unfinished spans.
inlineDelimiter = null;
fence = marker;
tokens.lastIndex = lineEnd;
continue;
}
}
if (fence !== null) {
continue;
}
let delimiter = marker ?? match[0];
if (delimiter.startsWith("`")) {
// Backslash escapes apply outside a code span, not within one.
let escapeStart = match.index;
while (escapeStart > 0 && content[escapeStart - 1] === "\\") {
escapeStart--;
}
if (inlineDelimiter === null && (match.index - escapeStart) % 2 === 1) {
// An escape consumes one character, not the whole delimiter run.
delimiter = delimiter.slice(1);
if (!delimiter) continue;
}
if (inlineDelimiter === null) {
inlineDelimiter = delimiter;
} else if (inlineDelimiter === delimiter) {
inlineDelimiter = null;
}
continue;
}
if (inlineDelimiter !== null || match[0] !== THINK_OPEN_TAG) {
continue;
}
contentParts.push(content.slice(contentStart, match.index));
const reasoningStart = tokens.lastIndex;
const close = content.indexOf(THINK_CLOSE_TAG, reasoningStart);
const reasoning = content
.slice(reasoningStart, close === -1 ? undefined : close)
.trim();
if (reasoning) {
reasoningParts.push(reasoning);
}
contentStart =
close === -1 ? content.length : close + THINK_CLOSE_TAG.length;
tokens.lastIndex = contentStart;
}
contentParts.push(content.slice(contentStart));
return {
content: cleaned.trim(),
content: contentParts.join("").trim(),
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
};
}

View File

@ -13,6 +13,44 @@ const REASONING_TEXT =
"The user asked who I am, so I will list the core capabilities.";
const ANSWER_TEXT = "I am DeerFlow, an open-source super agent.";
for (const literal of ["<think>sample reasoning</think>", "<think>"]) {
test(`preserves literal code ${literal} and its following explanation`, async ({
page,
}, testInfo) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: SETTLED_THREAD_ID,
title: "Literal reasoning tags in code",
messages: [
{
type: "human",
id: "literal-human",
content: "Show a reasoning-tag example.",
},
{
type: "ai",
id: "literal-ai",
content: `Example:\n\n\`\`\`xml\n${literal}\n\`\`\`\n\nThis is literal code, not model reasoning.`,
},
],
},
],
});
await page.goto(`/workspace/chats/${SETTLED_THREAD_ID}`);
await expect(
page.locator("pre").filter({ hasText: literal }),
).toBeVisible();
await expect(
page.getByText("This is literal code, not model reasoning."),
).toBeVisible();
await expect(page.getByText("Reasoning", { exact: true })).toHaveCount(0);
await page.screenshot({
path: testInfo.outputPath("literal-think-code.png"),
});
});
}
const INITIAL_MESSAGES = [
{
type: "human",
@ -21,6 +59,110 @@ const INITIAL_MESSAGES = [
},
];
for (const [opener, indent] of [
["- ~~~xml", " "],
["10. ```xml", " "],
]) {
test(`separates literal and real reasoning after a list fence: ${opener}`, async ({
page,
}, testInfo) => {
const closer = opener!.includes("~~~") ? "~~~" : "```";
mockLangGraphAPI(page, {
threads: [
{
thread_id: SETTLED_THREAD_ID,
title: "List-contained reasoning example",
messages: [
...INITIAL_MESSAGES,
{
type: "ai",
id: "list-fence-ai",
content: `${opener}\n${indent}<think>literal example</think>\n${indent}${closer}\n\n<think>Actual model reasoning.</think>Visible answer after the list.`,
},
],
},
],
});
await page.goto(`/workspace/chats/${SETTLED_THREAD_ID}`);
await expect(
page
.locator("li pre")
.filter({ hasText: "<think>literal example</think>" }),
).toBeVisible();
await expect(page.getByText("Reasoning", { exact: true })).toBeVisible();
await expect(
page.getByText("Visible answer after the list.", { exact: true }),
).toBeVisible();
await expect(
page.getByText("<think>Actual model reasoning.</think>", {
exact: false,
}),
).toHaveCount(0);
await page.screenshot({
path: testInfo.outputPath("list-fence-reasoning.png"),
});
});
}
for (const block of ["# Result", "- Result", "```sh\necho hi\n```"]) {
test(`extracts reasoning after a block interrupts inline code: ${block}`, async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: SETTLED_THREAD_ID,
title: "Reasoning at a block boundary",
messages: [
...INITIAL_MESSAGES,
{
type: "ai",
id: "block-boundary-ai",
content: `Run \`this command\n${block}\n<think>Internal boundary reasoning.</think>Visible final answer.`,
},
],
},
],
});
await page.goto(`/workspace/chats/${SETTLED_THREAD_ID}`);
await expect(page.getByText("Reasoning", { exact: true })).toBeVisible();
await expect(
page.getByText("Visible final answer.", { exact: false }),
).toBeVisible();
await expect(
page.getByText("<think>Internal boundary reasoning.</think>", {
exact: false,
}),
).toHaveCount(0);
});
}
test("preserves inline code after the first backtick is escaped", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: SETTLED_THREAD_ID,
title: "Escaped backtick run",
messages: [
...INITIAL_MESSAGES,
{
type: "ai",
id: "escaped-backtick-ai",
content: "Use \\``<think>sample</think>` literally.",
},
],
},
],
});
await page.goto(`/workspace/chats/${SETTLED_THREAD_ID}`);
await expect(
page.locator("code").filter({ hasText: "<think>sample</think>" }),
).toBeVisible();
await expect(page.getByText("Reasoning", { exact: true })).toHaveCount(0);
});
const SETTLED_AI_MESSAGE = {
type: "ai",
id: "msg-ai-4576-settled",

View File

@ -516,6 +516,290 @@ test("keeps tool-call reasoning in the processing group while the final answer's
});
describe("inline <think> tag splitting", () => {
test.each([
"- ```sh\n echo hi\n ```",
"+ ~~~xml\n <think>literal</think>\n ~~~",
"* ```xml\n <think>literal</think>\n ```",
"1. ```xml\n <think>literal</think>\n ```",
"10) ```xml\n <think>literal</think>\n ```",
"- - ```xml\n <think>literal</think>\n ```",
" - ```xml\n <think>literal</think>\n ```",
"-\t```xml\n\t<think>literal</think>\n\t```",
"- ````xml\n ```\n <think>literal</think>\n `````",
"- ~~~xml\n ```\n <think>literal</think>\n ~~~",
"- ```xml\r\n <think>literal</think>\r\n ```",
])("extracts reasoning after a list-contained fence: %j", (code) => {
const prefix = `${code}\n\n`;
const message = aiMessage(`${prefix}<think>real</think>Answer.`);
const expected = `${prefix}Answer.`.trim();
expect(extractContentFromMessage(message)).toBe(expected);
expect(extractReasoningContentFromMessage(message)).toBe("real");
expect(getMessageCopyData(message)).toBe(expected);
expect(getAssistantTurnCopyData([message])).toBe(expected);
});
test.each([
"- ~~~xml\n <think>literal",
"10. ```xml\n <think>literal",
"- ```xml\n first\n\n <think>literal",
])("preserves an unfinished list fence while streaming: %j", (code) => {
const message = aiMessage(code);
expect(extractContentFromMessage(message)).toBe(code);
expect(extractReasoningContentFromMessage(message)).toBeNull();
expect(getMessageCopyData(message)).toBe(code);
expect(getAssistantTurnCopyData([message])).toBe(code);
});
test("ends an unclosed list fence when its list item ends", () => {
const prefix = "- ~~~xml\n <think>literal</think>\n\n";
const message = aiMessage(`${prefix}<think>real</think>Answer.`);
expect(extractContentFromMessage(message)).toBe(`${prefix}Answer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real");
expect(getMessageCopyData(message)).toBe(`${prefix}Answer.`);
expect(getAssistantTurnCopyData([message])).toBe(`${prefix}Answer.`);
});
test("does not turn a list-contained inline span into a fence", () => {
const code = "- ```prefix <think>literal</think>```";
const message = aiMessage(`${code}\n\n<think>real</think>Answer.`);
expect(extractContentFromMessage(message)).toBe(`${code}\n\nAnswer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real");
});
test.each([
[
"fenced pair",
"Example:\n```xml\n<think>sample</think>\n```\nExplanation.",
],
["fenced opener", "Example:\n```xml\n<think>\n```\nExplanation."],
["tilde fence", "~~~xml\n<think>sample</think>\n~~~"],
["unfinished fence", "```xml\n<think>sample"],
["longer fence", "````xml\n```\n<think>sample</think>\n````"],
["different fence marker", "~~~xml\n```\n<think>sample</think>\n~~~"],
["indented code", " <think>sample</think>"],
["inline prefix", "Use `prefix <think>sample</think>` literally."],
["multiple backticks", "Use ``prefix ` <think>sample</think>`` literally."],
["unfinished inline code", "Use `prefix <think>sample"],
])("preserves literal tags in %s", (_name, content) => {
const message = aiMessage(content);
expect(extractContentFromMessage(message)).toBe(content.trim());
expect(extractReasoningContentFromMessage(message)).toBeNull();
expect(getMessageCopyData(message)).toBe(content.trim());
expect(getAssistantTurnCopyData([message])).toBe(content.trim());
});
test.each([" ", "\t"])(
"closes multiline inline code on an indented continuation: %j",
(indent) => {
const code = `Use \`first line\n${indent}second line\` literally.`;
const message = aiMessage(
`${code}\n<think>real reasoning</think>Answer.`,
);
expect(extractContentFromMessage(message)).toBe(`${code}\nAnswer.`);
expect(extractReasoningContentFromMessage(message)).toBe(
"real reasoning",
);
expect(getMessageCopyData(message)).toBe(`${code}\nAnswer.`);
expect(getAssistantTurnCopyData([message])).toBe(`${code}\nAnswer.`);
},
);
test.each(["\n\n", "\n \n", "\r\n\t\r\n"])(
"ends an unfinished inline span at a paragraph boundary: %j",
(separator) => {
const prefix = `Run \`this command${separator}`;
const message = aiMessage(
`${prefix}<think>real reasoning</think>Answer.`,
);
expect(extractContentFromMessage(message)).toBe(`${prefix}Answer.`);
expect(extractReasoningContentFromMessage(message)).toBe(
"real reasoning",
);
expect(getMessageCopyData(message)).toBe(`${prefix}Answer.`);
},
);
test.each([" ", "\t"])(
"extracts reasoning on an indented paragraph continuation: %j",
(indent) => {
const prefix = `Note this:\n${indent}`;
const message = aiMessage(
`${prefix}<think>real reasoning</think>\nAnswer.`,
);
expect(extractContentFromMessage(message)).toBe(`${prefix}\nAnswer.`);
expect(extractReasoningContentFromMessage(message)).toBe(
"real reasoning",
);
expect(getMessageCopyData(message)).toBe(`${prefix}\nAnswer.`);
},
);
test.each([
"# Result\n",
" ###### Result\n",
"- Result\n",
"+ Result\n",
"* Result\n",
"1. Result\n",
"1) Result\n",
"---\n",
"===\n",
"- \n",
"* * *\n",
"___\n",
"```sh\necho hi\n```\n",
"~~~sh\necho hi\n~~~\n",
])("ends an unfinished inline span at a block boundary: %j", (block) => {
const prefix = `Run \`this command\n${block}`;
const message = aiMessage(`${prefix}<think>real reasoning</think>Answer.`);
expect(extractContentFromMessage(message)).toBe(`${prefix}Answer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real reasoning");
expect(getMessageCopyData(message)).toBe(`${prefix}Answer.`);
expect(getAssistantTurnCopyData([message])).toBe(`${prefix}Answer.`);
});
test.each(["*", "_", "-"])(
"handles a long thematic-break near-match without backtracking: %s",
(marker) => {
const prefix = `${marker.repeat(3)}${" ".repeat(40_000)}x\n`;
const message = aiMessage(`${prefix}<think>real</think>Answer.`);
// Time the first extraction, not a content-cache hit. The old overlapping
// whitespace repetitions take seconds; leave ample headroom for slow CI.
const start = performance.now();
const answer = extractContentFromMessage(message);
const elapsed = performance.now() - start;
expect(answer).toBe(`${prefix}Answer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real");
expect(elapsed).toBeLessThan(500);
},
);
test.each(["* * *", "_ _ _", "- - -", "---", "==="])(
"keeps trailing spaces and tabs valid on a block boundary: %s",
(line) => {
const prefix = `Run \`unfinished\n${line}${" \t".repeat(100)}\r\n`;
const message = aiMessage(`${prefix}<think>real</think>Answer.`);
expect(extractContentFromMessage(message)).toBe(`${prefix}Answer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real");
},
);
test.each([
"#not-a-heading",
"####### Not a heading",
"2. Cannot interrupt a paragraph",
"+ ",
"ordinary continuation",
])("keeps an inline span across a non-boundary: %j", (line) => {
const code = `Use \`first line\n${line}\n<think>sample</think>\` literally.`;
const message = aiMessage(`${code} <think>real</think>Answer.`);
expect(extractContentFromMessage(message)).toBe(`${code} Answer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real");
expect(getMessageCopyData(message)).toBe(`${code} Answer.`);
});
test.each(["# Heading `unfinished", "# Heading `closed`"])(
"does not carry a heading's inline state into the following paragraph: %s",
(heading) => {
const message = aiMessage(`${heading}\n<think>real</think>Answer.`);
expect(extractContentFromMessage(message)).toBe(`${heading}\nAnswer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real");
},
);
test.each([
"Run `unfinished\n```xml\n# Heading\n- List\n<think>sample</think>\n```",
"Run `unfinished\n# Use `<think>sample</think>` literally",
"Run `unfinished\n- Use `<think>sample</think>` literally",
])("preserves literal tags in the new block: %s", (code) => {
const message = aiMessage(`${code}\n<think>real</think>Answer.`);
expect(extractContentFromMessage(message)).toBe(`${code}\nAnswer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real");
expect(getMessageCopyData(message)).toBe(`${code}\nAnswer.`);
});
test.each([
"Use \\``<think>sample</think>` literally.",
"Use \\```<think>sample</think>`` literally.",
"Use \\\\``<think>sample</think>`` literally.",
"Use `<think>sample</think>\\` literally.",
])("escapes only one backtick outside an inline span: %s", (code) => {
const message = aiMessage(`${code} <think>real</think>Answer.`);
expect(extractContentFromMessage(message)).toBe(`${code} Answer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real");
expect(getMessageCopyData(message)).toBe(`${code} Answer.`);
expect(getAssistantTurnCopyData([message])).toBe(`${code} Answer.`);
});
test.each([
" first line\n <think>sample</think>",
"Intro.\n\n first line\n\n <think>sample</think>",
"```\nfirst line\n\n<think>sample</think>\n```",
])("preserves code blocks across lines and blank lines: %s", (code) => {
const message = aiMessage(
`${code}\n\n<think>real reasoning</think>Answer.`,
);
expect(extractContentFromMessage(message)).toBe(
`${code}\n\nAnswer.`.trim(),
);
expect(extractReasoningContentFromMessage(message)).toBe("real reasoning");
expect(getMessageCopyData(message)).toBe(`${code}\n\nAnswer.`.trim());
});
test("finds real streaming reasoning after a literal inline opener", () => {
const message = aiMessage("Use `<think>` literally. <think>real reasoning");
expect(extractContentFromMessage(message)).toBe("Use `<think>` literally.");
expect(extractReasoningContentFromMessage(message)).toBe("real reasoning");
});
test("keeps code between real closed and streaming reasoning blocks", () => {
const code = "```xml\n<think>sample</think>\n```";
const message = aiMessage(`<think>first</think>\n${code}\n<think>second`);
expect(extractContentFromMessage(message)).toBe(code);
expect(extractReasoningContentFromMessage(message)).toBe("first\n\nsecond");
});
test("does not let an unfinished code fence inside reasoning hide the answer", () => {
const message = aiMessage(
"<think>Consider:\n```python\nprint(1)</think>Answer.",
);
expect(extractContentFromMessage(message)).toBe("Answer.");
expect(extractReasoningContentFromMessage(message)).toBe(
"Consider:\n```python\nprint(1)",
);
});
test("does not close a fence on a marker followed by non-whitespace", () => {
const message = aiMessage(
"```xml\n```not-a-close\n<think>sample</think>\n```\n<think>real</think>Answer.",
);
expect(extractContentFromMessage(message)).toBe(
"```xml\n```not-a-close\n<think>sample</think>\n```\nAnswer.",
);
expect(extractReasoningContentFromMessage(message)).toBe("real");
});
test("escaped backticks do not turn real reasoning into literal code", () => {
const message = aiMessage("Escaped \\` marker. <think>real</think>Answer.");
expect(extractContentFromMessage(message)).toBe(
"Escaped \\` marker. Answer.",
);
expect(extractReasoningContentFromMessage(message)).toBe("real");
});
test.each([
"```prefix <think>sample</think>```",
// A bare triple-backtick run at line start would begin a fenced block.
"Use ```prefix\n<think>sample</think>\ntail ```",
])(
"recognizes multi-backtick inline code before real reasoning: %s",
(code) => {
const message = aiMessage(`${code} <think>real</think>Answer.`);
expect(extractContentFromMessage(message)).toBe(`${code} Answer.`);
expect(extractReasoningContentFromMessage(message)).toBe("real");
},
);
test("strips a fully closed <think> block from AI content", () => {
const message = aiMessage("<think>internal reasoning</think>final answer");
expect(extractContentFromMessage(message)).toBe("final answer");