authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 15:10:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 15:10:39-07:00
loga36af0637a63ebf316beb5db5670be226df01cbc
treee3766ff0a3ff780a446c4976733d0203409e56c8
parent8251c8de78a021014b83ffe986dc52a480d71e41
signature Signed by PGP key B5690EEEBB952194

fix(server): streamed section titles wait for the text under them (#13504)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 80 insertions(+), 2 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+55
......@@ -5174,4 +5174,59 @@ describe("splitBufferedAssistantText", () => {
51745174 rest: "```\n- one\n- two\n",
51755175 });
51765176 });
5177
5178 it("holds a heading until the block under it is done", () => {
5179 expect(splitBufferedAssistantText("intro\n\n## Setup\n\nInstall it")).toEqual({
5180 ready: "intro\n\n",
5181 rest: "## Setup\n\nInstall it",
5182 });
5183 expect(
5184 splitBufferedAssistantText("intro\n\n# Plan\n\n## Setup\n\nInstall it.\n\nNext"),
5185 ).toEqual({
5186 ready: "intro\n\n# Plan\n\n## Setup\n\nInstall it.\n\n",
5187 rest: "Next",
5188 });
5189 });
5190
5191 it("delivers the paragraph above a heading with no blank line between them", () => {
5192 expect(splitBufferedAssistantText("para\n## Setup\n\nInstall")).toEqual({
5193 ready: "para\n",
5194 rest: "## Setup\n\nInstall",
5195 });
5196 // A bold line there continues the paragraph, so both stay buffered.
5197 expect(splitBufferedAssistantText("para\n**Setup**\n\nInstall")).toEqual({
5198 ready: "",
5199 rest: "para\n**Setup**\n\nInstall",
5200 });
5201 });
5202
5203 it("holds a line of only bold text like a heading", () => {
5204 expect(splitBufferedAssistantText("**Risk by area:**\n\n| a |\n|---|\n")).toEqual({
5205 ready: "",
5206 rest: "**Risk by area:**\n\n| a |\n|---|\n",
5207 });
5208 expect(splitBufferedAssistantText("**Use *npm* now**\n\nInstall it")).toEqual({
5209 ready: "",
5210 rest: "**Use *npm* now**\n\nInstall it",
5211 });
5212 expect(splitBufferedAssistantText("**Note:** read this.\n\nNext")).toEqual({
5213 ready: "**Note:** read this.\n\n",
5214 rest: "Next",
5215 });
5216 });
5217
5218 it("delivers a held heading with its first list item or its whole code block", () => {
5219 expect(splitBufferedAssistantText("## Steps\n\n- one\n- tw")).toEqual({
5220 ready: "## Steps\n\n- one\n",
5221 rest: "- tw",
5222 });
5223 expect(splitBufferedAssistantText("## Code\n\n```ts\na\n\nb\n")).toEqual({
5224 ready: "",
5225 rest: "## Code\n\n```ts\na\n\nb\n",
5226 });
5227 expect(splitBufferedAssistantText("## Code\n\n```ts\na\n```\nafter")).toEqual({
5228 ready: "## Code\n\n```ts\na\n```\n",
5229 rest: "after",
5230 });
5231 });
51775232});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+25-2
......@@ -211,6 +211,12 @@ const BLANK_LINE_PATTERN = /^[ \t]*$/;
211211// nested items count. The trailing space is required, so a partial `-` or
212212// `1.` never matches before the model finishes the marker.
213213const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/;
214// A section title: an ATX heading, or a line of only bold text, which models
215// often use as a heading.
216const SECTION_TITLE_PATTERN = /^ {0,3}(?:#{1,6}(?:[ \t]|$)|\*\*(?:[^*]|\*(?!\*))+\*\*:?$)/;
217// An unindented ATX heading ends the paragraph or list above it, even with no
218// blank line between them. A bold line would continue the paragraph instead.
219const TOP_LEVEL_HEADING_PATTERN = /^#{1,6}(?:[ \t]|$)/;
214220
215221/**
216222 * Splits buffered assistant text at the last blank line, closing code fence,
......@@ -221,17 +227,26 @@ const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/;
221227 * never leaks; a list item start is the one lookahead that may sit on the
222228 * partial line, since tight lists have no blank lines between items and would
223229 * otherwise land all at once.
230 *
231 * A section title holds the boundary until a content line follows it, so a
232 * title never lands alone and waits above a block that is still streaming.
224233 */
225234export function splitBufferedAssistantText(text: string): { ready: string; rest: string } {
226235 let openFence: { marker: string; indent: number } | null = null;
227236 let boundary = -1;
228237 let lineStart = 0;
238 let titleAwaitingContent = false;
229239 for (;;) {
230240 const newline = text.indexOf("\n", lineStart);
231241 const line = text
232242 .slice(lineStart, newline === -1 ? text.length : newline)
233243 .replace(/[ \t\r]+$/, "");
234 if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) {
244 if (
245 openFence === null &&
246 lineStart > 0 &&
247 !titleAwaitingContent &&
248 LIST_ITEM_START_PATTERN.test(line)
249 ) {
235250 boundary = lineStart;
236251 }
237252 if (newline === -1) {
......@@ -243,6 +258,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest:
243258 const marker = fenceMatch[2]!;
244259 if (openFence === null) {
245260 openFence = { marker, indent };
261 titleAwaitingContent = false;
246262 } else if (
247263 marker[0] === openFence.marker[0] &&
248264 marker.length >= openFence.marker.length &&
......@@ -254,7 +270,14 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest:
254270 boundary = newline + 1;
255271 }
256272 } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) {
257 boundary = newline + 1;
273 if (!titleAwaitingContent) {
274 boundary = newline + 1;
275 }
276 } else if (openFence === null) {
277 if (lineStart > 0 && !titleAwaitingContent && TOP_LEVEL_HEADING_PATTERN.test(line)) {
278 boundary = lineStart;
279 }
280 titleAwaitingContent = SECTION_TITLE_PATTERN.test(line);
258281 }
259282 lineStart = newline + 1;
260283 }