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
//! P5-5 (COMPOSABLE-HARNESS-DESIGN.md §2 module 21 `session.tree`; §2.1 D-6;
//! §2.2 C7; §5.2 P5 row 5): end-to-end proofs for the native in-place
//! session tree — tying `crate::session_tree::SessionTree` together with
//! `crate::session::Session` (the linear-projection bridge) and
//! `crate::store::SessionStore` (the `.tree.json` sidecar), the seam this
//! module actually lives at.
//!
//! Four load-bearing proofs, one test group each:
//! 1. **Default-off / linear-session byte-identity**: a `Session` that never
//! touches a `SessionTree` behaves exactly as before (no `.tree.json`
//! ever written; `SessionStore::save`/`load` unchanged).
//! 2. **Linear-projection exactness**: `Session::to_session_tree` →
//! `SessionTree::linear_projection` → `Session::apply_session_tree`
//! round-trips a linear session's messages exactly.
//! 3. **Lossless rewind**: rewound-past data is recoverable through the
//! store after a save/load cycle (not just in memory).
//! 4. **C7 linear export**: a branched tree's active path + off-path branch
//! summaries survive a save/load cycle, with the off-path branch's full
//! data still recoverable from the very same sidecar.
use supercode::session::Session;
use supercode::session_tree::{BranchSummarizer, MAIN_BRANCH};
use supercode::store::SessionStore;
use supercode::{ChatMessage, Result};
fn temp_store() -> (SessionStore, std::path::PathBuf) {
// `SystemTime` has coarser-than-nanosecond resolution on some platforms.
// These tests run in parallel inside one process, so pid+reported nanos
// can collide and make independent tests concurrently truncate/read the
// same `.tree.json` file. A process-local nonce makes isolation
// deterministic regardless of clock resolution or thread scheduling.
static NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let nonce = NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"supercode-session-tree-native-{}-{}-{nonce}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
(SessionStore::open(&dir).unwrap(), dir)
}
fn content_of(m: &ChatMessage) -> &str {
m.content.as_deref().unwrap_or("")
}
#[test]
fn parallel_temp_stores_are_always_isolated() {
let barrier = std::sync::Arc::new(std::sync::Barrier::new(32));
let handles: Vec<_> = (0..32)
.map(|_| {
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
let (_store, path) = temp_store();
path
})
})
.collect();
let paths: std::collections::HashSet<_> =
handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(paths.len(), 32);
for path in paths {
let _ = std::fs::remove_dir_all(path);
}
}
// ============================================================================
// 1. Default-off byte-identity: a linear session's SAVE/LOAD path is
// completely untouched by this module's existence.
// ============================================================================
#[test]
fn a_session_that_never_touches_the_tree_writes_no_tree_sidecar() {
let (store, tmp) = temp_store();
let mut session = Session::from_claude_code_str("").unwrap();
session.messages.push(ChatMessage::user("hello"));
session.messages.push(ChatMessage::assistant("hi"));
let jsonl = session.to_native_jsonl();
store.save("sess", "t", &jsonl).unwrap();
// The plain save/load path is exactly what it always was.
assert_eq!(store.load("sess").unwrap(), jsonl);
// No `.tree.json` sidecar exists — this module's storage is opt-in,
// never implicit.
assert!(store.load_tree("sess").unwrap().is_none());
assert!(!tmp.join("sess.tree.json").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
// ============================================================================
// 2. Linear-projection exactness: the Session <-> SessionTree bridge is a
// lossless round trip for the degenerate (never-branched) case.
// ============================================================================
#[test]
fn session_to_tree_and_back_preserves_the_message_sequence_exactly() {
let mut session = Session::from_claude_code_str("").unwrap();
session.messages.push(ChatMessage::user("turn 0"));
session.messages.push(ChatMessage::assistant("turn 1"));
session.messages.push(ChatMessage::user("turn 2"));
let original = session.messages.clone();
let tree = session.to_session_tree(1_700_000_000_000);
assert!(!tree.has_branches());
// A round-tripped clone starts identical to the original.
let mut round_tripped = session.clone();
round_tripped.apply_session_tree(&tree).unwrap();
assert_eq!(round_tripped.messages.len(), original.len());
for (a, b) in round_tripped.messages.iter().zip(original.iter()) {
assert_eq!(content_of(a), content_of(b));
assert_eq!(a.role, b.role);
}
}
/// The linear projection is deterministic AND matches the active path
/// exactly even after the tree has branched: a consumer applying the tree
/// back onto a `Session` sees precisely the active branch, never a mix.
#[test]
fn linear_projection_after_branching_matches_only_the_active_path() {
let mut session = Session::from_claude_code_str("").unwrap();
session.messages.push(ChatMessage::user("turn 0"));
session.messages.push(ChatMessage::assistant("turn 1"));
let mut tree = session.to_session_tree(1);
// Fork at the root and continue down the new branch.
tree.branch("n0", Some("alt".to_string()), 2).unwrap();
tree.append_message(ChatMessage::user("alt turn 1"), 3);
session.apply_session_tree(&tree).unwrap();
assert_eq!(session.messages.len(), 2);
assert_eq!(content_of(&session.messages[1]), "alt turn 1");
// Computing the projection again is deterministic (same input, same
// output) — not a one-shot mutation artifact.
let again = tree.linear_projection().unwrap();
assert_eq!(again.len(), session.messages.len());
for (a, b) in again.iter().zip(session.messages.iter()) {
assert_eq!(content_of(a), content_of(b));
}
}
// ============================================================================
// 2b. F2 (MEDIUM, ported from the Fable-5 adversarial review): a corrupt
// tree must fail CLOSED — `SessionTree::linear_projection`/
// `Session::apply_session_tree` must ERROR on a structurally-corrupt
// tree, never silently collapse it to an empty transcript. Before the
// fix, `linear_projection()` did `.unwrap_or_default()` over the checked
// `linear_projection_of`, and `apply_session_tree` then unconditionally
// assigned that (possibly-empty-on-error) result to `Session::messages`
// — so a corrupt-but-valid-JSON `.tree.json` sidecar would silently WIPE
// a session's messages instead of surfacing the corruption.
// ============================================================================
#[test]
fn attack_apply_session_tree_errors_instead_of_silently_wiping_messages_on_cycle() {
let mut session = Session::from_claude_code_str("").unwrap();
session.messages.push(ChatMessage::user("precious 0"));
session.messages.push(ChatMessage::assistant("precious 1"));
let mut tree = session.to_session_tree(1);
// Simulate a corrupted/hand-edited .tree.json: 2-cycle. Valid JSON, so
// load_tree would accept it.
tree.nodes.get_mut("n0").unwrap().parent = Some("n1".to_string());
// The checked API errors...
assert!(tree.linear_projection_of(MAIN_BRANCH).is_err());
// ...and now the convenience API propagates that error too, rather than
// masking it to an empty Vec.
assert!(tree.linear_projection().is_err());
// apply_session_tree must therefore ALSO error, and leave the session's
// precious messages untouched.
let err = session.apply_session_tree(&tree).unwrap_err();
assert!(err.to_string().contains("cycle"));
assert_eq!(session.messages.len(), 2);
assert_eq!(content_of(&session.messages[0]), "precious 0");
assert_eq!(content_of(&session.messages[1]), "precious 1");
}
#[test]
fn attack_dangling_leaf_errors_instead_of_masking_to_empty() {
let mut tree = supercode::session_tree::SessionTree::from_linear(&[ChatMessage::user("x")], 1);
tree.branches.get_mut(MAIN_BRANCH).unwrap().leaf = Some("ghost".to_string());
assert!(tree.linear_projection_of(MAIN_BRANCH).is_err()); // checked: error
assert!(tree.linear_projection().is_err()); // no longer masked
}
#[test]
fn attack_active_branch_pointing_nowhere_errors_instead_of_masking_to_empty() {
let mut tree = supercode::session_tree::SessionTree::from_linear(&[ChatMessage::user("x")], 1);
tree.active_branch = "no-such-branch".to_string();
assert!(tree.linear_projection().is_err()); // no longer masked
}
// ============================================================================
// 3. Lossless rewind, proven THROUGH THE STORE (not just in memory): after a
// save/load cycle, the rewound-past data is still fully recoverable.
// ============================================================================
#[test]
fn rewound_data_is_recoverable_after_a_save_load_cycle() {
let (store, tmp) = temp_store();
let mut session = Session::from_claude_code_str("").unwrap();
for i in 0..4 {
session
.messages
.push(ChatMessage::user(format!("turn {i}")));
}
store.save("sess", "t", &session.to_native_jsonl()).unwrap();
let mut tree = session.to_session_tree(1_700_000_000_000);
let old_leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
let preserved = tree
.rewind("n1", 1_700_000_001_000)
.unwrap()
.expect("moved the pointer, so the old path is preserved");
store.save_tree("sess", &tree).unwrap();
// Reload from disk — a fresh in-memory value, not the same object.
let loaded = store.load_tree("sess").unwrap().expect("just saved");
assert_eq!(loaded.branches[&preserved].leaf, Some(old_leaf));
let recovered = loaded.linear_projection_of(&preserved).unwrap();
assert_eq!(recovered.len(), 4);
assert_eq!(content_of(&recovered[3]), "turn 3");
// The active (rewound) path, also reloaded from disk, is the shorter
// prefix — proving BOTH the rewind AND the preservation persisted.
assert_eq!(loaded.linear_projection().unwrap().len(), 2);
let _ = std::fs::remove_dir_all(&tmp);
}
// ============================================================================
// 4. C7: exporting a branched tree to a linear target splices the active
// path and branch-summarizes the off-path branches into the SAME
// sidecar — nothing dropped, everything still recoverable.
// ============================================================================
struct StubSummarizer;
impl BranchSummarizer for StubSummarizer {
fn summarize(&self, branch_text: &str) -> Result<String> {
Ok(format!(
"digest of {} char(s)",
branch_text.trim_end().len()
))
}
fn model_id(&self) -> &str {
"test-small-model"
}
}
#[test]
fn c7_linear_export_splices_active_path_and_recovers_off_path_branch_from_the_sidecar() {
let (store, tmp) = temp_store();
let mut session = Session::from_claude_code_str("").unwrap();
session.messages.push(ChatMessage::user("root turn"));
store.save("sess", "t", &session.to_native_jsonl()).unwrap();
let mut tree = session.to_session_tree(1);
// Branch off, and continue BOTH paths a little.
tree.branch("n0", Some("side-quest".to_string()), 2)
.unwrap();
tree.append_message(ChatMessage::user("side turn"), 3);
tree.summarize_branch_with("side-quest", &StubSummarizer, 4)
.unwrap();
tree.switch_branch(MAIN_BRANCH).unwrap();
tree.append_message(ChatMessage::assistant("main turn"), 5);
// Simulate a CX-style linear export: splice + persist the full tree
// (with its now-attached branch summary) to the sidecar, and write ONLY
// the active path to a (simulated) linear target file.
let (linear_export, summaries) = tree.splice_for_linear_export().unwrap();
store.save_tree("sess", &tree).unwrap();
// The "linear target file" only ever sees the active path.
assert_eq!(linear_export.len(), 2);
assert_eq!(content_of(&linear_export[0]), "root turn");
assert_eq!(content_of(&linear_export[1]), "main turn");
// Exactly one off-path branch was summarized, with a model-generated
// (not stub-fallback) digest.
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].branch, "side-quest");
assert_eq!(summaries[0].model_id.as_deref(), Some("test-small-model"));
// Recoverability: reload the sidecar from disk and pull the FULL
// off-path branch back out — nothing was dropped by the export.
let loaded = store.load_tree("sess").unwrap().expect("saved above");
let recovered = loaded.linear_projection_of("side-quest").unwrap();
assert_eq!(recovered.len(), 2);
assert_eq!(content_of(&recovered[1]), "side turn");
assert!(loaded.branches["side-quest"]
.summary
.as_ref()
.unwrap()
.summary
.contains("digest of"));
let _ = std::fs::remove_dir_all(&tmp);
}