| author | |
| committer | |
| log | a36af0637a63ebf316beb5db5670be226df01cbc |
| tree | e3766ff0a3ff780a446c4976733d0203409e56c8 |
| parent | 8251c8de78a021014b83ffe986dc52a480d71e41 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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 | 211 | // nested items count. The trailing space is required, so a partial `-` or |
| 212 | 212 | // `1.` never matches before the model finishes the marker. |
| 213 | 213 | const 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. | |
| 216 | const 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. | |
| 219 | const TOP_LEVEL_HEADING_PATTERN = /^#{1,6}(?:[ \t]|$)/; | |
| 214 | 220 | |
| 215 | 221 | /** |
| 216 | 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 | 227 | * never leaks; a list item start is the one lookahead that may sit on the |
| 222 | 228 | * partial line, since tight lists have no blank lines between items and would |
| 223 | 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 | */ |
| 225 | 234 | export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { |
| 226 | 235 | let openFence: { marker: string; indent: number } | null = null; |
| 227 | 236 | let boundary = -1; |
| 228 | 237 | let lineStart = 0; |
| 238 | let titleAwaitingContent = false; | |
| 229 | 239 | for (;;) { |
| 230 | 240 | const newline = text.indexOf("\n", lineStart); |
| 231 | 241 | const line = text |
| 232 | 242 | .slice(lineStart, newline === -1 ? text.length : newline) |
| 233 | 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 | 250 | boundary = lineStart; |
| 236 | 251 | } |
| 237 | 252 | if (newline === -1) { |
| ... | ... | @@ -243,6 +258,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: |
| 243 | 258 | const marker = fenceMatch[2]!; |
| 244 | 259 | if (openFence === null) { |
| 245 | 260 | openFence = { marker, indent }; |
| 261 | titleAwaitingContent = false; | |
| 246 | 262 | } else if ( |
| 247 | 263 | marker[0] === openFence.marker[0] && |
| 248 | 264 | marker.length >= openFence.marker.length && |
| ... | ... | @@ -254,7 +270,14 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: |
| 254 | 270 | boundary = newline + 1; |
| 255 | 271 | } |
| 256 | 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 | 282 | lineStart = newline + 1; |
| 260 | 283 | } |