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()