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", () => {...@@ -5174,4 +5174,59 @@ describe("splitBufferedAssistantText", () => {
5174 rest: "```\n- one\n- two\n",5174 rest: "```\n- one\n- two\n",
5175 });5175 });
5176 });5176 });
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 });
5177});5232});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+25-2
...@@ -211,6 +211,12 @@ const BLANK_LINE_PATTERN = /^[ \t]*$/;...@@ -211,6 +211,12 @@ const BLANK_LINE_PATTERN = /^[ \t]*$/;
211// nested items count. The trailing space is required, so a partial `-` or211// nested items count. The trailing space is required, so a partial `-` or
212// `1.` never matches before the model finishes the marker.212// `1.` never matches before the model finishes the marker.
213const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/;213const 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
215/**221/**
216 * Splits buffered assistant text at the last blank line, closing code fence,222 * 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]/;...@@ -221,17 +227,26 @@ const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/;
221 * never leaks; a list item start is the one lookahead that may sit on the227 * never leaks; a list item start is the one lookahead that may sit on the
222 * partial line, since tight lists have no blank lines between items and would228 * partial line, since tight lists have no blank lines between items and would
223 * otherwise land all at once.229 * 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.
224 */233 */
225export function splitBufferedAssistantText(text: string): { ready: string; rest: string } {234export function splitBufferedAssistantText(text: string): { ready: string; rest: string } {
226 let openFence: { marker: string; indent: number } | null = null;235 let openFence: { marker: string; indent: number } | null = null;
227 let boundary = -1;236 let boundary = -1;
228 let lineStart = 0;237 let lineStart = 0;
238 let titleAwaitingContent = false;
229 for (;;) {239 for (;;) {
230 const newline = text.indexOf("\n", lineStart);240 const newline = text.indexOf("\n", lineStart);
231 const line = text241 const line = text
232 .slice(lineStart, newline === -1 ? text.length : newline)242 .slice(lineStart, newline === -1 ? text.length : newline)
233 .replace(/[ \t\r]+$/, "");243 .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 ) {
235 boundary = lineStart;250 boundary = lineStart;
236 }251 }
237 if (newline === -1) {252 if (newline === -1) {
...@@ -243,6 +258,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest:...@@ -243,6 +258,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest:
243 const marker = fenceMatch[2]!;258 const marker = fenceMatch[2]!;
244 if (openFence === null) {259 if (openFence === null) {
245 openFence = { marker, indent };260 openFence = { marker, indent };
261 titleAwaitingContent = false;
246 } else if (262 } else if (
247 marker[0] === openFence.marker[0] &&263 marker[0] === openFence.marker[0] &&
248 marker.length >= openFence.marker.length &&264 marker.length >= openFence.marker.length &&
...@@ -254,7 +270,14 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest:...@@ -254,7 +270,14 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest:
254 boundary = newline + 1;270 boundary = newline + 1;
255 }271 }
256 } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) {272 } 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);
258 }281 }
259 lineStart = newline + 1;282 lineStart = newline + 1;
260 }283 }