ADR 0242: Delta-only coalesced streaming updates
- Status: Accepted
- Date: 2026-09-14
- Decision: D412
- Amends: ADR 0127, ADR 0130, ADR 0149, ADR 0153
- Issue: #299
Context
Long Agent turns streamed at 200+ tokens/s from the provider, but PI-Desktop displayed them at a much lower rate that got worse the longer the turn ran. Stop plus a new prompt restored speed; the same turn's later short thinking blocks stayed slow. Subagents showed the same lag. TTFT was fine.
Each message_update flattened the full assistant snapshot, compared it with startsWith against the accumulated string, JSON.stringify'd the whole UiMessage onto sidecar stdout, and AgentHost boundPayload() stringified it again to check frame size. Renderer batching only ran after that work. A length-n stream therefore cost about O(n²) bytes and CPU on the hot path and backlogged the pipeline.
The renderer already split history from the active tail, but the active AssistantTurn rebuilt every activity part on each token and handed new delegation Maps to every ActivityGroup, so historical groups in the same turn re-rendered with the tail.
RACP already documented item.delta as non-durable delta text, with the full row completed in item.completed. The local event still carried a full snapshot on every update.
Decision
Keep currentAssistant as the in-memory authority in the runtime. On the append-only streaming path, emit message_update with:
stream: "delta"deltaText/deltaThinkingresetText/resetThinkingwhen the provider replaced rather than appended- a slim
messageidentity (id, role, status, createdAt, model/provider, parentToolCallId, agentName) without growingcontent/thinking
A 16ms coalescer in DesktopAgentRuntime concatenates pending deltas and flushes immediately before tool_start, tool_end, message_end, error, abort, retry snapshots, and other semantic events so order is preserved.
message_start and message_end still carry a full UiMessage. Retry and other snapshot replacements omit stream and still replace the live row.
First-party consumers apply deltas:
- AgentHost patches
activeItemsand skipsJSON.stringifyfor small delta frames - Electron main reconstructs the inflight checkpoint snapshot from deltas
- the renderer concatenates same-frame deltas, then patches the live
UiMessage
The transcript projection reuses unchanged activity parts and keeps delegation status/timing Maps stable when their contents did not change. Only the live tail ActivityGroup receives runtimeActivity.
Protocol version remains 11. The new fields are additive. Consumers that ignore stream and replace with message still receive message_end as the authoritative row.
Consequences
- Cross-process streaming bytes grow with the new chunk, not with the cumulative snapshot.
- A long turn no longer forms a serialization backlog that later short chunks wait behind.
- Historical activity groups in the active turn do not re-render for each tail token.
- Crash-recovery checkpoints still see a reconstructed full snapshot.
- Remote RACP
item.deltapayloads match the documented delta contract.
Alternatives
- Coalesce only in the renderer: rejected; the O(n²) stringify already happened in the sidecar and main.
- Drop tokens or raise the debounce to 50ms: rejected; that hides backlog rather than removing it.
- New
message_deltaevent type: unnecessary; additive fields on the existingmessage_updatekeep old consumers compiling.