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...")
Copied!

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> fieldDescription
kind'input'.
codecMessageIdPrimary key. The client-owned id minted when the input is published.
parentCodecMessageIdThe codec-message-id of the immediately preceding reply run on this chain, or undefined for the first input in a conversation.
forkOfThe 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.
projectionThe codec's per-input projection, folded from every input event under this codec-message-id.
serialAbly serial of the first observed message for this input. Absent for optimistic (locally-created) inputs.
RunNode<TProjection> fieldDescription
kind'run'.
runIdPrimary key. Agent-minted.
parentCodecMessageIdThe codec-message-id of the input node this Run replies to (the user prompt the agent answered). undefined for the root Run.
forkOfThe 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.
regeneratesCodecMessageIdThe codec-message-id of the assistant message this Run regenerates. Verbatim from the wire's msg-regenerate.
clientIdThe Ably clientId of the client that started the Run, from run-client-id.
stateA 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.
projectionThe codec's per-Run projection, folded from every event published under this run-id.
invocationIdThe 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 / endSerialAbly 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-start and ends with ai-run-end. The View filters active Runs by status === 'active'; without an ai-run-end the Run stays active.
  • Suspend and resume are distinct events: ai-run-suspend parks a Run at 'suspended' (still live); ai-run-resume re-activates it. Both are non-terminal; only ai-run-end is 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:

  1. 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.
  2. Add the current node to the visible chain.
  3. If the current node has siblings, select the one indicated by the View's selections map (or default to the newest).
  4. Move to the selected node's children: nodes whose parentCodecMessageId is the current node's primary key.
  5. 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:

MethodDescription
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.