ADR-0031: An assistant turn is an ordered transcript of parts, not a flattened bubble
- Status: Proposed
- Date: 2026-06-03
- Deciders: @karasu
- Supersedes: —
- Superseded by: —
Context
ADR-0016 established the streaming-first session UI: live tokens, thinking visibility, deferred persistence. It got the transport right — the harness emits an ordered HarnessOutputEvent stream (message.start → interleaved message.delta {kind} / message.tool_call / message.tool_result → message.end) and the daemon relays it on the output channel with a monotonic per-channel seq. It did not specify how that ordered stream is assembled into a turn for display, and the implementation that grew under it buckets the stream by kind. The result is the bug this ADR fixes.
What the operator sees today
A single primary turn renders as three stacked sections, in this fixed order regardless of what actually happened:
- one collapsible thinking blob at the top (all reasoning across the whole turn, concatenated),
- the text as one Markdown blob,
- a flat list of tool cards at the bottom.
A real discovery loop does not happen in that order. A trace of the first working loop (primary, kimi-k2p6-turbo, 11 model steps, ~70s) makes this concrete. The persisted content field reads:
"Let me read the key files to understand the DSL schema and API types.Let me read the existing task screen and the router to understand the current task UI surface.Now I have a complete picture. Let me create the implementation task based on this discovery.Done. I've created #11…"
Those are four separate text segments emitted in four different model steps, with tool batches (search_glob ×4, file_read ×5, search_grep ×3, …, kaged_issue_create) firing between them. Flattened, they read as glued-together nonsense ("API types.Let me read") followed by a wall of ~30 tool cards divorced from the prose that motivated each one. The narration "Let me read the key files… Done, I've created #11" only makes sense with the reads sitting inside it. As a paragraph with the tools swept to the bottom, it is, in the operator's words, stupid.
This directly violates the manifesto's operator-visibility commitment. kaged exists so the operator can see what the agent did. A turn is a sequence of actions and the reasoning around them; collapsing it into a press-release paragraph plus an appendix of tool calls hides the actual causal flow — exactly the opacity kaged was built against. Compare opencode, which renders the turn as a literal timeline: think → text → tool → think → text → tool → … in the order it occurred.
The load-bearing fact: this is not a storage problem
The ordered timeline already exists in storage, intact, today. persistAssistantMessage (packages/daemon/src/runtime/primary-runner.ts) writes metadata.contentBlocks = assistant.content — the full (TextContent | ThinkingContent | ToolCall)[] array in chronological order, spanning all loop steps (this is why text from step 1 and step 4 sit adjacent in the flattened field — they were adjacent in contentBlocks, separated by the tool blocks the flattener dropped). metadata.toolResults holds each result keyed by tool-call id. No schema migration is required. No new columns. The data is right; the assembly is wrong.
The flattening happens in exactly two places, both downstream of storage:
reconstructMessageParts(session-handlers.ts) — builds the RESTparts[]array. It already interleavestext+tool_call+tool_resultin order (tests confirm:["text","tool_call","tool_result","text"]). But it hoists thinking out of the timeline into a separate concatenatedthinkingstring (thinking += block.thinking), discarding its position.content(the flattened text) is carried alongside as a peer field.MessageBubble/StreamingBubble(session-messages.tsx,session-pane.tsx) — renderenrichment.thinking(blob) →<Markdown>{message.content}</Markdown>(blob) →message.parts.map()(tool cards only; text parts are explicitlyreturn null-ed, so the ordered text inpartsis thrown away in favour of the flattenedcontent). The liveStreamingStatemirrors this: separatetext,thinking, andtoolCalls[]accumulators, so even mid-stream the interleaving is destroyed.
So the "bubble" is a lossy view over a faithful record. The fix is to make the view faithful too — and it is mostly deletion of the bucketing logic.
Decision
A model turn is rendered as an ordered transcript of typed parts —
thinking,text, andtool_call+tool_result— in the exact sequence the model produced them, both live and persisted.parts[](carryingthinkingas a first-class ordered part) becomes the single source of truth for rendering a turn. The flattenedcontentstring is demoted to a derived field used only for LLM history reconstruction, search, and fallback rendering of legacy messages that predateparts.
The "bubble" stops being one fluent reply with a thinking header and a tool appendix. It becomes a transcript.
Specifics
thinkingbecomes an orderedMessagePart. Extend theMessagePartunion (in bothpackages/daemon/src/runtime/session-handlers.tsandpackages/ui/src/lib/api-types.ts) with{ type: "thinking"; thinking: string }.reconstructMessagePartspushes thinking blocks intopartsin position instead of concatenating into a side string. The function's separatethinkingreturn value is retained transitionally for back-compat hydration but is no longer the rendering input.parts[]is the render contract.MessageBubblerendersmessage.partsin order:thinking→ collapsible<details>(tier-5 reasoning surface,kaged-prose-thinking, opacity 0.8, no motion);text→<Markdown>;tool_call→ToolCallCardpaired with itstool_result; an orphantool_result(no matching call in the same turn) renders standalone. The top-levelenrichment.thinkingblob and the standalone<Markdown>{message.content}</Markdown>are removed from the model-turn render path.contentis demoted, not deleted. It remains the flattened concatenation of text parts. It is the input toreconstructMessages/reconstructCompactableMessages(LLM history — unaffected, already correct), to search, and to the fallback render branch: whenpartsis absent (legacy messages persisted before this ADR),MessageBubblefalls back to the currentcontent+ hoisted-thinkingrendering. No migration; old turns degrade gracefully to the old look.Live streaming interleaves. Replace
StreamingState's{ text, thinking, toolCalls[] }buckets with a single orderedparts: StreamingPart[]accumulator. Theoutput-channel frames already arrive in stream order with monotonicseq;message.deltaalready carrieskind: "text" | "thinking". The accumulator appends to the current open part when the incoming kind matches, and opens a new part when the kind changes or atool_call/tool_resultarrives. No new transport field is needed — arrival order is the order.StreamingBubblerenders the same transcript component asMessageBubble, so live and persisted views are identical by construction (the streaming view simply has a trailing open part and[running]tool states).Per-step thinking is preserved, not merged. The persisted turn is the ordered concatenation of every loop step's content blocks. Implementation must confirm the harness retains each step's
thinkingblock as a distinct, positioned entry inassistant.content(rather than dropping or merging reasoning across steps); if it currently merges, that is fixed here so each reasoning burst sits with the actions it preceded.No new badge vocabulary; align the tool card to the formal set. Per the brand guide, tool-call state uses the bracket-lock badges already standardised:
[RUNNING](amber),[OK](cyan),[FAILED](magenta).ToolCallCard's current ad-hoc lowercaserunning/done/erroris brought onto theStatusBadgevocabulary. Code surfaces stay tier-5 sacred: zero animation on the transcript body, syntax-highlighted JSON in the brand palette only.
Consequences
What this commits us to
parts[]is the canonical render shape for a turn. Any new content kind (e.g. images, citations) is added to theMessagePartunion and gets a position in the transcript, not a new stacked section.- Live and persisted renders share one transcript component. They cannot drift, because there is one renderer.
- The
contentfield is permanently a derived projection ofparts, never the inverse. Writers populatecontentBlocks;contentis computed from it.
What this forecloses
- The "executive summary on top, receipts on the bottom" layout. If a future need arises to also show a turn-level summary, it is an explicit additional element, not a replacement for the transcript.
What becomes easier
- Reading a discovery loop. The reasoning sits with the reads it motivated; the issue-create sits under the prose that announces it.
- Subagent turns, replays, and (later) guest read-only session views — all render through the same faithful transcript.
- Debugging agent behaviour from the UI alone, without dropping into a Langfuse trace to recover ordering.
What becomes harder
- Nothing structurally. The change is net-negative LOC in the render path (bucketing logic deleted). The one cost is a transitional fallback branch for legacy
parts-less messages, retired whenever those age out.
Alternatives considered
Alternative A — Persist one message per loop step
Stop aggregating the 11 steps into one primary record; write a separate message per step so ordering falls out of created_at. Rejected: it fragments the turn at the storage layer for a presentation problem the storage layer doesn't have. contentBlocks already preserves cross-step order in one record; splitting would complicate compaction (reconstructCompactableMessages couples tool pairs per record), checkpointing, and the run↔message relationship for zero gain.
Alternative B — Add an explicit contentIndex/timestamp to every part
Carry a positional index or per-part timestamp through the transport and persist it. Rejected as over-engineering: contentBlocks is already an array (order is intrinsic), and the WS stream already arrives in order with monotonic seq. Position is free; a parallel ordering field is redundant state that can disagree with the array it describes.
Alternative C — Render-only fix, leave thinking hoisted
Fix MessageBubble to interleave text and tools from parts, but keep thinking as the top blob (it's "just reasoning"). Rejected: the whole point is the timeline. A thinking burst that precedes a specific tool batch is evidence of why that batch ran; floating it to the top severs that. Thinking is a positioned part like any other.
Amendment checklist
Doc-first, then TDD. Accept this ADR, then execute in one chain:
Specs
-
docs/specs/http-api.md—outputchannel table: documentmessage.delta.kind,message.tool_result.{tool_call_id,is_error}(the table currently omits both, though the implementation inws-registry.tsalready sends them). RESTGET …/messages: document theparts[]shape including the newthinkingpart, and state thatcontentis a derived/compat field, not the render source. -
docs/specs/agent.md— note that turn rendering consumespartsorder positionally;HarnessOutputEventordering is the contract (no new field). Confirm per-stepthinkingretention requirement. - UI session-render spec (the message-stream surface under
docs/specs/ui/) — replace the "thinking header / content / tool list" description with the ordered-transcript model; specify the legacy fallback branch; specify shared component for live + persisted. -
docs/brand/brand-guide.md— no change needed; cite the existing badge vocabulary ([RUNNING]/[OK]/[FAILED]) for the tool-card alignment.
Code
-
packages/ui/src/lib/api-types.ts— add{ type: "thinking"; thinking: string }toMessagePart. -
packages/daemon/src/runtime/session-handlers.ts— add thethinkingvariant to the daemonMessagePartunion;reconstructMessagePartspushes thinking in order;mapMessageItemkeeps emittingcontent(compat) and may drop the separatethinkingfield once the UI no longer reads it. -
packages/ui/src/components/screens/session-messages.tsx—MessageBubblerenderspartsas an ordered transcript; remove the standalonecontentblob andenrichment.thinkingheader from theparts-present path; keep the fallback forparts-absent messages. Extract a sharedTurnTranscriptcomponent. AlignToolCallCardstate badges toStatusBadge. -
packages/ui/src/components/screens/session-pane.tsx—StreamingState.parts: StreamingPart[]replacing thetext/thinking/toolCallsbuckets;handleOutputappends/opens parts by arrival;StreamingBubblerendersTurnTranscript. -
packages/harness/— verify (and fix if needed) that per-stepthinkingblocks survive intoassistant.contentin order.
Tests
-
message-parts-reconstruction.test.ts— assertthinkingappears as a positioned part, e.g.["thinking","text","tool_call","tool_result","thinking","text"]; assert multi-step interleaving is preserved. - UI component test —
TurnTranscriptrenders parts in array order;parts-absent message uses the legacy fallback. - UI streaming test — interleaved
delta(text)/delta(thinking)/tool_call/tool_resultframes produce an ordered transcript matching final persisted order.
References
- ADR-0016 — streaming-first UI (this refines its render model; transport unchanged)
- ADR-0012 — Mastra substrate; per-turn aggregation lives in the harness
packages/daemon/src/runtime/primary-runner.ts—persistAssistantMessage(contentBlocks+toolResultsare the faithful record)packages/daemon/src/runtime/session-handlers.ts—reconstructMessageParts,MessagePartpackages/ui/src/components/screens/session-messages.tsx,session-pane.tsx— the two flattening render pathspackages/daemon/src/runtime/ws-registry.ts—harnessEventToPayload(frames already carrykindand ordering viaseq)docs/specs/agent.md—HarnessOutputEventshapedocs/brand/brand-guide.md— tier-5 sacred code surfaces, motion ladder, bracket-lock badge vocabulary- Trace
bdc19ad5…— the first working loop; the concrete evidence