supercode-interchange 0.4.8

Canonical, provider-neutral session interchange primitives for Supercode
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
//! P5-5 (design §2 module 21 `session.tree`: "D5 in-place tree,
//! rewind-anywhere, branch summaries, entry labels"; §2.1 D-6 `session.tree →
//! core.session(tree-addressable transcript)`; §2.2 C7; §5.2 P5 row 5:
//! "loaders are already tree-aware (C7 resolution); adds in-place
//! rewind/branch/label on the native store").
//!
//! # What this is
//!
//! [`Session`](crate::session::Session) and the composition layer's session store
//! already carry a **linear** transcript — `messages: Vec<ChatMessage>` — and
//! the loaders already reconstruct MULTI-FILE tree structure on import
//! (`Session::reconstruct_tree`, the C7 resolution's "import-side preserved
//! only"). What was missing is the native, IN-PLACE tree: the ability to
//! address any turn by id, rewind the active pointer to an earlier one
//! without deleting anything, explicitly fork a new branch, attach a short
//! summary to an off-path branch, and label any node — all on supercode's
//! OWN session store (CC/PI's defining in-place-tree feature, catalog D5).
//!
//! # C7 — tree-with-linear-projection
//!
//! Conflict 7 (design §2.2): `session.tree`'s in-place DAG is structurally
//! incompatible with a strictly-linear export target (the CX rollout shape).
//! The resolution the design commits to is **core stays
//! tree-with-linear-projection**: [`SessionTree::linear_projection`] always
//! derives the active branch's message sequence deterministically by walking
//! parent pointers from the root to the active leaf — this is what
//! [`crate::session::Session::messages`] / the agent loop / exporters keep
//! consuming unchanged. A tree session with zero branches (the common case,
//! and the ONLY case before this module's operations are ever invoked) is the
//! *degenerate single-path tree*: its projection is byte-for-byte the same
//! sequence [`crate::session::Session::messages`] already held — see
//! the linear-projection regression test.
//!
//! Exporting a branched tree to a linear-only format is
//! [`SessionTree::splice_for_linear_export`]: it returns the active path
//! (spliced, exactly like today's linear export) plus a
//! [`BranchSummary`] for every OFF-path branch. Nothing is deleted by this —
//! the full tree (every node of every branch) stays intact in
//! [`SessionTree`] / its `<name>.tree.json` sidecar
//! (through the session store's explicit tree writer); the summary is an added,
//! human-readable POINTER (`BranchSummary::branch` names which branch the
//! full data still lives under), never a replacement for it (§1.13 lossless).
//!
//! # Lossless rewind (§1.13)
//!
//! [`SessionTree::rewind`] never deletes a node. Moving the active branch's
//! leaf pointer backward leaves every node — including the ones the pointer
//! used to point through — exactly where it was in the DAG. Whenever the
//! rewind actually moves the pointer off the branch's previous leaf, the OLD
//! leaf is preserved under a freshly-named sibling branch (so it stays
//! independently addressable/enumerable, not merely "still linked in but
//! orphaned from every named branch") — see
//! the rewind-preservation regression test.
//! The next turn appended after a rewind becomes a NEW child of the rewind
//! target, i.e. a sibling of whatever used to follow it — exactly "rewind =
//! fork at the rewind point" (module 21's row).
//!
//! # Off by default / byte-identical
//!
//! Nothing in this module is on any hot path. A [`SessionTree`] is only ever
//! constructed by an explicit caller (never implicitly by
//! [`crate::session::Session`] loading/saving, never by a runtime agent loop) and its sidecar
//! (`<name>.tree.json`) is only ever written by an explicit
//! an explicit session-store tree-write call — so a session that never
//! invokes any tree operation has no `.tree.json` file at all, and every
//! existing linear read/write path (`Session::to_native_jsonl`/
//! `from_native_str`, `SessionStore::save`/`load`) is untouched
//! byte-for-byte. `capabilities.session_tree.enabled` (§3.1, module 21;
//! exposed by the composition layer's `SessionTree` module switch and runtime
//! configuration flags for tree enablement, summaries, and labels, allowing a caller to
//! gate on — this module's own API has no runtime dependency on that flag
//! (a library caller can always use [`SessionTree`] directly, exactly like
//! native store forking does not gate on any capability
//! either).

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use crate::sidecar::NativeTurn;
use crate::{ChatMessage, InterchangeError as Error, Result};

/// A tree-node id. Assigned by `SessionTree`'s monotonic allocator — a
/// counter (`"n0"`, `"n1"`, ...), not content-derived or random, so ids are
/// deterministic and trivially testable, and so two nodes can never collide.
pub type NodeId = String;

/// One addressable turn in the tree (module 21's "entry"). Carries the full
/// [`ChatMessage`] (this IS the full-fidelity source for a branched session
/// — see the module doc's "off by default" note: the plain linear transcript
/// file remains the record for an UNBRANCHED session; this sidecar only
/// exists once a tree operation actually ran), its parent/children links,
/// and an optional human/agent-set label (module 21 "entry labels").
///
/// **Lossless persistence (§1.13).** [`Self::message`] is a plain
/// [`ChatMessage`] in memory, but this type's `Serialize`/`Deserialize`
/// impls (below) are hand-written rather than derived: they route the
/// message through [`NativeTurn`] — the SAME full-fidelity wire record
/// [`crate::session::Session::to_native_jsonl_v2`] already uses to persist
/// live-appended turns — instead of `ChatMessage`'s own wire `Serialize`.
/// `ChatMessage`'s hand-rolled wire serde (`message.rs:57-79`) is deliberately
/// lossy: it OMITS `metadata` entirely (never meant to reach a provider
/// request body) and collapses `content` whenever `content_parts` is also
/// set. That lossy shape is correct for an outbound API request; it is
/// WRONG for this sidecar, which is the ONLY durable record of an off-path
/// branch's messages (a rewound-past branch has no other file backing it).
/// `NativeTurn` was built for exactly this distinction (see its module doc:
/// "the sidecar must retain what the wire serde must drop") — reusing it
/// here, rather than inventing a second parallel lossless representation,
/// keeps `TreeNode.message` byte-for-byte round-trippable: `metadata` intact,
/// `content` AND `content_parts` both intact (independently — `NativeTurn`
/// does not collapse one into the other).
#[derive(Debug, Clone)]
pub struct TreeNode {
    /// This node's id.
    pub id: NodeId,
    /// The parent node id. `None` only for the tree's root.
    pub parent: Option<NodeId>,
    /// Child node ids, in the order they were created. More than one entry
    /// here IS a branch point (multiple turns following the same parent).
    pub children: Vec<NodeId>,
    /// The turn itself.
    pub message: ChatMessage,
    /// A human/agent-set label on this node (module 21 "entry labels"),
    /// e.g. a checkpoint name or an annotation. `None` (the default) —
    /// unlabeled.
    pub label: Option<String>,
    /// Unix-ms wall-clock time this node was created.
    pub created_at_ms: i64,
}

