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}