From 291e488e23483da0cc9d404517a95d4ef0df7aa4 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:38:53 -0700 Subject: [PATCH 1/2] fix(server): stream tight list items one at a time in paragraph mode Paragraph mode only released buffered assistant text at a blank line or a closing code fence. Tight markdown lists have neither between items, so a heading would land and then the whole list arrived at once when the model finished it. Treat the start of a list item (bullet or ordered, at any indentation, outside a fence) as a delivery boundary too, so each item lands once the next one starts. The marker must be followed by whitespace so a partial marker never triggers a delivery. Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.test.ts | 21 +++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 27 +++++++++++++++---- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index c4f57726f20f..306557fecb5c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -4651,4 +4651,25 @@ describe("splitBufferedAssistantText", () => { rest: "~~~\n```\n\nx\n", }); }); + + it("delivers tight list items one at a time", () => { + expect(splitBufferedAssistantText("## Steps\n\n- one\n- two\n- thr")).toEqual({ + ready: "## Steps\n\n- one\n- two\n", + rest: "- thr", + }); + expect(splitBufferedAssistantText("1. one\n2. two\n more\n3. ")).toEqual({ + ready: "1. one\n2. two\n more\n", + rest: "3. ", + }); + }); + + it("keeps a partial list marker and list-like code buffered", () => { + expect(splitBufferedAssistantText("intro\n-")).toEqual({ ready: "", rest: "intro\n-" }); + expect(splitBufferedAssistantText("intro\n1.")).toEqual({ ready: "", rest: "intro\n1." }); + expect(splitBufferedAssistantText("- one\n")).toEqual({ ready: "", rest: "- one\n" }); + expect(splitBufferedAssistantText("```\n- one\n- two\n")).toEqual({ + ready: "", + rest: "```\n- one\n- two\n", + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d7ae589047bf..2244ca7ec693 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -194,13 +194,20 @@ const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; // CommonMark blank lines hold only spaces and tabs. Other whitespace, such as // a no-break space, is paragraph content. const BLANK_LINE_PATTERN = /^[ \t]*$/; +// A bullet or ordered marker followed by whitespace, at any indentation so +// nested items count. The trailing space is required, so a partial `-` or +// `1.` never matches before the model finishes the marker. +const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; /** - * Splits buffered assistant text at the last blank line or closing code fence - * that is not inside an open fenced code block. `ready` is safe to deliver now - * because the markdown before it will not change shape as more text arrives. - * `rest` stays buffered until the next boundary or completion. Only fully - * terminated lines count, so a trailing partial line never leaks. + * Splits buffered assistant text at the last blank line, closing code fence, + * or list item start that is not inside an open fenced code block. `ready` is + * safe to deliver now because the markdown before it will not change shape as + * more text arrives. `rest` stays buffered until the next boundary or + * completion. Only fully terminated lines count, so a trailing partial line + * never leaks; a list item start is the one lookahead that may sit on the + * partial line, since tight lists have no blank lines between items and would + * otherwise land all at once. */ export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { let openFence: { marker: string; indent: number } | null = null; @@ -209,10 +216,20 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: for (;;) { const newline = text.indexOf("\n", lineStart); if (newline === -1) { + if ( + openFence === null && + lineStart > 0 && + LIST_ITEM_START_PATTERN.test(text.slice(lineStart)) + ) { + boundary = lineStart; + } break; } const line = text.slice(lineStart, newline).replace(/[ \t\r]+$/, ""); const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line); + if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) { + boundary = lineStart; + } if (fenceMatch) { const indent = fenceMatch[1]!.length; const marker = fenceMatch[2]!; From 1dfd91b43372fcedc9ce459debd20c2732958611 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:50:30 -0700 Subject: [PATCH 2/2] fix(server): trim the partial line before the list-item check A bare marker with only trailing whitespace on the partial line was a boundary while the same terminated line was not, and delivering before `- \n` turns the preceding paragraph into a setext heading. Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.test.ts | 7 +++++-- .../Layers/ProviderRuntimeIngestion.ts | 17 ++++++----------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 306557fecb5c..062a91f8cb53 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -4657,15 +4657,18 @@ describe("splitBufferedAssistantText", () => { ready: "## Steps\n\n- one\n- two\n", rest: "- thr", }); - expect(splitBufferedAssistantText("1. one\n2. two\n more\n3. ")).toEqual({ + expect(splitBufferedAssistantText("1. one\n2. two\n more\n3. t")).toEqual({ ready: "1. one\n2. two\n more\n", - rest: "3. ", + rest: "3. t", }); }); it("keeps a partial list marker and list-like code buffered", () => { expect(splitBufferedAssistantText("intro\n-")).toEqual({ ready: "", rest: "intro\n-" }); expect(splitBufferedAssistantText("intro\n1.")).toEqual({ ready: "", rest: "intro\n1." }); + // `intro\n- \n` would parse as a setext heading, so a bare marker with only + // trailing whitespace is not a boundary on the partial line either. + expect(splitBufferedAssistantText("intro\n- ")).toEqual({ ready: "", rest: "intro\n- " }); expect(splitBufferedAssistantText("- one\n")).toEqual({ ready: "", rest: "- one\n" }); expect(splitBufferedAssistantText("```\n- one\n- two\n")).toEqual({ ready: "", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 2244ca7ec693..32eaefd7cf1f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -215,21 +215,16 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: let lineStart = 0; 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)) { + boundary = lineStart; + } if (newline === -1) { - if ( - openFence === null && - lineStart > 0 && - LIST_ITEM_START_PATTERN.test(text.slice(lineStart)) - ) { - boundary = lineStart; - } break; } - const line = text.slice(lineStart, newline).replace(/[ \t\r]+$/, ""); const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line); - if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) { - boundary = lineStart; - } if (fenceMatch) { const indent = fenceMatch[1]!.length; const marker = fenceMatch[2]!;