fix(tui): anchor splitReasoning unclosed-tag regex; stop eating last paragraph#29426
Conversation
`splitReasoning()` strips paired `<think>…</think>` 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 `<think>` 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\n<think>internal note\n\nfinal answer paragraph two.')
→ text: 'final answer paragraph one.' ← paragraph two GONE
After:
→ text: 'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.'
Updated the existing trailing-unclosed test to lead with `<think>` (the
real-world shape) and added a regression test pinning the mid-text case.
ui-tui type-check clean, 808/808 vitest pass.
🔎 Lint report:
|
There was a problem hiding this comment.
Pull request overview
This PR fixes an intermittent TUI rendering issue where the last paragraph of a model response could disappear when the output contained a literal reasoning tag (e.g. <think>) mid-prose. The fix tightens splitReasoning()’s “unclosed tag” detection to only match when the opener appears at the start of the message (allowing leading whitespace), preventing accidental capture of trailing visible text.
Changes:
- Anchor the “unclosed
<tag>to EOF” regex insplitReasoning()to^(with optional leading whitespace) so mid-message<think>mentions don’t consume the remainder of the message. - Update the existing unclosed-tag test to reflect real streaming shape (leading unclosed reasoning).
- Add a regression test ensuring mid-text
<think>does not strip trailing prose.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| ui-tui/src/lib/reasoning.ts | Anchors unclosed-tag regex to message start to prevent greedy mid-prose matches from hiding trailing output. |
| ui-tui/src/tests/reasoning.test.ts | Updates/extends tests to cover the corrected unclosed-tag behavior and the regression case. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ousResearch#29426) `splitReasoning()` strips paired `<think>…</think>` 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 `<think>` 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\n<think>internal note\n\nfinal answer paragraph two.') → text: 'final answer paragraph one.' ← paragraph two GONE After: → text: 'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.' Updated the existing trailing-unclosed test to lead with `<think>` (the real-world shape) and added a regression test pinning the mid-text case. ui-tui type-check clean, 808/808 vitest pass.
…ousResearch#29426) `splitReasoning()` strips paired `<think>…</think>` 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 `<think>` 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\n<think>internal note\n\nfinal answer paragraph two.') → text: 'final answer paragraph one.' ← paragraph two GONE After: → text: 'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.' Updated the existing trailing-unclosed test to lead with `<think>` (the real-world shape) and added a regression test pinning the mid-text case. ui-tui type-check clean, 808/808 vitest pass.
…ousResearch#29426) `splitReasoning()` strips paired `<think>…</think>` 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 `<think>` 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\n<think>internal note\n\nfinal answer paragraph two.') → text: 'final answer paragraph one.' ← paragraph two GONE After: → text: 'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.' Updated the existing trailing-unclosed test to lead with `<think>` (the real-world shape) and added a regression test pinning the mid-text case. ui-tui type-check clean, 808/808 vitest pass.
…ousResearch#29426) `splitReasoning()` strips paired `<think>…</think>` 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 `<think>` 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\n<think>internal note\n\nfinal answer paragraph two.') → text: 'final answer paragraph one.' ← paragraph two GONE After: → text: 'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.' Updated the existing trailing-unclosed test to lead with `<think>` (the real-world shape) and added a regression test pinning the mid-text case. ui-tui type-check clean, 808/808 vitest pass. #AI commit#
…ousResearch#29426) `splitReasoning()` strips paired `<think>…</think>` 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 `<think>` 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\n<think>internal note\n\nfinal answer paragraph two.') → text: 'final answer paragraph one.' ← paragraph two GONE After: → text: 'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.' Updated the existing trailing-unclosed test to lead with `<think>` (the real-world shape) and added a regression test pinning the mid-text case. ui-tui type-check clean, 808/808 vitest pass.
Symptom
User report: "TUI eats last paragraph of output" — happens both during streaming and on completed turns, intermittent.
Root cause
ui-tui/src/lib/reasoning.ts → splitReasoning()first strips paired<think>…</think>blocks, then runs an unclosed-trailing regex to catch reasoning that hasn't yet streamed its closer. That second regex was unanchored and greedy:So any literal
<think>anywhere in prose — a model quoting the tag, a code example, partial stream-mid-tag before the</think>arrives — consumed every paragraph after it to EOF. The trailing content was reclassified as reasoning (hidden behind details) and silently disappeared from the visible message body.This applied to all five tags handled:
think,reasoning,thinking,thought,REASONING_SCRATCHPAD.Empirical repro (pre-fix)
Fix
Anchor the unclosed-trailing regex to start-of-input (allowing leading whitespace). Real reasoning streams always lead the message — that's the only place an unclosed opener legitimately appears during streaming. Mid-prose
<think>mentions stay in the visible text.Post-fix
Leading unclosed reasoning still works:
Tests
treats unclosed trailing <think>… as reasoning→treats unclosed leading <think>… as reasoning (real reasoning-model stream). The old fixture pinned the buggy behavior (mid-text<think>eating the prefix); replaced with the real-world shape.does not strip trailing prose after a stray mid-text <think> mentionpinning the exact symptom.Verification
Related
This is one cause of the symptom — the other plausible class is over-aggressive stripping in
finalTail(turnController.ts). I investigated that path during diagnosis and didn't find a concrete eating-case in the empirical test matrix, so leaving it alone. If "eats last paragraph" recurs after this lands, that's the next place to look.