/// The on-disk shape of a [`TreeNode`]: identical except `message` is a
/// [`NativeTurn`] rather than a plain [`ChatMessage`] — see [`TreeNode`]'s
/// doc comment for why. Private: only [`TreeNode`]'s own `Serialize`/
/// `Deserialize` impls (below) construct one.
#[derive(Serialize, Deserialize)]
struct TreeNodeWire {
    id: NodeId,
    parent: Option<NodeId>,
    #[serde(default)]
    children: Vec<NodeId>,
    message: NativeTurn,
    #[serde(default)]
    label: Option<String>,
    #[serde(default)]
    created_at_ms: i64,
}

impl From<&TreeNode> for TreeNodeWire {
    fn from(n: &TreeNode) -> Self {
        // Build the `NativeTurn` by hand rather than via its
        // `From<&ChatMessage>` impl: that impl stamps `ts` with the CURRENT
        // wall-clock time (`sidecar.rs`'s `now_rfc3339()`), which would make
        // re-saving an already-loaded, unmodified tree produce different
        // bytes each time — breaking this sidecar's save→load→save
        // byte-identity guarantee. `ts` is derived deterministically from
        // the node's own `created_at_ms` instead (and is write-only for this
        // use: `NativeTurn::into_message` discards `ts`/`supercode_turn`
        // on the way back, so no information depends on its exact value —
        // only on it being stable).
        let message = NativeTurn {
            supercode_turn: 1,
            ts: crate::sidecar::ms_to_rfc3339(n.created_at_ms),
            role: n.message.role,
            content: n.message.content.clone(),
            content_parts: n.message.content_parts.clone(),
            tool_calls: n.message.tool_calls.clone(),
            tool_call_id: n.message.tool_call_id.clone(),
            name: n.message.name.clone(),
            metadata: n.message.metadata.clone(),
        };
        TreeNodeWire {
            id: n.id.clone(),
            parent: n.parent.clone(),
            children: n.children.clone(),
            message,
            label: n.label.clone(),
            created_at_ms: n.created_at_ms,
        }
    }
}

impl From<TreeNodeWire> for TreeNode {
    fn from(w: TreeNodeWire) -> Self {
        TreeNode {
            id: w.id,
            parent: w.parent,
            children: w.children,
            message: w.message.into_message(),
            label: w.label,
            created_at_ms: w.created_at_ms,
        }
    }
}

impl Serialize for TreeNode {
    fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
        TreeNodeWire::from(self).serialize(ser)
    }
}

impl<'de> Deserialize<'de> for TreeNode {
    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
        TreeNodeWire::deserialize(de).map(TreeNode::from)
    }
}

/// A branch-carried summary (module 21 "branch summaries", the C7
/// lossy→sidecar-backed path): a short human-readable digest of a branch,
/// paired with the pointer back to the full branch data (`branch`, a key
/// into [`SessionTree::branches`] — the full nodes never move or get
/// deleted, so this is always resolvable back to the source, §1.13).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchSummary {
    /// The short summary text.
    pub summary: String,
    /// The node id this summary was generated as-of (normally the branch's
    /// leaf at generation time).
    pub node_id: NodeId,
    /// Which branch (a key into [`SessionTree::branches`]) this summary
    /// describes — the recoverability pointer: the full branch is still
    /// right there, keyed by this name, never dropped.
    pub branch: String,
    /// Which model produced this summary, if generated via
    /// [`BranchSummarizer`] (mirrors `reduce/summarize.rs`'s
    /// `SpanSummary::model_id`). `None` for a caller-provided summary text.
    #[serde(default)]
    pub model_id: Option<String>,
    /// Unix-ms wall-clock time the summary was generated.
    #[serde(default)]
    pub created_at_ms: i64,
}

/// A named pointer into the tree: `leaf` is the node this branch currently
/// ends at (its "current-leaf pointer", module 21's phrase). `None` only for
/// a brand-new, still-empty tree's implicit branch before any node exists.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
    /// The branch's name (unique within [`SessionTree::branches`]).
    pub name: String,
    /// The node this branch currently points at (its leaf/current position).
    pub leaf: Option<NodeId>,
    /// An attached summary (module 21 "branch summaries"), set by
    /// [`SessionTree::summarize_branch`]/[`SessionTree::summarize_branch_with`].
    /// `None` — the overwhelmingly common case (a branch nobody has
    /// summarized, e.g. the active one).
    #[serde(default)]
    pub summary: Option<BranchSummary>,
    /// Unix-ms wall-clock time this branch was created.
    #[serde(default)]
    pub created_at_ms: i64,
}

/// The default/active branch name for a session that has never explicitly
/// branched — the degenerate single-path tree's one branch.
pub const MAIN_BRANCH: &str = "main";

/// The native in-place conversation tree (module 21). See the module doc
/// comment for the full design (C7 tree-with-linear-projection, lossless
/// rewind, branch summaries).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionTree {
    /// Every node in the tree, keyed by id. A [`BTreeMap`] (not a
    /// [`std::collections::HashMap`]) so iteration/serialization order is
    /// deterministic — load-bearing for the lossless round-trip tests
    /// (`assert_eq!` on two independently-loaded trees must not flake on
    /// hash-iteration order).
    pub nodes: BTreeMap<NodeId, TreeNode>,
    /// The tree's single root node id. `None` only for a brand-new, empty
    /// tree.
    pub root: Option<NodeId>,
    /// Every branch, keyed by name. Always has at least [`MAIN_BRANCH`] once
    /// [`SessionTree::new`]/[`SessionTree::from_linear`] have run.
    pub branches: BTreeMap<String, Branch>,
    /// The currently-active branch name — a key into [`Self::branches`].
    pub active_branch: String,
    /// The next id [`Self::alloc_id`] will hand out.
    #[serde(default)]
    next_id: u64,
}

impl Default for SessionTree {
    fn default() -> Self {
        Self::new()
    }
}

impl SessionTree {
    /// A brand-new, empty tree: no nodes, one branch ([`MAIN_BRANCH`]) with
    /// no leaf yet, active.
    pub fn new() -> Self {
        let mut branches = BTreeMap::new();
        branches.insert(
            MAIN_BRANCH.to_string(),
            Branch {
                name: MAIN_BRANCH.to_string(),
                leaf: None,
                summary: None,
                created_at_ms: 0,
            },
        );
        SessionTree {
            nodes: BTreeMap::new(),
            root: None,
            branches,
            active_branch: MAIN_BRANCH.to_string(),
            next_id: 0,
        }
    }

    /// Build a tree from an existing LINEAR message sequence — the
    /// degenerate single-path tree (C7): each message becomes a node,
    /// chained to the previous one, with [`MAIN_BRANCH`]'s leaf ending at the
    /// last message. [`Self::linear_projection`] on the result is
    /// byte-for-byte `messages` (see
    /// the linear-projection regression test) —
    /// this is the bridge a caller uses to materialize a tree lazily out of
    /// an ordinary [`crate::session::Session::messages`], the FIRST time a
    /// tree operation (rewind/branch/label) is actually invoked on it.
    /// `created_at_ms` is stamped on every synthesized node (a single
    /// timestamp for the whole import, since the source linear messages
    /// carry no per-turn timestamp of their own).
    pub fn from_linear(messages: &[ChatMessage], created_at_ms: i64) -> Self {
        let mut tree = Self::new();
        for m in messages {
            tree.append_message(m.clone(), created_at_ms);
        }
        tree
    }

    /// Allocate a fresh, never-before-used node id. Collision-checked against
    /// [`Self::nodes`] rather than blindly trusting [`Self::next_id`]: a
    /// sidecar hand-edited (or written by an older/different process) can
    /// deserialize with `next_id` behind the actual highest-used id — most
    /// simply, `#[serde(default)]` on `next_id` means a sidecar that omits
    /// the field entirely loads as `next_id: 0`, and the very next append
    /// would otherwise hand out `"n0"` again and [`std::collections::BTreeMap::insert`]
    /// would SILENTLY REPLACE the existing root. Looping past any id that's
    /// already occupied makes that impossible regardless of how `next_id`
    /// got out of sync with the actual node set.
    fn alloc_id(&mut self) -> NodeId {
        loop {
            let id = format!("n{}", self.next_id);
            self.next_id += 1;
            if !self.nodes.contains_key(&id) {
                return id;
            }
        }
    }

    /// Look up a node by id.
    pub fn node(&self, id: &str) -> Option<&TreeNode> {
        self.nodes.get(id)
    }

    fn require_node(&self, id: &str) -> Result<&TreeNode> {
        self.nodes
            .get(id)
            .ok_or_else(|| Error::Other(format!("session tree has no node `{id}`")))
    }

    fn require_branch(&self, name: &str) -> Result<&Branch> {
        self.branches
            .get(name)
            .ok_or_else(|| Error::Other(format!("session tree has no branch `{name}`")))
    }

    /// Append a new turn as a child of the ACTIVE branch's current leaf
    /// (ordinary turn continuation — the tree's analog of pushing onto
    /// [`crate::session::Session::messages`]). Returns the new node's id.
    /// This is the only way [`Self::root`] is ever set (on the very first
    /// node the whole tree ever gets).
    pub fn append_message(&mut self, message: ChatMessage, created_at_ms: i64) -> NodeId {
        let parent = self
            .branches
            .get(&self.active_branch)
            .and_then(|b| b.leaf.clone());
        let id = self.alloc_id();
        self.nodes.insert(
            id.clone(),
            TreeNode {
                id: id.clone(),
                parent: parent.clone(),
                children: Vec::new(),
                message,
                label: None,
                created_at_ms,
            },
        );
        match &parent {
            Some(p) => {
                if let Some(pn) = self.nodes.get_mut(p) {
                    pn.children.push(id.clone());
                }
            }
            None => self.root = Some(id.clone()),
        }
        if let Some(b) = self.branches.get_mut(&self.active_branch) {
            b.leaf = Some(id.clone());
        }
        id
    }

    /// A branch name derived from `base` that doesn't collide with any
    /// existing branch — `base`, or `base-2`, `base-3`, ... the first free
    /// one. Used by [`Self::rewind`] (to auto-name the preserved sibling) and
    /// by [`Self::branch`] when the caller passes no explicit name.
    fn fresh_branch_name(&self, base: &str) -> String {
        if !self.branches.contains_key(base) {
            return base.to_string();
        }
        let mut n = 2u64;
        loop {
            let candidate = format!("{base}-{n}");
            if !self.branches.contains_key(&candidate) {
                return candidate;
            }
            n += 1;
        }
    }

    /// Rewind-anywhere (module 21): move the ACTIVE branch's current-leaf
    /// pointer back to `node_id`. `node_id` must already exist in the tree —
    /// an unknown id is an error, never silently ignored or treated as a
    /// no-op (the "never corrupt/dangling" requirement).
    ///
    /// **Lossless.** No node is ever deleted by this. If the active branch's
    /// leaf was pointing somewhere other than `node_id` before the call, that
    /// OLD leaf — and therefore the whole path back to (but not past) the
    /// nearest still-referenced ancestor — is preserved under a fresh
    /// sibling branch name (using the internal fresh-name allocator) so it stays
    /// independently addressable, not merely still-linked-in-but-unnamed.
    /// Returns that sibling branch's name, or `None` if the rewind was a
    /// no-op (`node_id` was already the active leaf, or the branch had no
    /// leaf yet).
    ///
    /// The next [`Self::append_message`] after a rewind creates a NEW child
    /// of `node_id` — a sibling of whatever child used to follow it, exactly
    /// "rewind = fork at the rewind point."
    pub fn rewind(&mut self, node_id: &str, timestamp_ms: i64) -> Result<Option<String>> {
        self.require_node(node_id)?;
        let old_leaf = self
            .branches
            .get(&self.active_branch)
            .and_then(|b| b.leaf.clone());
        let preserved = match &old_leaf {
            Some(old) if old != node_id => {
                let name = self.fresh_branch_name(&format!("{}-rewound", self.active_branch));
                self.branches.insert(
                    name.clone(),
                    Branch {
                        name: name.clone(),
                        leaf: Some(old.clone()),
                        summary: None,
                        created_at_ms: timestamp_ms,
                    },
                );
                Some(name)
            }
            _ => None,
        };
        if let Some(b) = self.branches.get_mut(&self.active_branch) {
            b.leaf = Some(node_id.to_string());
        }
        Ok(preserved)
    }

