Skip to content

fix(thinking): drop stale thinking chunks that outlive their turn's answer - #1347

Open
chr1syy wants to merge 2 commits into
RunMaestro:rcfrom
chr1syy:fix/t1-thinking-log-ordering
Open

fix(thinking): drop stale thinking chunks that outlive their turn's answer#1347
chr1syy wants to merge 2 commits into
RunMaestro:rcfrom
chr1syy:fix/t1-thinking-log-ordering

Conversation

@chr1syy

@chr1syy chr1syy commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes finding T1.

Problem

A thinking log could survive below the final answer, duplicating it. Two writers hit the same tab.logs array on different schedules:

Writer Path Schedule
thinking chunks useAgentThinkingListener requestAnimationFrame, ~16ms
final answer useBatchedSessionUpdates batched flush, 200ms

The mid-turn clear point lives only in the slow writer. The fast writer's non-continuation branch appended unconditionally, so a chunk buffered just before a flush was applied after it, landing below the answer it duplicated.

Fix

Refuse to append a thinking log when the tab's last log is already the turn's stdout answer - a late chunk for an answered turn is stale by definition.

Deliberately NOT changed: the 200ms flush interval (documented as load-bearing for input latency), and claude-code's forward-all-partials behavior in StdoutHandler (its comment records that gating on isReasoning was already tried and silenced the thinking display for ordinary turns).

Testing

Scoped tests only, per repo convention. Adds 100 lines to useAgentThinkingListener.test.tsx covering the race directly: buffered chunk, stdout flush first, then the rAF fires - asserts no thinking log is appended after the answer. Also asserts the normal path and continuation coalescing still work.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Prevented outdated thinking messages from reappearing after an answer is displayed.
    • Preserved new thinking updates received after intermediate answers.
    • Ensured correct thinking behavior in standard and sticky modes, including after prompts or tool activity.
    • Improved handling of batched thinking and answer updates.
  • Tests

    • Added regression coverage for stale thinking messages, intermediate responses, and message coalescing.

The thinking listener writes on a ~16ms rAF while the final answer arrives
via the 200ms batched flush, which owns the only mid-turn clear point. A
chunk buffered just before that flush was applied just after it, appending
a stale thinking log below the completed answer.

Guard the fast writer's non-continuation branch: when the tab's last log is
already the turn's stdout answer, drop the chunk. Applies in sticky mode too
(sticky preserves logs already on screen, it does not entitle a stale buffer
to create a new one).

Closes finding T1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The thinking listener now timestamps buffered thinking batches and drops batches that are not newer than a stdout answer. Regression tests cover normal and sticky modes, prompts, intermediate stdout messages, existing thinking logs, and tool activity.

Changes

Thinking listener behavior

Layer / File(s) Summary
Timestamped stale chunk guard
src/renderer/hooks/agent/internal/useAgentThinkingListener.ts
The listener stores the first chunk timestamp for each buffered batch, preserves it while coalescing chunks, and suppresses stale batches after a stdout answer.
Regression coverage
src/__tests__/renderer/hooks/agent/internal/useAgentThinkingListener.test.tsx
Tests cover stale thinking, valid post-answer thinking, prompts, existing-log coalescing, sticky mode, and tool activity.

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

Possibly related PRs

Suggested reviewers: reachrazamair, pedramamini

🚥 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 and concisely describes the main change: dropping stale thinking chunks after their turn's answer.
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 unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds a renderer-side guard intended to prevent rAF-buffered thinking chunks from appearing below an answer, together with regression coverage for the targeted race.

  • Drops a thinking chunk whenever the tab’s current last log is stdout.
  • Adds tests for stale chunks, normal appends, continuation coalescing, sticky mode, and tool activity.
  • The source-only guard also suppresses legitimate reasoning after intermediate stdout within an ongoing turn.

Confidence Score: 4/5

This needs to distinguish turn-final answers from intermediate stdout before merging, otherwise valid later reasoning can disappear during active turns.

The parser can emit intermediate text through both thinking and stdout paths, while the new unconditional stdout check discards any reasoning chunk that arrives after such an intermediate stdout flush.

