返回 CodeWhale
test_cases_02.rs
根目录 / crates / tui / src / session_manager / test_cases_02.rs
1
2
3 fn write_session_record(
4 manager: &SessionManager,
5 id: &str,
6 workspace: &Path,
7 updated_at: DateTime<Utc>,
8 ) {
9 let session = SavedSession {
10 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
11 messages: vec![make_test_message("user", "hi")],
12 metadata: SessionMetadata {
13 id: id.to_string(),
14 title: format!("session-{id}"),
15 created_at: updated_at,
16 updated_at,
17 message_count: 1,
18 total_tokens: 0,
19 model: "deepseek-v4-flash".to_string(),
20 model_provider: "deepseek".to_string(),
21 model_provider_id: None,
22 workspace: workspace.to_path_buf(),
23 mode: None,
24 cost: SessionCostSnapshot::default(),
25 parent_session_id: None,
26 forked_from_message_count: None,
27 runtime_store: None,
28 cumulative_turn_secs: 0,
29 archived: false,
30 spawn_depth: 0,
31 },
32 journal: None,
33 leaf_id: None,
34 system_prompt: None,
35 context_references: Vec::new(),
36 artifacts: Vec::new(),
37 approval_receipts: Vec::new(),
38 work_state: None,
39 window_title: None,
40 last_auto_route: None,
41 turn_outcomes: Vec::new(),
42 };
43 manager.save_session(&session).expect("save");
44 }
45
46 fn write_empty_session_record(
47 manager: &SessionManager,
48 id: &str,
49 workspace: &Path,
50 updated_at: DateTime<Utc>,
51 ) {
52 let session = SavedSession {
53 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
54 messages: Vec::new(),
55 metadata: SessionMetadata {
56 id: id.to_string(),
57 title: DEFAULT_SESSION_TITLE.to_string(),
58 created_at: updated_at,
59 updated_at,
60 message_count: 0,
61 total_tokens: 0,
62 model: "deepseek-v4-pro".to_string(),
63 model_provider: "deepseek".to_string(),
64 model_provider_id: None,
65 workspace: workspace.to_path_buf(),
66 mode: Some("yolo".to_string()),
67 cost: SessionCostSnapshot::default(),
68 parent_session_id: None,
69 forked_from_message_count: None,
70 runtime_store: None,
71 cumulative_turn_secs: 0,
72 archived: false,
73 spawn_depth: 0,
74 },
75 journal: None,
76 leaf_id: None,
77 system_prompt: None,
78 context_references: Vec::new(),
79 artifacts: Vec::new(),
80 approval_receipts: Vec::new(),
81 work_state: None,
82 window_title: None,
83 last_auto_route: None,
84 turn_outcomes: Vec::new(),
85 };
86 manager.save_session(&session).expect("save empty");
87 }
88
89 // === session retention and independent runtime data ===
90
91 #[test]
92 fn cleanup_preserves_artifacts_without_a_session_snapshot() {
93 let tmp = tempdir().expect("tempdir");
94 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
95 let workspace = tmp.path().join("ws");
96
97 let orphan = "11111111-1111-4111-8111-111111111111";
98 let live = "22222222-2222-4222-8222-222222222222";
99 for id in [orphan, live] {
100 let artifacts = tmp.path().join(id).join("artifacts");
101 fs::create_dir_all(&artifacts).expect("artifact dir");
102 fs::write(artifacts.join("art_evidence.txt"), b"stdout").expect("artifact");
103 }
104 // Only `live` still has a session document.
105 write_session_record(&manager, live, &workspace, Utc::now());
106
107 manager.cleanup_old_sessions().expect("cleanup");
108
109 assert!(
110 tmp.path()
111 .join(orphan)
112 .join("artifacts/art_evidence.txt")
113 .exists(),
114 "an absent snapshot does not authorize deleting independent evidence"
115 );
116 assert!(
117 tmp.path().join(live).join("artifacts").exists(),
118 "a directory whose session still exists must be left alone"
119 );
120 }
121
122 #[tokio::test]
123 async fn cleanup_in_another_process_preserves_runtime_without_a_snapshot() {
124 const PROBE: &str = "CODEWHALE_RUNTIME_RETENTION_PROBE";
125 if let Some(directory) = std::env::var_os(PROBE) {
126 let manager = SessionManager::new(PathBuf::from(directory)).expect("child manager");
127 manager.cleanup_old_sessions().expect("child retention");
128 return;
129 }
130 let tmp = tempdir().expect("tempdir");
131 let sessions = tmp.path().join("sessions");
132 let manager = SessionManager::new(sessions.clone()).expect("manager");
133 let runtime = sessions.join("44444444-4444-4444-8444-444444444444/runtime");
134 let store = crate::runtime_threads::RuntimeThreadStore::open(runtime.clone())
135 .expect("live automation store");
136 let first = store
137 .append_event(
138 "thread_probe",
139 None,
140 None,
141 "probe",
142 serde_json::json!({"step": 1}),
143 )
144 .await
145 .expect("first durable event");
146 let run_cleanup = || {
147 let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
148 .args([
149 "--exact",
150 "session_manager::tests::cleanup_in_another_process_preserves_runtime_without_a_snapshot",
151 "--nocapture",
152 ])
153 .env(PROBE, &sessions)
154 .output()
155 .expect("independent cleanup process");
156 assert!(
157 output.status.success(),
158 "{}\n{}",
159 String::from_utf8_lossy(&output.stdout),
160 String::from_utf8_lossy(&output.stderr)
161 );
162 assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
163 };
164 assert!(
165 manager
166 .list_sessions()
167 .expect("no interactive snapshot")
168 .is_empty()
169 );
170 run_cleanup();
171 assert_eq!(
172 store.current_seq().await.expect("live cursor survives"),
173 first.seq
174 );
175 drop(store);
176 // A closed store can still own resumable events. It is not garbage
177 // simply because no process or interactive transcript claims it.
178 run_cleanup();
179 let reopened = crate::runtime_threads::RuntimeThreadStore::open(runtime)
180 .expect("reopen preserved automation store");
181 assert_eq!(
182 reopened.current_seq().await.expect("recovered cursor"),
183 first.seq
184 );
185 let next = reopened
186 .append_event(
187 "thread_probe",
188 None,
189 None,
190 "probe",
191 serde_json::json!({"step": 2}),
192 )
193 .await
194 .expect("continue recovered event sequence");
195 assert_eq!(next.seq, first.seq + 1);
196 }
197
198 #[test]
199 fn a_crashed_sessions_evidence_survives_even_without_its_document() {
200 // Recovery reads exactly this: a checkpoint with no session document.
201 // Reclaiming its evidence would delete what recovery needs.
202 let tmp = tempdir().expect("tempdir");
203 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
204 let crashed = "33333333-3333-4333-8333-333333333333";
205
206 fs::create_dir_all(tmp.path().join(crashed).join("artifacts")).expect("artifacts");
207 let checkpoints = tmp.path().join("checkpoints");
208 fs::create_dir_all(&checkpoints).expect("checkpoints dir");
209 fs::write(checkpoints.join(format!("{crashed}.json")), b"{}").expect("checkpoint");
210
211 manager.cleanup_old_sessions().expect("cleanup");
212
213 assert!(
214 tmp.path().join(crashed).exists(),
215 "a crashed session's evidence must outlive its missing document"
216 );
217 }
218
219 #[test]
220 fn reclamation_never_touches_bookkeeping_directories() {
221 let tmp = tempdir().expect("tempdir");
222 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
223 // `checkpoints` is not a session id and must survive being empty.
224 let checkpoints = tmp.path().join("checkpoints");
225 fs::create_dir_all(&checkpoints).expect("checkpoints dir");
226 let not_a_session = tmp.path().join("some-user-folder");
227 fs::create_dir_all(&not_a_session).expect("user dir");
228
229 manager.cleanup_old_sessions().expect("cleanup");
230
231 assert!(checkpoints.exists(), "checkpoints/ is not a session dir");
232 assert!(
233 not_a_session.exists(),
234 "a name that is not a valid session id is not ours to remove"
235 );
236 }
237
238 #[test]
239 fn save_and_resume_reconstructs_closed_and_interrupted_approvals() {
240 let tmp = tempdir().expect("tempdir");
241 let sessions_dir = tmp.path().join("sessions");
242 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
243 let session = create_saved_session(
244 &[make_test_message("user", "approval recovery")],
245 "test-model",
246 tmp.path(),
247 0,
248 None,
249 );
250 let session_id = session.metadata.id.clone();
251 let store = ApprovalReceiptStore::new(sessions_dir);
252 store
253 .append(
254 &session_id,
255 &ApprovalReceipt::asked("tool-complete", "exec_shell"),
256 )
257 .expect("persist completed ask");
258 store
259 .append(
260 &session_id,
261 &ApprovalReceipt::decided("tool-complete", ApprovalOutcome::Denied),
262 )
263 .expect("persist completed decision");
264 store
265 .append(
266 &session_id,
267 &ApprovalReceipt::asked("tool-interrupted", "write_file"),
268 )
269 .expect("persist interrupted ask");
270
271 manager.save_session(&session).expect("save session");
272 let resumed = manager
273 .load_session_snapshot(&session_id)
274 .expect("resume session");
275 let replay = ApprovalReplay::from_receipts(&resumed.approval_receipts)
276 .expect("replay resumed approval evidence");
277
278 assert_eq!(resumed.messages, session.messages);
279 assert_eq!(replay.completed.len(), 1);
280 assert_eq!(replay.completed[0].outcome, ApprovalOutcome::Denied);
281 assert_eq!(replay.unmatched_asks.len(), 1);
282 assert!(matches!(
283 &replay.unmatched_asks[0],
284 ApprovalReceipt::Asked { tool_call_id, .. } if tool_call_id == "tool-interrupted"
285 ));
286 assert_eq!(
287 manager
288 .replay_approvals(&session_id)
289 .expect("replay canonical sidecar"),
290 replay
291 );
292 }
293
294 #[test]
295 fn saved_history_keeps_execution_identity_in_snapshots_and_journal() {
296 let tmp = tempdir().unwrap();
297 let manager = SessionManager::new(tmp.path().join("sessions")).unwrap();
298 let mut messages = vec![make_test_message("user", "inspect")];
299 for execution in ["execution-a", "execution-b"] {
300 messages.push(Message {
301 role: Role::Assistant,
302 content: vec![ContentBlock::ToolUse {
303 id: "reused-provider-id".to_string(),
304 execution_id: Some(execution.to_string()),
305 name: "read".to_string(),
306 input: serde_json::json!({"path": "README.md"}),
307 caller: Some(codewhale_models::ToolCaller {
308 caller_type: "code_execution".to_string(),
309 tool_id: Some("provider-parent".to_string()),
310 }),
311 thought_signature: Some("provider-signature".to_string()),
312 }],
313 });
314 messages.push(Message {
315 role: Role::User,
316 content: vec![ContentBlock::ToolResult {
317 tool_use_id: "reused-provider-id".to_string(),
318 execution_id: Some(execution.to_string()),
319 content: format!("result for {execution}"),
320 is_error: None,
321 content_blocks: None,
322 }],
323 });
324 }
325 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
326 manager.save_session(&session).unwrap();
327 manager.save_checkpoint(&session).unwrap();
328 let snapshot = manager.load_session_snapshot(&session.metadata.id).unwrap();
329 let checkpoint = manager
330 .load_session_checkpoint(&session.metadata.id)
331 .unwrap()
332 .unwrap();
333 for restored in [snapshot, checkpoint] {
334 assert_eq!(restored.messages, messages);
335 assert_eq!(
336 restored.journal.as_ref().unwrap().active_messages(false),
337 messages
338 );
339 let first = restored.messages[1].content[0].tool_call_key();
340 let second = restored.messages[3].content[0].tool_call_key();
341 assert_ne!(first, second);
342 assert_eq!(first, restored.messages[2].content[0].tool_call_key());
343 assert_eq!(second, restored.messages[4].content[0].tool_call_key());
344 }
345 }
346
347 #[test]
348 fn approval_hydration_distinguishes_missing_and_present_logs_on_disk() {
349 let ask = ApprovalReceipt::asked("receipt-hydration", "exec_shell");
350 let decision = ApprovalReceipt::decided("receipt-hydration", ApprovalOutcome::ApprovedOnce);
351 let embedded = vec![ask.clone(), decision];
352 let mut prefix_with_torn_decision = serde_json::to_vec(&ask).unwrap();
353 prefix_with_torn_decision.extend_from_slice(b"\n{\"phase\":\"decided\"");
354 let cases = [
355 ("missing", None, embedded.clone()),
356 ("empty", Some(Vec::new()), Vec::new()),
357 (
358 "torn-first",
359 Some(b"{\"phase\":\"asked\"".to_vec()),
360 Vec::new(),
361 ),
362 (
363 "asked-prefix",
364 Some(prefix_with_torn_decision),
365 vec![ask.clone()],
366 ),
367 ];
368 for with_lock in [false, true] {
369 for (name, log_bytes, expected) in &cases {
370 let tmp = tempdir().unwrap();
371 let sessions_dir = tmp.path().join("sessions");
372 let manager = SessionManager::new(sessions_dir.clone()).unwrap();
373 let mut session = create_saved_session(
374 &[make_test_message("user", "receipt recovery")],
375 "test-model",
376 tmp.path(),
377 0,
378 None,
379 );
380 session.approval_receipts = embedded.clone();
381 let id = &session.metadata.id;
382 // Persist legacy embedded evidence while no sidecar exists.
383 let saved_path = manager.save_session(&session).unwrap();
384 let checkpoint_path = manager.save_checkpoint(&session).unwrap();
385 let saved_before = fs::read(&saved_path).unwrap();
386 let checkpoint_before = fs::read(&checkpoint_path).unwrap();
387 let store = ApprovalReceiptStore::new(sessions_dir.clone());
388 let log_path = sessions_dir.join(id).join("approval_receipts.jsonl");
389 if with_lock {
390 // Exercise the live-writer lock path as well as an imported
391 // log without a lock, then simulate its final on-disk bytes.
392 store.append(id, &ask).unwrap();
393 }
394 if let Some(bytes) = log_bytes {
395 fs::create_dir_all(log_path.parent().unwrap()).unwrap();
396 fs::write(&log_path, bytes).unwrap();
397 } else if with_lock {
398 fs::remove_file(&log_path).unwrap();
399 }
400 let resumed = manager.load_session_snapshot(id).unwrap();
401 let checkpoint = manager.load_session_checkpoint(id).unwrap().unwrap();
402 for loaded in [&resumed, &checkpoint] {
403 assert_eq!(
404 &loaded.approval_receipts, expected,
405 "{name}, lock={with_lock}"
406 );
407 assert_eq!(loaded.messages, session.messages);
408 let replay = ApprovalReplay::from_receipts(&loaded.approval_receipts).unwrap();
409 assert_eq!(
410 replay.completed.len(),
411 usize::from(*name == "missing"),
412 "{name}"
413 );
414 assert_eq!(
415 replay.unmatched_asks.len(),
416 usize::from(*name == "asked-prefix"),
417 "{name}"
418 );
419 }
420 assert_eq!(
421 fs::read(&saved_path).unwrap(),
422 saved_before,
423 "read-only snapshot"
424 );
425 assert_eq!(
426 fs::read(&checkpoint_path).unwrap(),
427 checkpoint_before,
428 "read-only checkpoint"
429 );
430 // Saving the old in-memory snapshot must hydrate too, so stale
431 // approvals cannot be reintroduced into either persisted file.
432 manager.save_session(&session).unwrap();
433 manager.save_checkpoint(&session).unwrap();
434 for path in [&saved_path, &checkpoint_path] {
435 let saved: SavedSession =
436 serde_json::from_slice(&fs::read(path).unwrap()).unwrap();
437 assert_eq!(
438 &saved.approval_receipts, expected,
439 "persisted {name}, lock={with_lock}"
440 );
441 }
442 match log_bytes {
443 Some(bytes) => assert_eq!(fs::read(&log_path).unwrap(), *bytes, "{name}"),
444 None => assert!(!log_path.exists(), "missing log must not be created"),
445 }
446 }
447 }
448 }
449
450 #[test]
451 fn approval_hydration_rejects_complete_corruption_without_replacing_saved_evidence() {
452 let ask = ApprovalReceipt::asked("receipt-invalid", "exec_shell");
453 let decision = ApprovalReceipt::decided("receipt-invalid", ApprovalOutcome::ApprovedOnce);
454 let embedded = vec![ask.clone(), decision.clone()];
455 let mut interior = b"not-json\n".to_vec();
456 interior.extend_from_slice(&serde_json::to_vec(&ask).unwrap());
457 interior.push(b'\n');
458 let mut orphan_decision = serde_json::to_vec(&decision).unwrap();
459 orphan_decision.push(b'\n');
460 for bytes in [
461 b"not-json\n".to_vec(),
462 interior,
463 b"{\"phase\":\"unknown\"}".to_vec(),
464 orphan_decision,
465 ] {
466 let tmp = tempdir().unwrap();
467 let sessions_dir = tmp.path().join("sessions");
468 let manager = SessionManager::new(sessions_dir.clone()).unwrap();
469 let mut session = create_saved_session(
470 &[make_test_message("user", "invalid receipt recovery")],
471 "test-model",
472 tmp.path(),
473 0,
474 None,
475 );
476 session.approval_receipts = embedded.clone();
477 let id = &session.metadata.id;
478 let saved_path = manager.save_session(&session).unwrap();
479 let checkpoint_path = manager.save_checkpoint(&session).unwrap();
480 let saved_before = fs::read(&saved_path).unwrap();
481 let checkpoint_before = fs::read(&checkpoint_path).unwrap();
482 ApprovalReceiptStore::new(sessions_dir.clone())
483 .append(id, &ask)
484 .unwrap();
485 let log_path = sessions_dir.join(id).join("approval_receipts.jsonl");
486 fs::write(&log_path, &bytes).unwrap();
487 assert_eq!(
488 manager.load_session_snapshot(id).unwrap_err().kind(),
489 io::ErrorKind::InvalidData
490 );
491 assert_eq!(
492 manager.load_session_checkpoint(id).unwrap_err().kind(),
493 io::ErrorKind::InvalidData
494 );
495 assert_eq!(
496 manager.save_session(&session).unwrap_err().kind(),
497 io::ErrorKind::InvalidData
498 );
499 assert_eq!(
500 manager.save_checkpoint(&session).unwrap_err().kind(),
501 io::ErrorKind::InvalidData
502 );
503 assert_eq!(fs::read(&saved_path).unwrap(), saved_before);
504 assert_eq!(fs::read(&checkpoint_path).unwrap(), checkpoint_before);
505 assert_eq!(fs::read(&log_path).unwrap(), bytes);
506 }
507 }
508
509 #[test]
510 fn session_boot_owner_stamps_only_the_creating_instance() {
511 let tmp = tempdir().expect("tempdir");
512 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
513 let workspace = tmp.path().join("ws");
514
515 // A record this instance creates is stamped with this boot id and is
516 // therefore not prior-instance work.
517 write_session_record(&manager, "mine", &workspace, Utc::now());
518 assert_eq!(
519 manager.session_boot_owner("mine").as_deref(),
520 Some(current_session_boot_id())
521 );
522 assert!(!manager.session_from_prior_instance("mine"));
523
524 // An id with no durable record at all is this instance's own
525 // not-yet-persisted session.
526 assert!(!manager.session_from_prior_instance("unsaved"));
527
528 // A record stamped by another boot id stays owned by that instance,
529 // even after this instance re-serializes it (crash recovery must not
530 // re-badge restored work as ours).
531 manager
532 .record_session_boot_owner("theirs", "boot_other_instance")
533 .expect("stamp");
534 write_session_record(&manager, "theirs", &workspace, Utc::now());
535 assert_eq!(
536 manager.session_boot_owner("theirs").as_deref(),
537 Some("boot_other_instance")
538 );
539 assert!(manager.session_from_prior_instance("theirs"));
540
541 // A legacy record with no marker is classified as prior-instance
542 // work, and a later re-save keeps it unclaimed.
543 write_session_record(&manager, "legacy", &workspace, Utc::now());
544 manager.clear_session_boot_owner("legacy");
545 assert!(manager.session_from_prior_instance("legacy"));
546 write_session_record(&manager, "legacy", &workspace, Utc::now());
547 assert!(manager.session_from_prior_instance("legacy"));
548
549 // Deleting the record drops its marker.
550 manager.delete_session("theirs").expect("delete");
551 assert_eq!(manager.session_boot_owner("theirs"), None);
552 }
553
554 #[test]
555 fn session_boot_owner_sidecar_never_lists_as_a_session() {
556 let tmp = tempdir().expect("tempdir");
557 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
558 write_session_record(&manager, "real", &tmp.path().join("ws"), Utc::now());
559 assert!(manager.session_boot_owners_path().exists());
560 let listed = manager.list_sessions().expect("list");
561 assert_eq!(listed.len(), 1);
562 assert_eq!(listed[0].id, "real");
563 // The reserved stem cannot be claimed as a session id either.
564 assert!(manager.load_session("session_boot_owners").is_err());
565 }
566
567 #[test]
568 fn test_session_manager_new() {
569 let tmp = tempdir().expect("tempdir");
570 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
571 assert!(tmp.path().join("sessions").exists());
572 let _ = manager;
573 }
574
575 #[test]
576 fn test_save_and_load_session() {
577 let tmp = tempdir().expect("tempdir");
578 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
579
580 let messages = vec![
581 make_test_message("user", "Hello!"),
582 make_test_message("assistant", "Hi there!"),
583 ];
584
585 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
586 let session_id = session.metadata.id.clone();
587
588 manager.save_session(&session).expect("save");
589
590 let loaded = manager.load_session(&session_id).expect("load");
591 assert_eq!(loaded.metadata.id, session_id);
592 assert_eq!(loaded.messages.len(), 2);
593 }
594
595 /// #4681: reopening a session must not surface `<turn_meta>` machine
596 /// blocks in the transcript. Covers the current trailing shape and the
597 /// legacy leading shape (sessions saved before the turn-meta tail move),
598 /// while the loaded API history keeps both envelopes intact for replay.
599 #[test]
600 fn rehydrated_turn_meta_blocks_never_render_in_history_cells() {
601 let tmp = tempdir().expect("tempdir");
602 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
603
604 let turn_meta = "<turn_meta>\nCurrent local date: 2026-08-01\n</turn_meta>";
605 let trailing_shape = Message {
606 role: Role::User,
607 content: vec![
608 ContentBlock::Text {
609 text: "Fix the flaky test".to_string(),
610 cache_control: None,
611 },
612 ContentBlock::Text {
613 text: turn_meta.to_string(),
614 cache_control: None,
615 },
616 ],
617 };
618 let legacy_leading_shape = Message {
619 role: Role::User,
620 content: vec![
621 ContentBlock::Text {
622 text: turn_meta.to_string(),
623 cache_control: None,
624 },
625 ContentBlock::Text {
626 text: "Now add the docs".to_string(),
627 cache_control: None,
628 },
629 ],
630 };
631 let messages = vec![
632 trailing_shape,
633 make_test_message("assistant", "Done."),
634 legacy_leading_shape,
635 ];
636 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
637 let session_id = session.metadata.id.clone();
638 manager.save_session(&session).expect("save");
639
640 let loaded = manager.load_session(&session_id).expect("load");
641
642 // Display path: no rendered cell may carry turn_meta markup.
643 let rendered: Vec<HistoryCell> = loaded
644 .messages
645 .iter()
646 .flat_map(history_cells_from_message)
647 .collect();
648 let user_texts: Vec<&str> = rendered
649 .iter()
650 .filter_map(|cell| match cell {
651 HistoryCell::User { content } => Some(content.as_str()),
652 _ => None,
653 })
654 .collect();
655 assert_eq!(user_texts, vec!["Fix the flaky test", "Now add the docs"]);
656 assert!(
657 !user_texts.iter().any(|text| text.contains("<turn_meta")),
658 "rendered cells must not contain turn_meta markup: {user_texts:?}"
659 );
660
661 // Model-facing replay: the persisted envelopes survive the round trip.
662 let replayed_envelopes = loaded
663 .messages
664 .iter()
665 .flat_map(|message| &message.content)
666 .filter(|block| {
667 matches!(block, ContentBlock::Text { text, .. } if text.contains("<turn_meta>"))
668 })
669 .count();
670 assert_eq!(replayed_envelopes, 2);
671 }
672
673 #[test]
674 fn runtime_snapshot_load_preserves_in_flight_tool_call() {
675 let tmp = tempdir().expect("tempdir");
676 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
677 let messages = vec![Message {
678 role: Role::Assistant,
679 content: vec![ContentBlock::ToolUse {
680 execution_id: None,
681 id: "call-in-flight".to_string(),
682 name: "read_file".to_string(),
683 input: serde_json::json!({"path": "README.md"}),
684 caller: None,
685 thought_signature: None,
686 }],
687 }];
688 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
689 let session_id = session.metadata.id.clone();
690 manager.save_session(&session).expect("save");
691
692 let loaded = manager
693 .load_session_snapshot(&session_id)
694 .expect("snapshot load");
695
696 assert_eq!(loaded.messages, messages);
697 assert_eq!(loaded.metadata.message_count, 1);
698 assert!(!loaded.messages.iter().any(|message| {
699 message.content.iter().any(|block| {
700 matches!(
701 block,
702 ContentBlock::ToolResult { content, .. }
703 if content.contains("crashed_and_repaired")
704 )
705 })
706 }));
707 }
708
709 #[test]
710 fn explicit_session_recovery_is_reported_and_idempotent_after_save() {
711 let tmp = tempdir().expect("tempdir");
712 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
713 let messages = vec![Message {
714 role: Role::Assistant,
715 content: vec![ContentBlock::ToolUse {
716 execution_id: None,
717 id: "call-crashed".to_string(),
718 name: "read_file".to_string(),
719 input: serde_json::json!({"path": "README.md"}),
720 caller: None,
721 thought_signature: None,
722 }],
723 }];
724 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
725 let session_id = session.metadata.id.clone();
726 manager.save_session(&session).expect("save");
727
728 let recovered = manager
729 .recover_session_for_resume(&session_id)
730 .expect("recover");
731 assert!(recovered.changed);
732 assert_eq!(recovered.repaired_call_count, 1);
733 assert_eq!(recovered.duplicate_result_count, 0);
734 assert_eq!(recovered.orphan_result_count, 0);
735 manager
736 .save_session(&recovered.session)
737 .expect("persist recovery");
738
739 let second = manager
740 .recover_session_for_resume(&session_id)
741 .expect("recover twice");
742 assert!(!second.changed);
743 assert_eq!(second.repaired_call_count, 0);
744 assert_eq!(second.session.messages, recovered.session.messages);
745 }
746
747 #[test]
748 fn resume_session_persists_repair_once() {
749 let tmp = tempdir().expect("tempdir");
750 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
751 let messages = vec![Message {
752 role: Role::Assistant,
753 content: vec![ContentBlock::ToolUse {
754 execution_id: None,
755 id: "call-crashed".to_string(),
756 name: "read_file".to_string(),
757 input: serde_json::json!({"path": "README.md"}),
758 caller: None,
759 thought_signature: None,
760 }],
761 }];
762 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
763 let session_id = session.metadata.id.clone();
764 manager.save_session(&session).expect("save");
765
766 let first = manager.resume_session(&session_id).expect("first resume");
767 assert!(first.changed);
768 assert_eq!(first.repaired_call_count, 1);
769
770 // The repair is already durable: a second resume finds a clean record
771 // instead of re-running and re-logging the same repair on every load.
772 let second = manager.resume_session(&session_id).expect("second resume");
773 assert!(!second.changed);
774 assert_eq!(second.repaired_call_count, 0);
775 assert_eq!(second.session.messages, first.session.messages);
776 }
777
778 #[test]
779 fn load_session_repairs_dangling_tool_call_with_visible_receipt() {
780 let tmp = tempdir().expect("tempdir");
781 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
782 let messages = vec![Message {
783 role: Role::Assistant,
784 content: vec![ContentBlock::ToolUse {
785 execution_id: None,
786 id: "call-crashed".to_string(),
787 name: "read_file".to_string(),
788 input: serde_json::json!({"path": "README.md"}),
789 caller: None,
790 thought_signature: None,
791 }],
792 }];
793 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
794 let session_id = session.metadata.id.clone();
795 manager.save_session(&session).expect("save");
796
797 let loaded = manager.load_session(&session_id).expect("load");
798
799 assert_eq!(loaded.metadata.message_count, loaded.messages.len());
800 assert!(loaded.messages.iter().any(|message| {
801 message.content.iter().any(|block| {
802 matches!(
803 block,
804 ContentBlock::ToolResult {
805 tool_use_id,
806 content,
807 is_error: Some(true),
808 ..
809 } if tool_use_id == "call-crashed" && content.contains("crashed_and_repaired")
810 )
811 })
812 }));
813 assert_eq!(
814 loaded.journal.as_ref().map(SessionJournal::to_messages),
815 Some(loaded.messages.clone()),
816 "the append-only journal must follow the repaired active branch"
817 );
818 assert!(loaded.messages.iter().any(|message| {
819 (message.role == "assistant"
820 || message.role == codewhale_models::INTERRUPTED_ASSISTANT_ROLE)
821 && message.content.iter().any(|block| {
822 matches!(
823 block,
824 ContentBlock::Text { text, .. }
825 if text.contains("[tool_history_repair]")
826 )
827 })
828 }));
829 }
830
831 #[test]
832 fn save_and_load_session_preserves_rich_update_plan_tool_payload() {
833 let tmp = tempdir().expect("tempdir");
834 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
835 let messages = vec![
836 make_test_message("user", "plan this carefully"),
837 Message {
838 role: Role::Assistant,
839 content: vec![ContentBlock::ToolUse {
840 execution_id: None,
841 id: "plan-1".to_string(),
842 name: "update_plan".to_string(),
843 input: serde_json::json!({
844 "objective": "Make Plan mode reviewable",
845 "sources_used": ["gh issue view 2691"],
846 "critical_files": ["crates/tui/src/tools/plan.rs"],
847 "constraints": ["Preserve legacy update_plan payloads"],
848 "verification_plan": "Run focused plan tests",
849 "handoff_packet": "Next agent should inspect replay",
850 "plan": [
851 { "step": "render replay card", "status": "completed" }
852 ]
853 }),
854 caller: None,
855 thought_signature: None,
856 }],
857 },
858 Message {
859 role: Role::User,
860 content: vec![ContentBlock::ToolResult {
861 execution_id: None,
862 tool_use_id: "plan-1".to_string(),
863 content: "Plan updated".to_string(),
864 is_error: None,
865 content_blocks: None,
866 }],
867 },
868 ];
869 let session = create_saved_session(&messages, "deepseek-v4-flash", tmp.path(), 42, None);
870 let session_id = session.metadata.id.clone();
871
872 manager.save_session(&session).expect("save");
873 let loaded = manager.load_session(&session_id).expect("load");
874
875 assert_eq!(loaded.messages.len(), 3);
876 let cells = history_cells_from_message(&loaded.messages[1]);
877 let Some(HistoryCell::Tool(ToolCell::PlanUpdate(cell))) = cells.first() else {
878 panic!("expected loaded update_plan to replay as a PlanUpdate cell");
879 };
880 assert_eq!(
881 cell.snapshot.objective.as_deref(),
882 Some("Make Plan mode reviewable")
883 );
884 assert_eq!(
885 cell.snapshot.critical_files,
886 vec!["crates/tui/src/tools/plan.rs"]
887 );
888 assert_eq!(cell.snapshot.items[0].status, StepStatus::Completed);
889 }
890
891 #[test]
892 fn save_session_preserves_large_tool_outputs_for_cache_fidelity() {
893 let tmp = tempdir().expect("tempdir");
894 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
895 let raw = "RAW_SESSION_SENTINEL\n".repeat(2_000);
896 let messages = vec![
897 Message {
898 role: Role::Assistant,
899 content: vec![ContentBlock::ToolUse {
900 execution_id: None,
901 id: "call-big".to_string(),
902 name: "exec_shell".to_string(),
903 input: serde_json::json!({"command": "cargo test -p codewhale-tui"}),
904 caller: None,
905 thought_signature: None,
906 }],
907 },
908 Message {
909 role: Role::User,
910 content: vec![ContentBlock::ToolResult {
911 execution_id: None,
912 tool_use_id: "call-big".to_string(),
913 content: raw.clone(),
914 is_error: None,
915 content_blocks: None,
916 }],
917 },
918 ];
919 let mut session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
920 session.artifacts.push(crate::artifacts::ArtifactRecord {
921 id: "art_call-big".to_string(),
922 kind: crate::artifacts::ArtifactKind::ToolOutput,
923 session_id: session.metadata.id.clone(),
924 tool_call_id: "call-big".to_string(),
925 tool_name: "exec_shell".to_string(),
926 created_at: Utc::now(),
927 byte_size: raw.len() as u64,
928 preview: "checking crate ... error[E0425]".to_string(),
929 storage_path: PathBuf::from("artifacts/art_call-big.txt"),
930 });
931
932 let path = manager.save_session(&session).expect("save");
933 let persisted_json = fs::read_to_string(path).expect("read persisted session");
934 // Raw output is preserved in-session so resume can hit the LLM cache.
935 assert!(persisted_json.contains("RAW_SESSION_SENTINEL"));
936
937 let loaded = manager.load_session(&session.metadata.id).expect("load");
938 let ContentBlock::ToolResult { content, .. } = &loaded.messages[1].content[0] else {
939 panic!("expected loaded tool result");
940 };
941 // Loaded session retains the original output for cache fidelity.
942 assert!(content.contains("RAW_SESSION_SENTINEL"));
943 assert!(!content.contains("[TOOL_OUTPUT_RECEIPT]"));
944 }
945
946 #[test]
947 fn load_session_preserves_legacy_large_tool_outputs_for_cache_fidelity() {
948 let tmp = tempdir().expect("tempdir");
949 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
950 let raw = "RAW_LEGACY_RESUME_SENTINEL\n".repeat(2_000);
951 let messages = vec![
952 Message {
953 role: Role::Assistant,
954 content: vec![ContentBlock::ToolUse {
955 execution_id: None,
956 id: "call-legacy".to_string(),
957 name: "exec_shell".to_string(),
958 input: serde_json::json!({"command": "cargo check"}),
959 caller: None,
960 thought_signature: None,
961 }],
962 },
963 Message {
964 role: Role::User,
965 content: vec![ContentBlock::ToolResult {
966 execution_id: None,
967 tool_use_id: "call-legacy".to_string(),
968 content: raw.clone(),
969 is_error: None,
970 content_blocks: None,
971 }],
972 },
973 ];
974 let mut session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
975 session.artifacts.push(crate::artifacts::ArtifactRecord {
976 id: "art_call-legacy".to_string(),
977 kind: crate::artifacts::ArtifactKind::ToolOutput,
978 session_id: session.metadata.id.clone(),
979 tool_call_id: "call-legacy".to_string(),
980 tool_name: "exec_shell".to_string(),
981 created_at: Utc::now(),
982 byte_size: raw.len() as u64,
983 preview: "cargo check output".to_string(),
984 storage_path: PathBuf::from("artifacts/art_call-legacy.txt"),
985 });
986 let path = manager
987 .validated_session_path(&session.metadata.id)
988 .expect("path");
989 fs::write(
990 &path,
991 serde_json::to_string_pretty(&session).expect("serialize legacy session"),
992 )
993 .expect("write legacy raw session");
994 assert!(
995 fs::read_to_string(&path)
996 .expect("read legacy raw")
997 .contains("RAW_LEGACY_RESUME_SENTINEL")
998 );
999
1000 let loaded = manager.load_session(&session.metadata.id).expect("load");
1001 let ContentBlock::ToolResult { content, .. } = &loaded.messages[1].content[0] else {
1002 panic!("expected loaded tool result");
1003 };
1004 // Loaded session preserves original output so resume can hit the LLM cache.
1005 assert!(content.contains("RAW_LEGACY_RESUME_SENTINEL"));
1006 assert!(!content.contains("[TOOL_OUTPUT_RECEIPT]"));
1007 }
1008
1009 #[test]
1010 fn test_list_sessions() {
1011 let tmp = tempdir().expect("tempdir");
1012 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1013
1014 // Create a few sessions
1015 for i in 0..3 {
1016 let messages = vec![make_test_message("user", &format!("Session {i}"))];
1017 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1018 manager.save_session(&session).expect("save");
1019 }
1020
1021 let sessions = manager.list_sessions().expect("list");
1022 assert_eq!(sessions.len(), 3);
1023 }
1024
1025 #[test]
1026 fn default_manager_copies_legacy_sessions_when_primary_already_exists() {
1027 let _lock = crate::test_support::lock_test_env();
1028 let tmp = tempdir().expect("tempdir");
1029 let home = tmp.path().join("home");
1030 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1031 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
1032
1033 let primary_sessions = home.join(".codewhale").join("sessions");
1034 let legacy_sessions = home.join(".deepseek").join("sessions");
1035 fs::create_dir_all(&primary_sessions).expect("primary sessions");
1036 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
1037 fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
1038 fs::write(
1039 legacy_sessions.join("checkpoints").join("latest.json"),
1040 "{}",
1041 )
1042 .expect("legacy checkpoint");
1043
1044 let mut legacy_session = create_saved_session(
1045 &[make_test_message("user", "find my old session")],
1046 "test-model",
1047 tmp.path(),
1048 100,
1049 None,
1050 );
1051 legacy_session.metadata.id = "legacy-visible".to_string();
1052 legacy_session.metadata.title = "session from legacy home".to_string();
1053 fs::write(
1054 legacy_sessions.join("legacy-visible.json"),
1055 serde_json::to_string_pretty(&legacy_session).expect("serialize legacy session"),
1056 )
1057 .expect("write legacy session");
1058
1059 let manager = SessionManager::default_location().expect("default manager");
1060 assert_eq!(manager.sessions_dir(), primary_sessions.as_path());
1061 assert!(primary_sessions.join("legacy-visible.json").exists());
1062 assert!(!primary_sessions.join("checkpoints").exists());
1063 assert!(legacy_sessions.join("legacy-visible.json").exists());
1064
1065 let sessions = manager.list_sessions().expect("list");
1066 assert_eq!(sessions.len(), 1);
1067 assert_eq!(sessions[0].id, "legacy-visible");
1068 }
1069
1070 #[test]
1071 fn legacy_session_copy_never_overwrites_primary_session() {
1072 let _lock = crate::test_support::lock_test_env();
1073 let tmp = tempdir().expect("tempdir");
1074 let home = tmp.path().join("home");
1075 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1076 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
1077
1078 let primary_sessions = home.join(".codewhale").join("sessions");
1079 let legacy_sessions = home.join(".deepseek").join("sessions");
1080 fs::create_dir_all(&primary_sessions).expect("primary sessions");
1081 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
1082
1083 let primary_path = primary_sessions.join("same-id.json");
1084 fs::write(&primary_path, "primary data wins").expect("write primary session");
1085 fs::write(
1086 legacy_sessions.join("same-id.json"),
1087 "legacy data must not overwrite",
1088 )
1089 .expect("write legacy session");
1090
1091 let dir = default_sessions_dir().expect("default session dir");
1092 assert_eq!(dir, primary_sessions);
1093 assert_eq!(
1094 fs::read_to_string(primary_path).expect("read primary session"),
1095 "primary data wins"
1096 );
1097 }
1098
1099 #[test]
1100 fn explicit_codewhale_home_disables_legacy_session_copy() {
1101 let _lock = crate::test_support::lock_test_env();
1102 let tmp = tempdir().expect("tempdir");
1103 let home = tmp.path().join("home");
1104 let explicit_home = tmp.path().join("explicit-codewhale");
1105 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1106 let _codewhale_home =
1107 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &explicit_home);
1108
1109 let legacy_sessions = home.join(".deepseek").join("sessions");
1110 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
1111 fs::write(legacy_sessions.join("legacy-visible.json"), "{}").expect("write legacy session");
1112
1113 let dir = default_sessions_dir().expect("default session dir");
1114 assert_eq!(dir, explicit_home.join("sessions"));
1115 assert!(!dir.join("legacy-visible.json").exists());
1116 }
1117
1118 #[cfg(unix)]
1119 #[test]
1120 fn non_unicode_codewhale_home_is_still_an_explicit_session_boundary() {
1121 use std::os::unix::ffi::OsStringExt;
1122
1123 let _lock = crate::test_support::lock_test_env();
1124 let tmp = tempdir().expect("tempdir");
1125 let home = tmp.path().join("home");
1126 let explicit_home = tmp.path().join(std::ffi::OsString::from_vec(
1127 b"codewhale-\xff-home".to_vec(),
1128 ));
1129 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1130 let _codewhale_home =
1131 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &explicit_home);
1132
1133 let legacy_sessions = home.join(".deepseek").join("sessions");
1134 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
1135 fs::write(legacy_sessions.join("ambient.json"), "ambient").expect("ambient legacy session");
1136 let safe_primary = tmp.path().join("safe-primary");
1137 fs::create_dir_all(&safe_primary).expect("safe primary");
1138
1139 assert_eq!(
1140 merge_missing_legacy_session_entries(&safe_primary).expect("merge decision"),
1141 0
1142 );
1143 assert!(!safe_primary.join("ambient.json").exists());
1144 }
1145
1146 #[test]
1147 fn latest_session_for_workspace_ignores_newer_other_directory() {
1148 let tmp = tempdir().expect("tempdir");
1149 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1150 let workspace_a = tmp.path().join("aa").join("aaa");
1151 let workspace_b = tmp.path().join("bb").join("bbb");
1152 fs::create_dir_all(&workspace_a).expect("mkdir workspace a");
1153 fs::create_dir_all(&workspace_b).expect("mkdir workspace b");
1154 fs::create_dir_all(tmp.path().join(".git")).expect("mkdir invalid git boundary");
1155
1156 write_session_record(
1157 &manager,
1158 "current-workspace",
1159 &workspace_a,
1160 Utc::now() - chrono::Duration::minutes(10),
1161 );
1162 write_session_record(&manager, "other-workspace", &workspace_b, Utc::now());
1163
1164 let global = manager
1165 .list_sessions()
1166 .expect("list")
1167 .into_iter()
1168 .next()
1169 .expect("global latest");
1170 assert_eq!(global.id, "other-workspace");
1171
1172 let scoped = manager
1173 .get_latest_session_for_workspace(&workspace_a)
1174 .expect("latest for workspace")
1175 .expect("scoped latest");
1176 assert_eq!(scoped.id, "current-workspace");
1177 }
1178
1179 #[test]
1180 fn latest_session_for_workspace_ignores_invalid_parent_git_marker() {
1181 let tmp = tempdir().expect("tempdir");
1182 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1183 let workspace_a = tmp.path().join("aa").join("aaa");
1184 let workspace_b = tmp.path().join("bb").join("bbb");
1185 fs::create_dir_all(&workspace_a).expect("mkdir workspace a");
1186 fs::create_dir_all(&workspace_b).expect("mkdir workspace b");
1187 fs::create_dir_all(tmp.path().join(".git")).expect("mkdir invalid git marker");
1188
1189 write_session_record(
1190 &manager,
1191 "current-workspace",
1192 &workspace_a,
1193 Utc::now() - chrono::Duration::minutes(10),
1194 );
1195 write_session_record(&manager, "other-workspace", &workspace_b, Utc::now());
1196
1197 let scoped = manager
1198 .get_latest_session_for_workspace(&workspace_a)
1199 .expect("latest for workspace")
1200 .expect("scoped latest");
1201 assert_eq!(scoped.id, "current-workspace");
1202 }
1203
1204 #[test]
1205 fn latest_session_for_workspace_matches_same_git_repository() {
1206 let tmp = tempdir().expect("tempdir");
1207 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1208 let repo = tmp.path().join("repo");
1209 let repo_app = repo.join("apps").join("client");
1210 let repo_crate = repo.join("crates").join("server");
1211 let other_repo = tmp.path().join("other").join("project");
1212 fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
1213 fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1214 fs::create_dir_all(&repo_app).expect("mkdir repo app");
1215 fs::create_dir_all(&repo_crate).expect("mkdir repo crate");
1216 fs::create_dir_all(&other_repo).expect("mkdir other repo");
1217
1218 write_session_record(
1219 &manager,
1220 "same-repo",
1221 &repo_app,
1222 Utc::now() - chrono::Duration::minutes(5),
1223 );
1224 write_session_record(&manager, "other-repo", &other_repo, Utc::now());
1225
1226 let scoped = manager
1227 .get_latest_session_for_workspace(&repo_crate)
1228 .expect("latest for workspace")
1229 .expect("same repo latest");
1230 assert_eq!(scoped.id, "same-repo");
1231 }
1232
1233 #[test]
1234 fn latest_session_for_workspace_skips_empty_auto_created_session() {
1235 let tmp = tempdir().expect("tempdir");
1236 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1237 let workspace = tmp.path().join("repo");
1238 fs::create_dir_all(&workspace).expect("mkdir workspace");
1239
1240 write_session_record(
1241 &manager,
1242 "interrupted-user-turn",
1243 &workspace,
1244 Utc::now() - chrono::Duration::minutes(5),
1245 );
1246 write_empty_session_record(&manager, "empty-auto-shell", &workspace, Utc::now());
1247
1248 let global = manager
1249 .list_sessions()
1250 .expect("list")
1251 .into_iter()
1252 .next()
1253 .expect("global latest");
1254 assert_eq!(global.id, "empty-auto-shell");
1255
1256 let scoped = manager
1257 .get_latest_session_for_workspace(&workspace)
1258 .expect("latest for workspace")
1259 .expect("scoped latest");
1260 assert_eq!(scoped.id, "interrupted-user-turn");
1261 }
1262
1263 #[test]
1264 fn test_load_by_prefix() {
1265 let tmp = tempdir().expect("tempdir");
1266 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1267
1268 let messages = vec![make_test_message("user", "Test session")];
1269 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1270 let prefix = truncate_id(&session.metadata.id).to_string();
1271 manager.save_session(&session).expect("save");
1272
1273 let loaded = manager.load_session_by_prefix(&prefix).expect("load");
1274 assert_eq!(loaded.messages.len(), 1);
1275 }
1276
1277 #[test]
1278 fn test_delete_session() {
1279 let tmp = tempdir().expect("tempdir");
1280 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1281
1282 let messages = vec![make_test_message("user", "To be deleted")];
1283 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1284 let session_id = session.metadata.id.clone();
1285
1286 manager.save_session(&session).expect("save");
1287 assert!(manager.load_session(&session_id).is_ok());
1288
1289 manager.delete_session(&session_id).expect("delete");
1290 assert!(manager.load_session(&session_id).is_err());
1291 }
1292
1293 #[test]
1294 fn delete_session_removes_artifact_directory() {
1295 let tmp = tempdir().expect("tempdir");
1296 let sessions_dir = tmp.path().join("sessions");
1297 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1298
1299 let session = create_saved_session(
1300 &[make_test_message("user", "artifact session")],
1301 "test-model",
1302 tmp.path(),
1303 100,
1304 None,
1305 );
1306 let session_id = session.metadata.id.clone();
1307 let artifact_dir = sessions_dir.join(&session_id).join("artifacts");
1308 fs::create_dir_all(&artifact_dir).expect("artifact dir");
1309 fs::write(artifact_dir.join("art_call.txt"), "raw output").expect("artifact file");
1310
1311 manager.save_session(&session).expect("save");
1312 manager.delete_session(&session_id).expect("delete");
1313
1314 assert!(!sessions_dir.join(format!("{session_id}.json")).exists());
1315 assert!(!sessions_dir.join(&session_id).exists());
1316 }
1317
1318 #[test]
1319 fn delete_session_removes_its_work_graph_import_archive() {
1320 let tmp = tempdir().expect("tempdir");
1321 let sessions_dir = tmp.path().join("sessions");
1322 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1323 let session = create_saved_session(
1324 &[make_test_message("user", "archived transcript")],
1325 "test-model",
1326 tmp.path(),
1327 0,
1328 None,
1329 );
1330 let session_id = session.metadata.id.clone();
1331 let path = manager.save_session(&session).expect("save");
1332 let archive_dir = sessions_dir.join(WORK_GRAPH_IMPORT_ARCHIVE_DIR);
1333 fs::create_dir_all(&archive_dir).expect("archive dir");
1334 let archive = archive_dir.join(path.file_name().expect("file name"));
1335 fs::copy(&path, &archive).expect("archive copy");
1336 let other = archive_dir.join("other-session.json");
1337 fs::write(&other, "{}").expect("other archive");
1338
1339 manager.delete_session(&session_id).expect("delete");
1340
1341 assert!(
1342 !archive.exists(),
1343 "the deleted transcript's copy is removed"
1344 );
1345 assert!(other.exists(), "other sessions' copies are untouched");
1346 }
1347
1348 /// Retention keeps a session's crash-recovery checkpoint but retires the
1349 /// ordinary snapshot, and the pre-import copy is an ordinary snapshot.
1350 #[test]
1351 fn retention_removes_the_import_archive_copy_of_a_recoverable_session() {
1352 let tmp = tempdir().expect("tempdir");
1353 let sessions_dir = tmp.path().join("sessions");
1354 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1355 let session = create_saved_session(
1356 &[make_test_message("user", "archived transcript")],
1357 "test-model",
1358 tmp.path(),
1359 0,
1360 None,
1361 );
1362 let session_id = session.metadata.id.clone();
1363 let path = manager.save_session(&session).expect("save");
1364 manager.save_checkpoint(&session).expect("checkpoint");
1365 let archive_dir = sessions_dir.join(WORK_GRAPH_IMPORT_ARCHIVE_DIR);
1366 fs::create_dir_all(&archive_dir).expect("archive dir");
1367 let archive = archive_dir.join(path.file_name().expect("file name"));
1368 fs::copy(&path, &archive).expect("archive copy");
1369
1370 manager
1371 .remove_session(&session_id, SessionRemoval::Retention)
1372 .expect("retention");
1373
1374 assert!(!path.exists(), "the ordinary snapshot is retired");
1375 assert!(!archive.exists(), "so is its pre-import copy");
1376 assert!(
1377 manager
1378 .load_session_checkpoint(&session_id)
1379 .expect("checkpoint")
1380 .is_some(),
1381 "crash recovery is kept"
1382 );
1383 }
1384
1385 /// Deletion does not follow a linked archive directory to a same-named
1386 /// file somewhere else.
1387 #[cfg(unix)]
1388 #[test]
1389 fn delete_does_not_follow_a_linked_import_archive() {
1390 let tmp = tempdir().expect("tempdir");
1391 let sessions_dir = tmp.path().join("sessions");
1392 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1393 let session = create_saved_session(
1394 &[make_test_message("user", "transcript")],
1395 "test-model",
1396 tmp.path(),
1397 0,
1398 None,
1399 );
1400 let session_id = session.metadata.id.clone();
1401 let path = manager.save_session(&session).expect("save");
1402 let elsewhere = tmp.path().join("elsewhere");
1403 fs::create_dir_all(&elsewhere).expect("elsewhere");
1404 let unrelated = elsewhere.join(path.file_name().expect("file name"));
1405 fs::write(&unrelated, "{}").expect("unrelated file");
1406 std::os::unix::fs::symlink(&elsewhere, sessions_dir.join(WORK_GRAPH_IMPORT_ARCHIVE_DIR))
1407 .expect("link");
1408
1409 manager.delete_session(&session_id).expect("delete");
1410
1411 assert!(!path.exists());
1412 assert!(unrelated.exists(), "a file behind the link is untouched");
1413 }
1414
1415 #[test]
1416 fn a_session_file_naming_another_session_is_refused() {
1417 let tmp = tempdir().expect("tempdir");
1418 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1419 let session = create_saved_session(&[], "test-model", tmp.path(), 0, None);
1420 manager.save_session(&session).expect("save");
1421 let id = session.metadata.id.clone();
1422 let source = manager.validated_session_path(&id).expect("path");
1423 std::fs::copy(&source, source.with_file_name("impostor.json")).expect("copy");
1424
1425 assert_eq!(manager.load_session(&id).expect("own id").metadata.id, id);
1426 // The stray copy does not make the real session ambiguous.
1427 assert_eq!(
1428 manager
1429 .load_session_by_prefix(&id)
1430 .expect("resume by id with a copy present")
1431 .metadata
1432 .id,
1433 id
1434 );
1435 let err = manager
1436 .load_session("impostor")
1437 .expect_err("mismatched id must fail");
1438 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1439 }
1440
1441 #[test]
1442 fn test_session_id_rejects_invalid_characters() {
1443 let tmp = tempdir().expect("tempdir");
1444 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1445
1446 let err = manager
1447 .load_session("../outside")
1448 .expect_err("invalid id should fail");
1449 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1450
1451 let err = manager
1452 .delete_session("sess bad")
1453 .expect_err("invalid id should fail");
1454 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1455 }
1456
1457 #[test]
1458 fn test_session_manager_rejects_relative_traversal_dir() {
1459 let err = SessionManager::new(PathBuf::from("../sessions"))
1460 .expect_err("relative traversal directory should fail");
1461 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1462 }
1463
1464 #[test]
1465 fn test_truncate_title() {
1466 assert_eq!(truncate_title("Short", 50), "Short");
1467 assert_eq!(
1468 truncate_title("This is a very long title that should be truncated", 20),
1469 "This is a very lo..."
1470 );
1471 assert_eq!(truncate_title("Line 1\nLine 2", 50), "Line 1");
1472 }
1473
1474 #[test]
1475 fn extract_user_prompt_strips_turn_meta_prefix() {
1476 assert_eq!(
1477 extract_user_prompt("<turn_meta>{\"cache\":\"x\"}</turn_meta>\nReal prompt"),
1478 "Real prompt"
1479 );
1480 assert_eq!(extract_user_prompt(" Real prompt"), "Real prompt");
1481 assert_eq!(
1482 extract_user_prompt("<turn_meta>{\"unterminated\":true}\nReal prompt"),
1483 "{\"unterminated\":true}\nReal prompt"
1484 );
1485 }
1486
1487 #[test]
1488 fn create_saved_session_uses_prompt_after_turn_meta_for_title() {
1489 let tmp = tempdir().expect("tempdir");
1490 let messages = vec![make_test_message(
1491 "user",
1492 "<turn_meta>{\"cache\":\"x\"}</turn_meta>\nFix the session picker history pane",
1493 )];
1494 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1495 assert_eq!(
1496 session.metadata.title,
1497 "Fix the session picker history pane"
1498 );
1499 }
1500
1501 #[test]
1502 fn create_saved_session_skips_runtime_handoffs_when_deriving_title() {
1503 let tmp = tempdir().expect("tempdir");
1504 // Operate/automation sessions start with runtime-owned control traffic
1505 // as the first `user` message. The auto-title must come from the real
1506 // prompt that follows, never from the internal envelope.
1507 let messages = vec![
1508 crate::runtime_handoff::operate_contract_runtime_message(),
1509 make_test_message("user", "Ship the session-title fix"),
1510 ];
1511 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1512 assert_eq!(session.metadata.title, "Ship the session-title fix");
1513 assert!(
1514 !session.metadata.title.contains("codewhale:runtime"),
1515 "internal envelope leaked into the session title: {}",
1516 session.metadata.title
1517 );
1518 }
1519
1520 #[test]
1521 fn create_saved_session_with_only_runtime_traffic_keeps_placeholder_title() {
1522 let tmp = tempdir().expect("tempdir");
1523 let waiting = crate::runtime_handoff::waiting_for_subagents_runtime_message(2);
1524 let restored =
1525 crate::runtime_handoff::project_owned_messages_for_restore(vec![waiting.clone()])
1526 .into_iter()
1527 .next()
1528 .expect("restore projection yields one message");
1529 // Runtime handoffs must stay out of the auto-title. Exercise the
1530 // Operate contract, a waiting/restored
1531 // sub-agent checkpoint, and a background shell completion.
1532 let messages = vec![
1533 crate::runtime_handoff::operate_contract_runtime_message(),
1534 waiting,
1535 restored,
1536 crate::runtime_handoff::shell_completion_runtime_message(&[]),
1537 ];
1538 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1539 assert_eq!(session.metadata.title, DEFAULT_SESSION_TITLE);
1540 assert!(
1541 !session.metadata.title.contains("codewhale:runtime"),
1542 "internal envelope leaked into the session title: {}",
1543 session.metadata.title
1544 );
1545 }
1546
1547 #[test]
1548 fn import_foreign_derives_title_from_the_first_real_user_message() {
1549 let tmp = tempdir().expect("tempdir");
1550 // Importing a session whose transcript opens with the Operate contract
1551 // (the shape this bug produced on export) must not re-derive the
1552 // envelope as the imported title.
1553 let container = container_with(
1554 vec![
1555 crate::runtime_handoff::operate_contract_runtime_message(),
1556 make_test_message("user", "Fix the session picker"),
1557 ],
1558 tmp.path(),
1559 );
1560 let imported = crate::session_manager::SavedSession::import_foreign(
1561 container,
1562 tmp.path().to_path_buf(),
1563 "test-model".to_string(),
1564 )
1565 .expect("import succeeds");
1566 assert_eq!(imported.metadata.title, "Fix the session picker");
1567 assert!(
1568 !imported.metadata.title.contains("codewhale:runtime"),
1569 "internal envelope leaked into the imported session title: {}",
1570 imported.metadata.title
1571 );
1572 }
1573
1574 #[test]
1575 fn import_foreign_keeps_placeholder_when_only_runtime_traffic() {
1576 let tmp = tempdir().expect("tempdir");
1577 let container = container_with(
1578 vec![crate::runtime_handoff::operate_contract_runtime_message()],
1579 tmp.path(),
1580 );
1581 let imported = crate::session_manager::SavedSession::import_foreign(
1582 container,
1583 tmp.path().to_path_buf(),
1584 "test-model".to_string(),
1585 )
1586 .expect("import succeeds");
1587 assert_eq!(imported.metadata.title, DEFAULT_SESSION_TITLE);
1588 }
1588 lines RUST