authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-14 18:16:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 18:16:42-07:00
logb5b29e7b8b12536a0619fb6df54d20d2b649d6e5
treefac98df642cbb0cfd2b7480fd2d1b3c11eeec80a
parent5ea6439816470288d3f2b6b43635fea41fbbb101
signature Signed by PGP key B5690EEEBB952194

fix(server): stream tight list items one at a time in paragraph mode (#11833)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 42 insertions(+), 6 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+24
......@@ -4651,4 +4651,28 @@ describe("splitBufferedAssistantText", () => {
46514651 rest: "~~~\n```\n\nx\n",
46524652 });
46534653 });
4654
4655 it("delivers tight list items one at a time", () => {
4656 expect(splitBufferedAssistantText("## Steps\n\n- one\n- two\n- thr")).toEqual({
4657 ready: "## Steps\n\n- one\n- two\n",
4658 rest: "- thr",
4659 });
4660 expect(splitBufferedAssistantText("1. one\n2. two\n more\n3. t")).toEqual({
4661 ready: "1. one\n2. two\n more\n",
4662 rest: "3. t",
4663 });
4664 });
4665
4666 it("keeps a partial list marker and list-like code buffered", () => {
4667 expect(splitBufferedAssistantText("intro\n-")).toEqual({ ready: "", rest: "intro\n-" });
4668 expect(splitBufferedAssistantText("intro\n1.")).toEqual({ ready: "", rest: "intro\n1." });
4669 // `intro\n- \n` would parse as a setext heading, so a bare marker with only
4670 // trailing whitespace is not a boundary on the partial line either.
4671 expect(splitBufferedAssistantText("intro\n- ")).toEqual({ ready: "", rest: "intro\n- " });
4672 expect(splitBufferedAssistantText("- one\n")).toEqual({ ready: "", rest: "- one\n" });
4673 expect(splitBufferedAssistantText("```\n- one\n- two\n")).toEqual({
4674 ready: "",
4675 rest: "```\n- one\n- two\n",
4676 });
4677 });
46544678});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+18-6
......@@ -194,13 +194,20 @@ const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/;
194194// CommonMark blank lines hold only spaces and tabs. Other whitespace, such as
195195// a no-break space, is paragraph content.
196196const BLANK_LINE_PATTERN = /^[ \t]*$/;
197// A bullet or ordered marker followed by whitespace, at any indentation so
198// nested items count. The trailing space is required, so a partial `-` or
199// `1.` never matches before the model finishes the marker.
200const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/;
197201
198202/**
199 * Splits buffered assistant text at the last blank line or closing code fence
200 * that is not inside an open fenced code block. `ready` is safe to deliver now
201 * because the markdown before it will not change shape as more text arrives.
202 * `rest` stays buffered until the next boundary or completion. Only fully
203 * terminated lines count, so a trailing partial line never leaks.
203 * Splits buffered assistant text at the last blank line, closing code fence,
204 * or list item start that is not inside an open fenced code block. `ready` is
205 * safe to deliver now because the markdown before it will not change shape as
206 * more text arrives. `rest` stays buffered until the next boundary or
207 * completion. Only fully terminated lines count, so a trailing partial line
208 * never leaks; a list item start is the one lookahead that may sit on the
209 * partial line, since tight lists have no blank lines between items and would
210 * otherwise land all at once.
204211 */
205212export function splitBufferedAssistantText(text: string): { ready: string; rest: string } {
206213 let openFence: { marker: string; indent: number } | null = null;
......@@ -208,10 +215,15 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest:
208215 let lineStart = 0;
209216 for (;;) {
210217 const newline = text.indexOf("\n", lineStart);
218 const line = text
219 .slice(lineStart, newline === -1 ? text.length : newline)
220 .replace(/[ \t\r]+$/, "");
221 if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) {
222 boundary = lineStart;
223 }
211224 if (newline === -1) {
212225 break;
213226 }
214 const line = text.slice(lineStart, newline).replace(/[ \t\r]+$/, "");
215227 const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line);
216228 if (fenceMatch) {
217229 const indent = fenceMatch[1]!.length;