From 015ebcc88ca62d0777ea94c088f959764b1d1f81 Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:38:20 -0700 Subject: [PATCH] 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 --- README.md | 2 + frontend/src/AGENTS.md | 2 + frontend/src/core/messages/utils.ts | 237 ++++++++++++--- .../e2e/streaming-reasoning-order.spec.ts | 142 +++++++++ .../tests/unit/core/messages/utils.test.ts | 284 ++++++++++++++++++ 5 files changed, 627 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 9be678232..5e50f7dd4 100644 --- a/README.md +++ b/README.md @@ -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 `` 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. diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 676f0165b..ecb22159e 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -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 `` 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 diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index ba08d3e19..a96198b82 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -575,55 +575,212 @@ export function extractTextFromMessage(message: Message) { } const THINK_OPEN_TAG = ""; -const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g; +const THINK_CLOSE_TAG = ""; interface InlineReasoningSplit { content: string; reasoning: string | null; } -function splitInlineReasoning(content: string): InlineReasoningSplit { - const reasoningParts: string[] = []; - - // First pass: strip every fully closed `...` 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 `` opener whose `` 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 `` 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)|`+|/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, }; } diff --git a/frontend/tests/e2e/streaming-reasoning-order.spec.ts b/frontend/tests/e2e/streaming-reasoning-order.spec.ts index 4139b9d48..4e9682bbd 100644 --- a/frontend/tests/e2e/streaming-reasoning-order.spec.ts +++ b/frontend/tests/e2e/streaming-reasoning-order.spec.ts @@ -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 ["sample reasoning", ""]) { + 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}literal example\n${indent}${closer}\n\nActual model reasoning.Visible answer after the list.`, + }, + ], + }, + ], + }); + await page.goto(`/workspace/chats/${SETTLED_THREAD_ID}`); + await expect( + page + .locator("li pre") + .filter({ hasText: "literal example" }), + ).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("Actual model reasoning.", { + 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}\nInternal boundary reasoning.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("Internal boundary reasoning.", { + 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 \\``sample` literally.", + }, + ], + }, + ], + }); + await page.goto(`/workspace/chats/${SETTLED_THREAD_ID}`); + await expect( + page.locator("code").filter({ hasText: "sample" }), + ).toBeVisible(); + await expect(page.getByText("Reasoning", { exact: true })).toHaveCount(0); +}); + const SETTLED_AI_MESSAGE = { type: "ai", id: "msg-ai-4576-settled", diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts index 1fcc3346d..a9f413565 100644 --- a/frontend/tests/unit/core/messages/utils.test.ts +++ b/frontend/tests/unit/core/messages/utils.test.ts @@ -516,6 +516,290 @@ test("keeps tool-call reasoning in the processing group while the final answer's }); describe("inline tag splitting", () => { + test.each([ + "- ```sh\n echo hi\n ```", + "+ ~~~xml\n literal\n ~~~", + "* ```xml\n literal\n ```", + "1. ```xml\n literal\n ```", + "10) ```xml\n literal\n ```", + "- - ```xml\n literal\n ```", + " - ```xml\n literal\n ```", + "-\t```xml\n\tliteral\n\t```", + "- ````xml\n ```\n literal\n `````", + "- ~~~xml\n ```\n literal\n ~~~", + "- ```xml\r\n literal\r\n ```", + ])("extracts reasoning after a list-contained fence: %j", (code) => { + const prefix = `${code}\n\n`; + const message = aiMessage(`${prefix}realAnswer.`); + 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 literal", + "10. ```xml\n literal", + "- ```xml\n first\n\n 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 literal\n\n"; + const message = aiMessage(`${prefix}realAnswer.`); + 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 literal```"; + const message = aiMessage(`${code}\n\nrealAnswer.`); + expect(extractContentFromMessage(message)).toBe(`${code}\n\nAnswer.`); + expect(extractReasoningContentFromMessage(message)).toBe("real"); + }); + + test.each([ + [ + "fenced pair", + "Example:\n```xml\nsample\n```\nExplanation.", + ], + ["fenced opener", "Example:\n```xml\n\n```\nExplanation."], + ["tilde fence", "~~~xml\nsample\n~~~"], + ["unfinished fence", "```xml\nsample"], + ["longer fence", "````xml\n```\nsample\n````"], + ["different fence marker", "~~~xml\n```\nsample\n~~~"], + ["indented code", " sample"], + ["inline prefix", "Use `prefix sample` literally."], + ["multiple backticks", "Use ``prefix ` sample`` literally."], + ["unfinished inline code", "Use `prefix 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}\nreal reasoningAnswer.`, + ); + 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}real reasoningAnswer.`, + ); + 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}real reasoning\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}real reasoningAnswer.`); + 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}realAnswer.`); + // 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}realAnswer.`); + 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}\nsample\` literally.`; + const message = aiMessage(`${code} realAnswer.`); + 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}\nrealAnswer.`); + expect(extractContentFromMessage(message)).toBe(`${heading}\nAnswer.`); + expect(extractReasoningContentFromMessage(message)).toBe("real"); + }, + ); + + test.each([ + "Run `unfinished\n```xml\n# Heading\n- List\nsample\n```", + "Run `unfinished\n# Use `sample` literally", + "Run `unfinished\n- Use `sample` literally", + ])("preserves literal tags in the new block: %s", (code) => { + const message = aiMessage(`${code}\nrealAnswer.`); + expect(extractContentFromMessage(message)).toBe(`${code}\nAnswer.`); + expect(extractReasoningContentFromMessage(message)).toBe("real"); + expect(getMessageCopyData(message)).toBe(`${code}\nAnswer.`); + }); + + test.each([ + "Use \\``sample` literally.", + "Use \\```sample`` literally.", + "Use \\\\``sample`` literally.", + "Use `sample\\` literally.", + ])("escapes only one backtick outside an inline span: %s", (code) => { + const message = aiMessage(`${code} realAnswer.`); + 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 sample", + "Intro.\n\n first line\n\n sample", + "```\nfirst line\n\nsample\n```", + ])("preserves code blocks across lines and blank lines: %s", (code) => { + const message = aiMessage( + `${code}\n\nreal reasoningAnswer.`, + ); + 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 `` literally. real reasoning"); + expect(extractContentFromMessage(message)).toBe("Use `` literally."); + expect(extractReasoningContentFromMessage(message)).toBe("real reasoning"); + }); + + test("keeps code between real closed and streaming reasoning blocks", () => { + const code = "```xml\nsample\n```"; + const message = aiMessage(`first\n${code}\nsecond`); + 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( + "Consider:\n```python\nprint(1)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\nsample\n```\nrealAnswer.", + ); + expect(extractContentFromMessage(message)).toBe( + "```xml\n```not-a-close\nsample\n```\nAnswer.", + ); + expect(extractReasoningContentFromMessage(message)).toBe("real"); + }); + + test("escaped backticks do not turn real reasoning into literal code", () => { + const message = aiMessage("Escaped \\` marker. realAnswer."); + expect(extractContentFromMessage(message)).toBe( + "Escaped \\` marker. Answer.", + ); + expect(extractReasoningContentFromMessage(message)).toBe("real"); + }); + + test.each([ + "```prefix sample```", + // A bare triple-backtick run at line start would begin a fenced block. + "Use ```prefix\nsample\ntail ```", + ])( + "recognizes multi-backtick inline code before real reasoning: %s", + (code) => { + const message = aiMessage(`${code} realAnswer.`); + expect(extractContentFromMessage(message)).toBe(`${code} Answer.`); + expect(extractReasoningContentFromMessage(message)).toBe("real"); + }, + ); + test("strips a fully closed block from AI content", () => { const message = aiMessage("internal reasoningfinal answer"); expect(extractContentFromMessage(message)).toBe("final answer");