diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 79339e8329..07c53711ce 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -7363,4 +7363,63 @@ describe("splitBufferedAssistantText", () => { rest: "```\n- one\n- two\n", }); }); + + it("holds headings until the following block is ready", () => { + expect(splitBufferedAssistantText("intro\n\n## Setup\n\nInstall it")).toEqual({ + ready: "intro\n\n", + rest: "## Setup\n\nInstall it", + }); + expect( + splitBufferedAssistantText("intro\n\n# Plan\n\n## Setup\n\nInstall it.\n\nNext"), + ).toEqual({ + ready: "intro\n\n# Plan\n\n## Setup\n\nInstall it.\n\n", + rest: "Next", + }); + expect(splitBufferedAssistantText("para\n## Setup\n\nInstall")).toEqual({ + ready: "para\n", + rest: "## Setup\n\nInstall", + }); + expect(splitBufferedAssistantText("intro\n\n## ")).toEqual({ + ready: "intro\n\n", + rest: "## ", + }); + }); + + it("holds standalone bold titles, but not bold text within a paragraph", () => { + expect(splitBufferedAssistantText("**Risk by area:**\n\n| a |\n|---|\n")).toEqual({ + ready: "", + rest: "**Risk by area:**\n\n| a |\n|---|\n", + }); + expect(splitBufferedAssistantText("**Use *npm* now**\n\nInstall it")).toEqual({ + ready: "", + rest: "**Use *npm* now**\n\nInstall it", + }); + expect(splitBufferedAssistantText("**Note:** read this.\n\nNext")).toEqual({ + ready: "**Note:** read this.\n\n", + rest: "Next", + }); + expect(splitBufferedAssistantText("para\n**Setup**\n\nInstall")).toEqual({ + ready: "", + rest: "para\n**Setup**\n\nInstall", + }); + }); + + it("delivers a title with its first list item or completed code block", () => { + expect(splitBufferedAssistantText("## Steps\n\n- one\n- tw")).toEqual({ + ready: "## Steps\n\n- one\n", + rest: "- tw", + }); + expect(splitBufferedAssistantText("## Code\n\n```ts\na\n\nb\n")).toEqual({ + ready: "", + rest: "## Code\n\n```ts\na\n\nb\n", + }); + expect(splitBufferedAssistantText("## Code\n\n```ts\na\n```\nafter")).toEqual({ + ready: "## Code\n\n```ts\na\n```\n", + rest: "after", + }); + expect(splitBufferedAssistantText("```md\n## Inside\n\ntext\n")).toEqual({ + ready: "", + rest: "```md\n## Inside\n\ntext\n", + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a8aee8640a..b3904ce182 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -275,18 +275,28 @@ const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; const BLANK_LINE_PATTERN = /^[ \t]*$/; // A complete list marker lets the previous item land before the next is done. const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; +// Models also use a line of bold text as a section title. +const SECTION_TITLE_PATTERN = /^ {0,3}(?:#{1,6}(?:[ \t]|$)|\*\*(?:[^*]|\*(?!\*))+\*\*:?$)/; +// A top-level heading ends the paragraph above it even without a blank line. +const TOP_LEVEL_HEADING_PATTERN = /^#{1,6}(?:[ \t]|$)/; /** Return text whose Markdown shape is complete, leaving the active block buffered. */ export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { let openFence: { marker: string; indent: number } | null = null; let boundary = -1; let lineStart = 0; + let titleAwaitingContent = false; for (;;) { const newline = text.indexOf("\n", lineStart); const line = text .slice(lineStart, newline === -1 ? text.length : newline) .replace(/[ \t\r]+$/, ""); - if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) { + if ( + openFence === null && + lineStart > 0 && + !titleAwaitingContent && + LIST_ITEM_START_PATTERN.test(line) + ) { boundary = lineStart; } if (newline === -1) break; @@ -296,6 +306,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: const marker = fenceMatch[2]!; if (openFence === null) { openFence = { marker, indent }; + titleAwaitingContent = false; } else if ( marker[0] === openFence.marker[0] && marker.length >= openFence.marker.length && @@ -306,7 +317,14 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: boundary = newline + 1; } } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { - boundary = newline + 1; + if (!titleAwaitingContent) { + boundary = newline + 1; + } + } else if (openFence === null) { + if (lineStart > 0 && !titleAwaitingContent && TOP_LEVEL_HEADING_PATTERN.test(line)) { + boundary = lineStart; + } + titleAwaitingContent = SECTION_TITLE_PATTERN.test(line); } lineStart = newline + 1; }