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    /// Whether this tree has actually branched (more than just the implicit
595    /// [`MAIN_BRANCH`]) — i.e. it is no longer the degenerate single-path
596    /// case. A caller can use this to decide whether a `.tree.json` sidecar
597    /// is even worth persisting (a never-branched tree is exactly the
598    /// pre-existing linear session, byte for byte, so the C7 default-off
599    /// posture never requires writing one).
600    pub fn has_branches(&self) -> bool {
601        self.branches.len() > 1
602    }
603
604    /// Attach a caller-provided summary to `branch` directly (module 21
605    /// "branch summaries"). `node_id` records which node the summary is
606    /// as-of (the branch's current leaf, normally); `model_id` is `None` for
607    /// a caller-provided (not model-generated) summary. Errors if `branch`
608    /// doesn't exist.
609    ///
610    /// Errors if `branch` has no leaf yet (a brand-new, still-empty branch) —
611    /// a leafless branch has no node to summarize *as-of*, and recording a
612    /// [`BranchSummary::node_id`] of `""` would be a pointer to a node that
613    /// doesn't exist (F4: never fabricate a dangling pointer).
614    pub fn summarize_branch(
615        &mut self,
616        branch: &str,
617        summary: impl Into<String>,
618        model_id: Option<String>,
619        timestamp_ms: i64,
620    ) -> Result<()> {
621        let leaf = self.require_branch(branch)?.leaf.clone().ok_or_else(|| {
622            Error::Other(format!(
623                "session tree branch `{branch}` has no leaf yet — nothing to summarize"
624            ))
625        })?;
626        let b = self
627            .branches
628            .get_mut(branch)
629            .expect("just checked via require_branch");
630        b.summary = Some(BranchSummary {
631            summary: summary.into(),
632            node_id: leaf,
633            branch: branch.to_string(),
634            model_id,
635            created_at_ms: timestamp_ms,
636        });
637        Ok(())
638    }
639
640    /// Render a branch's linear projection into plain text (one line per
641    /// turn, `role: content`) — the input a [`BranchSummarizer`] side-call
642    /// summarizes, mirroring `reduce/summarize.rs`'s `render_span_text`
643    /// shape.
644    pub fn render_branch_text(&self, branch: &str) -> Result<String> {
645        let messages = self.linear_projection_of(branch)?;
646        let mut out = String::new();
647        for m in &messages {
648            let role = match m.role {
649                crate::message::Role::System => "system",
650                crate::message::Role::User => "user",
651                crate::message::Role::Assistant => "assistant",
652                crate::message::Role::Tool => "tool",
653            };
654            out.push_str(role);
655            out.push_str(": ");
656            out.push_str(m.content.as_deref().unwrap_or(""));
657            out.push('\n');
658        }
659        Ok(out)
660    }
661
662    /// Summarize `branch` via a small-model side-call (D-9, the mechanism
663    /// an optional caller-supplied branch summarizer
664    /// also uses): renders the branch's text
665    /// ([`Self::render_branch_text`]) and calls `summarizer`. **Never fails
666    /// the caller** — mirroring `reduce/summarize.rs`'s "never blocks, never
667    /// fails the pass" posture: if `summarizer` errors (a timeout, a
668    /// provider error, budget exhaustion — whatever it models), this falls
669    /// back to a deterministic stub summary (`"[N turns, unsummarized]"`)
670    /// rather than propagating the error, so a C7 export can always
671    /// complete. Errors only if `branch` itself doesn't exist.
672    pub fn summarize_branch_with(
673        &mut self,
674        branch: &str,
675        summarizer: &dyn BranchSummarizer,
676        timestamp_ms: i64,
677    ) -> Result<()> {
678        let text = self.render_branch_text(branch)?;
679        let turn_count = self.linear_projection_of(branch)?.len();
680        match summarizer.summarize(&text) {
681            Ok(summary) => {
682                self.summarize_branch(
683                    branch,
684                    summary,
685                    Some(summarizer.model_id().to_string()),
686                    timestamp_ms,
687                )?;
688            }
689            Err(_) => {
690                self.summarize_branch(
691                    branch,
692                    format!("[{turn_count} turn(s), unsummarized]"),
693                    None,
694                    timestamp_ms,
695                )?;
696            }
697        }
698        Ok(())
699    }
700
701    /// C7 export mechanism: splice the ACTIVE branch's messages (exactly
702    /// [`Self::linear_projection`] — what a strictly-linear export target,
703    /// e.g. the CX rollout shape, can represent) plus a [`BranchSummary`]
704    /// for every OFF-path branch (every branch other than the active one).
705    /// An off-path branch that already carries a [`Branch::summary`] reuses
706    /// it as-is; one that doesn't gets a fresh deterministic stub summary
707    /// (`"[N turn(s), unsummarized]"`) — this method takes `&self` (read
708    /// only) precisely so it never needs a live [`BranchSummarizer`] side-call
709    /// inline; a caller wanting model-generated summaries should call
710    /// [`Self::summarize_branch_with`] on each off-path branch FIRST, then
711    /// call this. Nothing here mutates or drops any node — see the module
712    /// doc's "Lossless rewind" / C7 sections: the full multi-branch
713    /// [`SessionTree`] (this method's `&self` receiver) remains the
714    /// recoverable source of truth regardless of what the caller does with
715    /// the returned linear messages.
716    ///
717    /// **Fail-closed** on the active path, same posture as
718    /// [`Self::linear_projection`]: a corrupt active branch errors instead of
719    /// silently exporting an empty transcript (F2). Off-path branches are
720    /// summarized best-effort (see [`Self::summarize_branch_with`]'s "never
721    /// blocks" contract) — a corrupt OFF-path branch does not fail the whole
722    /// export, but never claims false turn-count precision either; see
723    /// [`BranchSummary`]'s construction below.
724    pub fn splice_for_linear_export(&self) -> Result<(Vec<ChatMessage>, Vec<BranchSummary>)> {
725        let active = self.linear_projection()?;
726        let mut summaries = Vec::new();
727        for (name, b) in &self.branches {
728            if name == &self.active_branch {
729                continue;
730            }
731            if let Some(s) = &b.summary {
732                summaries.push(s.clone());
733            } else {
734                // F4: don't mask a corrupt/leafless off-path branch behind a
735                // deterministic-looking "[0 turn(s)]" stub — that reads as
736                // "an empty conversation" when the real state is "this
737                // branch's data couldn't be read." Surface the real state in
738                // the summary text instead (never errors the whole export
739                // over ONE off-path branch — same "never blocks" posture as
740                // `Self::summarize_branch_with`), and stamp the branch's own
741                // `created_at_ms` rather than a placeholder `0`.
742                let (summary_text, node_id) = match &b.leaf {
743                    None => (
744                        format!("[branch `{name}` has no leaf yet — nothing to summarize]"),
745                        String::new(),
746                    ),
747                    Some(leaf) => match self.linear_projection_of(name) {
748                        Ok(msgs) => (
749                            format!("[{} turn(s), unsummarized]", msgs.len()),
750                            leaf.clone(),
751                        ),
752                        Err(e) => (
753                            format!("[branch `{name}` could not be read, unsummarized: {e}]"),
754                            leaf.clone(),
755                        ),
756                    },
757                };
758                summaries.push(BranchSummary {
759                    summary: summary_text,
760                    node_id,
761                    branch: name.clone(),
762                    model_id: None,
763                    created_at_ms: b.created_at_ms,
764                });
765            }
766        }
767        Ok((active, summaries))
768    }
769}
770
771/// Injectable branch-summarization side-call (D-9), the module-21 analog of
772/// a caller-supplied branch summarizer
773/// — same shape, deliberately: a real implementation calls out to a cheap
774/// model; tests inject a deterministic fake. See
775/// [`SessionTree::summarize_branch_with`]'s doc comment for the "never
776/// blocks, never fails the caller" contract this trait's `Err` feeds into.
777pub trait BranchSummarizer {
778    /// Summarize `branch_text` (the rendering [`SessionTree::render_branch_text`]
779    /// produces) into a short paragraph. `Err` means the caller falls back to
780    /// a deterministic stub — see
781    /// [`SessionTree::summarize_branch_with`].
782    fn summarize(&self, branch_text: &str) -> Result<String>;
783
784    /// Identifier of the model behind this summarizer (recorded on
785    /// [`BranchSummary::model_id`]).
786    fn model_id(&self) -> &str;
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    fn msgs(n: usize) -> Vec<ChatMessage> {
794        (0..n)
795            .map(|i| ChatMessage::user(format!("turn {i}")))
796            .collect()
797    }
798
799    fn content_of(m: &ChatMessage) -> &str {
800        m.content.as_deref().unwrap_or("")
801    }
802
803    // ---------------------------------------------------------------
804    // C7: linear projection exactness / default-off degenerate case.
805    // ---------------------------------------------------------------
806
807    #[test]
808    fn linear_projection_of_a_from_linear_tree_matches_the_source_messages() {
809        let source = msgs(5);
810        let tree = SessionTree::from_linear(&source, 1_700_000_000_000);
811        let projected = tree.linear_projection().unwrap();
812        assert_eq!(projected.len(), source.len());
813        for (p, s) in projected.iter().zip(source.iter()) {
814            assert_eq!(content_of(p), content_of(s));
815        }
816        // A never-branched tree is the degenerate single-path case.
817        assert!(!tree.has_branches());
818    }
819
820    #[test]
821    fn empty_tree_has_empty_linear_projection() {
822        let tree = SessionTree::new();
823        assert!(tree.linear_projection().unwrap().is_empty());
824        assert_eq!(tree.root, None);
825    }
826
827    #[test]
828    fn append_message_chains_and_advances_the_active_leaf() {
829        let mut tree = SessionTree::new();
830        let n0 = tree.append_message(ChatMessage::user("hello"), 1);
831        let n1 = tree.append_message(ChatMessage::assistant("hi"), 2);
832        assert_eq!(tree.root, Some(n0.clone()));
833        assert_eq!(tree.branches[MAIN_BRANCH].leaf, Some(n1.clone()));
834        assert_eq!(tree.node(&n1).unwrap().parent, Some(n0.clone()));
835        assert_eq!(tree.node(&n0).unwrap().children, vec![n1]);
836    }
837
838    // ---------------------------------------------------------------
839    // Rewind — lossless-ness proof.
840    // ---------------------------------------------------------------
841
842    #[test]
843    fn rewind_to_unknown_node_errors_not_corrupts() {
844        let mut tree = SessionTree::from_linear(&msgs(3), 1);
845        let before = tree.clone_for_test();
846        let err = tree.rewind("does-not-exist", 2).unwrap_err();
847        assert!(err.to_string().contains("does-not-exist"));
848        // Nothing changed.
849        assert_eq!(
850            tree.branches[MAIN_BRANCH].leaf,
851            before.branches[MAIN_BRANCH].leaf
852        );
853        assert_eq!(tree.nodes.len(), before.nodes.len());
854    }
855
856    #[test]
857    fn rewind_preserves_the_rewound_past_as_a_recoverable_sibling_branch() {
858        let mut tree = SessionTree::from_linear(&msgs(4), 1); // n0..n3, leaf n3
859        let n1 = "n1".to_string();
860        let old_leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
861        assert_eq!(old_leaf, "n3");
862
863        let preserved = tree.rewind(&n1, 100).unwrap().expect("moved the pointer");
864        // The active branch now sits at n1.
865        assert_eq!(tree.branches[MAIN_BRANCH].leaf, Some(n1.clone()));
866        // But the rewound-past data (n2, n3) is NOT deleted: every node is
867        // still present...
868        assert!(tree.node("n2").is_some());
869        assert!(tree.node("n3").is_some());
870        // ...AND still independently reachable/enumerable as its own named
871        // branch, ending exactly where `main` used to.
872        assert_eq!(tree.branches[&preserved].leaf, Some(old_leaf));
873        let recovered = tree.linear_projection_of(&preserved).unwrap();
874        assert_eq!(recovered.len(), 4);
875        assert_eq!(content_of(&recovered[3]), "turn 3");
876
877        // The active (rewound) branch's own projection is the shorter prefix.
878        let active = tree.linear_projection().unwrap();
879        assert_eq!(active.len(), 2);
880        assert_eq!(content_of(&active[1]), "turn 1");
881    }
882
883    #[test]
884    fn rewind_to_the_current_leaf_is_a_no_op_and_preserves_nothing_new() {
885        let mut tree = SessionTree::from_linear(&msgs(2), 1);
886        let leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
887        let branch_count_before = tree.branches.len();
888        let preserved = tree.rewind(&leaf, 2).unwrap();
889        assert_eq!(preserved, None);
890        assert_eq!(tree.branches.len(), branch_count_before);
891    }
892
893    #[test]
894    fn appending_after_rewind_forks_a_new_sibling_child() {
895        let mut tree = SessionTree::from_linear(&msgs(3), 1); // n0,n1,n2
896        let n0 = "n0".to_string();
897        tree.rewind(&n0, 10).unwrap();
898        let new_child = tree.append_message(ChatMessage::user("alt turn 1"), 11);
899        // n0 now has two children: the original n1, and the new fork.
900        let n0_children = &tree.node(&n0).unwrap().children;
901        assert_eq!(n0_children.len(), 2);
902        assert!(n0_children.contains(&"n1".to_string()));
903        assert!(n0_children.contains(&new_child));
904        // The active projection reflects the NEW path.
905        let active = tree.linear_projection().unwrap();
906        assert_eq!(active.len(), 2);
907        assert_eq!(content_of(&active[1]), "alt turn 1");
908    }
909
910    #[test]
911    fn no_api_can_create_a_cycle_linear_projection_of_a_hand_edited_cycle_errors() {
912        let mut tree = SessionTree::from_linear(&msgs(2), 1);
913        // Hand-corrupt: make n0's parent point at n1 (n1's parent is n0) —
914        // a 2-cycle. No public API of this module can produce this; this
915        // simulates a corrupted/hand-edited `.tree.json`.
916        tree.nodes.get_mut("n0").unwrap().parent = Some("n1".to_string());
917        let err = tree.linear_projection_of(MAIN_BRANCH).unwrap_err();
918        assert!(err.to_string().contains("cycle"));
919    }
920
921    // ---------------------------------------------------------------
922    // Branch — explicit fork + switch.
923    // ---------------------------------------------------------------
924
925    #[test]
926    fn branch_forks_at_a_node_and_switches_active() {
927        let mut tree = SessionTree::from_linear(&msgs(3), 1); // n0,n1,n2 on main
928        let name = tree.branch("n1", Some("alt".to_string()), 5).unwrap();
929        assert_eq!(name, "alt");
930        assert_eq!(tree.active_branch, "alt");
931        assert_eq!(tree.branches["alt"].leaf, Some("n1".to_string()));
932
933        tree.append_message(ChatMessage::user("alt turn"), 6);
934        let alt_projection = tree.linear_projection().unwrap();
935        assert_eq!(alt_projection.len(), 3);
936        assert_eq!(content_of(&alt_projection[2]), "alt turn");
937
938        // `main` is untouched.
939        let main_projection = tree.linear_projection_of(MAIN_BRANCH).unwrap();
940        assert_eq!(main_projection.len(), 3);
941        assert_eq!(content_of(&main_projection[2]), "turn 2");
942    }
943
944    #[test]
945    fn branch_auto_names_when_no_name_given() {
946        let mut tree = SessionTree::from_linear(&msgs(2), 1);
947        let a = tree.branch("n0", None, 1).unwrap();
948        // Switch back to main before creating a second auto-named branch.
949        tree.switch_branch(MAIN_BRANCH).unwrap();
950        let b = tree.branch("n0", None, 2).unwrap();
951        assert_ne!(a, b);
952    }
953
954    #[test]
955    fn branch_with_duplicate_explicit_name_errors() {
956        let mut tree = SessionTree::from_linear(&msgs(2), 1);
957        tree.branch("n0", Some("x".to_string()), 1).unwrap();
958        tree.switch_branch(MAIN_BRANCH).unwrap();
959        let err = tree.branch("n0", Some("x".to_string()), 2).unwrap_err();
960        assert!(err.to_string().contains("x"));
961    }
962
963    #[test]
964    fn branch_at_unknown_node_errors() {
965        let mut tree = SessionTree::from_linear(&msgs(1), 1);
966        assert!(tree.branch("ghost", None, 1).is_err());
967    }
968
969    #[test]
970    fn switch_branch_to_unknown_name_errors() {
971        let mut tree = SessionTree::from_linear(&msgs(1), 1);
972        assert!(tree.switch_branch("ghost").is_err());
973    }
974
975    // ---------------------------------------------------------------
976    // F3 (LOW, ported from the Fable-5 review's
977    // `attack_missing_next_id_field_causes_silent_node_overwrite`): id
978    // allocation must never collide with an existing node, even when
979    // `next_id` itself is untrustworthy (e.g. a sidecar written by an older
980    // process, or hand-edited to omit the field — `#[serde(default)]` then
981    // loads it as `0`).
982    // ---------------------------------------------------------------
983
984    #[test]
985    fn missing_next_id_field_no_longer_causes_a_silent_node_overwrite() {
986        let tree = SessionTree::from_linear(
987            &[ChatMessage::user("original n0"), ChatMessage::user("n1")],
988            1,
989        );
990        let mut v: serde_json::Value = serde_json::to_value(&tree).unwrap();
991        // Confirm next_id IS normally serialized (so the honest write side is
992        // safe), then strip it to simulate a hand-edited/older sidecar.
993        assert!(v.get("next_id").is_some());
994        v.as_object_mut().unwrap().remove("next_id");
995        let mut reloaded: SessionTree = serde_json::from_value(v).unwrap();
996        let id = reloaded.append_message(ChatMessage::user("usurper"), 2);
997        // The allocator must skip past the already-used "n0"/"n1" rather
998        // than colliding with the existing root.
999        assert_ne!(id, "n0");
1000        assert_ne!(id, "n1");
1001        // The original n0 message must survive untouched.
1002        assert_eq!(
1003            reloaded.node("n0").unwrap().message.content.as_deref(),
1004            Some("original n0")
1005        );
1006        assert_eq!(
1007            reloaded.node("n1").unwrap().message.content.as_deref(),
1008            Some("n1")
1009        );
1010        // And the new turn landed under its own fresh id.
1011        assert_eq!(
1012            reloaded.node(&id).unwrap().message.content.as_deref(),
1013            Some("usurper")
1014        );
1015    }
1016
1017    #[test]
1018    fn alloc_id_skips_past_several_hand_planted_collisions_in_a_row() {
1019        // A `next_id` that collides with several already-occupied ids in a
1020        // row (not just the very next candidate) must skip ALL of them, not
1021        // just one — proving the allocator loops rather than checking once.
1022        let mut tree = SessionTree::new();
1023        for i in 5..8 {
1024            tree.nodes.insert(
1025                format!("n{i}"),
1026                TreeNode {
1027                    id: format!("n{i}"),
1028                    parent: None,
1029                    children: Vec::new(),
1030                    message: ChatMessage::user(format!("planted {i}")),
1031                    label: None,
1032                    created_at_ms: 0,
1033                },
1034            );
1035        }
1036        tree.next_id = 5; // simulates a stale/hand-edited counter
1037        let id = tree.append_message(ChatMessage::user("first real append"), 1);
1038        assert_eq!(id, "n8"); // n5, n6, n7 are all taken; n8 is the first free one
1039        for i in 5..8 {
1040            assert_eq!(
1041                tree.node(&format!("n{i}")).unwrap().message.content,
1042                Some(format!("planted {i}"))
1043            );
1044        }
1045    }
1046
1047    // ---------------------------------------------------------------
1048    // Labels.
1049    // ---------------------------------------------------------------
1050
1051    #[test]
1052    fn label_and_clear_label_round_trip() {
1053        let mut tree = SessionTree::from_linear(&msgs(2), 1);
1054        tree.label("n0", "checkpoint-a").unwrap();
1055        assert_eq!(
1056            tree.node("n0").unwrap().label.as_deref(),
1057            Some("checkpoint-a")
1058        );
1059        tree.clear_label("n0").unwrap();
1060        assert_eq!(tree.node("n0").unwrap().label, None);
1061    }
1062
1063    #[test]
1064    fn label_unknown_node_errors() {
1065        let mut tree = SessionTree::from_linear(&msgs(1), 1);
1066        assert!(tree.label("ghost", "x").is_err());
1067    }
1068
1069    // ---------------------------------------------------------------
1070    // Branch summaries + C7 splice-for-linear-export.
1071    // ---------------------------------------------------------------
1072
1073    struct FakeSummarizer(&'static str);
1074    impl BranchSummarizer for FakeSummarizer {
1075        fn summarize(&self, _branch_text: &str) -> Result<String> {
1076            Ok(format!("summary via {}", self.0))
1077        }
1078        fn model_id(&self) -> &str {
1079            self.0
1080        }
1081    }
1082
1083    struct FailingSummarizer;
1084    impl BranchSummarizer for FailingSummarizer {
1085        fn summarize(&self, _branch_text: &str) -> Result<String> {
1086            Err(Error::Other("boom".to_string()))
1087        }
1088        fn model_id(&self) -> &str {
1089            "unused"
1090        }
1091    }
1092
1093    #[test]
1094    fn summarize_branch_with_records_model_generated_summary() {
1095        let mut tree = SessionTree::from_linear(&msgs(3), 1);
1096        tree.branch("n0", Some("off-path".to_string()), 5).unwrap();
1097        tree.switch_branch(MAIN_BRANCH).unwrap();
1098        tree.summarize_branch_with("off-path", &FakeSummarizer("haiku-test"), 9)
1099            .unwrap();
1100        let s = tree.branches["off-path"].summary.as_ref().unwrap();
1101        assert_eq!(s.summary, "summary via haiku-test");
1102        assert_eq!(s.model_id.as_deref(), Some("haiku-test"));
1103        assert_eq!(s.branch, "off-path");
1104    }
1105
1106    #[test]
1107    fn summarize_branch_with_never_fails_on_summarizer_error() {
1108        let mut tree = SessionTree::from_linear(&msgs(3), 1);
1109        tree.branch("n0", Some("off-path".to_string()), 5).unwrap();
1110        tree.switch_branch(MAIN_BRANCH).unwrap();
1111        // The summarizer errors, but the call itself must still succeed
1112        // (never blocks/fails the export — mirrors reduce/summarize.rs).
1113        tree.summarize_branch_with("off-path", &FailingSummarizer, 9)
1114            .unwrap();
1115        let s = tree.branches["off-path"].summary.as_ref().unwrap();
1116        assert!(s.summary.contains("unsummarized"));
1117        assert_eq!(s.model_id, None);
1118    }
1119
1120    /// F4 (LOW hygiene): `summarize_branch` on a leafless branch must not
1121    /// record a [`BranchSummary::node_id`] of `""` — a pointer to a node
1122    /// that doesn't exist. A branch only ever has no leaf immediately after
1123    /// [`SessionTree::new`] (before any node exists); switching to it and
1124    /// summarizing it before appending anything is exactly that case.
1125    #[test]
1126    fn summarize_branch_on_a_leafless_branch_errors_instead_of_recording_an_empty_node_id() {
1127        let mut tree = SessionTree::new();
1128        let err = tree
1129            .summarize_branch(MAIN_BRANCH, "premature summary", None, 1)
1130            .unwrap_err();
1131        assert!(err.to_string().contains(MAIN_BRANCH));
1132        // No summary — in particular no dangling `node_id: ""` — was
1133        // recorded.
1134        assert!(tree.branches[MAIN_BRANCH].summary.is_none());
1135    }
1136
1137    /// C7 proof: splicing a branched tree for a linear export target returns
1138    /// EXACTLY the active path (nothing more, nothing less) plus a summary
1139    /// for every off-path branch — and the source tree (every node of every
1140    /// branch) is completely untouched by the call, so the full data is
1141    /// still recoverable via the SAME [`SessionTree`] / its sidecar
1142    /// afterward.
1143    #[test]
1144    fn splice_for_linear_export_returns_active_path_and_summarizes_off_path_branches() {
1145        let mut tree = SessionTree::from_linear(&msgs(2), 1); // n0,n1 on main
1146        tree.branch("n0", Some("side-quest".to_string()), 5)
1147            .unwrap();
1148        tree.append_message(ChatMessage::user("side turn"), 6);
1149        tree.switch_branch(MAIN_BRANCH).unwrap();
1150        // main stays where it was: n0,n1.
1151
1152        let before_node_count = tree.nodes.len();
1153        let (active, summaries) = tree.splice_for_linear_export().unwrap();
1154
1155        // The active path is exactly `main`'s projection.
1156        assert_eq!(active.len(), 2);
1157        assert_eq!(content_of(&active[1]), "turn 1");
1158
1159        // Exactly one off-path branch (side-quest) is summarized.
1160        assert_eq!(summaries.len(), 1);
1161        assert_eq!(summaries[0].branch, "side-quest");
1162        assert!(summaries[0].summary.contains("unsummarized")); // never explicitly summarized above
1163
1164        // Nothing was dropped: the off-path branch's full data is STILL
1165        // there, recoverable via the pointer the summary carries.
1166        assert_eq!(tree.nodes.len(), before_node_count);
1167        let recovered = tree.linear_projection_of(&summaries[0].branch).unwrap();
1168        assert_eq!(recovered.len(), 2);
1169        assert_eq!(content_of(&recovered[1]), "side turn");
1170    }
1171
1172    #[test]
1173    fn splice_for_linear_export_reuses_an_explicit_summary_if_already_set() {
1174        let mut tree = SessionTree::from_linear(&msgs(1), 1);
1175        tree.branch("n0", Some("side".to_string()), 5).unwrap();
1176        tree.summarize_branch("side", "hand-written summary", None, 6)
1177            .unwrap();
1178        tree.switch_branch(MAIN_BRANCH).unwrap();
1179        let (_active, summaries) = tree.splice_for_linear_export().unwrap();
1180        assert_eq!(summaries.len(), 1);
1181        assert_eq!(summaries[0].summary, "hand-written summary");
1182    }
1183
1184    #[test]
1185    fn a_degenerate_single_path_tree_splices_to_the_whole_transcript_with_no_summaries() {
1186        let tree = SessionTree::from_linear(&msgs(3), 1);
1187        let (active, summaries) = tree.splice_for_linear_export().unwrap();
1188        assert_eq!(active.len(), 3);
1189        assert!(summaries.is_empty());
1190    }
1191
1192    /// F4 (LOW hygiene): a corrupted OFF-path branch must not silently
1193    /// splice into a misleading `"[0 turn(s), unsummarized]"` stub — that
1194    /// reads as "an empty conversation," which is a lie; the branch is
1195    /// actually unreadable. The active-path export (the part a linear
1196    /// consumer actually uses) must still succeed — one broken off-path
1197    /// branch does not fail the whole export (non-destructive, same "never
1198    /// blocks" posture as [`SessionTree::summarize_branch_with`]) — but the
1199    /// stub text for that branch must say so, not claim zero turns.
1200    #[test]
1201    fn splice_for_linear_export_surfaces_a_corrupt_off_path_branch_instead_of_masking_it_as_empty()
1202    {
1203        let mut tree = SessionTree::from_linear(&msgs(2), 1); // n0,n1 on main
1204        tree.branch("n0", Some("side-quest".to_string()), 5)
1205            .unwrap();
1206        tree.append_message(ChatMessage::user("side turn"), 6);
1207        tree.switch_branch(MAIN_BRANCH).unwrap();
1208        // Hand-corrupt the off-path branch into a cycle.
1209        let side_leaf = tree.branches["side-quest"].leaf.clone().unwrap();
1210        tree.nodes.get_mut(&side_leaf).unwrap().parent = Some(side_leaf.clone());
1211
1212        let (active, summaries) = tree.splice_for_linear_export().unwrap();
1213        // The active (main) path is completely unaffected.
1214        assert_eq!(active.len(), 2);
1215
1216        assert_eq!(summaries.len(), 1);
1217        assert_eq!(summaries[0].branch, "side-quest");
1218        // Must NOT claim "[0 turn(s), unsummarized]" — that would be
1219        // indistinguishable from a genuinely empty branch.
1220        assert!(!summaries[0].summary.contains("0 turn"));
1221        // Must actually say the branch couldn't be read.
1222        assert!(
1223            summaries[0].summary.contains("could not be read")
1224                || summaries[0].summary.contains("corrupt")
1225        );
1226    }
1227
1228    /// F4 (LOW hygiene): a leafless off-path branch (no node has ever been
1229    /// appended to it) gets an honest stub, not a fabricated `node_id: ""`.
1230    #[test]
1231    fn splice_for_linear_export_on_a_leafless_off_path_branch_does_not_fabricate_a_node_id() {
1232        let mut tree = SessionTree::from_linear(&msgs(1), 1);
1233        // A branch with no leaf can only arise via direct construction (no
1234        // public API leaves one leafless) — simulate a hand-edited sidecar.
1235        tree.branches.insert(
1236            "empty-branch".to_string(),
1237            Branch {
1238                name: "empty-branch".to_string(),
1239                leaf: None,
1240                summary: None,
1241                created_at_ms: 0,
1242            },
1243        );
1244        let (_active, summaries) = tree.splice_for_linear_export().unwrap();
1245        let s = summaries
1246            .iter()
1247            .find(|s| s.branch == "empty-branch")
1248            .unwrap();
1249        assert_eq!(s.node_id, "");
1250        assert!(s.summary.contains("no leaf"));
1251    }
1252
1253    // Test-only helper: a plain value clone (this whole type is already
1254    // `Clone`), named separately so its call sites read as "the untouched
1255    // baseline" rather than an ordinary working copy.
1256    impl SessionTree {
1257        fn clone_for_test(&self) -> Self {
1258            self.clone()
1259        }
1260    }
1261}