From 88f5186d3573a1a96215206980b1ac2c9c637680 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 20 May 2026 14:09:38 -0500 Subject: [PATCH] fix(tui): anchor splitReasoning unclosed-tag regex to start of input (#29426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `splitReasoning()` strips paired `` blocks first, then runs an unclosed-trailing regex to catch reasoning that hasn't yet streamed its closer. That second regex was unanchored and greedy: new RegExp(`<${tag}>([\\s\\S]*)$`, 'i') So any literal `` somewhere in prose — a model quoting the tag, a code example, or a stream-mid-tag before the closer arrives — consumed every paragraph after it to EOF. User-visible symptom: "TUI eats last paragraph of output," both during streaming and on settled turns. Real reasoning streams always lead the message (that's the only place an unclosed opener can legitimately appear during streaming). Anchor the regex to `^\s*` so mid-prose mentions of the tag are preserved. Empirical repro before the fix: splitReasoning('final answer paragraph one.\n\ninternal note\n\nfinal answer paragraph two.') → text: 'final answer paragraph one.' ← paragraph two GONE After: → text: 'final answer paragraph one.\n\ninternal note\n\nfinal answer paragraph two.' Updated the existing trailing-unclosed test to lead with `` (the real-world shape) and added a regression test pinning the mid-text case. ui-tui type-check clean, 808/808 vitest pass. --- ui-tui/src/__tests__/reasoning.test.ts | 21 ++++++++++++++++++--- ui-tui/src/lib/reasoning.ts | 7 ++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/__tests__/reasoning.test.ts b/ui-tui/src/__tests__/reasoning.test.ts index d14a0a2975a..5d661e0059d 100644 --- a/ui-tui/src/__tests__/reasoning.test.ts +++ b/ui-tui/src/__tests__/reasoning.test.ts @@ -21,11 +21,26 @@ describe('splitReasoning', () => { expect(text).toBe('body') }) - it('treats unclosed trailing … as reasoning', () => { - const { reasoning, text } = splitReasoning('answer start still deciding') + it('treats unclosed leading … as reasoning (real reasoning-model stream)', () => { + const { reasoning, text } = splitReasoning('still deciding') expect(reasoning).toBe('still deciding') - expect(text).toBe('answer start') + expect(text).toBe('') + }) + + it('does not strip trailing prose after a stray mid-text mention', () => { + // Regression for "TUI eats last paragraph of output": when the model + // emits a literal `` somewhere in prose (quoted explanation, code + // example, partial stream-mid-tag), the trailing greedy unclosed-tag + // regex used to consume every paragraph after it. Real unclosed + // reasoning blocks always lead the message — anchor to ^ so prose + // mentions are preserved. + const { reasoning, text } = splitReasoning( + 'final answer paragraph one.\n\ninternal note never closed\n\nfinal answer paragraph two.' + ) + + expect(reasoning).toBe('') + expect(text).toBe('final answer paragraph one.\n\ninternal note never closed\n\nfinal answer paragraph two.') }) it('returns empty reasoning and untouched text when no tags present', () => { diff --git a/ui-tui/src/lib/reasoning.ts b/ui-tui/src/lib/reasoning.ts index eba63918c41..d80260dbd4f 100644 --- a/ui-tui/src/lib/reasoning.ts +++ b/ui-tui/src/lib/reasoning.ts @@ -21,7 +21,12 @@ export function splitReasoning(input: string): SplitReasoning { return '' }) - const unclosed = new RegExp(`<${tag}>([\\s\\S]*)$`, 'i') + // Anchor to start-of-input so a literal `` mid-prose (model quoting + // the word, code blocks containing the tag, etc.) doesn't eat every + // paragraph after it. Real unclosed reasoning blocks always lead the + // message — that's how reasoning models stream. See test + // "does not strip trailing prose after a stray mid-text mention". + const unclosed = new RegExp(`^\\s*<${tag}>([\\s\\S]*)$`, 'i') text = text.replace(unclosed, (_m, inner: string) => { const trimmed = inner.trim()