Conversation tree
Internal mechanics of the conversation tree: the input-and-run two-node turn model, message-anchored sibling resolution, and how the View walks the tree to produce a flat message list.
AI Transport models a conversation as a tree of two node kinds: an InputNode for every user prompt and a RunNode for every agent reply. Each turn is two nodes joined by a parent edge: the input node hangs off the previous reply, and the reply run hangs off its input. Each node holds a per-node codec projection folded from the events published under its key; the View walks the chosen branch and extracts a flat CodecMessage<TMessage>[] from those projections. Each entry pairs the domain message with the codec-message-id the SDK keys on.
A conversation with one edit and one regenerate looks like:
(root) └── I1 (user M1: "Plan a trip to Lisbon") └── R1 (agent reply, runId R1, assistant M2: "Here's a 3-day itinerary...") ├── R1' (regenerate sibling of R1, runId R1', regeneratesCodecMessageId M2, │ assistant M2': "Here's an alternative...") ├── I2 (user M3: "Make it 5 days") │ └── R2 (runId R2, assistant M4: "5-day itinerary...") └── I2' (edit sibling of I2, user M3': "Focus on food", forkOf M3) └── R3 (runId R3, assistant M4': "Food-focused itinerary...")CopyCopied!
Two kinds of sibling groups surface:
Input edits are sibling input nodes. I2 and I2' share the same parent (R1's codecMessageId M2), and I2'.forkOf = M3 links it to the original prompt.
Regenerate siblings are sibling reply runs. R1 and R1' share the same parent (I1's codecMessageId M1), and R1'.regeneratesCodecMessageId = M2 records the slot it replaces. Reply-run siblings group by shared parent; they carry no Run-level forkOf.
Branch selection is message-anchored: the branch decision lives on a codec-message-id, not on a runId. The View exposes branchSelection(codecMessageId) and selectSibling(codecMessageId, index) so a UI renders navigation arrows next to a bubble without knowing whether it is an input or a run.
Why two node kinds
A run-only tree could not represent an edit cleanly: editing a user prompt is not a different agent run, it is a different user prompt that the agent then replies to. Splitting input and reply into separate nodes lets the tree carry both kinds of branching:
- An edit produces a sibling input node at the parent reply (or root for the first prompt). The new input gets its own subtree of replies underneath.
- A regenerate produces a sibling reply run at the same input. Both replies attach to the same prompt; the View picks which one to render.
The same parent-edge mechanic handles both. The tree never asks "is this branch an edit or a regenerate?"; it asks "what siblings share this parent?", and the kind of the node tells you what shape of UI to draw.
InputNode and RunNode
Every node in the tree is a ConversationNode = InputNode | RunNode. Narrow on kind ('input' or 'run') before reading kind-specific fields.
InputNode<TProjection> field | Description |
|---|---|
kind | 'input'. |
codecMessageId | Primary key. The client-owned id minted when the input is published. |
parentCodecMessageId | The codec-message-id of the immediately preceding reply run on this chain, or undefined for the first input in a conversation. |
forkOf | The codec-message-id this input forks from when it is an edit, or undefined for the first version. Sibling input nodes share the same forkOf anchor. |
projection | The codec's per-input projection, folded from every input event under this codec-message-id. |
serial | Ably serial of the first observed message for this input. Absent for optimistic (locally-created) inputs. |
RunNode<TProjection> field | Description |
|---|---|
kind | 'run'. |
runId | Primary key. Agent-minted. |
parentCodecMessageId | The codec-message-id of the input node this Run replies to (the user prompt the agent answered). undefined for the root Run. |
forkOf | The node key of the node this Run replaces, or undefined if this Run is not a fork. Reply-run regenerate siblings do not use this; they group by shared parent. |
regeneratesCodecMessageId | The codec-message-id of the assistant message this Run regenerates. Verbatim from the wire's msg-regenerate. |
clientId | The Ably clientId of the client that started the Run, from run-client-id. |
state | A RunNodeState discriminated on status: { status } is 'active' between ai-run-start and ai-run-end, 'suspended' after ai-run-suspend until ai-run-resume, otherwise a non-error terminal RunEndReason; the { status: 'error', error } arm carries the terminal Ably.ErrorInfo. |
projection | The codec's per-Run projection, folded from every event published under this run-id. |
invocationId | The agent-minted invocation id observed from the most recent ai-run-start (or ai-run-resume for continuations). Empty string until run-start arrives. |
startSerial / endSerial | Ably serials of the lifecycle events. Used for sibling ordering. |
Run identity is resolved on the channel
The Tree adopts run identity from the wire, not from the HTTP POST body. A fresh run begins when the client publishes an input event with no run-id; the agent's createRun mints a run-id, stamps it on ai-run-start and every output event, and the Tree creates a RunNode from the run-start. A continuation begins when the input event carries an existing run-id (the client knew it from ActiveRun.runId and stamped it on the publish); the agent reads that id from the input event's wire headers, reuses it instead of minting fresh, and publishes ai-run-resume so the Tree routes the resume to the existing RunNode.
There is no run-continue header: the presence or absence of run-id on the input event is the entire signal.
Coordination via Run lifecycle events
The Tree does not arbitrate winners. Coordination is explicit:
- Lifecycle is paired: every Run begins with
ai-run-startand ends withai-run-end. The View filters active Runs bystatus === 'active'; without anai-run-endthe Run stays active. - Suspend and resume are distinct events:
ai-run-suspendparks a Run at'suspended'(still live);ai-run-resumere-activates it. Both are non-terminal; onlyai-run-endis terminal. - Sibling order is by serial: when more than one node lands at the same parent, they are ordered by serial (oldest first). The default selection at a fork point is the newest sibling.
Earlier versions of the SDK described a "winner / precedence" model on the Tree. That model is gone. The current design relies on Run.start() to publish the bracketing events explicitly and on ai-run-resume to mark continuations; no implicit precedence is needed.
Sibling resolution
tree.getSiblingNodes(key) returns the sibling group the node keyed by key belongs to. The key is either a RunNode.runId or an InputNode.codecMessageId. The two sibling-group shapes are: input edits, where input nodes share a parent and chain via forkOf (the original user prompt plus every edit of it); and regenerate siblings, where reply runs share an input-node parent (the original reply plus every regenerate of it).
The result is ordered oldest-first by serial. It is a single-element array when the node has no siblings, and an empty array when the key is unknown.
The View consumes this to compute branchSelection(codecMessageId), which returns a BranchSelection<TMessage> bundle (hasSiblings, siblings, index, selected). The View's per-instance selections map records the selected sibling at each anchor; selectSibling(codecMessageId, index) updates it and emits an 'update' event.
Walk the tree
The View walks the tree to produce a flat ordered list of nodes along the currently selected branch:
- Start at the root. The root is either the first input node (a conversation that opens with a user prompt) or, in rare cases, a Run with no
parentCodecMessageId. - Add the current node to the visible chain.
- If the current node has siblings, select the one indicated by the View's
selectionsmap (or default to the newest). - Move to the selected node's children: nodes whose
parentCodecMessageIdis the current node's primary key. - Stop when there are no children.
The View concatenates each visible node's messages from codec.getMessages(node.projection), then applies regenerate substitution: when the visible chain contains both an original reply and its regenerate sibling, the regenerate's content replaces the original in the rendered message list. The result is a CodecMessage<TMessage>[] representing the currently selected conversation. Each pair carries the domain message and its codec-message-id.
Serial ordering and optimistic reconciliation
Messages within a node are ordered by their Ably serial. The serial is the total order Ably assigns on the channel, consistent across all subscribers. Optimistic messages do not have a serial when first inserted; they sort after every confirmed message until the server publish lands. When the published event arrives the Tree promotes the placeholder to the real serial and re-sorts. In practice the change is invisible because the optimistic message was published moments before the confirmation.
Query the tree
The Tree interface exposes:
| Method | Description |
|---|---|
getRunNode(runId) | Look up a Run by id. |
getNodeByCodecMessageId(codecMessageId) | Find the input or run node that owns a given codec-message-id. Returns a ConversationNode union; narrow on kind before reading kind-specific fields. |
getSiblingNodes(key) | The sibling group for a node key (edit siblings for an input, regenerate siblings for a run). Ordered oldest-first; single-element when there are no siblings; empty when the key is unknown. |
on('update', ...) | Structural changes (node insert, delete, sort). |
on('ably-message', ...) | Raw inbound channel messages. |
on('run', ...) | Run lifecycle events. |
on('output', ...) | Decoded agent outputs folded for a Run, with routing metadata (runId, inputCodecMessageId, codecMessageId, serial). Fires once per inbound message after its fold; fires with an empty events array for inputs-only folds so it doubles as a projection-changed signal. |
The View consumes these to drive its branch-aware projection. Most application code reaches for the View; the Tree is the lower-level surface for inspection.
Related pages
- Branching, edit, and regenerate: the user-facing feature.
- Wire protocol: the
parent,fork-of, andmsg-regenerateheaders that build the tree. - Codec architecture: how decoded events fold into per-node projections.
- Optimistic updates: how serial promotion works in the View.