    /// Explicit branch (module 21): fork the conversation at `from_node`,
    /// creating a NEW branch (named `name`, or an auto-generated
    /// `"branch-N"` if `None`) whose leaf starts at `from_node`, and switch
    /// the active branch to it. Errors if `from_node` doesn't exist, or if
    /// `name` is `Some` and already taken (an explicit name collision is a
    /// caller mistake worth surfacing, unlike [`Self::rewind`]'s
    /// auto-generated names which always self-disambiguate).
    pub fn branch(
        &mut self,
        from_node: &str,
        name: Option<String>,
        timestamp_ms: i64,
    ) -> Result<String> {
        self.require_node(from_node)?;
        let name = match name {
            Some(n) => {
                if self.branches.contains_key(&n) {
                    return Err(Error::Other(format!(
                        "session tree already has a branch named `{n}`"
                    )));
                }
                n
            }
            None => self.fresh_branch_name("branch"),
        };
        self.branches.insert(
            name.clone(),
            Branch {
                name: name.clone(),
                leaf: Some(from_node.to_string()),
                summary: None,
                created_at_ms: timestamp_ms,
            },
        );
        self.active_branch = name.clone();
        Ok(name)
    }

    /// Switch the active branch to an already-existing one. Errors if `name`
    /// doesn't name a branch (no silent fallback to `main`).
    pub fn switch_branch(&mut self, name: &str) -> Result<()> {
        self.require_branch(name)?;
        self.active_branch = name.to_string();
        Ok(())
    }

    /// Label (module 21 "entry labels") a node — a human/agent annotation,
    /// persisted on the node itself (so it round-trips with the rest of the
    /// tree, §1.13). Errors if `node_id` doesn't exist.
    pub fn label(&mut self, node_id: &str, label: impl Into<String>) -> Result<()> {
        let node = self
            .nodes
            .get_mut(node_id)
            .ok_or_else(|| Error::Other(format!("session tree has no node `{node_id}`")))?;
        node.label = Some(label.into());
        Ok(())
    }

    /// Clear a node's label, if any. Errors if `node_id` doesn't exist (same
    /// existence-checking posture as [`Self::label`]).
    pub fn clear_label(&mut self, node_id: &str) -> Result<()> {
        let node = self
            .nodes
            .get_mut(node_id)
            .ok_or_else(|| Error::Other(format!("session tree has no node `{node_id}`")))?;
        node.label = None;
        Ok(())
    }

    /// The linear projection of the ACTIVE branch (C7): walk from the root to
    /// the active branch's leaf via parent pointers, returning the messages
    /// in root→leaf order. This is what any linear consumer (the agent loop,
    /// an exporter) must see. `Vec::new()` for an empty tree (no leaf yet).
    ///
    /// **Fail-closed.** This is a thin `self.active_branch`-bound wrapper
    /// around [`Self::linear_projection_of`] and propagates its `Err`
    /// (a missing active branch, a cycle, a dangling leaf) rather than
    /// masking it to an empty `Vec` — a structurally-corrupt tree must ERROR,
    /// never silently look like a session with zero messages. (An earlier
    /// version of this method used `.unwrap_or_default()` here, which let a
    /// corrupt-but-valid-JSON `.tree.json` sidecar pass [`Self::linear_projection`]
    /// straight through to [`crate::session::Session::apply_session_tree`]
    /// and silently EMPTY [`crate::session::Session::messages`] — see that
    /// method's doc comment.)
    pub fn linear_projection(&self) -> Result<Vec<ChatMessage>> {
        self.linear_projection_of(&self.active_branch)
    }

    /// The linear projection of any named branch (not just the active one) —
    /// the general form [`Self::linear_projection`] is built on. Errors if
    /// `branch` doesn't exist; returns `Ok(Vec::new())` for a branch with no
    /// leaf yet (a fresh, still-empty tree's `main`).
    ///
    /// Defensively cycle-guarded: a malformed/hand-edited tree with a parent
    /// cycle returns an error instead of looping forever — this ties into
    /// the "a rewind to a nonexistent node is an error, not corruption"
    /// requirement's sibling guarantee (no API in this module can ever
    /// CREATE a cycle — [`Self::append_message`]'s parent is always the
    /// pre-existing leaf, [`Self::rewind`]/[`Self::branch`] only ever move a
    /// leaf POINTER to an existing node, never rewrite a `parent` link — but
    /// a tree loaded from a hand-edited or corrupted `.tree.json` sidecar
    /// could still contain one, and this must not hang or panic on it).
    pub fn linear_projection_of(&self, branch: &str) -> Result<Vec<ChatMessage>> {
        let b = self.require_branch(branch)?;
        let Some(mut cursor) = b.leaf.clone() else {
            return Ok(Vec::new());
        };
        let mut chain = Vec::new();
        let mut visited = BTreeSet::new();
        loop {
            if !visited.insert(cursor.clone()) {
                return Err(Error::Other(format!(
                    "session tree branch `{branch}` contains a cycle at node `{cursor}`"
                )));
            }
            let node = self.require_node(&cursor)?;
            chain.push(node.message.clone());
            match &node.parent {
                Some(p) => cursor = p.clone(),
                None => break,
            }
        }
        chain.reverse();
        Ok(chain)
    }

    /// Whether this tree has actually branched (more than just the implicit
    /// [`MAIN_BRANCH`]) — i.e. it is no longer the degenerate single-path
    /// case. A caller can use this to decide whether a `.tree.json` sidecar
    /// is even worth persisting (a never-branched tree is exactly the
    /// pre-existing linear session, byte for byte, so the C7 default-off
    /// posture never requires writing one).
    pub fn has_branches(&self) -> bool {
        self.branches.len() > 1
    }

    /// Attach a caller-provided summary to `branch` directly (module 21
    /// "branch summaries"). `node_id` records which node the summary is
    /// as-of (the branch's current leaf, normally); `model_id` is `None` for
    /// a caller-provided (not model-generated) summary. Errors if `branch`
    /// doesn't exist.
    ///
    /// Errors if `branch` has no leaf yet (a brand-new, still-empty branch) —
    /// a leafless branch has no node to summarize *as-of*, and recording a
    /// [`BranchSummary::node_id`] of `""` would be a pointer to a node that
    /// doesn't exist (F4: never fabricate a dangling pointer).
    pub fn summarize_branch(
        &mut self,
        branch: &str,
        summary: impl Into<String>,
        model_id: Option<String>,
        timestamp_ms: i64,
    ) -> Result<()> {
        let leaf = self.require_branch(branch)?.leaf.clone().ok_or_else(|| {
            Error::Other(format!(
                "session tree branch `{branch}` has no leaf yet — nothing to summarize"
            ))
        })?;
        let b = self
            .branches
            .get_mut(branch)
            .expect("just checked via require_branch");
        b.summary = Some(BranchSummary {
            summary: summary.into(),
            node_id: leaf,
            branch: branch.to_string(),
            model_id,
            created_at_ms: timestamp_ms,
        });
        Ok(())
    }

