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}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    fn msgs(n: usize) -> Vec<ChatMessage> {
829        (0..n)
830            .map(|i| ChatMessage::user(format!("turn {i}")))
831            .collect()
832    }
833
834    fn content_of(m: &ChatMessage) -> &str {
835        m.content.as_deref().unwrap_or("")
836    }
837
838    // ---------------------------------------------------------------
839    // C7: linear projection exactness / default-off degenerate case.
840    // ---------------------------------------------------------------
841
842    #[test]
843    fn linear_projection_of_a_from_linear_tree_matches_the_source_messages() {
844        let source = msgs(5);
845        let tree = SessionTree::from_linear(&source, 1_700_000_000_000);
846        let projected = tree.linear_projection().unwrap();
847        assert_eq!(projected.len(), source.len());
848        for (p, s) in projected.iter().zip(source.iter()) {
849            assert_eq!(content_of(p), content_of(s));
850        }
851        // A never-branched tree is the degenerate single-path case.
852        assert!(!tree.has_branches());
853    }
854
855    #[test]
856    fn empty_tree_has_empty_linear_projection() {
857        let tree = SessionTree::new();
858        assert!(tree.linear_projection().unwrap().is_empty());
859        assert_eq!(tree.root, None);
860    }
861
862    #[test]
863    fn append_message_chains_and_advances_the_active_leaf() {
864        let mut tree = SessionTree::new();
865        let n0 = tree.append_message(ChatMessage::user("hello"), 1);
866        let n1 = tree.append_message(ChatMessage::assistant("hi"), 2);
867        assert_eq!(tree.root, Some(n0.clone()));
868        assert_eq!(tree.branches[MAIN_BRANCH].leaf, Some(n1.clone()));
869        assert_eq!(tree.node(&n1).unwrap().parent, Some(n0.clone()));
870        assert_eq!(tree.node(&n0).unwrap().children, vec![n1]);
871    }
872
873    // ---------------------------------------------------------------
874    // Rewind — lossless-ness proof.
875    // ---------------------------------------------------------------
876
877    #[test]
878    fn rewind_to_unknown_node_errors_not_corrupts() {
879        let mut tree = SessionTree::from_linear(&msgs(3), 1);
880        let before = tree.clone_for_test();
881        let err = tree.rewind("does-not-exist", 2).unwrap_err();
882        assert!(err.to_string().contains("does-not-exist"));
883        // Nothing changed.
884        assert_eq!(
885            tree.branches[MAIN_BRANCH].leaf,
886            before.branches[MAIN_BRANCH].leaf
887        );
888        assert_eq!(tree.nodes.len(), before.nodes.len());
889    }
890
891    #[test]
892    fn rewind_preserves_the_rewound_past_as_a_recoverable_sibling_branch() {
893        let mut tree = SessionTree::from_linear(&msgs(4), 1); // n0..n3, leaf n3
894        let n1 = "n1".to_string();
895        let old_leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
896        assert_eq!(old_leaf, "n3");
897
898        let preserved = tree.rewind(&n1, 100).unwrap().expect("moved the pointer");
899        // The active branch now sits at n1.
900        assert_eq!(tree.branches[MAIN_BRANCH].leaf, Some(n1.clone()));
901        // But the rewound-past data (n2, n3) is NOT deleted: every node is
902        // still present...
903        assert!(tree.node("n2").is_some());
904        assert!(tree.node("n3").is_some());
905        // ...AND still independently reachable/enumerable as its own named
906        // branch, ending exactly where `main` used to.
907        assert_eq!(tree.branches[&preserved].leaf, Some(old_leaf));
908        let recovered = tree.linear_projection_of(&preserved).unwrap();
909        assert_eq!(recovered.len(), 4);
910        assert_eq!(content_of(&recovered[3]), "turn 3");
911
912        // The active (rewound) branch's own projection is the shorter prefix.
913        let active = tree.linear_projection().unwrap();
914        assert_eq!(active.len(), 2);
915        assert_eq!(content_of(&active[1]), "turn 1");
916    }
917
918    #[test]
919    fn rewind_to_the_current_leaf_is_a_no_op_and_preserves_nothing_new() {
920        let mut tree = SessionTree::from_linear(&msgs(2), 1);
921        let leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
922        let branch_count_before = tree.branches.len();
923        let preserved = tree.rewind(&leaf, 2).unwrap();
924        assert_eq!(preserved, None);
925        assert_eq!(tree.branches.len(), branch_count_before);
926    }
927
928    #[test]
929    fn appending_after_rewind_forks_a_new_sibling_child() {
930        let mut tree = SessionTree::from_linear(&msgs(3), 1); // n0,n1,n2
931        let n0 = "n0".to_string();
932        tree.rewind(&n0, 10).unwrap();
933        let new_child = tree.append_message(ChatMessage::user("alt turn 1"), 11);
934        // n0 now has two children: the original n1, and the new fork.
935        let n0_children = &tree.node(&n0).unwrap().children;
936        assert_eq!(n0_children.len(), 2);
937        assert!(n0_children.contains(&"n1".to_string()));
938        assert!(n0_children.contains(&new_child));
939        // The active projection reflects the NEW path.
940        let active = tree.linear_projection().unwrap();
941        assert_eq!(active.len(), 2);
942        assert_eq!(content_of(&active[1]), "alt turn 1");
943    }
944
945    #[test]
946    fn no_api_can_create_a_cycle_linear_projection_of_a_hand_edited_cycle_errors() {
947        let mut tree = SessionTree::from_linear(&msgs(2), 1);
948        // Hand-corrupt: make n0's parent point at n1 (n1's parent is n0) —
949        // a 2-cycle. No public API of this module can produce this; this
950        // simulates a corrupted/hand-edited `.tree.json`.
951        tree.nodes.get_mut("n0").unwrap().parent = Some("n1".to_string());
952        let err = tree.linear_projection_of(MAIN_BRANCH).unwrap_err();
953        assert!(err.to_string().contains("cycle"));
954    }
955
956    // ---------------------------------------------------------------
957    // Branch — explicit fork + switch.
958    // ---------------------------------------------------------------
959
960    #[test]
961    fn branch_forks_at_a_node_and_switches_active() {
962        let mut tree = SessionTree::from_linear(&msgs(3), 1); // n0,n1,n2 on main
963        let name = tree.branch("n1", Some("alt".to_string()), 5).unwrap();
964        assert_eq!(name, "alt");
965        assert_eq!(tree.active_branch, "alt");
966        assert_eq!(tree.branches["alt"].leaf, Some("n1".to_string()));
967
968        tree.append_message(ChatMessage::user("alt turn"), 6);
969        let alt_projection = tree.linear_projection().unwrap();
970        assert_eq!(alt_projection.len(), 3);
971        assert_eq!(content_of(&alt_projection[2]), "alt turn");
972
973        // `main` is untouched.
974        let main_projection = tree.linear_projection_of(MAIN_BRANCH).unwrap();
975        assert_eq!(main_projection.len(), 3);
976        assert_eq!(content_of(&main_projection[2]), "turn 2");
977    }
978
979    #[test]
980    fn branch_auto_names_when_no_name_given() {
981        let mut tree = SessionTree::from_linear(&msgs(2), 1);
982        let a = tree.branch("n0", None, 1).unwrap();
983        // Switch back to main before creating a second auto-named branch.
984        tree.switch_branch(MAIN_BRANCH).unwrap();
985        let b = tree.branch("n0", None, 2).unwrap();
986        assert_ne!(a, b);
987    }
988
989    #[test]
990    fn branch_with_duplicate_explicit_name_errors() {
991        let mut tree = SessionTree::from_linear(&msgs(2), 1);
992        tree.branch("n0", Some("x".to_string()), 1).unwrap();
993        tree.switch_branch(MAIN_BRANCH).unwrap();
994        let err = tree.branch("n0", Some("x".to_string()), 2).unwrap_err();
995        assert!(err.to_string().contains("x"));
996    }
997
998    #[test]
999    fn branch_at_unknown_node_errors() {
1000        let mut tree = SessionTree::from_linear(&msgs(1), 1);
1001        assert!(tree.branch("ghost", None, 1).is_err());
1002    }
1003
1004    #[test]
1005    fn switch_branch_to_unknown_name_errors() {
1006        let mut tree = SessionTree::from_linear(&msgs(1), 1);
1007        assert!(tree.switch_branch("ghost").is_err());
1008    }
1009
1010    // ---------------------------------------------------------------
1011    // F3 (LOW, ported from the Fable-5 review's
1012    // `attack_missing_next_id_field_causes_silent_node_overwrite`): id
1013    // allocation must never collide with an existing node, even when
1014    // `next_id` itself is untrustworthy (e.g. a sidecar written by an older
1015    // process, or hand-edited to omit the field — `#[serde(default)]` then
1016    // loads it as `0`).
1017    // ---------------------------------------------------------------
1018
1019    #[test]
1020    fn missing_next_id_field_no_longer_causes_a_silent_node_overwrite() {
1021        let tree = SessionTree::from_linear(
1022            &[ChatMessage::user("original n0"), ChatMessage::user("n1")],
1023            1,
1024        );
1025        let mut v: serde_json::Value = serde_json::to_value(&tree).unwrap();
1026        // Confirm next_id IS normally serialized (so the honest write side is
1027        // safe), then strip it to simulate a hand-edited/older sidecar.
1028        assert!(v.get("next_id").is_some());
1029        v.as_object_mut().unwrap().remove("next_id");
1030        let mut reloaded: SessionTree = serde_json::from_value(v).unwrap();
1031        let id = reloaded.append_message(ChatMessage::user("usurper"), 2);
1032        // The allocator must skip past the already-used "n0"/"n1" rather
1033        // than colliding with the existing root.
1034        assert_ne!(id, "n0");
1035        assert_ne!(id, "n1");
1036        // The original n0 message must survive untouched.
1037        assert_eq!(
1038            reloaded.node("n0").unwrap().message.content.as_deref(),
1039            Some("original n0")
1040        );
1041        assert_eq!(
1042            reloaded.node("n1").unwrap().message.content.as_deref(),
1043            Some("n1")
1044        );
1045        // And the new turn landed under its own fresh id.
1046        assert_eq!(
1047            reloaded.node(&id).unwrap().message.content.as_deref(),
1048            Some("usurper")
1049        );
1050    }
1051
1052    #[test]
1053    fn alloc_id_skips_past_several_hand_planted_collisions_in_a_row() {
1054        // A `next_id` that collides with several already-occupied ids in a
1055        // row (not just the very next candidate) must skip ALL of them, not
1056        // just one — proving the allocator loops rather than checking once.
1057        let mut tree = SessionTree::new();
1058        for i in 5..8 {
1059            tree.nodes.insert(
1060                format!("n{i}"),
1061                TreeNode {
1062                    id: format!("n{i}"),
1063                    parent: None,
1064                    children: Vec::new(),
1065                    message: ChatMessage::user(format!("planted {i}")),
1066                    label: None,
1067                    created_at_ms: 0,
1068                },
1069            );
1070        }
1071        tree.next_id = 5; // simulates a stale/hand-edited counter
1072        let id = tree.append_message(ChatMessage::user("first real append"), 1);
1073        assert_eq!(id, "n8"); // n5, n6, n7 are all taken; n8 is the first free one
1074        for i in 5..8 {
1075            assert_eq!(
1076                tree.node(&format!("n{i}")).unwrap().message.content,
1077                Some(format!("planted {i}"))
1078            );
1079        }
1080    }
1081
1082    // ---------------------------------------------------------------
1083    // Labels.
1084    // ---------------------------------------------------------------
1085
1086    #[test]
1087    fn label_and_clear_label_round_trip() {
1088        let mut tree = SessionTree::from_linear(&msgs(2), 1);
1089        tree.label("n0", "checkpoint-a").unwrap();
1090        assert_eq!(
1091            tree.node("n0").unwrap().label.as_deref(),
1092            Some("checkpoint-a")
1093        );
1094        tree.clear_label("n0").unwrap();
1095        assert_eq!(tree.node("n0").unwrap().label, None);
1096    }
1097
1098    #[test]
1099    fn label_unknown_node_errors() {
1100        let mut tree = SessionTree::from_linear(&msgs(1), 1);
1101        assert!(tree.label("ghost", "x").is_err());
1102    }
1103
1104    // ---------------------------------------------------------------
1105    // Branch summaries + C7 splice-for-linear-export.
1106    // ---------------------------------------------------------------
1107
1108    struct FakeSummarizer(&'static str);
1109    impl BranchSummarizer for FakeSummarizer {
1110        fn summarize(&self, _branch_text: &str) -> Result<String> {
1111            Ok(format!("summary via {}", self.0))
1112        }
1113        fn model_id(&self) -> &str {
1114            self.0
1115        }
1116    }
1117
1118    struct FailingSummarizer;
1119    impl BranchSummarizer for FailingSummarizer {
1120        fn summarize(&self, _branch_text: &str) -> Result<String> {
1121            Err(Error::Other("boom".to_string()))
1122        }
1123        fn model_id(&self) -> &str {
1124            "unused"
1125        }
1126    }
1127
1128    #[test]
1129    fn summarize_branch_with_records_model_generated_summary() {
1130        let mut tree = SessionTree::from_linear(&msgs(3), 1);
1131        tree.branch("n0", Some("off-path".to_string()), 5).unwrap();
1132        tree.switch_branch(MAIN_BRANCH).unwrap();
1133        tree.summarize_branch_with("off-path", &FakeSummarizer("haiku-test"), 9)
1134            .unwrap();
1135        let s = tree.branches["off-path"].summary.as_ref().unwrap();
1136        assert_eq!(s.summary, "summary via haiku-test");
1137        assert_eq!(s.model_id.as_deref(), Some("haiku-test"));
1138        assert_eq!(s.branch, "off-path");
1139    }
1140
1141    #[test]
1142    fn summarize_branch_with_never_fails_on_summarizer_error() {
1143        let mut tree = SessionTree::from_linear(&msgs(3), 1);
1144        tree.branch("n0", Some("off-path".to_string()), 5).unwrap();
1145        tree.switch_branch(MAIN_BRANCH).unwrap();
1146        // The summarizer errors, but the call itself must still succeed
1147        // (never blocks/fails the export — mirrors reduce/summarize.rs).
1148        tree.summarize_branch_with("off-path", &FailingSummarizer, 9)
1149            .unwrap();
1150        let s = tree.branches["off-path"].summary.as_ref().unwrap();
1151        assert!(s.summary.contains("unsummarized"));
1152        assert_eq!(s.model_id, None);
1153    }
1154
1155    /// F4 (LOW hygiene): `summarize_branch` on a leafless branch must not
1156    /// record a [`BranchSummary::node_id`] of `""` — a pointer to a node
1157    /// that doesn't exist. A branch only ever has no leaf immediately after
1158    /// [`SessionTree::new`] (before any node exists); switching to it and
1159    /// summarizing it before appending anything is exactly that case.
1160    #[test]
1161    fn summarize_branch_on_a_leafless_branch_errors_instead_of_recording_an_empty_node_id() {
1162        let mut tree = SessionTree::new();
1163        let err = tree
1164            .summarize_branch(MAIN_BRANCH, "premature summary", None, 1)
1165            .unwrap_err();
1166        assert!(err.to_string().contains(MAIN_BRANCH));
1167        // No summary — in particular no dangling `node_id: ""` — was
1168        // recorded.
1169        assert!(tree.branches[MAIN_BRANCH].summary.is_none());
1170    }
1171
1172    /// C7 proof: splicing a branched tree for a linear export target returns
1173    /// EXACTLY the active path (nothing more, nothing less) plus a summary
1174    /// for every off-path branch — and the source tree (every node of every
1175    /// branch) is completely untouched by the call, so the full data is
1176    /// still recoverable via the SAME [`SessionTree`] / its sidecar
1177    /// afterward.
1178    #[test]
1179    fn splice_for_linear_export_returns_active_path_and_summarizes_off_path_branches() {
1180        let mut tree = SessionTree::from_linear(&msgs(2), 1); // n0,n1 on main
1181        tree.branch("n0", Some("side-quest".to_string()), 5)
1182            .unwrap();
1183        tree.append_message(ChatMessage::user("side turn"), 6);
1184        tree.switch_branch(MAIN_BRANCH).unwrap();
1185        // main stays where it was: n0,n1.
1186
1187        let before_node_count = tree.nodes.len();
1188        let (active, summaries) = tree.splice_for_linear_export().unwrap();
1189
1190        // The active path is exactly `main`'s projection.
1191        assert_eq!(active.len(), 2);
1192        assert_eq!(content_of(&active[1]), "turn 1");
1193
1194        // Exactly one off-path branch (side-quest) is summarized.
1195        assert_eq!(summaries.len(), 1);
1196        assert_eq!(summaries[0].branch, "side-quest");
1197        assert!(summaries[0].summary.contains("unsummarized")); // never explicitly summarized above
1198
1199        // Nothing was dropped: the off-path branch's full data is STILL
1200        // there, recoverable via the pointer the summary carries.
1201        assert_eq!(tree.nodes.len(), before_node_count);
1202        let recovered = tree.linear_projection_of(&summaries[0].branch).unwrap();
1203        assert_eq!(recovered.len(), 2);
1204        assert_eq!(content_of(&recovered[1]), "side turn");
1205    }
1206
1207    #[test]
1208    fn splice_for_linear_export_reuses_an_explicit_summary_if_already_set() {
1209        let mut tree = SessionTree::from_linear(&msgs(1), 1);
1210        tree.branch("n0", Some("side".to_string()), 5).unwrap();
1211        tree.summarize_branch("side", "hand-written summary", None, 6)
1212            .unwrap();
1213        tree.switch_branch(MAIN_BRANCH).unwrap();
1214        let (_active, summaries) = tree.splice_for_linear_export().unwrap();
1215        assert_eq!(summaries.len(), 1);
1216        assert_eq!(summaries[0].summary, "hand-written summary");
1217    }
1218
1219    #[test]
1220    fn a_degenerate_single_path_tree_splices_to_the_whole_transcript_with_no_summaries() {
1221        let tree = SessionTree::from_linear(&msgs(3), 1);
1222        let (active, summaries) = tree.splice_for_linear_export().unwrap();
1223        assert_eq!(active.len(), 3);
1224        assert!(summaries.is_empty());
1225    }
1226
1227    /// F4 (LOW hygiene): a corrupted OFF-path branch must not silently
1228    /// splice into a misleading `"[0 turn(s), unsummarized]"` stub — that
1229    /// reads as "an empty conversation," which is a lie; the branch is
1230    /// actually unreadable. The active-path export (the part a linear
1231    /// consumer actually uses) must still succeed — one broken off-path
1232    /// branch does not fail the whole export (non-destructive, same "never
1233    /// blocks" posture as [`SessionTree::summarize_branch_with`]) — but the
1234    /// stub text for that branch must say so, not claim zero turns.
1235    #[test]
1236    fn splice_for_linear_export_surfaces_a_corrupt_off_path_branch_instead_of_masking_it_as_empty()
1237    {
1238        let mut tree = SessionTree::from_linear(&msgs(2), 1); // n0,n1 on main
1239        tree.branch("n0", Some("side-quest".to_string()), 5)
1240            .unwrap();
1241        tree.append_message(ChatMessage::user("side turn"), 6);
1242        tree.switch_branch(MAIN_BRANCH).unwrap();
1243        // Hand-corrupt the off-path branch into a cycle.
1244        let side_leaf = tree.branches["side-quest"].leaf.clone().unwrap();
1245        tree.nodes.get_mut(&side_leaf).unwrap().parent = Some(side_leaf.clone());
1246
1247        let (active, summaries) = tree.splice_for_linear_export().unwrap();
1248        // The active (main) path is completely unaffected.
1249        assert_eq!(active.len(), 2);
1250
1251        assert_eq!(summaries.len(), 1);
1252        assert_eq!(summaries[0].branch, "side-quest");
1253        // Must NOT claim "[0 turn(s), unsummarized]" — that would be
1254        // indistinguishable from a genuinely empty branch.
1255        assert!(!summaries[0].summary.contains("0 turn"));
1256        // Must actually say the branch couldn't be read.
1257        assert!(
1258            summaries[0].summary.contains("could not be read")
1259                || summaries[0].summary.contains("corrupt")
1260        );
1261    }
1262
1263    /// F4 (LOW hygiene): a leafless off-path branch (no node has ever been
1264    /// appended to it) gets an honest stub, not a fabricated `node_id: ""`.
1265    #[test]
1266    fn splice_for_linear_export_on_a_leafless_off_path_branch_does_not_fabricate_a_node_id() {
1267        let mut tree = SessionTree::from_linear(&msgs(1), 1);
1268        // A branch with no leaf can only arise via direct construction (no
1269        // public API leaves one leafless) — simulate a hand-edited sidecar.
1270        tree.branches.insert(
1271            "empty-branch".to_string(),
1272            Branch {
1273                name: "empty-branch".to_string(),
1274                leaf: None,
1275                summary: None,
1276                created_at_ms: 0,
1277            },
1278        );
1279        let (_active, summaries) = tree.splice_for_linear_export().unwrap();
1280        let s = summaries
1281            .iter()
1282            .find(|s| s.branch == "empty-branch")
1283            .unwrap();
1284        assert_eq!(s.node_id, "");
1285        assert!(s.summary.contains("no leaf"));
1286    }
1287
1288    // Test-only helper: a plain value clone (this whole type is already
1289    // `Clone`), named separately so its call sites read as "the untouched
1290    // baseline" rather than an ordinary working copy.
1291    impl SessionTree {
1292        fn clone_for_test(&self) -> Self {
1293            self.clone()
1294        }
1295    }
1296}