Skip to content

feat(server): render model reasoning as timeline activities - #7632

Open
jbbottoms wants to merge 2 commits into
pingdotgg:mainfrom
jbbottoms:feat/render-reasoning-activities
Open

feat(server): render model reasoning as timeline activities#7632
jbbottoms wants to merge 2 commits into
pingdotgg:mainfrom
jbbottoms:feat/render-reasoning-activities

Conversation

@jbbottoms

@jbbottoms jbbottoms commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Models that stream reasoning (Claude thinking blocks, Codex item/reasoning/*, OpenCode reasoning parts, local reasoning models behind OpenCode) currently show nothing in any T3 client — the thinking never renders on web, desktop, or mobile.

Why

Adapters already emit reasoning. Ingestion drops it in two places:

  • content.delta with streamKind: "reasoning_text" / "reasoning_summary_text" has no consumer — the assistant-delta path in ProviderRuntimeIngestion matches assistant_text exactly.
  • item.started/updated/completed with itemType: "reasoning" fail the isToolLifecycleItemType gate in all three activity projections, so they project to [].

(Separately, ClaudeAdapter emits thinking deltas without an itemId because content_block_start creates no block state for thinking blocks — this change works with or without that, by falling back to a per-turn buffer key.)

What

Server-only, one file plus tests. In ProviderRuntimeIngestion:

  • Buffer reasoning deltas per (thread, itemId ?? turn).
  • While streaming, emit a throttled tool.updated activity (summary/title "Thinking", status: "inProgress", detail = tail of the accumulated text). The first delta emits immediately so the row appears; afterwards re-emit only every 600 buffered chars, mirroring the O(N²) persistence guard the tool item.updated path documents.
  • Flush a tool.completed activity (summary/title "Thought", full text capped at 6000 chars) on item.completed for a reasoning item (richer of streamed text vs item detail wins, and the buffer is consumed so turn boundaries cannot double-flush), on turn.completed / turn.aborted / request.opened / user-input.requested, and on session.exited (which also drops the thread's buffers).

Reusing the existing tool activity shape (tone: "tool", toolCallId for collapse) means web, desktop, and mobile all render, collapse, and expand the rows with zero client changes — streaming updates collapse into one row via deriveToolLifecycleCollapseKey, exactly like streaming tool output.

Proof

  • 3 new tests in ProviderRuntimeIngestion.test.ts: streaming + turn-boundary flush; reasoning item.completed flush picking the richer text and not double-flushing at the turn boundary; no reasoning activities for plain assistant text. Full file: 52/52 pass. tsgo --noEmit: clean.
  • Live: OpenCode lane against a local Qwen3.8-27B — "Thinking" streams during the turn and a "Thought" row with the full reasoning body renders in the web timeline (screenshot in a comment below).

Notes for review

  • Reasoning summaries (reasoning_summary_text) share the item's buffer with full reasoning text; adapters that emit both for one item interleave. Codex GPT models emit only summaries, so in practice each item carries one kind.
  • Two provider-side limits observed while testing, out of scope here: Claude Code CLI redacts thinking text in its stream-json output ("thinking": "" with token estimates only), and Codex reasoning items can arrive with empty content/summary when summaries are not enabled. In both cases this change correctly renders nothing.
  • The 6000-char completed-detail cap and the "[reasoning truncated]" marker are deliberate: payload.detail persists to the projection table and the event store.

🤖 Generated with Claude Code


Note

Medium Risk
Adds in-memory buffering and new persisted activity payloads on a hot ingestion path; turn-scoping and caps limit wrong-flush and storage blow-up, but timeline volume and ordering still depend on provider event timing.

Overview
Provider runtime ingestion now turns streamed model reasoning into thread timeline activities so web, desktop, and mobile can show “Thinking” / “Thought” without client changes.

Reasoning from content.delta (reasoning_text / reasoning_summary_text) and item.completed with itemType: "reasoning" is buffered per thread and item (or per turn when itemId is missing). While streaming, ingestion emits throttled tool.updated rows (summary Thinking, inProgress, tail-truncated detail); on flush it emits tool.completed (Thought, full text capped at 6k with an optional truncation marker). Flushes happen on reasoning item completion (preferring richer item detail over streamed text), on turn boundaries (turn.completed / turn.aborted and approval/user-input pauses) scoped to that turn so stale completions cannot wipe active reasoning, and on session.exited (buffers cleared).

Reusing the existing tool activity shape and toolCallId keeps collapse behavior aligned with streaming tools. ProviderRuntimeIngestion.test.ts adds coverage for streaming + turn flush, item.completed richness and no double-flush, superseded-turn boundaries, and no noise from plain assistant text.

Reviewed by Cursor Bugbot for commit 26afe44. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Render model reasoning as 'Thinking' and 'Thought' timeline activities

  • Adds handleReasoningEvent to ProviderRuntimeIngestion.ts to buffer reasoning_text deltas and emit periodic tool.updated ('Thinking') activities.
  • Emits a final tool.completed ('Thought') activity on item.completed or turn/session boundaries, using the richer of buffered or item text.
  • Enforces size limits via REASONING_BUFFER_TEXT_CAP (60000) and REASONING_FINAL_DETAIL_LIMIT (6000), adding truncation markers when text is capped.
  • Risk: Introduces reasoningBuffersByThread state that retains up to 60000 characters per active reasoning stream until flushed.

Macroscope summarized 26afe44.

Reasoning arrives from adapters as content.delta events with streamKind
reasoning_text / reasoning_summary_text (and, for some adapters, as item
lifecycle events with itemType "reasoning"), but ingestion only consumed
assistant_text deltas and tool-lifecycle item types, so no client ever
rendered a thought.

Buffer reasoning deltas per (thread, item-or-turn), emit a throttled
tool.updated activity (title "Thinking") while streaming, and flush a
tool.completed activity (title "Thought") on reasoning item completion,
turn boundaries, and session exit. Reusing the existing tool activity
shape means web, desktop, and mobile all render and collapse the rows
with zero client changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c77e7583-4587-40d0-a034-ff34df54ae53

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 20, 2026
@jbbottoms

Copy link
Copy Markdown
Contributor Author

Live proof — patched server, OpenCode lane against a local Qwen3.8-27B. "Thinking" streamed during the turn; the completed "Thought" row below expands to the full reasoning body:

reasoning rendered in the web timeline

Comment thread apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Comment thread apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts Outdated
Comment thread apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
@macroscopeapp

macroscopeapp Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a new feature (reasoning activity rendering) with substantial new logic including buffer management and event handling. There is also an unresolved Medium severity finding about steered reasoning states remaining in progress. Both factors warrant human review.

You can add or adjust custom eligibility rules. Learn more.

…rs only after dispatch succeeds

Review findings from the PR bots, both real:

- A late turn.completed/turn.aborted (or request boundary) from a superseded
  turn flushed and wiped EVERY reasoning buffer on the thread, including the
  active turn's. Boundaries now flush only buffers attributed to the boundary
  turn (unattributed buffers still flush on any boundary; session.exited
  still flushes and drops everything).
- Buffer state advanced before dispatch: emittedLength moved before the
  streaming append and the buffer was deleted before the completion flush,
  so a failed dispatch silently lost the thought. Both now mutate only after
  the dispatch succeeds, so the logged-and-skipped failure path gets a
  natural retry at the next delta or boundary.

New regression test: a stale turn's boundary leaves the active turn's buffer
intact and its own flush id never appears. 53/53 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jbbottoms

Copy link
Copy Markdown
Contributor Author

Both findings were real — fixed in 26afe44:

  • Turn boundaries now flush only buffers attributed to the boundary's turn, so a late turn.completed from a superseded turn can no longer flush or wipe the active turn's reasoning (unattributed buffers still flush on any boundary; session.exited still flushes and drops everything).
  • Buffer state now mutates only after the activity dispatch succeeds: emittedLength advances after the streaming append, and the buffer is deleted after the completion flush, so the logged-and-skipped failure path gets a natural retry at the next delta or boundary instead of silently losing the thought.

Added a regression test for the superseded-turn case (stale boundary leaves the active buffer intact, active boundary flushes it). 53/53 pass, typecheck clean.

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 26afe44. Configure here.

continue;
}
yield* flushReasoningBuffer(event, threadId, buffer);
byKey.delete(key);

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.

Steered reasoning stays in progress

Medium Severity

Turn-boundary flush now skips every buffer whose turnId does not match the completing turn. OpenCode-style steers never emit turn.completed for the superseded turn, so its already-emitted Thinking row stays inProgress until session.exited instead of completing when the replacement turn settles.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 26afe44. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant