packages/agent/docs/mobile-handoff/01-harness/05-assistant-output/message-update.md
message_update Write AmplificationScope: harness-local. Depends on delta tracking for the landed Chord
Op/WireOpvocabulary and scoped storage for durability. Chord delta tracking has landed; scoped storage and this Harness integration have not.
{ type: "message_update", runId, message, event, frame? }
Three representations of the same thing travel together:
message — the full AssistantMessage;event — an AssistantMessageEvent, which itself carries partial: AssistantMessage,
a second full copy;frame — the actual delta, optional.Per streamed token this is roughly two complete snapshots plus a delta, so bytes grow quadratically over a response.
The reducer does not even use the delta:
case "message_update":
if (next.operation?.id === event.runId && event.message.role === "assistant") {
next.operation.streamingMessage = event.message;
}
A straight assignment. So on any wire, sending the event is worse than sending a
snapshot — it ships two where replace ships one.
The wire adapter is already halfway to the fix: it drops event and sends message
plus frame. That is the full snapshot together with the delta that produced it.
/**
* Compact, replayable assistant-message progress. Terminal settlement is
* intentionally excluded and must be persisted separately.
*/
export type AssistantMessageFrame =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start" | "text_delta" | "text_end"; contentIndex: number; ... }
| { type: "thinking_start" | "thinking_delta" | "thinking_end"; ... }
| { type: "toolcall_start" | "toolcall_checkpoint" | "toolcall_delta" | "toolcall_end"; ... };
AssistantMessageFrameEncoder produces it, reduceAssistantMessageFrames folds it,
and openFrameProgress already persists it with appendList. The delta format is
built, used, and durable. It simply is not what message_update carries.
pi already does wire-is-delta one layer down. PiMessagesEvent — the serialized form
a pi-messages backend sends — has no partial:
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "text_end"; contentIndex: number; content: string; contentSignature?: string }
pi-messages.ts then rehydrates: it holds a local partial, mutates it per event
(partial.content[i].text += event.delta), and returns the in-process
AssistantMessageEvent with partial attached.
So the convention is established. It just stops at the provider edge instead of continuing through the harness.
| { type: "message_update"; runId: string; entryId: string; frame: AssistantMessageFrame }
message and event are removed; frame stops being optional.
The blast radius is small because HarnessEvent is not what most streaming consumers
read. AgentEvent (agent-loop.ts) and AgentSessionEvent (agent-session.ts) are
separate unions that happen to share the tag name and build their own
message_update from the pi-ai event directly. They are out of scope here.
Real consumers and producers of HarnessEvent.message_update:
| site | change |
|---|---|
runtime/drive/response.ts | emit the required semantic frame; stop attaching full snapshots |
runtime/reducer.ts | fold the frame instead of assigning event.message |
| lane/facet state adapter | run that fold under the Chord tracker and emit Op[]/encoded WireOp[] |
experimental/harness-wire-adapter.ts | stop treating raw HarnessEvent as the final replication format |
harness/telemetry.ts | name list only |
protocol/harness.ts | replication carries encoded WireOp[], not the raw event |
message_end continues to carry the settled message, because frames deliberately
exclude terminal settlement. That is once per message, not once per token.
reduceAssistantMessageFrames is a whole-stream fold over an Iterable. The
reducer needs a step function:
export function applyAssistantMessageFrame(
state: AssistantFrameState,
frame: AssistantMessageFrame,
): void;
Plain mutation on a plain object. No Draft, no Immer. An earlier draft
argued for a draft-mutating signature so it would compose inside a produce
recipe; that motivation is gone (delta.md §8). The step function is still
needed — the whole-stream version becomes a loop over it — just for the simpler
reason that the reducer folds one frame at a time.
AssistantMessageFrame is pi-ai's semantic delta vocabulary (text_delta, text_end, …). Delta tracking §6 defines no second frame wrapper: in-process replication carries Op[], and a wire adapter carries encoded WireOp[]. Pi-ai frames stop at the fold; Chord ops cross the replication boundary.
AssistantMessageFrame is pi-ai's own delta vocabulary and stays. What changes is
that it is no longer the durable unit or the replication unit.
The harness folds frames into LaneView by plain mutation. Under the Chord tracker that yields:
["a",["operation","streamingMessage","content",0,"text"],"Let me "]
Measured, interned ops are smaller than frames on this workload — 13.6 KB against 21.5 KB raw for 200 deltas — because a frame carries three keys of its own where an interned op carries one integer. So the size argument for putting frames on the wire is dead.
What frames keep is semantic: text_end carries authoritative content plus a
signature in one atomic unit, where ops would need two with a weaker contract
about their relationship. That is why they remain the input vocabulary and are
folded before anything crosses a boundary.
Text and thinking fold purely: block.text += frame.delta, and *_end overwrites
with authoritative content plus signature. The ended flag in ReducerBlockState
is used only for validation and can be dropped.
Tool calls cannot. toolcall_delta does state.json += frame.delta, accumulating
a raw JSON string that is never stored on the message — the block holds
arguments, the parsed value. You cannot recover the accumulator from the
snapshot, and you cannot append a delta to a parsed object.
So the accumulator has to become part of the value being folded — e.g.
operation.frameState[contentIndex].json on LaneView — leaving
AssistantMessage clean. Generally:
A replicated reducer's state must be part of the replicated value. Anything held beside it diverges on any consumer that did not run the producer's fold.
The corollary is that arguments itself should not be replicated. It is
derived from json, and a parsed object is a fresh reference on every parse, so
storing both ships two copies of the same information. Derive it on demand.
This is safe precisely because parseStreamingJson is total — four fallbacks
ending in {}, it cannot throw — so a replica derives unconditionally with no
error path and no agreement protocol. There is no parse-failure state to
represent, and no block needs an error slot.
parseStreamingJson on a growing string once per delta is quadratic per message.
Since arguments is now derived rather than replicated, this cost falls on whoever reads it rather than on every consumer. Presentation can refresh derived arguments at semantic checkpoints and toolcall_end instead of parsing on every delta; that policy is separate from why the encoder currently emits a checkpoint (§6).
toolcall_checkpoint is forEncoderBlockState carries caughtUp and catchupJson because a queued provider event's shared partial may already be ahead of that event's delta. The checkpoint catches the semantic frame stream up to the authoritative tool-call arguments visible at block start; it is not currently a general late-subscriber protocol.
After frames fold into tracked state, a Chord root replacement (r) is the replication and durable-recovery resync point. toolcall_checkpoint remains semantic input to that fold rather than carrying transport responsibility.
openFrameProgress calls appendList(pendingAssistantFrames(...)), so every
frame is a line. A long response is thousands of writes.
The address is renamed pendingAssistantOutput and becomes a list<WireOp[]>, matching pendingToolOutput (tool-output handoff §7.2), and it stops being a list of frames. The progress sink encodes tracked Op[] with one stateful encoder per response before appending each durable WireOp[] batch; explicit rebase() calls produce bounded root-replacement batches for recovery. The list lives in an ephemeral scope so it is unlinked on settle rather than persisting in the main log (scoped storage).
The important property is that this list is not history: deleteList runs on
settle in response.ts, deferred.ts, and terminal.ts. It exists so a crash
mid-response can recover a partial assistant message. The settled message is persisted
separately.
That means per-frame durability buys almost nothing, and the write rate can be traded away directly:
text_delta frames for one contentIndex
collapses to a single frame with the joined text. The fold result is identical, so
this is lossless for our purposes.A crash then loses at most one window of in-flight streaming.
DSH cannot make this trade. Its assistant/chunk events are canonical log entries, so
per-token durability is mandatory and it attacks size instead:
text-chunks / reasoning-chunks / tool-call-chunks, lossless and roughly 60%
smaller on a real session, with reading unconditional so layout never depends on the
write switch;Their packing must reconstruct exact event boundaries, sequence numbers and timestamps,
because seq = log.length and validation requires a contiguous logical log. Ours does
not, because the frames are discarded at settle — so plain concatenation is available
to us and packing machinery is not needed.
HarnessEvent.message_update stops being self-describing. A listener that attaches
mid-stream sees a delta against a partial it does not hold.
Any correct listener already has the base, because watch() installs the subscription
and captures the snapshot inside one readLane critical section, buffering until
start(). But a listener that calls on("message_update") directly, without a watch,
is no longer viable — worth knowing before committing.