Files Needing Attention: src/renderer/hooks/agent/internal/useAgentThinkingListener.ts and its focused test file

Important Files Changed

Filename Overview
src/renderer/hooks/agent/internal/useAgentThinkingListener.ts Adds the stale-chunk guard, but uses stdout as an imprecise proxy for turn completion and can discard ongoing-turn reasoning.
src/tests/renderer/hooks/agent/internal/useAgentThinkingListener.test.tsx Covers the intended race and several neighboring paths, but lacks a case where reasoning resumes after intermediate stdout in the same turn.

Sequence Diagram

sequenceDiagram
    participant Agent
    participant StdoutHandler
    participant Batch as 200ms stdout batch
    participant RAF as Thinking rAF
    participant Logs as Tab logs
    Agent->>StdoutHandler: Non-reasoning partial
    StdoutHandler->>RAF: thinking-chunk
    StdoutHandler->>Batch: streamed text
    Batch->>Logs: Append intermediate stdout
    Agent->>StdoutHandler: Later reasoning partial
    StdoutHandler->>RAF: thinking-chunk
    RAF->>Logs: Inspect last log
    Logs-->>RAF: source is stdout
    RAF--xLogs: Drop legitimate reasoning
Loading

Reviews (1): Last reviewed commit: "MAESTRO: drop stale thinking chunks that..." | Re-trigger Greptile

// now would resurrect a stale prefix of that same answer underneath
// it. Tested explicitly (not as !isContinuation) because a
// self-contained thinking card is a non-continuation too.
if (lastLog?.source === 'stdout') continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Intermediate stdout drops reasoning

When Claude Code or Factory Droid emits more reasoning after an intermediate non-reasoning partial has flushed as stdout, this source-only guard treats that partial as the completed answer and drops the later thinking chunk, causing the thinking display to stop prematurely during an active turn.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

…(T1 review)

Greptile P1 on PR RunMaestro#1347: dropping a thinking chunk whenever the tab's last log
is `stdout` was too broad. Claude Code and Factory Droid stream at MESSAGE
granularity - one `type:"assistant"` event per completed message - so an
intermediate assistant message flushes as stdout mid-turn and more reasoning
can legitimately follow it. The source-only guard treated that intermediate
message as the completed answer and silenced the thinking display for the rest
of the turn.

Only a chunk that was ALREADY buffered when the stdout landed is stale; one
that arrived afterwards is new content. The buffer now records when buffering
started (first chunk of the coalesced batch wins), and the guard drops only
when `lastLog.timestamp >= bufferedAt`.

Tests: the two existing stale-straggler cases stamped the answer at
`timestamp: 1`, which predates the buffer and therefore modelled the
intermediate case rather than the race - they now place the answer AFTER
buffering via a `landAnswerAfterBuffering` helper, which is the real
interleaving. Adds a case for reasoning arriving after an intermediate stdout
message; verified it fails against the old source-only guard.
@chr1syy

chr1syy commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks - the P1 is real and is now fixed in ca2937d3e.

You're right that a source-only guard cannot distinguish the turn's final answer from an intermediate assistant message. Since claude-code and factory-droid stream at message granularity, an intermediate message flushes as stdout mid-turn, and the guard silenced every later reasoning chunk for the rest of that turn.

The distinguishing signal is when the chunk was buffered relative to the stdout log:

  • buffered BEFORE the stdout landed -> it belonged to the block the inline clear point already removed, so it is stale
  • buffered AFTER -> it is new reasoning following an intermediate message, and must be appended

The rAF buffer now records bufferedAt (first chunk of the coalesced batch wins) and the guard drops only when lastLog.timestamp >= bufferedAt.

Test changes worth calling out: the two original stale-straggler cases stamped the answer at timestamp: 1, which predates the buffer - so they were actually encoding your intermediate-message scenario rather than the race. They now place the answer after buffering via a landAnswerAfterBuffering helper, which is the real interleaving. Added a case covering reasoning that arrives after an intermediate stdout message, and verified it fails against the old source-only guard.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/renderer/hooks/agent/internal/useAgentThinkingListener.ts`:
- Around line 64-69: Update the buffering logic using thinkingChunkBufferRef so
thinking content received after an intermediate stdout event is kept separate
from the stale pre-stdout batch. Track timestamped segments or split the batch
at the stdout boundary, and ensure the rAF flush does not drop valid post-stdout
reasoning. Add a regression test covering a thinking chunk, stdout log, later
thinking chunk, and rAF flush.
- Around line 142-144: Update the stale-buffer check in the rAF flush logic
around outlivedItsAnswer to locate the most recent stdout entry in
targetTab.logs, rather than relying on lastLog, and compare buffered.bufferedAt
against that stdout timestamp so newer buffered chunks are retained. Add a
regression test covering buffered thinking followed by stdout, tool activity,
and the rAF flush.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: acd3ad56-39b8-46e2-bff6-7ff2ee87dc7f

📥 Commits

Reviewing files that changed from the base of the PR and between 94a5cde and ca2937d.

📒 Files selected for processing (2)
  • src/__tests__/renderer/hooks/agent/internal/useAgentThinkingListener.test.tsx
  • src/renderer/hooks/agent/internal/useAgentThinkingListener.ts

Comment on lines +64 to +69
const existing = thinkingChunkBufferRef.current.get(bufferKey);
thinkingChunkBufferRef.current.set(bufferKey, {
text: (existing?.text ?? '') + content,
// Keep the FIRST chunk's timestamp for the whole coalesced batch.
bufferedAt: existing?.bufferedAt ?? Date.now(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Keep post-stdout chunks separate from stale chunks.

This buffer retains the first chunk timestamp for all later content. If a chunk buffers before an intermediate stdout log and another chunk arrives after it before the rAF flush, line 143 drops both chunks. This suppresses valid later reasoning.

Store timestamped chunk segments, or split the buffered batch at the stdout boundary. Add a regression test for: thinking chunk, stdout log, later thinking chunk, then rAF flush.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/internal/useAgentThinkingListener.ts` around lines
64 - 69, Update the buffering logic using thinkingChunkBufferRef so thinking
content received after an intermediate stdout event is kept separate from the
stale pre-stdout batch. Track timestamped segments or split the batch at the
stdout boundary, and ensure the rAF flush does not drop valid post-stdout
reasoning. Add a regression test covering a thinking chunk, stdout log, later
thinking chunk, and rAF flush.

Comment on lines +142 to +144
const outlivedItsAnswer =
lastLog?.source === 'stdout' && lastLog.timestamp >= buffered.bufferedAt;
if (outlivedItsAnswer) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Check the latest stdout log instead of only lastLog.

A stale chunk can buffer before stdout, then a tool entry can append before the rAF flush. In that case lastLog is not stdout, so this code appends the stale chunk after the tool entry.

Find the most recent stdout log in targetTab.logs before comparing timestamps. Keep chunks whose bufferedAt is later than that stdout timestamp. Add a regression test for: buffered thinking, stdout, tool activity, then rAF flush.

Proposed fix
-	const outlivedItsAnswer =
-		lastLog?.source === 'stdout' && lastLog.timestamp >= buffered.bufferedAt;
+	const latestStdoutLog = [...targetTab.logs]
+		.reverse()
+		.find((log) => log.source === 'stdout');
+	const outlivedItsAnswer =
+		latestStdoutLog?.timestamp >= buffered.bufferedAt;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const outlivedItsAnswer =
lastLog?.source === 'stdout' && lastLog.timestamp >= buffered.bufferedAt;
if (outlivedItsAnswer) continue;
const latestStdoutLog = [...targetTab.logs]
.reverse()
.find((log) => log.source === 'stdout');
const outlivedItsAnswer =
latestStdoutLog?.timestamp >= buffered.bufferedAt;
if (outlivedItsAnswer) continue;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/internal/useAgentThinkingListener.ts` around lines
142 - 144, Update the stale-buffer check in the rAF flush logic around
outlivedItsAnswer to locate the most recent stdout entry in targetTab.logs,
rather than relying on lastLog, and compare buffered.bufferedAt against that
stdout timestamp so newer buffered chunks are retained. Add a regression test
covering buffered thinking followed by stdout, tool activity, and the rAF flush.

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