Skip to content

Commit c48c1a8

Browse files
kevin-dpclaudeKyleAMathews
authored
feat(agents-server-ui): stream model reasoning into the UI (#4508)
## Summary While the model is "thinking" (Anthropic extended thinking, DeepSeek-R1 reasoning, Moonshot K2, OpenAI Responses summaries) the agent response now shows the reasoning text faded above the answer, with the existing `Thinking` shimmer heading plus elapsed-time ticker. Once the reasoning settles it collapses to `▸ Thought for 12s` — click to expand. Multiple reasoning rows per run render independently in order (one per LLM step in tool-using turns). UX intentionally mirrors Claude Code + OpenCode patterns. ## Implementation (end-to-end) - **Schema** — `reasoning` row gains `run_id`, `encrypted` (Anthropic redacted-thinking opaque payload, must round-trip back to the model verbatim), and `summary_title` (extracted at write time). New `reasoningDeltas` collection mirrors `textDeltas`. Strictly additive. - **Bridge** — `OutboundBridge` gains `onReasoningStart` / `onReasoningDelta` / `onReasoningEnd`, parallel to the text path. Reasoning counter added to `OutboundIdSeed`. - **Adapter** — `pi-adapter.ts` routes pi-ai's `thinking_start` / `thinking_delta` / `thinking_end` events to the bridge. Parses a `**Title**\n\n<body>` heading once at write time (OpenAI Responses; no-op for Anthropic / DeepSeek / Moonshot). Defensive: handles late `thinking_delta` without a preceding `thinking_start`, and closes an open reasoning row on `message_end` (e.g. provider abort). - **Timeline** — Live `reasoning: Collection<EntityTimelineReasoningItem>` on `EntityTimelineRunRow`, content built via the same delta-join pattern as `EntityTimelineTextItem.content`. - **UI** — New `<ReasoningSection>` renders above items in `AgentResponseLive`: - **Live**: faded markdown via `Streamdown` with `ThinkingIndicator` heading + summary title + elapsed-time ticker - **Settled**: `▸ Thought for Ns` with click-to-expand. Closure duration snapshotted from `Date.now() - timestamp` using the same `sawStreamingRef` trick from the elapsed-time PR — accurate for in-session settles, stays a bare `Thought` for rows already settled on first mount (no real end timestamp available client-side). - **Redacted**: Anthropic safety-filter payloads render `⊘ Reasoning redacted by provider safety filters`. The encrypted payload is still persisted server-side so the model gets it back on the next turn. ## Reference Patterns informed by reading OpenCode's reasoning implementation: - 3-event streaming protocol (`reasoning-start` / `reasoning-delta` / `reasoning-end`) - `ReasoningPart` storage shape including `encrypted` for Anthropic round-trip - `reasoningSummary()` headline parser (5-line regex, OpenAI Responses only) - Collapsed-by-default UX with click-to-expand ## Test plan - [x] `pnpm typecheck` clean in `agents-runtime` + `agents-server-ui` - [x] `pnpm test outbound-bridge pi-adapter entity-timeline` in `agents-runtime` (95 passed: 18 bridge + 21 adapter + 56 timeline) - [x] `pnpm test` in `agents-server-ui` (66 passed) - [x] `pnpm -C packages/agents-runtime build` — dist artifacts emit cleanly - [ ] Manual: prompt Anthropic Claude with extended-thinking enabled; verify streaming reasoning appears faded above the answer with elapsed ticker, then collapses to `Thought for Ns` on settle - [ ] Manual: multi-step tool-using turn; verify each step's reasoning renders as a separate collapsible row ## Notes - Cached `AgentResponse` (the non-Live path used for old scrollback sections) doesn't yet surface reasoning — historical rows recorded before this PR lack the data anyway. Follow-up if we discover sessions where this matters. - The pre-existing `runtime-dsl.test.ts` 401 failures (and `dispatch-policy-routing.test.ts` 500 failures) reproduce identically on clean `main` and were not introduced by this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Kyle Mathews <mathews.kyle@gmail.com>
1 parent 0b26edf commit c48c1a8

14 files changed

Lines changed: 1277 additions & 50 deletions

.changeset/reasoning-content.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@electric-ax/agents-server-ui': minor
3+
'@electric-ax/agents-runtime': minor
4+
'@electric-ax/agents': patch
5+
'@electric-ax/agents-desktop': patch
6+
---
7+
8+
Stream model reasoning / extended-thinking content into the UI. While
9+
the model is "thinking" (Anthropic extended thinking, DeepSeek-R1
10+
reasoning, Moonshot K2, OpenAI Responses summaries) the agent response
11+
now shows the live reasoning text faded above the answer, with the
12+
existing `Thinking` shimmer heading and an elapsed-time ticker. Once
13+
the reasoning settles it collapses to `▸ Thought for 12s` — click to
14+
expand. Multiple reasoning rows per run are rendered independently in
15+
order, so tool-using turns show each step's reasoning separately.
16+
17+
Implementation:
18+
19+
- **Schema**`reasoning` row gains `run_id`, `encrypted` (Anthropic
20+
redacted-thinking opaque payload, must round-trip back to the model
21+
verbatim), and `summary_title` (extracted at write time for
22+
providers that emit a bolded heading). New `reasoningDeltas`
23+
collection mirrors `textDeltas` for streamed content.
24+
- **Bridge**`OutboundBridge` gains `onReasoningStart` /
25+
`onReasoningDelta` / `onReasoningEnd`, parallel to the text path.
26+
- **Adapter**`pi-adapter.ts` routes pi-ai's `thinking_start` /
27+
`thinking_delta` / `thinking_end` events to the bridge, parses the
28+
`**Title**\n\n<body>` heading (OpenAI Responses only) once at
29+
`thinking_end` so the UI doesn't re-parse on every render.
30+
- **Timeline**`EntityTimelineRunRow` gains a live
31+
`reasoning: Collection<EntityTimelineReasoningItem>` with content
32+
built from a delta-join, mirroring `EntityTimelineTextItem`.
33+
- **UI** — New `<ReasoningSection>` component renders above the
34+
answer in `AgentResponseLive`. Live shows faded markdown via
35+
`Streamdown` with `ThinkingIndicator` heading + summary title +
36+
elapsed-time ticker. Settled collapses to `Thought for Ns` with
37+
click-to-expand. Redacted Anthropic blocks render a single muted
38+
line — content is opaque, but the encrypted payload is still
39+
persisted server-side so the model gets it back next turn.
40+
41+
Providers without reasoning emit nothing → no reasoning section
42+
rendered. Historical responses recorded before this PR have no
43+
closure cue, same as today.
44+
45+
Anthropic extended thinking is now always-on for reasoning-capable
46+
models: `reasoningEffort: auto` maps to the minimal budget
47+
(1024 tokens), matching the OpenAI branch where `auto` already
48+
defaulted to `minimal`. Explicit `low`/`medium`/`high` scale the
49+
budget as before.

packages/agents-runtime/src/entity-schema.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,24 @@ type ToolCallValue = {
187187
}
188188
type ReasoningValue = {
189189
key?: string
190+
run_id?: string
190191
status: `streaming` | `completed`
192+
// Anthropic emits "redacted thinking" content blocks the client can't
193+
// display but MUST round-trip back to the model on the next turn or
194+
// the conversation errors. Persist verbatim, render nothing.
195+
encrypted?: string
196+
// OpenAI's Responses API surfaces reasoning with a bolded title line
197+
// (`**Inspecting PR workflow**\n\n<body>`). We split it out at write
198+
// time so the UI can drive a separate heading without re-parsing on
199+
// every render. Empty / absent for providers that don't emit titles
200+
// (Anthropic, DeepSeek-R1, Moonshot K2).
201+
summary_title?: string
202+
}
203+
type ReasoningDeltaValue = {
204+
key?: string
205+
reasoning_id: string
206+
run_id: string
207+
delta: string
191208
}
192209
type ErrorEventValue = {
193210
key?: string
@@ -530,7 +547,20 @@ function createReasoningSchema(): Schema<ReasoningValue> {
530547
return z.object({
531548
key: z.string().optional(),
532549
...timelineOrderField,
550+
run_id: z.string().optional(),
533551
status: z.enum([`streaming`, `completed`]),
552+
encrypted: z.string().optional(),
553+
summary_title: z.string().optional(),
554+
})
555+
}
556+
557+
function createReasoningDeltaSchema(): Schema<ReasoningDeltaValue> {
558+
return z.object({
559+
key: z.string().optional(),
560+
...timelineOrderField,
561+
reasoning_id: z.string(),
562+
run_id: z.string(),
563+
delta: z.string(),
534564
})
535565
}
536566

@@ -861,6 +891,7 @@ export type Text = SequencedPersistedRow<TextValue>
861891
export type TextDelta = SequencedPersistedRow<TextDeltaValue>
862892
export type ToolCall = SequencedPersistedRow<ToolCallValue>
863893
export type Reasoning = SequencedPersistedRow<ReasoningValue>
894+
export type ReasoningDelta = SequencedPersistedRow<ReasoningDeltaValue>
864895
export type ErrorEvent = SequencedPersistedRow<ErrorEventValue>
865896
export type MessageReceived = SequencedPersistedRow<MessageReceivedValue>
866897
export type WakeEntry = SequencedPersistedRow<WakeEntryValue>
@@ -951,6 +982,7 @@ export const ENTITY_COLLECTIONS = {
951982
textDeltas: `textDeltas`,
952983
toolCalls: `toolCalls`,
953984
reasoning: `reasoning`,
985+
reasoningDeltas: `reasoningDeltas`,
954986
errors: `errors`,
955987
inbox: `inbox`,
956988
wakes: `wakes`,
@@ -975,6 +1007,8 @@ export const BUILT_IN_EVENT_SCHEMAS = {
9751007
tool_call: createToolCallSchema() as unknown as BuiltInEntitySchema<ToolCall>,
9761008
reasoning:
9771009
createReasoningSchema() as unknown as BuiltInEntitySchema<Reasoning>,
1010+
reasoning_delta:
1011+
createReasoningDeltaSchema() as unknown as BuiltInEntitySchema<ReasoningDelta>,
9781012
error: createErrorEventSchema() as unknown as BuiltInEntitySchema<ErrorEvent>,
9791013
inbox:
9801014
createMessageReceivedSchema() as unknown as BuiltInEntitySchema<MessageReceived>,
@@ -1010,6 +1044,7 @@ type EntityCollectionsDefinition = {
10101044
textDeltas: CollectionDefinition<TextDelta>
10111045
toolCalls: CollectionDefinition<ToolCall>
10121046
reasoning: CollectionDefinition<Reasoning>
1047+
reasoningDeltas: CollectionDefinition<ReasoningDelta>
10131048
errors: CollectionDefinition<ErrorEvent>
10141049
inbox: CollectionDefinition<MessageReceived>
10151050
wakes: CollectionDefinition<WakeEntry>
@@ -1062,6 +1097,12 @@ export const builtInCollections: EntityCollectionsDefinition = {
10621097
type: `reasoning`,
10631098
primaryKey: `key`,
10641099
},
1100+
reasoningDeltas: {
1101+
schema:
1102+
BUILT_IN_EVENT_SCHEMAS.reasoning_delta as StandardSchemaV1<ReasoningDelta>,
1103+
type: `reasoning_delta`,
1104+
primaryKey: `key`,
1105+
},
10651106
errors: {
10661107
schema: BUILT_IN_EVENT_SCHEMAS.error as StandardSchemaV1<ErrorEvent>,
10671108
type: `error`,

packages/agents-runtime/src/entity-timeline.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,24 @@ export type EntityTimelineRunItem =
242242
toolCall: EntityTimelineToolCallItem
243243
}
244244

245+
export interface EntityTimelineReasoningItem {
246+
key: string
247+
run_id?: string
248+
order: TimelineOrder
249+
status: `streaming` | `completed`
250+
// The concatenated `reasoning_delta` content lives under
251+
// `body.content` rather than top-level — the wrapper is what
252+
// forces TanStack DB to materialize the include before the row
253+
// reaches `useLiveQuery`. See the timeline-query comment.
254+
body?: { content: string }
255+
// Optional bolded title parsed at write time — only OpenAI Responses
256+
// emits these; null for Anthropic / DeepSeek / Moonshot.
257+
summary_title?: string
258+
// Anthropic redacted-thinking opaque payload. Persist verbatim so we
259+
// can echo it back on the next turn; the UI shows a placeholder.
260+
encrypted?: string
261+
}
262+
245263
export interface EntityTimelineStepItem {
246264
key: string
247265
run_id?: string
@@ -267,6 +285,7 @@ export interface EntityTimelineRunRow {
267285
status: `started` | `completed` | `failed`
268286
finish_reason?: string
269287
items: Collection<EntityTimelineRunItem>
288+
reasoning: Collection<EntityTimelineReasoningItem>
270289
steps: Collection<EntityTimelineStepItem>
271290
errors: Collection<EntityTimelineErrorItem>
272291
// Per-run token totals summed across all `steps` of the run.
@@ -1385,6 +1404,13 @@ function buildEntityTimelineQuery(
13851404
run_id: error.run_id,
13861405
}))
13871406

1407+
// Union texts + tool calls into a single ordered stream. The
1408+
// text-delta join lives at this level (vs. inside the consumer's
1409+
// `items.select`) so the correlation key is `text.key` — a field
1410+
// on the raw text row — rather than a projected scalar. The only
1411+
// delta-join alias constraint is that it must NOT collide with
1412+
// the `chunk` alias used in the reasoning content sub-query
1413+
// below; that's why this one is `textChunk`.
13881414
const runItemsSource = q
13891415
.unionAll({
13901416
text: db.collections.texts,
@@ -1402,11 +1428,13 @@ function buildEntityTimelineQuery(
14021428
textContent: concat(
14031429
toArray(
14041430
q
1405-
.from({ chunk: db.collections.textDeltas })
1406-
.where(({ chunk }) => eq(chunk.text_id, text.key))
1407-
.orderBy(({ chunk }) => coalesce(chunk._timeline_order, `~`))
1408-
.orderBy(({ chunk }) => chunk.key)
1409-
.select(({ chunk }) => chunk.delta)
1431+
.from({ textChunk: db.collections.textDeltas })
1432+
.where(({ textChunk }) => eq(textChunk.text_id, text.key))
1433+
.orderBy(({ textChunk }) =>
1434+
coalesce(textChunk._timeline_order, `~`)
1435+
)
1436+
.orderBy(({ textChunk }) => textChunk.key)
1437+
.select(({ textChunk }) => textChunk.delta)
14101438
)
14111439
),
14121440
toolCall: caseWhen(toolCall.key, {
@@ -1422,6 +1450,43 @@ function buildEntityTimelineQuery(
14221450
}),
14231451
}))
14241452

1453+
// Mirror `runItemsSource`'s shape for reasoning rows: the
1454+
// `concat(toArray(...))` include is *defined* on this top-level
1455+
// source, then the `reasoning:` consumer inside `runSource.select`
1456+
// below dereferences it into `content: r.reasoningContent`. The
1457+
// two-layer source/consumer split is load-bearing: `useLiveQuery`
1458+
// reads of a sub-collection that has an include co-defined in the
1459+
// same select return the row with `content: null` + a deferred
1460+
// `Symbol(includesRouting)` marker. Naming the include field in a
1461+
// downstream `.select` is what forces materialization — exactly
1462+
// how `items.text.content` pulls `item.textContent` out of
1463+
// `runItemsSource`. Alias is `reasoningChunk` to avoid colliding
1464+
// with `textChunk` used above.
1465+
const runReasoningSource = q
1466+
.from({ reasoning: db.collections.reasoning })
1467+
.select(({ reasoning }) => ({
1468+
key: reasoning.key,
1469+
run_id: reasoning.run_id,
1470+
order: coalesce(reasoning._timeline_order, `~`),
1471+
status: reasoning.status,
1472+
summary_title: reasoning.summary_title,
1473+
encrypted: reasoning.encrypted,
1474+
reasoningContent: concat(
1475+
toArray(
1476+
q
1477+
.from({ reasoningChunk: db.collections.reasoningDeltas })
1478+
.where(({ reasoningChunk }) =>
1479+
eq(reasoningChunk.reasoning_id, reasoning.key)
1480+
)
1481+
.orderBy(({ reasoningChunk }) =>
1482+
coalesce(reasoningChunk._timeline_order, `~`)
1483+
)
1484+
.orderBy(({ reasoningChunk }) => reasoningChunk.key)
1485+
.select(({ reasoningChunk }) => reasoningChunk.delta)
1486+
)
1487+
),
1488+
}))
1489+
14251490
const runTokensSource = q
14261491
.from({ step: db.collections.steps })
14271492
.groupBy(({ step }) => step.run_id)
@@ -1484,6 +1549,28 @@ function buildEntityTimelineQuery(
14841549
}),
14851550
toolCall: item.toolCall,
14861551
})),
1552+
reasoning: q
1553+
.from({ r: runReasoningSource })
1554+
.where(({ r }) => eq(r.run_id, run.key))
1555+
.orderBy(({ r }) => r.order)
1556+
.orderBy(({ r }) => r.key)
1557+
.select(({ r }) => ({
1558+
key: r.key,
1559+
run_id: r.run_id,
1560+
order: r.order,
1561+
status: r.status,
1562+
// Wrap the include reference inside a `caseWhen` object body
1563+
// — the same construct items uses to materialize
1564+
// `item.textContent` into `text.content`. Bare top-level
1565+
// references leave the include deferred until UI reads it
1566+
// through `useLiveQuery`, which never gets through. UI reads
1567+
// `entry.body?.content` instead of `entry.content`.
1568+
body: caseWhen(r.key, {
1569+
content: r.reasoningContent,
1570+
}),
1571+
summary_title: r.summary_title,
1572+
encrypted: r.encrypted,
1573+
})),
14871574
steps: q
14881575
.from({ step: db.collections.steps })
14891576
.where(({ step }) => eq(step.run_id, run.key))

0 commit comments

Comments
 (0)