    /// Render a branch's linear projection into plain text (one line per
    /// turn, `role: content`) — the input a [`BranchSummarizer`] side-call
    /// summarizes, mirroring `reduce/summarize.rs`'s `render_span_text`
    /// shape.
    pub fn render_branch_text(&self, branch: &str) -> Result<String> {
        let messages = self.linear_projection_of(branch)?;
        let mut out = String::new();
        for m in &messages {
            let role = match m.role {
                crate::message::Role::System => "system",
                crate::message::Role::User => "user",
                crate::message::Role::Assistant => "assistant",
                crate::message::Role::Tool => "tool",
            };
            out.push_str(role);
            out.push_str(": ");
            out.push_str(m.content.as_deref().unwrap_or(""));
            out.push('\n');
        }
        Ok(out)
    }

    /// Summarize `branch` via a small-model side-call (D-9, the mechanism
    /// an optional caller-supplied branch summarizer
    /// also uses): renders the branch's text
    /// ([`Self::render_branch_text`]) and calls `summarizer`. **Never fails
    /// the caller** — mirroring `reduce/summarize.rs`'s "never blocks, never
    /// fails the pass" posture: if `summarizer` errors (a timeout, a
    /// provider error, budget exhaustion — whatever it models), this falls
    /// back to a deterministic stub summary (`"[N turns, unsummarized]"`)
    /// rather than propagating the error, so a C7 export can always
    /// complete. Errors only if `branch` itself doesn't exist.
    pub fn summarize_branch_with(
        &mut self,
        branch: &str,
        summarizer: &dyn BranchSummarizer,
        timestamp_ms: i64,
    ) -> Result<()> {
        let text = self.render_branch_text(branch)?;
        let turn_count = self.linear_projection_of(branch)?.len();
        match summarizer.summarize(&text) {
            Ok(summary) => {
                self.summarize_branch(
                    branch,
                    summary,
                    Some(summarizer.model_id().to_string()),
                    timestamp_ms,
                )?;
            }
            Err(_) => {
                self.summarize_branch(
                    branch,
                    format!("[{turn_count} turn(s), unsummarized]"),
                    None,
                    timestamp_ms,
                )?;
            }
        }
        Ok(())
    }

    /// C7 export mechanism: splice the ACTIVE branch's messages (exactly
    /// [`Self::linear_projection`] — what a strictly-linear export target,
    /// e.g. the CX rollout shape, can represent) plus a [`BranchSummary`]
    /// for every OFF-path branch (every branch other than the active one).
    /// An off-path branch that already carries a [`Branch::summary`] reuses
    /// it as-is; one that doesn't gets a fresh deterministic stub summary
    /// (`"[N turn(s), unsummarized]"`) — this method takes `&self` (read
    /// only) precisely so it never needs a live [`BranchSummarizer`] side-call
    /// inline; a caller wanting model-generated summaries should call
    /// [`Self::summarize_branch_with`] on each off-path branch FIRST, then
    /// call this. Nothing here mutates or drops any node — see the module
    /// doc's "Lossless rewind" / C7 sections: the full multi-branch
    /// [`SessionTree`] (this method's `&self` receiver) remains the
    /// recoverable source of truth regardless of what the caller does with
    /// the returned linear messages.
    ///
    /// **Fail-closed** on the active path, same posture as
    /// [`Self::linear_projection`]: a corrupt active branch errors instead of
    /// silently exporting an empty transcript (F2). Off-path branches are
    /// summarized best-effort (see [`Self::summarize_branch_with`]'s "never
    /// blocks" contract) — a corrupt OFF-path branch does not fail the whole
    /// export, but never claims false turn-count precision either; see
    /// [`BranchSummary`]'s construction below.
    pub fn splice_for_linear_export(&self) -> Result<(Vec<ChatMessage>, Vec<BranchSummary>)> {
        let active = self.linear_projection()?;
        let mut summaries = Vec::new();
        for (name, b) in &self.branches {
            if name == &self.active_branch {
                continue;
            }
            if let Some(s) = &b.summary {
                summaries.push(s.clone());
            } else {
                // F4: don't mask a corrupt/leafless off-path branch behind a
                // deterministic-looking "[0 turn(s)]" stub — that reads as
                // "an empty conversation" when the real state is "this
                // branch's data couldn't be read." Surface the real state in
                // the summary text instead (never errors the whole export
                // over ONE off-path branch — same "never blocks" posture as
                // `Self::summarize_branch_with`), and stamp the branch's own
                // `created_at_ms` rather than a placeholder `0`.
                let (summary_text, node_id) = match &b.leaf {
                    None => (
                        format!("[branch `{name}` has no leaf yet — nothing to summarize]"),
                        String::new(),
                    ),
                    Some(leaf) => match self.linear_projection_of(name) {
                        Ok(msgs) => (
                            format!("[{} turn(s), unsummarized]", msgs.len()),
                            leaf.clone(),
                        ),
                        Err(e) => (
                            format!("[branch `{name}` could not be read, unsummarized: {e}]"),
                            leaf.clone(),
                        ),
                    },
                };
                summaries.push(BranchSummary {
                    summary: summary_text,
                    node_id,
                    branch: name.clone(),
                    model_id: None,
                    created_at_ms: b.created_at_ms,
                });
            }
        }
        Ok((active, summaries))
    }
}

/// Injectable branch-summarization side-call (D-9), the module-21 analog of
/// a caller-supplied branch summarizer
/// — same shape, deliberately: a real implementation calls out to a cheap
/// model; tests inject a deterministic fake. See
/// [`SessionTree::summarize_branch_with`]'s doc comment for the "never
/// blocks, never fails the caller" contract this trait's `Err` feeds into.
pub trait BranchSummarizer {
    /// Summarize `branch_text` (the rendering [`SessionTree::render_branch_text`]
    /// produces) into a short paragraph. `Err` means the caller falls back to
    /// a deterministic stub — see
    /// [`SessionTree::summarize_branch_with`].
    fn summarize(&self, branch_text: &str) -> Result<String>;

    /// Identifier of the model behind this summarizer (recorded on
    /// [`BranchSummary::model_id`]).
    fn model_id(&self) -> &str;
}

#[cfg(test)]
mod tests {
    use super::*;

    fn msgs(n: usize) -> Vec<ChatMessage> {
        (0..n)
            .map(|i| ChatMessage::user(format!("turn {i}")))
            .collect()
    }

    fn content_of(m: &ChatMessage) -> &str {
        m.content.as_deref().unwrap_or("")
    }

    // ---------------------------------------------------------------
    // C7: linear projection exactness / default-off degenerate case.
    // ---------------------------------------------------------------

    #[test]
    fn linear_projection_of_a_from_linear_tree_matches_the_source_messages() {
        let source = msgs(5);
        let tree = SessionTree::from_linear(&source, 1_700_000_000_000);
        let projected = tree.linear_projection().unwrap();
        assert_eq!(projected.len(), source.len());
        for (p, s) in projected.iter().zip(source.iter()) {
            assert_eq!(content_of(p), content_of(s));
        }
        // A never-branched tree is the degenerate single-path case.
        assert!(!tree.has_branches());
    }

    #[test]
    fn empty_tree_has_empty_linear_projection() {
        let tree = SessionTree::new();
        assert!(tree.linear_projection().unwrap().is_empty());
        assert_eq!(tree.root, None);
    }

    #[test]
    fn append_message_chains_and_advances_the_active_leaf() {
        let mut tree = SessionTree::new();
        let n0 = tree.append_message(ChatMessage::user("hello"), 1);
        let n1 = tree.append_message(ChatMessage::assistant("hi"), 2);
        assert_eq!(tree.root, Some(n0.clone()));
        assert_eq!(tree.branches[MAIN_BRANCH].leaf, Some(n1.clone()));
        assert_eq!(tree.node(&n1).unwrap().parent, Some(n0.clone()));
        assert_eq!(tree.node(&n0).unwrap().children, vec![n1]);
    }

    // ---------------------------------------------------------------
    // Rewind — lossless-ness proof.
    // ---------------------------------------------------------------

    #[test]
    fn rewind_to_unknown_node_errors_not_corrupts() {
        let mut tree = SessionTree::from_linear(&msgs(3), 1);
        let before = tree.clone_for_test();
        let err = tree.rewind("does-not-exist", 2).unwrap_err();
        assert!(err.to_string().contains("does-not-exist"));
        // Nothing changed.
        assert_eq!(
            tree.branches[MAIN_BRANCH].leaf,
            before.branches[MAIN_BRANCH].leaf
        );
        assert_eq!(tree.nodes.len(), before.nodes.len());
    }

    #[test]
    fn rewind_preserves_the_rewound_past_as_a_recoverable_sibling_branch() {
        let mut tree = SessionTree::from_linear(&msgs(4), 1); // n0..n3, leaf n3
        let n1 = "n1".to_string();
        let old_leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
        assert_eq!(old_leaf, "n3");

        let preserved = tree.rewind(&n1, 100).unwrap().expect("moved the pointer");
        // The active branch now sits at n1.
        assert_eq!(tree.branches[MAIN_BRANCH].leaf, Some(n1.clone()));
        // But the rewound-past data (n2, n3) is NOT deleted: every node is
        // still present...
        assert!(tree.node("n2").is_some());
        assert!(tree.node("n3").is_some());
        // ...AND still independently reachable/enumerable as its own named
        // branch, ending exactly where `main` used to.
        assert_eq!(tree.branches[&preserved].leaf, Some(old_leaf));
        let recovered = tree.linear_projection_of(&preserved).unwrap();
        assert_eq!(recovered.len(), 4);
        assert_eq!(content_of(&recovered[3]), "turn 3");

        // The active (rewound) branch's own projection is the shorter prefix.
        let active = tree.linear_projection().unwrap();
        assert_eq!(active.len(), 2);
        assert_eq!(content_of(&active[1]), "turn 1");
    }

    #[test]
    fn rewind_to_the_current_leaf_is_a_no_op_and_preserves_nothing_new() {
        let mut tree = SessionTree::from_linear(&msgs(2), 1);
        let leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
        let branch_count_before = tree.branches.len();
        let preserved = tree.rewind(&leaf, 2).unwrap();
        assert_eq!(preserved, None);
        assert_eq!(tree.branches.len(), branch_count_before);
    }

    #[test]
    fn appending_after_rewind_forks_a_new_sibling_child() {
        let mut tree = SessionTree::from_linear(&msgs(3), 1); // n0,n1,n2
        let n0 = "n0".to_string();
        tree.rewind(&n0, 10).unwrap();
        let new_child = tree.append_message(ChatMessage::user("alt turn 1"), 11);
        // n0 now has two children: the original n1, and the new fork.
        let n0_children = &tree.node(&n0).unwrap().children;
        assert_eq!(n0_children.len(), 2);
        assert!(n0_children.contains(&"n1".to_string()));
        assert!(n0_children.contains(&new_child));
        // The active projection reflects the NEW path.
        let active = tree.linear_projection().unwrap();
        assert_eq!(active.len(), 2);
        assert_eq!(content_of(&active[1]), "alt turn 1");
    }

    #[test]
    fn no_api_can_create_a_cycle_linear_projection_of_a_hand_edited_cycle_errors() {
        let mut tree = SessionTree::from_linear(&msgs(2), 1);
        // Hand-corrupt: make n0's parent point at n1 (n1's parent is n0) —
        // a 2-cycle. No public API of this module can produce this; this
        // simulates a corrupted/hand-edited `.tree.json`.
        tree.nodes.get_mut("n0").unwrap().parent = Some("n1".to_string());
        let err = tree.linear_projection_of(MAIN_BRANCH).unwrap_err();
        assert!(err.to_string().contains("cycle"));
    }

    // ---------------------------------------------------------------
    // Branch — explicit fork + switch.
    // ---------------------------------------------------------------

    #[test]
    fn branch_forks_at_a_node_and_switches_active() {
        let mut tree = SessionTree::from_linear(&msgs(3), 1); // n0,n1,n2 on main
        let name = tree.branch("n1", Some("alt".to_string()), 5).unwrap();
        assert_eq!(name, "alt");
        assert_eq!(tree.active_branch, "alt");
        assert_eq!(tree.branches["alt"].leaf, Some("n1".to_string()));

        tree.append_message(ChatMessage::user("alt turn"), 6);
        let alt_projection = tree.linear_projection().unwrap();
        assert_eq!(alt_projection.len(), 3);
        assert_eq!(content_of(&alt_projection[2]), "alt turn");

        // `main` is untouched.
        let main_projection = tree.linear_projection_of(MAIN_BRANCH).unwrap();
        assert_eq!(main_projection.len(), 3);
        assert_eq!(content_of(&main_projection[2]), "turn 2");
    }

    #[test]
    fn branch_auto_names_when_no_name_given() {
        let mut tree = SessionTree::from_linear(&msgs(2), 1);
        let a = tree.branch("n0", None, 1).unwrap();
        // Switch back to main before creating a second auto-named branch.
        tree.switch_branch(MAIN_BRANCH).unwrap();
        let b = tree.branch("n0", None, 2).unwrap();
        assert_ne!(a, b);
    }

    #[test]
    fn branch_with_duplicate_explicit_name_errors() {
        let mut tree = SessionTree::from_linear(&msgs(2), 1);
        tree.branch("n0", Some("x".to_string()), 1).unwrap();
        tree.switch_branch(MAIN_BRANCH).unwrap();
        let err = tree.branch("n0", Some("x".to_string()), 2).unwrap_err();
        assert!(err.to_string().contains("x"));
    }

    #[test]
    fn branch_at_unknown_node_errors() {
        let mut tree = SessionTree::from_linear(&msgs(1), 1);
        assert!(tree.branch("ghost", None, 1).is_err());
    }

    #[test]
    fn switch_branch_to_unknown_name_errors() {
        let mut tree = SessionTree::from_linear(&msgs(1), 1);
        assert!(tree.switch_branch("ghost").is_err());
    }

    // ---------------------------------------------------------------
    // F3 (LOW, ported from the Fable-5 review's
    // `attack_missing_next_id_field_causes_silent_node_overwrite`): id
    // allocation must never collide with an existing node, even when
    // `next_id` itself is untrustworthy (e.g. a sidecar written by an older
    // process, or hand-edited to omit the field — `#[serde(default)]` then
    // loads it as `0`).
    // ---------------------------------------------------------------

    #[test]
    fn missing_next_id_field_no_longer_causes_a_silent_node_overwrite() {
        let tree = SessionTree::from_linear(
            &[ChatMessage::user("original n0"), ChatMessage::user("n1")],
            1,
        );
        let mut v: serde_json::Value = serde_json::to_value(&tree).unwrap();
        // Confirm next_id IS normally serialized (so the honest write side is
        // safe), then strip it to simulate a hand-edited/older sidecar.
        assert!(v.get("next_id").is_some());
        v.as_object_mut().unwrap().remove("next_id");
        let mut reloaded: SessionTree = serde_json::from_value(v).unwrap();
        let id = reloaded.append_message(ChatMessage::user("usurper"), 2);
        // The allocator must skip past the already-used "n0"/"n1" rather
        // than colliding with the existing root.
        assert_ne!(id, "n0");
        assert_ne!(id, "n1");
        // The original n0 message must survive untouched.
        assert_eq!(
            reloaded.node("n0").unwrap().message.content.as_deref(),
            Some("original n0")
        );
        assert_eq!(
            reloaded.node("n1").unwrap().message.content.as_deref(),
            Some("n1")
        );
        // And the new turn landed under its own fresh id.
        assert_eq!(
            reloaded.node(&id).unwrap().message.content.as_deref(),
            Some("usurper")
        );
    }

    #[test]
    fn alloc_id_skips_past_several_hand_planted_collisions_in_a_row() {
        // A `next_id` that collides with several already-occupied ids in a
        // row (not just the very next candidate) must skip ALL of them, not
        // just one — proving the allocator loops rather than checking once.
        let mut tree = SessionTree::new();
        for i in 5..8 {
            tree.nodes.insert(
                format!("n{i}"),
                TreeNode {
                    id: format!("n{i}"),
                    parent: None,
                    children: Vec::new(),
                    message: ChatMessage::user(format!("planted {i}")),
                    label: None,
                    created_at_ms: 0,
                },
            );
        }
        tree.next_id = 5; // simulates a stale/hand-edited counter
        let id = tree.append_message(ChatMessage::user("first real append"), 1);
        assert_eq!(id, "n8"); // n5, n6, n7 are all taken; n8 is the first free one
        for i in 5..8 {
            assert_eq!(
                tree.node(&format!("n{i}")).unwrap().message.content,
                Some(format!("planted {i}"))
            );
        }
    }

    // ---------------------------------------------------------------
    // Labels.
    // ---------------------------------------------------------------

    #[test]
    fn label_and_clear_label_round_trip() {
        let mut tree = SessionTree::from_linear(&msgs(2), 1);
        tree.label("n0", "checkpoint-a").unwrap();
        assert_eq!(
            tree.node("n0").unwrap().label.as_deref(),
            Some("checkpoint-a")
        );
        tree.clear_label("n0").unwrap();
        assert_eq!(tree.node("n0").unwrap().label, None);
    }

    #[test]
    fn label_unknown_node_errors() {
        let mut tree = SessionTree::from_linear(&msgs(1), 1);
        assert!(tree.label("ghost", "x").is_err());
    }

    // ---------------------------------------------------------------
    // Branch summaries + C7 splice-for-linear-export.
    // ---------------------------------------------------------------

    struct FakeSummarizer(&'static str);
    impl BranchSummarizer for FakeSummarizer {
        fn summarize(&self, _branch_text: &str) -> Result<String> {
            Ok(format!("summary via {}", self.0))
        }
        fn model_id(&self) -> &str {
            self.0
        }
    }

    struct FailingSummarizer;
    impl BranchSummarizer for FailingSummarizer {
        fn summarize(&self, _branch_text: &str) -> Result<String> {
            Err(Error::Other("boom".to_string()))
        }
        fn model_id(&self) -> &str {
            "unused"
        }
    }

    #[test]
    fn summarize_branch_with_records_model_generated_summary() {
        let mut tree = SessionTree::from_linear(&msgs(3), 1);
        tree.branch("n0", Some("off-path".to_string()), 5).unwrap();
        tree.switch_branch(MAIN_BRANCH).unwrap();
        tree.summarize_branch_with("off-path", &FakeSummarizer("haiku-test"), 9)
            .unwrap();
        let s = tree.branches["off-path"].summary.as_ref().unwrap();
        assert_eq!(s.summary, "summary via haiku-test");
        assert_eq!(s.model_id.as_deref(), Some("haiku-test"));
        assert_eq!(s.branch, "off-path");
    }

    #[test]
    fn summarize_branch_with_never_fails_on_summarizer_error() {
        let mut tree = SessionTree::from_linear(&msgs(3), 1);
        tree.branch("n0", Some("off-path".to_string()), 5).unwrap();
        tree.switch_branch(MAIN_BRANCH).unwrap();
        // The summarizer errors, but the call itself must still succeed
        // (never blocks/fails the export — mirrors reduce/summarize.rs).
        tree.summarize_branch_with("off-path", &FailingSummarizer, 9)
            .unwrap();
        let s = tree.branches["off-path"].summary.as_ref().unwrap();
        assert!(s.summary.contains("unsummarized"));
        assert_eq!(s.model_id, None);
    }

    /// F4 (LOW hygiene): `summarize_branch` on a leafless branch must not
    /// record a [`BranchSummary::node_id`] of `""` — a pointer to a node
    /// that doesn't exist. A branch only ever has no leaf immediately after
    /// [`SessionTree::new`] (before any node exists); switching to it and
    /// summarizing it before appending anything is exactly that case.
    #[test]
    fn summarize_branch_on_a_leafless_branch_errors_instead_of_recording_an_empty_node_id() {
        let mut tree = SessionTree::new();
        let err = tree
            .summarize_branch(MAIN_BRANCH, "premature summary", None, 1)
            .unwrap_err();
        assert!(err.to_string().contains(MAIN_BRANCH));
        // No summary — in particular no dangling `node_id: ""` — was
        // recorded.
        assert!(tree.branches[MAIN_BRANCH].summary.is_none());
    }

    /// C7 proof: splicing a branched tree for a linear export target returns
    /// EXACTLY the active path (nothing more, nothing less) plus a summary
    /// for every off-path branch — and the source tree (every node of every
    /// branch) is completely untouched by the call, so the full data is
    /// still recoverable via the SAME [`SessionTree`] / its sidecar
    /// afterward.
    #[test]
    fn splice_for_linear_export_returns_active_path_and_summarizes_off_path_branches() {
        let mut tree = SessionTree::from_linear(&msgs(2), 1); // n0,n1 on main
        tree.branch("n0", Some("side-quest".to_string()), 5)
            .unwrap();
        tree.append_message(ChatMessage::user("side turn"), 6);
        tree.switch_branch(MAIN_BRANCH).unwrap();
        // main stays where it was: n0,n1.

        let before_node_count = tree.nodes.len();
        let (active, summaries) = tree.splice_for_linear_export().unwrap();

        // The active path is exactly `main`'s projection.
        assert_eq!(active.len(), 2);
        assert_eq!(content_of(&active[1]), "turn 1");

        // Exactly one off-path branch (side-quest) is summarized.
        assert_eq!(summaries.len(), 1);
        assert_eq!(summaries[0].branch, "side-quest");
        assert!(summaries[0].summary.contains("unsummarized")); // never explicitly summarized above

        // Nothing was dropped: the off-path branch's full data is STILL
        // there, recoverable via the pointer the summary carries.
        assert_eq!(tree.nodes.len(), before_node_count);
        let recovered = tree.linear_projection_of(&summaries[0].branch).unwrap();
        assert_eq!(recovered.len(), 2);
        assert_eq!(content_of(&recovered[1]), "side turn");
    }

    #[test]
    fn splice_for_linear_export_reuses_an_explicit_summary_if_already_set() {
        let mut tree = SessionTree::from_linear(&msgs(1), 1);
        tree.branch("n0", Some("side".to_string()), 5).unwrap();
        tree.summarize_branch("side", "hand-written summary", None, 6)
            .unwrap();
        tree.switch_branch(MAIN_BRANCH).unwrap();
        let (_active, summaries) = tree.splice_for_linear_export().unwrap();
        assert_eq!(summaries.len(), 1);
        assert_eq!(summaries[0].summary, "hand-written summary");
    }

    #[test]
    fn a_degenerate_single_path_tree_splices_to_the_whole_transcript_with_no_summaries() {
        let tree = SessionTree::from_linear(&msgs(3), 1);
        let (active, summaries) = tree.splice_for_linear_export().unwrap();
        assert_eq!(active.len(), 3);
        assert!(summaries.is_empty());
    }

    /// F4 (LOW hygiene): a corrupted OFF-path branch must not silently
    /// splice into a misleading `"[0 turn(s), unsummarized]"` stub — that
    /// reads as "an empty conversation," which is a lie; the branch is
    /// actually unreadable. The active-path export (the part a linear
    /// consumer actually uses) must still succeed — one broken off-path
    /// branch does not fail the whole export (non-destructive, same "never
    /// blocks" posture as [`SessionTree::summarize_branch_with`]) — but the
    /// stub text for that branch must say so, not claim zero turns.
    #[test]
    fn splice_for_linear_export_surfaces_a_corrupt_off_path_branch_instead_of_masking_it_as_empty()
    {
        let mut tree = SessionTree::from_linear(&msgs(2), 1); // n0,n1 on main
        tree.branch("n0", Some("side-quest".to_string()), 5)
            .unwrap();
        tree.append_message(ChatMessage::user("side turn"), 6);
        tree.switch_branch(MAIN_BRANCH).unwrap();
        // Hand-corrupt the off-path branch into a cycle.
        let side_leaf = tree.branches["side-quest"].leaf.clone().unwrap();
        tree.nodes.get_mut(&side_leaf).unwrap().parent = Some(side_leaf.clone());

        let (active, summaries) = tree.splice_for_linear_export().unwrap();
        // The active (main) path is completely unaffected.
        assert_eq!(active.len(), 2);

        assert_eq!(summaries.len(), 1);
        assert_eq!(summaries[0].branch, "side-quest");
        // Must NOT claim "[0 turn(s), unsummarized]" — that would be
        // indistinguishable from a genuinely empty branch.
        assert!(!summaries[0].summary.contains("0 turn"));
        // Must actually say the branch couldn't be read.
        assert!(
            summaries[0].summary.contains("could not be read")
                || summaries[0].summary.contains("corrupt")
        );
    }

    /// F4 (LOW hygiene): a leafless off-path branch (no node has ever been
    /// appended to it) gets an honest stub, not a fabricated `node_id: ""`.
    #[test]
    fn splice_for_linear_export_on_a_leafless_off_path_branch_does_not_fabricate_a_node_id() {
        let mut tree = SessionTree::from_linear(&msgs(1), 1);
        // A branch with no leaf can only arise via direct construction (no
        // public API leaves one leafless) — simulate a hand-edited sidecar.
        tree.branches.insert(
            "empty-branch".to_string(),
            Branch {
                name: "empty-branch".to_string(),
                leaf: None,
                summary: None,
                created_at_ms: 0,
            },
        );
        let (_active, summaries) = tree.splice_for_linear_export().unwrap();
        let s = summaries
            .iter()
            .find(|s| s.branch == "empty-branch")
            .unwrap();
        assert_eq!(s.node_id, "");
        assert!(s.summary.contains("no leaf"));
    }

    // Test-only helper: a plain value clone (this whole type is already
    // `Clone`), named separately so its call sites read as "the untouched
    // baseline" rather than an ordinary working copy.
    impl SessionTree {
        fn clone_for_test(&self) -> Self {
            self.clone()
        }
    }
}