Skip to main content

supercode_interchange/
session_tree.rs

1//! P5-5 (design §2 module 21 `session.tree`: "D5 in-place tree,
2//! rewind-anywhere, branch summaries, entry labels"; §2.1 D-6 `session.tree →
3//! core.session(tree-addressable transcript)`; §2.2 C7; §5.2 P5 row 5:
4//! "loaders are already tree-aware (C7 resolution); adds in-place
5//! rewind/branch/label on the native store").
6//!
7//! # What this is
8//!
9//! [`Session`](crate::session::Session) and the composition layer's session store
10//! already carry a **linear** transcript — `messages: Vec<ChatMessage>` — and
11//! the loaders already reconstruct MULTI-FILE tree structure on import
12//! (`Session::reconstruct_tree`, the C7 resolution's "import-side preserved
13//! only"). What was missing is the native, IN-PLACE tree: the ability to
14//! address any turn by id, rewind the active pointer to an earlier one
15//! without deleting anything, explicitly fork a new branch, attach a short
16//! summary to an off-path branch, and label any node — all on supercode's
17//! OWN session store (CC/PI's defining in-place-tree feature, catalog D5).
18//!
19//! # C7 — tree-with-linear-projection
20//!
21//! Conflict 7 (design §2.2): `session.tree`'s in-place DAG is structurally
22//! incompatible with a strictly-linear export target (the CX rollout shape).
23//! The resolution the design commits to is **core stays
24//! tree-with-linear-projection**: [`SessionTree::linear_projection`] always
25//! derives the active branch's message sequence deterministically by walking
26//! parent pointers from the root to the active leaf — this is what
27//! [`crate::session::Session::messages`] / the agent loop / exporters keep
28//! consuming unchanged. A tree session with zero branches (the common case,
29//! and the ONLY case before this module's operations are ever invoked) is the
30//! *degenerate single-path tree*: its projection is byte-for-byte the same
31//! sequence [`crate::session::Session::messages`] already held — see
32//! the linear-projection regression test.
33//!
34//! Exporting a branched tree to a linear-only format is
35//! [`SessionTree::splice_for_linear_export`]: it returns the active path
36//! (spliced, exactly like today's linear export) plus a
37//! [`BranchSummary`] for every OFF-path branch. Nothing is deleted by this —
38//! the full tree (every node of every branch) stays intact in
39//! [`SessionTree`] / its `<name>.tree.json` sidecar
40//! (through the session store's explicit tree writer); the summary is an added,
41//! human-readable POINTER (`BranchSummary::branch` names which branch the
42//! full data still lives under), never a replacement for it (§1.13 lossless).
43//!
44//! # Lossless rewind (§1.13)
45//!
46//! [`SessionTree::rewind`] never deletes a node. Moving the active branch's
47//! leaf pointer backward leaves every node — including the ones the pointer
48//! used to point through — exactly where it was in the DAG. Whenever the
49//! rewind actually moves the pointer off the branch's previous leaf, the OLD
50//! leaf is preserved under a freshly-named sibling branch (so it stays
51//! independently addressable/enumerable, not merely "still linked in but
52//! orphaned from every named branch") — see
53//! the rewind-preservation regression test.
54//! The next turn appended after a rewind becomes a NEW child of the rewind
55//! target, i.e. a sibling of whatever used to follow it — exactly "rewind =
56//! fork at the rewind point" (module 21's row).
57//!
58//! # Off by default / byte-identical
59//!
60//! Nothing in this module is on any hot path. A [`SessionTree`] is only ever
61//! constructed by an explicit caller (never implicitly by
62//! [`crate::session::Session`] loading/saving, never by a runtime agent loop) and its sidecar
63//! (`<name>.tree.json`) is only ever written by an explicit
64//! an explicit session-store tree-write call — so a session that never
65//! invokes any tree operation has no `.tree.json` file at all, and every
66//! existing linear read/write path (`Session::to_native_jsonl`/
67//! `from_native_str`, `SessionStore::save`/`load`) is untouched
68//! byte-for-byte. `capabilities.session_tree.enabled` (§3.1, module 21;
69//! exposed by the composition layer's `SessionTree` module switch and runtime
70//! configuration flags for tree enablement, summaries, and labels, allowing a caller to
71//! gate on — this module's own API has no runtime dependency on that flag
72//! (a library caller can always use [`SessionTree`] directly, exactly like
73//! native store forking does not gate on any capability
74//! either).
75
76use std::collections::{BTreeMap, BTreeSet};
77
78use serde::{Deserialize, Serialize};
79
80use crate::sidecar::NativeTurn;
81use crate::{ChatMessage, InterchangeError as Error, Result};
82
83/// A tree-node id. Assigned by `SessionTree`'s monotonic allocator — a
84/// counter (`"n0"`, `"n1"`, ...), not content-derived or random, so ids are
85/// deterministic and trivially testable, and so two nodes can never collide.
86pub type NodeId = String;
87
88/// One addressable turn in the tree (module 21's "entry"). Carries the full
89/// [`ChatMessage`] (this IS the full-fidelity source for a branched session
90/// — see the module doc's "off by default" note: the plain linear transcript
91/// file remains the record for an UNBRANCHED session; this sidecar only
92/// exists once a tree operation actually ran), its parent/children links,
93/// and an optional human/agent-set label (module 21 "entry labels").
94///
95/// **Lossless persistence (§1.13).** [`Self::message`] is a plain
96/// [`ChatMessage`] in memory, but this type's `Serialize`/`Deserialize`
97/// impls (below) are hand-written rather than derived: they route the
98/// message through [`NativeTurn`] — the SAME full-fidelity wire record
99/// [`crate::session::Session::to_native_jsonl_v2`] already uses to persist
100/// live-appended turns — instead of `ChatMessage`'s own wire `Serialize`.
101/// `ChatMessage`'s hand-rolled wire serde (`message.rs:57-79`) is deliberately
102/// lossy: it OMITS `metadata` entirely (never meant to reach a provider
103/// request body) and collapses `content` whenever `content_parts` is also
104/// set. That lossy shape is correct for an outbound API request; it is
105/// WRONG for this sidecar, which is the ONLY durable record of an off-path
106/// branch's messages (a rewound-past branch has no other file backing it).
107/// `NativeTurn` was built for exactly this distinction (see its module doc:
108/// "the sidecar must retain what the wire serde must drop") — reusing it
109/// here, rather than inventing a second parallel lossless representation,
110/// keeps `TreeNode.message` byte-for-byte round-trippable: `metadata` intact,
111/// `content` AND `content_parts` both intact (independently — `NativeTurn`
112/// does not collapse one into the other).
113#[derive(Debug, Clone)]
114pub struct TreeNode {
115    /// This node's id.
116    pub id: NodeId,
117    /// The parent node id. `None` only for the tree's root.
118    pub parent: Option<NodeId>,
119    /// Child node ids, in the order they were created. More than one entry
120    /// here IS a branch point (multiple turns following the same parent).
121    pub children: Vec<NodeId>,
122    /// The turn itself.
123    pub message: ChatMessage,
124    /// A human/agent-set label on this node (module 21 "entry labels"),
125    /// e.g. a checkpoint name or an annotation. `None` (the default) —
126    /// unlabeled.
127    pub label: Option<String>,
128    /// Unix-ms wall-clock time this node was created.
129    pub created_at_ms: i64,
130}
131
132/// The on-disk shape of a [`TreeNode`]: identical except `message` is a
133/// [`NativeTurn`] rather than a plain [`ChatMessage`] — see [`TreeNode`]'s
134/// doc comment for why. Private: only [`TreeNode`]'s own `Serialize`/
135/// `Deserialize` impls (below) construct one.
136#[derive(Serialize, Deserialize)]
137struct TreeNodeWire {
138    id: NodeId,
139    parent: Option<NodeId>,
140    #[serde(default)]
141    children: Vec<NodeId>,
142    message: NativeTurn,
143    #[serde(default)]
144    label: Option<String>,
145    #[serde(default)]
146    created_at_ms: i64,
147}
148
149impl From<&TreeNode> for TreeNodeWire {
150    fn from(n: &TreeNode) -> Self {
151        // Build the `NativeTurn` by hand rather than via its
152        // `From<&ChatMessage>` impl: that impl stamps `ts` with the CURRENT
153        // wall-clock time (`sidecar.rs`'s `now_rfc3339()`), which would make
154        // re-saving an already-loaded, unmodified tree produce different
155        // bytes each time — breaking this sidecar's save→load→save
156        // byte-identity guarantee. `ts` is derived deterministically from
157        // the node's own `created_at_ms` instead (and is write-only for this
158        // use: `NativeTurn::into_message` discards `ts`/`supercode_turn`
159        // on the way back, so no information depends on its exact value —
160        // only on it being stable).
161        let message = NativeTurn {
162            supercode_turn: 1,
163            ts: crate::sidecar::ms_to_rfc3339(n.created_at_ms),
164            role: n.message.role,
165            content: n.message.content.clone(),
166            content_parts: n.message.content_parts.clone(),
167            tool_calls: n.message.tool_calls.clone(),
168            tool_call_id: n.message.tool_call_id.clone(),
169            name: n.message.name.clone(),
170            metadata: n.message.metadata.clone(),
171        };
172        TreeNodeWire {
173            id: n.id.clone(),
174            parent: n.parent.clone(),
175            children: n.children.clone(),
176            message,
177            label: n.label.clone(),
178            created_at_ms: n.created_at_ms,
179        }
180    }
181}
182
183impl From<TreeNodeWire> for TreeNode {
184    fn from(w: TreeNodeWire) -> Self {
185        TreeNode {
186            id: w.id,
187            parent: w.parent,
188            children: w.children,
189            message: w.message.into_message(),
190            label: w.label,
191            created_at_ms: w.created_at_ms,
192        }
193    }
194}
195
196impl Serialize for TreeNode {
197    fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
198        TreeNodeWire::from(self).serialize(ser)
199    }
200}
201
202impl<'de> Deserialize<'de> for TreeNode {
203    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
204        TreeNodeWire::deserialize(de).map(TreeNode::from)
205    }
206}
207
208/// A branch-carried summary (module 21 "branch summaries", the C7
209/// lossy→sidecar-backed path): a short human-readable digest of a branch,
210/// paired with the pointer back to the full branch data (`branch`, a key
211/// into [`SessionTree::branches`] — the full nodes never move or get
212/// deleted, so this is always resolvable back to the source, §1.13).
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct BranchSummary {
215    /// The short summary text.
216    pub summary: String,
217    /// The node id this summary was generated as-of (normally the branch's
218    /// leaf at generation time).
219    pub node_id: NodeId,
220    /// Which branch (a key into [`SessionTree::branches`]) this summary
221    /// describes — the recoverability pointer: the full branch is still
222    /// right there, keyed by this name, never dropped.
223    pub branch: String,
224    /// Which model produced this summary, if generated via
225    /// [`BranchSummarizer`] (mirrors `reduce/summarize.rs`'s
226    /// `SpanSummary::model_id`). `None` for a caller-provided summary text.
227    #[serde(default)]
228    pub model_id: Option<String>,
229    /// Unix-ms wall-clock time the summary was generated.
230    #[serde(default)]
231    pub created_at_ms: i64,
232}
233
234/// A named pointer into the tree: `leaf` is the node this branch currently
235/// ends at (its "current-leaf pointer", module 21's phrase). `None` only for
236/// a brand-new, still-empty tree's implicit branch before any node exists.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct Branch {
239    /// The branch's name (unique within [`SessionTree::branches`]).
240    pub name: String,
241    /// The node this branch currently points at (its leaf/current position).
242    pub leaf: Option<NodeId>,
243    /// An attached summary (module 21 "branch summaries"), set by
244    /// [`SessionTree::summarize_branch`]/[`SessionTree::summarize_branch_with`].
245    /// `None` — the overwhelmingly common case (a branch nobody has
246    /// summarized, e.g. the active one).
247    #[serde(default)]
248    pub summary: Option<BranchSummary>,
249    /// Unix-ms wall-clock time this branch was created.
250    #[serde(default)]
251    pub created_at_ms: i64,
252}
253
254/// The default/active branch name for a session that has never explicitly
255/// branched — the degenerate single-path tree's one branch.
256pub const MAIN_BRANCH: &str = "main";
257
258/// The native in-place conversation tree (module 21). See the module doc
259/// comment for the full design (C7 tree-with-linear-projection, lossless
260/// rewind, branch summaries).
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct SessionTree {
263    /// Every node in the tree, keyed by id. A [`BTreeMap`] (not a
264    /// [`std::collections::HashMap`]) so iteration/serialization order is
265    /// deterministic — load-bearing for the lossless round-trip tests
266    /// (`assert_eq!` on two independently-loaded trees must not flake on
267    /// hash-iteration order).
268    pub nodes: BTreeMap<NodeId, TreeNode>,
269    /// The tree's single root node id. `None` only for a brand-new, empty
270    /// tree.
271    pub root: Option<NodeId>,
272    /// Every branch, keyed by name. Always has at least [`MAIN_BRANCH`] once
273    /// [`SessionTree::new`]/[`SessionTree::from_linear`] have run.
274    pub branches: BTreeMap<String, Branch>,
275    /// The currently-active branch name — a key into [`Self::branches`].
276    pub active_branch: String,
277    /// The next id [`Self::alloc_id`] will hand out.
278    #[serde(default)]
279    next_id: u64,
280}
281
282impl Default for SessionTree {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288impl SessionTree {
289    /// A brand-new, empty tree: no nodes, one branch ([`MAIN_BRANCH`]) with
290    /// no leaf yet, active.
291    pub fn new() -> Self {
292        let mut branches = BTreeMap::new();
293        branches.insert(
294            MAIN_BRANCH.to_string(),
295            Branch {
296                name: MAIN_BRANCH.to_string(),
297                leaf: None,
298                summary: None,
299                created_at_ms: 0,
300            },
301        );
302        SessionTree {
303            nodes: BTreeMap::new(),
304            root: None,
305            branches,
306            active_branch: MAIN_BRANCH.to_string(),
307            next_id: 0,
308        }
309    }
310
311    /// Build a tree from an existing LINEAR message sequence — the
312    /// degenerate single-path tree (C7): each message becomes a node,
313    /// chained to the previous one, with [`MAIN_BRANCH`]'s leaf ending at the
314    /// last message. [`Self::linear_projection`] on the result is
315    /// byte-for-byte `messages` (see
316    /// the linear-projection regression test) —
317    /// this is the bridge a caller uses to materialize a tree lazily out of
318    /// an ordinary [`crate::session::Session::messages`], the FIRST time a
319    /// tree operation (rewind/branch/label) is actually invoked on it.
320    /// `created_at_ms` is stamped on every synthesized node (a single
321    /// timestamp for the whole import, since the source linear messages
322    /// carry no per-turn timestamp of their own).
323    pub fn from_linear(messages: &[ChatMessage], created_at_ms: i64) -> Self {
324        let mut tree = Self::new();
325        for m in messages {
326            tree.append_message(m.clone(), created_at_ms);
327        }
328        tree
329    }
330
331    /// Allocate a fresh, never-before-used node id. Collision-checked against
332    /// [`Self::nodes`] rather than blindly trusting [`Self::next_id`]: a
333    /// sidecar hand-edited (or written by an older/different process) can
334    /// deserialize with `next_id` behind the actual highest-used id — most
335    /// simply, `#[serde(default)]` on `next_id` means a sidecar that omits
336    /// the field entirely loads as `next_id: 0`, and the very next append
337    /// would otherwise hand out `"n0"` again and [`std::collections::BTreeMap::insert`]
338    /// would SILENTLY REPLACE the existing root. Looping past any id that's
339    /// already occupied makes that impossible regardless of how `next_id`
340    /// got out of sync with the actual node set.
341    fn alloc_id(&mut self) -> NodeId {
342        loop {
343            let id = format!("n{}", self.next_id);
344            self.next_id += 1;
345            if !self.nodes.contains_key(&id) {
346                return id;
347            }
348        }
349    }
350
351    /// Look up a node by id.
352    pub fn node(&self, id: &str) -> Option<&TreeNode> {
353        self.nodes.get(id)
354    }
355
356    fn require_node(&self, id: &str) -> Result<&TreeNode> {
357        self.nodes
358            .get(id)
359            .ok_or_else(|| Error::Other(format!("session tree has no node `{id}`")))
360    }
361
362    fn require_branch(&self, name: &str) -> Result<&Branch> {
363        self.branches
364            .get(name)
365            .ok_or_else(|| Error::Other(format!("session tree has no branch `{name}`")))
366    }
367
368    /// Append a new turn as a child of the ACTIVE branch's current leaf
369    /// (ordinary turn continuation — the tree's analog of pushing onto
370    /// [`crate::session::Session::messages`]). Returns the new node's id.
371    /// This is the only way [`Self::root`] is ever set (on the very first
372    /// node the whole tree ever gets).
373    pub fn append_message(&mut self, message: ChatMessage, created_at_ms: i64) -> NodeId {
374        let parent = self
375            .branches
376            .get(&self.active_branch)
377            .and_then(|b| b.leaf.clone());
378        let id = self.alloc_id();
379        self.nodes.insert(
380            id.clone(),
381            TreeNode {
382                id: id.clone(),
383                parent: parent.clone(),
384                children: Vec::new(),
385                message,
386                label: None,
387                created_at_ms,
388            },
389        );
390        match &parent {
391            Some(p) => {
392                if let Some(pn) = self.nodes.get_mut(p) {
393                    pn.children.push(id.clone());
394                }
395            }
396            None => self.root = Some(id.clone()),
397        }
398        if let Some(b) = self.branches.get_mut(&self.active_branch) {
399            b.leaf = Some(id.clone());
400        }
401        id
402    }
403
404    /// A branch name derived from `base` that doesn't collide with any
405    /// existing branch — `base`, or `base-2`, `base-3`, ... the first free
406    /// one. Used by [`Self::rewind`] (to auto-name the preserved sibling) and
407    /// by [`Self::branch`] when the caller passes no explicit name.
408    fn fresh_branch_name(&self, base: &str) -> String {
409        if !self.branches.contains_key(base) {
410            return base.to_string();
411        }
412        let mut n = 2u64;
413        loop {
414            let candidate = format!("{base}-{n}");
415            if !self.branches.contains_key(&candidate) {
416                return candidate;
417            }
418            n += 1;
419        }
420    }
421
422    /// Rewind-anywhere (module 21): move the ACTIVE branch's current-leaf
423    /// pointer back to `node_id`. `node_id` must already exist in the tree —
424    /// an unknown id is an error, never silently ignored or treated as a
425    /// no-op (the "never corrupt/dangling" requirement).
426    ///
427    /// **Lossless.** No node is ever deleted by this. If the active branch's
428    /// leaf was pointing somewhere other than `node_id` before the call, that
429    /// OLD leaf — and therefore the whole path back to (but not past) the
430    /// nearest still-referenced ancestor — is preserved under a fresh
431    /// sibling branch name (using the internal fresh-name allocator) so it stays
432    /// independently addressable, not merely still-linked-in-but-unnamed.
433    /// Returns that sibling branch's name, or `None` if the rewind was a
434    /// no-op (`node_id` was already the active leaf, or the branch had no
435    /// leaf yet).
436    ///
437    /// The next [`Self::append_message`] after a rewind creates a NEW child
438    /// of `node_id` — a sibling of whatever child used to follow it, exactly
439    /// "rewind = fork at the rewind point."
440    pub fn rewind(&mut self, node_id: &str, timestamp_ms: i64) -> Result<Option<String>> {
441        self.require_node(node_id)?;
442        let old_leaf = self
443            .branches
444            .get(&self.active_branch)
445            .and_then(|b| b.leaf.clone());
446        let preserved = match &old_leaf {
447            Some(old) if old != node_id => {
448                let name = self.fresh_branch_name(&format!("{}-rewound", self.active_branch));
449                self.branches.insert(
450                    name.clone(),
451                    Branch {
452                        name: name.clone(),
453                        leaf: Some(old.clone()),
454                        summary: None,
455                        created_at_ms: timestamp_ms,
456                    },
457                );
458                Some(name)
459            }
460            _ => None,
461        };
462        if let Some(b) = self.branches.get_mut(&self.active_branch) {
463            b.leaf = Some(node_id.to_string());
464        }
465        Ok(preserved)
466    }
467
468    /// Explicit branch (module 21): fork the conversation at `from_node`,
469    /// creating a NEW branch (named `name`, or an auto-generated
470    /// `"branch-N"` if `None`) whose leaf starts at `from_node`, and switch
471    /// the active branch to it. Errors if `from_node` doesn't exist, or if
472    /// `name` is `Some` and already taken (an explicit name collision is a
473    /// caller mistake worth surfacing, unlike [`Self::rewind`]'s
474    /// auto-generated names which always self-disambiguate).
475    pub fn branch(
476        &mut self,
477        from_node: &str,
478        name: Option<String>,
479        timestamp_ms: i64,
480    ) -> Result<String> {
481        self.require_node(from_node)?;
482        let name = match name {
483            Some(n) => {
484                if self.branches.contains_key(&n) {
485                    return Err(Error::Other(format!(
486                        "session tree already has a branch named `{n}`"
487                    )));
488                }
489                n
490            }
491            None => self.fresh_branch_name("branch"),
492        };
493        self.branches.insert(
494            name.clone(),
495            Branch {
496                name: name.clone(),
497                leaf: Some(from_node.to_string()),
498                summary: None,
499                created_at_ms: timestamp_ms,
500            },
501        );
502        self.active_branch = name.clone();
503        Ok(name)
504    }
505
506    /// Switch the active branch to an already-existing one. Errors if `name`
507    /// doesn't name a branch (no silent fallback to `main`).
508    pub fn switch_branch(&mut self, name: &str) -> Result<()> {
509        self.require_branch(name)?;
510        self.active_branch = name.to_string();
511        Ok(())
512    }
513
514    /// Label (module 21 "entry labels") a node — a human/agent annotation,
515    /// persisted on the node itself (so it round-trips with the rest of the
516    /// tree, §1.13). Errors if `node_id` doesn't exist.
517    pub fn label(&mut self, node_id: &str, label: impl Into<String>) -> Result<()> {
518        let node = self
519            .nodes
520            .get_mut(node_id)
521            .ok_or_else(|| Error::Other(format!("session tree has no node `{node_id}`")))?;
522        node.label = Some(label.into());
523        Ok(())
524    }
525
526    /// Clear a node's label, if any. Errors if `node_id` doesn't exist (same
527    /// existence-checking posture as [`Self::label`]).
528    pub fn clear_label(&mut self, node_id: &str) -> Result<()> {
529        let node = self
530            .nodes
531            .get_mut(node_id)
532            .ok_or_else(|| Error::Other(format!("session tree has no node `{node_id}`")))?;
533        node.label = None;
534        Ok(())
535    }
536
537    /// The linear projection of the ACTIVE branch (C7): walk from the root to
538    /// the active branch's leaf via parent pointers, returning the messages
539    /// in root→leaf order. This is what any linear consumer (the agent loop,
540    /// an exporter) must see. `Vec::new()` for an empty tree (no leaf yet).
541    ///
542    /// **Fail-closed.** This is a thin `self.active_branch`-bound wrapper
543    /// around [`Self::linear_projection_of`] and propagates its `Err`
544    /// (a missing active branch, a cycle, a dangling leaf) rather than
545    /// masking it to an empty `Vec` — a structurally-corrupt tree must ERROR,
546    /// never silently look like a session with zero messages. (An earlier
547    /// version of this method used `.unwrap_or_default()` here, which let a
548    /// corrupt-but-valid-JSON `.tree.json` sidecar pass [`Self::linear_projection`]
549    /// straight through to [`crate::session::Session::apply_session_tree`]
550    /// and silently EMPTY [`crate::session::Session::messages`] — see that
551    /// method's doc comment.)
552    pub fn linear_projection(&self) -> Result<Vec<ChatMessage>> {
553        self.linear_projection_of(&self.active_branch)
554    }
555
556    /// The linear projection of any named branch (not just the active one) —
557    /// the general form [`Self::linear_projection`] is built on. Errors if
558    /// `branch` doesn't exist; returns `Ok(Vec::new())` for a branch with no
559    /// leaf yet (a fresh, still-empty tree's `main`).
560    ///
561    /// Defensively cycle-guarded: a malformed/hand-edited tree with a parent
562    /// cycle returns an error instead of looping forever — this ties into
563    /// the "a rewind to a nonexistent node is an error, not corruption"
564    /// requirement's sibling guarantee (no API in this module can ever
565    /// CREATE a cycle — [`Self::append_message`]'s parent is always the
566    /// pre-existing leaf, [`Self::rewind`]/[`Self::branch`] only ever move a
567    /// leaf POINTER to an existing node, never rewrite a `parent` link — but
568    /// a tree loaded from a hand-edited or corrupted `.tree.json` sidecar
569    /// could still contain one, and this must not hang or panic on it).
570    pub fn linear_projection_of(&self, branch: &str) -> Result<Vec<ChatMessage>> {
571        let b = self.require_branch(branch)?;
572        let Some(mut cursor) = b.leaf.clone() else {
573            return Ok(Vec::new());
574        };
575        let mut chain = Vec::new();
576        let mut visited = BTreeSet::new();
577        loop {
578            if !visited.insert(cursor.clone()) {
579                return Err(Error::Other(format!(
580                    "session tree branch `{branch}` contains a cycle at node `{cursor}`"
581                )));
582            }
583            let node = self.require_node(&cursor)?;
584            chain.push(node.message.clone());
585            match &node.parent {
586                Some(p) => cursor = p.clone(),
587                None => break,
588            }
589        }
590        chain.reverse();
591        Ok(chain)
592    }
593
594    /// BP-8 (catalog:151): the NODE IDS along the active branch, root-first
595    /// — the id-level twin of [`Self::linear_projection`], which returns the
596    /// same nodes' messages. A caller that knows a conversation POSITION
597    /// (an index into the linear view) needs this to name the node at that
598    /// position, which is what "move the leaf anywhere" requires. Same
599    /// cycle guard and same fail-closed posture as the projection.
600    pub fn active_path(&self) -> Result<Vec<NodeId>> {
601        self.path_of(&self.active_branch)
602    }
603
604    /// [`Self::active_path`] for any named branch.
605    pub fn path_of(&self, branch: &str) -> Result<Vec<NodeId>> {
606        let b = self.require_branch(branch)?;
607        let Some(mut cursor) = b.leaf.clone() else {
608            return Ok(Vec::new());
609        };
610        let mut chain = Vec::new();
611        let mut visited = BTreeSet::new();
612        loop {
613            if !visited.insert(cursor.clone()) {
614                return Err(Error::Other(format!(
615                    "session tree branch `{branch}` contains a cycle at node `{cursor}`"
616                )));
617            }
618            let node = self.require_node(&cursor)?;
619            chain.push(cursor.clone());
620            match &node.parent {
621                Some(p) => cursor = p.clone(),
622                None => break,
623            }
624        }
625        chain.reverse();
626        Ok(chain)
627    }
628
629    /// Whether this tree has actually branched (more than just the implicit
630    /// [`MAIN_BRANCH`]) — i.e. it is no longer the degenerate single-path
631    /// case. A caller can use this to decide whether a `.tree.json` sidecar
632    /// is even worth persisting (a never-branched tree is exactly the
633    /// pre-existing linear session, byte for byte, so the C7 default-off
634    /// posture never requires writing one).
635    pub fn has_branches(&self) -> bool {
636        self.branches.len() > 1
637    }
638
639    /// Attach a caller-provided summary to `branch` directly (module 21
640    /// "branch summaries"). `node_id` records which node the summary is
641    /// as-of (the branch's current leaf, normally); `model_id` is `None` for
642    /// a caller-provided (not model-generated) summary. Errors if `branch`
643    /// doesn't exist.
644    ///
645    /// Errors if `branch` has no leaf yet (a brand-new, still-empty branch) —
646    /// a leafless branch has no node to summarize *as-of*, and recording a
647    /// [`BranchSummary::node_id`] of `""` would be a pointer to a node that
648    /// doesn't exist (F4: never fabricate a dangling pointer).
649    pub fn summarize_branch(
650        &mut self,
651        branch: &str,
652        summary: impl Into<String>,
653        model_id: Option<String>,
654        timestamp_ms: i64,
655    ) -> Result<()> {
656        let leaf = self.require_branch(branch)?.leaf.clone().ok_or_else(|| {
657            Error::Other(format!(
658                "session tree branch `{branch}` has no leaf yet — nothing to summarize"
659            ))
660        })?;
661        let b = self
662            .branches
663            .get_mut(branch)
664            .expect("just checked via require_branch");
665        b.summary = Some(BranchSummary {
666            summary: summary.into(),
667            node_id: leaf,
668            branch: branch.to_string(),
669            model_id,
670            created_at_ms: timestamp_ms,
671        });
672        Ok(())
673    }
674
675    /// Render a branch's linear projection into plain text (one line per
676    /// turn, `role: content`) — the input a [`BranchSummarizer`] side-call
677    /// summarizes, mirroring `reduce/summarize.rs`'s `render_span_text`
678    /// shape.
679    pub fn render_branch_text(&self, branch: &str) -> Result<String> {
680        let messages = self.linear_projection_of(branch)?;
681        let mut out = String::new();
682        for m in &messages {
683            let role = match m.role {
684                crate::message::Role::System => "system",
685                crate::message::Role::User => "user",
686                crate::message::Role::Assistant => "assistant",
687                crate::message::Role::Tool => "tool",
688            };
689            out.push_str(role);
690            out.push_str(": ");
691            out.push_str(m.content.as_deref().unwrap_or(""));
692            out.push('\n');
693        }
694        Ok(out)
695    }
696
697    /// Summarize `branch` via a small-model side-call (D-9, the mechanism
698    /// an optional caller-supplied branch summarizer
699    /// also uses): renders the branch's text
700    /// ([`Self::render_branch_text`]) and calls `summarizer`. **Never fails
701    /// the caller** — mirroring `reduce/summarize.rs`'s "never blocks, never
702    /// fails the pass" posture: if `summarizer` errors (a timeout, a
703    /// provider error, budget exhaustion — whatever it models), this falls
704    /// back to a deterministic stub summary (`"[N turns, unsummarized]"`)
705    /// rather than propagating the error, so a C7 export can always
706    /// complete. Errors only if `branch` itself doesn't exist.
707    pub fn summarize_branch_with(
708        &mut self,
709        branch: &str,
710        summarizer: &dyn BranchSummarizer,
711        timestamp_ms: i64,
712    ) -> Result<()> {
713        let text = self.render_branch_text(branch)?;
714        let turn_count = self.linear_projection_of(branch)?.len();
715        match summarizer.summarize(&text) {
716            Ok(summary) => {
717                self.summarize_branch(
718                    branch,
719                    summary,
720                    Some(summarizer.model_id().to_string()),
721                    timestamp_ms,
722                )?;
723            }
724            Err(_) => {
725                self.summarize_branch(
726                    branch,
727                    format!("[{turn_count} turn(s), unsummarized]"),
728                    None,
729                    timestamp_ms,
730                )?;
731            }
732        }
733        Ok(())
734    }
735
736    /// C7 export mechanism: splice the ACTIVE branch's messages (exactly
737    /// [`Self::linear_projection`] — what a strictly-linear export target,
738    /// e.g. the CX rollout shape, can represent) plus a [`BranchSummary`]
739    /// for every OFF-path branch (every branch other than the active one).
740    /// An off-path branch that already carries a [`Branch::summary`] reuses
741    /// it as-is; one that doesn't gets a fresh deterministic stub summary
742    /// (`"[N turn(s), unsummarized]"`) — this method takes `&self` (read
743    /// only) precisely so it never needs a live [`BranchSummarizer`] side-call
744    /// inline; a caller wanting model-generated summaries should call
745    /// [`Self::summarize_branch_with`] on each off-path branch FIRST, then
746    /// call this. Nothing here mutates or drops any node — see the module
747    /// doc's "Lossless rewind" / C7 sections: the full multi-branch
748    /// [`SessionTree`] (this method's `&self` receiver) remains the
749    /// recoverable source of truth regardless of what the caller does with
750    /// the returned linear messages.
751    ///
752    /// **Fail-closed** on the active path, same posture as
753    /// [`Self::linear_projection`]: a corrupt active branch errors instead of
754    /// silently exporting an empty transcript (F2). Off-path branches are
755    /// summarized best-effort (see [`Self::summarize_branch_with`]'s "never
756    /// blocks" contract) — a corrupt OFF-path branch does not fail the whole
757    /// export, but never claims false turn-count precision either; see
758    /// [`BranchSummary`]'s construction below.
759    pub fn splice_for_linear_export(&self) -> Result<(Vec<ChatMessage>, Vec<BranchSummary>)> {
760        let active = self.linear_projection()?;
761        let mut summaries = Vec::new();
762        for (name, b) in &self.branches {
763            if name == &self.active_branch {
764                continue;
765            }
766            if let Some(s) = &b.summary {
767                summaries.push(s.clone());
768            } else {
769                // F4: don't mask a corrupt/leafless off-path branch behind a
770                // deterministic-looking "[0 turn(s)]" stub — that reads as
771                // "an empty conversation" when the real state is "this
772                // branch's data couldn't be read." Surface the real state in
773                // the summary text instead (never errors the whole export
774                // over ONE off-path branch — same "never blocks" posture as
775                // `Self::summarize_branch_with`), and stamp the branch's own
776                // `created_at_ms` rather than a placeholder `0`.
777                let (summary_text, node_id) = match &b.leaf {
778                    None => (
779                        format!("[branch `{name}` has no leaf yet — nothing to summarize]"),
780                        String::new(),
781                    ),
782                    Some(leaf) => match self.linear_projection_of(name) {
783                        Ok(msgs) => (
784                            format!("[{} turn(s), unsummarized]", msgs.len()),
785                            leaf.clone(),
786                        ),
787                        Err(e) => (
788                            format!("[branch `{name}` could not be read, unsummarized: {e}]"),
789                            leaf.clone(),
790                        ),
791                    },
792                };
793                summaries.push(BranchSummary {
794                    summary: summary_text,
795                    node_id,
796                    branch: name.clone(),
797                    model_id: None,
798                    created_at_ms: b.created_at_ms,
799                });
800            }
801        }
802        Ok((active, summaries))
803    }
804}
805
806/// Injectable branch-summarization side-call (D-9), the module-21 analog of
807/// a caller-supplied branch summarizer
808/// — same shape, deliberately: a real implementation calls out to a cheap
809/// model; tests inject a deterministic fake. See
810/// [`SessionTree::summarize_branch_with`]'s doc comment for the "never
811/// blocks, never fails the caller" contract this trait's `Err` feeds into.
812pub trait BranchSummarizer {
813    /// Summarize `branch_text` (the rendering [`SessionTree::render_branch_text`]
814    /// produces) into a short paragraph. `Err` means the caller falls back to
815    /// a deterministic stub — see
816    /// [`SessionTree::summarize_branch_with`].
817    fn summarize(&self, branch_text: &str) -> Result<String>;
818
819    /// Identifier of the model behind this summarizer (recorded on
820    /// [`BranchSummary::model_id`]).
821    fn model_id(&self) -> &str;
822}