Skip to content

fix(tui): anchor splitReasoning unclosed-tag regex; stop eating last paragraph#29426

Merged
OutThisLife merged 1 commit into
mainfrom
bb/tui-eats-last-paragraph
May 20, 2026
Merged

fix(tui): anchor splitReasoning unclosed-tag regex; stop eating last paragraph#29426
OutThisLife merged 1 commit into
mainfrom
bb/tui-eats-last-paragraph

Conversation

@OutThisLife

Copy link
Copy Markdown
Collaborator

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:

const unclosed = new RegExp(`<${tag}>([\\s\\S]*)$`, 'i')

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)

splitReasoning(
  'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.'
)
// text:      'final answer paragraph one.'        ← paragraph two GONE
// reasoning: 'internal note\n\nfinal answer paragraph two.'

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.

- const unclosed = new RegExp(`<${tag}>([\\s\\S]*)$`, 'i')
+ const unclosed = new RegExp(`^\\s*<${tag}>([\\s\\S]*)$`, 'i')

Post-fix

splitReasoning(
  'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.'
)
// text:      'final answer paragraph one.\n\n<think>internal note\n\nfinal answer paragraph two.'  ✓
// reasoning: ''

Leading unclosed reasoning still works:

splitReasoning('<think>still deciding')
// reasoning: 'still deciding'
// text: ''

Tests

  • Updated treats unclosed trailing <think>… as reasoningtreats 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.
  • Added regression test does not strip trailing prose after a stray mid-text <think> mention pinning the exact symptom.

Verification

cd ui-tui
npm run type-check  # clean
npm test            # 808/808 passing (1 unrelated slashParity skipped — missing pyyaml in venv)
npm run build       # clean

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.

`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.
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: bb/tui-eats-last-paragraph vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 8972 on HEAD, 8972 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 4734 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in splitReasoning() 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.

@OutThisLife OutThisLife merged commit 88f5186 into main May 20, 2026
13 of 14 checks passed
@OutThisLife OutThisLife deleted the bb/tui-eats-last-paragraph branch May 20, 2026 19:09
@alt-glitch alt-glitch added type/bug Something isn't working comp/tui Terminal UI (ui-tui/ + tui_gateway/) P1 High — major feature broken, no workaround labels May 20, 2026
Lillard01 pushed a commit to Lillard01/hermes-agent that referenced this pull request May 21, 2026
…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.
Gpapas pushed a commit to Gpapas/hermes-agent that referenced this pull request May 23, 2026
…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.
Mucky010 pushed a commit to Mucky010/hermes-agent that referenced this pull request May 24, 2026
…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.
Bryce-huang pushed a commit to wbkunlun/hermes-agent that referenced this pull request May 29, 2026
…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#
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tui Terminal UI (ui-tui/ + tui_gateway/) P1 High — major feature broken, no workaround type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants