Skip to content

fix(chat): keep reused text blocks in order and drop empty reasoning spans - #3444

Merged
kwakayama merged 1 commit into
mainfrom
fix/text-block-order-and-empty-reasoning
Aug 7, 2026
Merged

kwakayama merged 1 commit into
mainfrom
fix/text-block-order-and-empty-reasoning

Conversation

@kwakayama

@kwakayama kwakayama commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #3438. After that landed I probed the rest of the streaming assembly for the same defect class. Two findings, one latent and one live.

handleTextStart had the identical overwrite bug

It replaced its map entry unconditionally, exactly as handleReasoningStart did before #3438. A content id reused across steps blanks the earlier answer and moves the later one below everything that streamed in between:

stream: text(STEP1) → tool → text(STEP2)
before: ["tool-calc", "text:STEP2"]              ← STEP1 destroyed
after:  ["text:STEP1", "tool-calc", "text:STEP2"]

Reasoning ids collide on every run because providers index them (reasoning-0). Text ids usually carry a random suffix (text-BG3xwKvhoiJGrrdZ), which is the only reason this hadn't surfaced. It is reachable today: src/provider/local/model-runtime-adapter.ts:145 builds text-${Date.now()} per doStream call, so two steps completing inside one millisecond reuse the id.

Worth flagging because it would have been hard to spot from the UI: getAnswerPartsForRendering already hides pre-tool text once a post-tool answer exists, so the screen looks correct while the persisted message silently loses content.

Same rule as reasoning now applies. A start on a block that is still open is a replayed start and keeps its text and its order; a start after the block closed opens a new block and retires the old one to closedTextBlocks.

Empty reasoning spans rendered an empty "Thought process"

A reasoning-start / reasoning-end pair with no delta produced {type: "reasoning", text: ""}, and nothing guarded against it — not parts-builder, not groupPartsInOrder, not the Reasoning component, which renders the disclosure trigger regardless of content. Providers open and close the span on any step that did no thinking, so this is common.

This predates #3438 (a single empty span already produced it), but #3438 increased exposure: empty spans sharing an id used to collapse into one overwritten slot and now each is retained.

Completed empty spans are dropped. Two deliberate carve-outs, both covered by tests:

  • An open empty span stays, so the thinking affordance still shows while a span streams.
  • A redacted or signed span stays even with empty text, because it carries meaning without visible content.

Verification

Probes against the built handler, five steps with both ids fully reused:

L: reason:R1, text:T1, reason:R2, text:T2, reason:R3, text:T3, reason:R4, text:T4, reason:R5, text:T5

Nothing lost, nothing reordered, no duplicates.

Suites: agent + chat + react 1390 passed / 0 failed. deno task typecheck clean, deno fmt --check and deno lint clean.

Not fixed here

Reusing a tool call id across steps destroys the earlier call the same way. Left alone deliberately: correlating a result to a call requires the id to be unique, so a duplicate id is already ambiguous at the protocol level rather than something the client should paper over.

The AG-UI encoder still emits duplicate reasoning messageIds across steps. use-chat is now robust to it, but other consumers of that wire are not. Fixing it there is a wire-contract change and wants its own PR.

Summary by CodeRabbit

  • Bug Fixes
    • Improved streaming responses when content identifiers are reused, preserving previously generated text and its ordering.
    • Prevented empty reasoning segments from appearing in responses.
    • Preserved meaningful reasoning content, including redaction metadata, during streaming.

…spans

Follow-up to #3438, from probing the rest of the streaming assembly for the
same defect class.

`handleTextStart` overwrote its map entry exactly the way `handleReasoningStart`
did, so a content id reused across steps blanked the earlier answer and moved
the later one below everything that streamed in between. Reasoning ids collide
on every run because providers index them (`reasoning-0`); text ids usually
carry a random suffix, which is the only reason this had not been seen. It is
reachable today through the local provider, whose text id is `text-${Date.now()}`
per doStream call — two steps inside one millisecond reuse it. The renderer
would have hidden the damage: `getAnswerPartsForRendering` already drops
pre-tool text when a post-tool answer exists, so the screen looks right while
the persisted message loses content.

Same rule as reasoning now applies: a start on an open block is a replayed
start and keeps its text and order, a start after the block closed opens a new
one and retires the old.

Separately, a reasoning span that opens and closes without content rendered an
empty "Thought process" disclosure, which providers produce on any step that
did no thinking. Completed empty spans are dropped. Open ones stay, so the
thinking affordance still appears while a span is streaming, and redacted or
signed spans stay because they carry meaning without visible text.
@kwakayama
kwakayama requested a review from kojiwakayama as a code owner August 7, 2026 02:50
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e76dbf05-ece0-4066-a64b-54e86f53af40

📥 Commits

Reviewing files that changed from the base of the PR and between 8f223ed and 8715b27.

📒 Files selected for processing (3)
  • src/agent/react/use-chat/streaming/handler.test.ts
  • src/agent/react/use-chat/streaming/handler.ts
  • src/agent/react/use-chat/streaming/parts-builder.ts

📝 Walkthrough

Walkthrough

The streaming handler now preserves completed text blocks when providers reuse IDs. The parts builder includes those blocks in order and omits empty completed reasoning spans while retaining meaningful and redacted reasoning data. Tests cover these cases.

Changes

Streaming block preservation

Layer / File(s) Summary
Text block lifecycle
src/agent/react/use-chat/streaming/handler.ts
The handler tracks closed text blocks, preserves duplicate starts, resets them for new messages, and passes them through both streaming paths.
Transcript part assembly
src/agent/react/use-chat/streaming/parts-builder.ts
The builder combines closed and active text blocks and omits completed reasoning spans without text, signatures, or redacted data.
Streaming regression coverage
src/agent/react/use-chat/streaming/handler.test.ts
Tests cover reused text IDs, restarted text blocks, empty reasoning spans, and redacted reasoning data.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: kojiwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes both primary fixes: preserving reused text block order and dropping empty reasoning spans.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/text-block-order-and-empty-reasoning

Comment @coderabbitai help to get the list of available commands.

@kwakayama
kwakayama added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 1e894d9 Aug 7, 2026
31 checks passed
@kwakayama
kwakayama deleted the fix/text-block-order-and-empty-reasoning branch August 7, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant