MessageRepository Branch Pointer Invariant#
MessageRepository is the branching message-tree data structure at the core of assistant-ui threads. Every node is a RepositoryMessage with three pointer fields:
prev— parent node (nullat root)next— currently-selected child (nullfor leaf nodes)children— ordered list of all child IDs
The active thread is the path from root.next to the leaf found by following next pointers; head always points to that leaf .
The invariant: Every next pointer on the path from root to head must point toward head. For each node n on that path, n.next must be the child that is an ancestor-or-equal of head. If any pointer on that path diverges to a sibling branch, findHead() and getMessages() return inconsistent results — findHead follows next pointers forward from any node, while getMessages walks prev pointers backward from head .
The private selectPathTo(message) method is the canonical way to repair ancestor pointers: it walks the full prev chain from the target message back to the root, setting each ancestor's next toward the target .
switchToBranch — Stale Ancestor Pointers (pre-PR #6978)#
The classic stale-pointer bug: switchToBranch previously updated only the immediate parent's next pointer before setting this.head. Every ancestor above that parent retained their old next, pointing at whatever branch was active before the switch.
Reproduction: Tree r → a → x and r → b → y. Switch to y (so r.next = b, b.next = y). Then call switchToBranch("r"). The root's next was still a (stale), so findHead(root) returned x, not y. getMessages() returned [r, a, x] instead of [r, b, y] .
PR #6978 fixed this by delegating to selectPathTo, which walks the full ancestor chain on every switchToBranch call . The regression test is "keeps a nested branch selected when revisiting its ancestor" .
resetHead — Descendant Eviction + Ancestor Repair#
resetHead(messageId) truncates the tree to a given node. It must handle two concerns:
-
Descendant eviction — all children and their descendants are deleted from
this.messages, andmessage.children/message.nextare set tonull. Without this, dangling children linger and can be accidentally resurrected viaswitchToBranch. -
Ancestor pointer repair — after truncation,
selectPathTo(message)is called to walk the fullprevchain and update every ancestor'snexttoward the new head . This mirrors theswitchToBranchfix and prevents stale pointers whenresetHeadtargets a non-leaf node.
Regression test: "keeps the selected root branch when deletion selects from the root" .
performOp / relink — Partial Repair Only#
performOp handles link, cut, and relink. For cut and relink, if the removed child was the parent's current next, the parent falls back to the last remaining sibling . This only fixes the direct parent. Any ancestor that pointed toward the removed node through that parent remains stale. Callers that relink a message mid-chain must subsequently call switchToBranch or resetHead to repair higher ancestor pointers .
For link, performOp calls selectPathTo only when the new child's subtree contains the current head — this prevents silently hijacking the active branch when a sibling is appended.
Other Invariants#
| Invariant | Enforced by |
|---|---|
| No cycles in ancestor chain | performOp/relink throws if the new parent chain already contains the child's ID |
| Level numbers consistent after reparent | updateLevels() BFS on every link/relink |
| Optimistic messages don't escape to off-branch siblings | evictOffBranchOptimisticMessages() runs after every head move |
| Export never references an optimistic parent ID | export() re-parents children up to nearest persisted ancestor |
canonicalHeadId skips optimistic head | canonicalHeadId getter walks prev past isOptimistic nodes |
Related PRs & Bug History#
| PR | Issue |
|---|---|
| #6978 | switchToBranch stale ancestor pointers — full ancestor chain repair via selectPathTo |
| #6980 | Export ordering: messages exported in insertion order, breaking import after reparenting; fixed to pre-order |
| #6174 | Phantom branches: deleted external-store messages lingered as switchable branches; fixed with deferred eviction |
| #6092 | Stack overflow on ~30,000-message chains; findHead, updateLevels, descendant deletion converted from recursive to iterative |
| #5262 | Duplicate-ID invariant restored; external-store duplicate IDs now deduped at ingestion |
| #2384 | Introduced descendant eviction in resetHead |