返回 CodeWhale
test_cases_03.rs
根目录 / crates / tui / src / session_manager / test_cases_03.rs
1
2
3 #[test]
4 fn title_derivation_skips_current_and_legacy_runtime_provenance() {
5 let tmp = tempdir().expect("tempdir");
6 for leading_metadata in [false, true] {
7 let mut runtime = make_test_message("user", "Internal diagnostic update");
8 let metadata = ContentBlock::Text {
9 text: "<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>"
10 .to_string(),
11 cache_control: None,
12 };
13 if leading_metadata {
14 runtime.content.insert(0, metadata);
15 } else {
16 runtime.content.push(metadata);
17 }
18 let messages = vec![
19 runtime,
20 make_test_message("user", "Fix the diagnostic display"),
21 ];
22 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
23 assert_eq!(session.metadata.title, "Fix the diagnostic display");
24 let imported = SavedSession::import_foreign(
25 container_with(messages, tmp.path()),
26 tmp.path().to_path_buf(),
27 "test-model".to_string(),
28 )
29 .expect("import succeeds");
30 assert_eq!(imported.metadata.title, "Fix the diagnostic display");
31 }
32 }
33
34 #[test]
35 fn title_derivation_keeps_user_authored_runtime_example() {
36 let tmp = tempdir().expect("tempdir");
37 let messages = vec![make_test_message(
38 "user",
39 "<codewhale:runtime_event> example",
40 )];
41 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
42 assert_eq!(session.metadata.title, "<codewhale:runtime_event> example");
43 }
44
45 /// The exact bytes `SessionManager::load_session_metadata` reads.
46 fn session_bytes(session: &SavedSession, stored_title: &str) -> Vec<u8> {
47 let mut stale = session.clone();
48 stale.metadata.title = stored_title.to_string();
49 serde_json::to_vec(&stale).expect("serialize session")
50 }
51
52 fn loaded_title(session: &SavedSession, stored_title: &str) -> String {
53 let buf = session_bytes(session, stored_title);
54 let mut metadata = extract_top_level_metadata(&buf).expect("metadata extractable");
55 assert_eq!(metadata.title, stored_title);
56 apply_legacy_title_recovery(&mut metadata, &buf);
57 metadata.title
58 }
59
60 /// What the superseded derivation stored for an Operate-contract session:
61 /// the first line of the engine envelope, cut at 50 characters.
62 fn legacy_operate_title() -> String {
63 let message = crate::runtime_handoff::operate_contract_runtime_message();
64 let ContentBlock::Text { text, .. } = &message.content[0] else {
65 panic!("operate contract opens with text");
66 };
67 truncate_title(text, 50)
68 }
69
70 #[test]
71 fn legacy_runtime_titles_recover_the_real_user_prompt() {
72 let tmp = tempdir().expect("tempdir");
73 let messages = vec![
74 crate::runtime_handoff::operate_contract_runtime_message(),
75 make_test_message("user", "Fix the diagnostic display"),
76 ];
77 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
78 let stored = legacy_operate_title();
79 assert!(
80 stored.starts_with("<codewhale:runtime_event kind="),
81 "{stored:?}",
82 );
83 assert_eq!(
84 loaded_title(&session, &stored),
85 "Fix the diagnostic display"
86 );
87 }
88
89 #[test]
90 fn legacy_recovery_leaves_renames_and_user_authored_titles_alone() {
91 let tmp = tempdir().expect("tempdir");
92 let messages = vec![
93 crate::runtime_handoff::operate_contract_runtime_message(),
94 make_test_message("user", "Fix the diagnostic display"),
95 ];
96 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
97 // Renames win, including ones that open with `<` and so pay for the
98 // message scan: provenance is proven, never guessed from the shape.
99 for rename in [
100 "Operate contract",
101 "<my own angle-bracket title>",
102 "<codewhale:runtime_event kind=\"operate_contract\" but renamed by me",
103 ] {
104 assert_eq!(loaded_title(&session, rename), rename);
105 }
106 }
107
108 #[test]
109 fn a_user_who_types_an_attributed_envelope_keeps_their_title() {
110 // The one case the earlier substring rule got wrong. The engine's
111 // envelope carries a runtime provenance line; a person's message does
112 // not, and the existing classifier is what tells them apart — so this
113 // title is theirs and survives.
114 let tmp = tempdir().expect("tempdir");
115 let typed = "<codewhale:runtime_event kind=\"operate_contract\" visibility=\"internal\">";
116 let messages = vec![make_test_message("user", typed)];
117 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
118 let stored = truncate_title(typed, 50);
119 assert_eq!(session.metadata.title, stored);
120 assert_eq!(loaded_title(&session, &stored), stored);
121 }
122
123 #[test]
124 fn legacy_recovery_names_a_runtime_only_session_by_the_default() {
125 // Nothing but runtime traffic: there is no user prompt to recover, and
126 // the array ended inside the read, so the neutral default is provable.
127 let tmp = tempdir().expect("tempdir");
128 let messages = vec![crate::runtime_handoff::operate_contract_runtime_message()];
129 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
130 assert_eq!(
131 loaded_title(&session, &legacy_operate_title()),
132 DEFAULT_SESSION_TITLE
133 );
134 }
135
136 #[test]
137 fn legacy_recovery_keeps_the_stored_title_when_the_read_was_truncated() {
138 // A prefix cut before the user's turn must not be read as "this
139 // conversation has no prompt".
140 let tmp = tempdir().expect("tempdir");
141 let messages = vec![
142 crate::runtime_handoff::operate_contract_runtime_message(),
143 make_test_message("user", "Fix the diagnostic display"),
144 ];
145 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
146 let stored = legacy_operate_title();
147 let full = session_bytes(&session, &stored);
148 let messages_at = full
149 .windows(10)
150 .position(|w| w == b"\"messages\"")
151 .expect("messages key present");
152 let cut = &full[..messages_at + 40];
153 let mut metadata = extract_top_level_metadata(cut).expect("metadata precedes messages");
154 apply_legacy_title_recovery(&mut metadata, cut);
155 assert_eq!(metadata.title, stored, "a truncated read must not rename");
156 }
157
158 #[test]
159 fn ordinary_titles_never_pay_for_the_message_scan() {
160 // #337's bounded read is the reason `list_sessions` is cheap. The `<`
161 // gate is a cost filter only; the rename decision is the provenance
162 // check above.
163 let tmp = tempdir().expect("tempdir");
164 let messages = vec![make_test_message("user", "Fix the diagnostic display")];
165 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
166 let mut metadata = session.metadata.clone();
167 assert!(!metadata.title.starts_with('<'));
168 apply_legacy_title_recovery(&mut metadata, &[]);
169 assert_eq!(metadata.title, "Fix the diagnostic display");
170 }
171
172 #[test]
173 fn leading_messages_stop_at_the_edge_of_a_truncated_prefix() {
174 let tmp = tempdir().expect("tempdir");
175 let messages = vec![
176 make_test_message("user", "first"),
177 make_test_message("assistant", "second"),
178 make_test_message("user", "third"),
179 ];
180 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
181 let buf = serde_json::to_vec(&session).expect("serialize");
182 let (all, complete) = extract_leading_messages(&buf, 24);
183 assert!(complete, "a whole file ends its messages array");
184 assert_eq!(all.len(), 3);
185
186 let (capped, complete) = extract_leading_messages(&buf, 2);
187 assert_eq!(capped.len(), 2);
188 assert!(!complete, "a capped scan has not seen the array end");
189
190 // The file midpoint depends on metadata path lengths and can already
191 // follow the messages array. Cut inside the third message instead.
192 let marker = b"\"third\"";
193 let cut = buf
194 .windows(marker.len())
195 .position(|window| window == marker)
196 .expect("third message is serialized")
197 + marker.len() / 2;
198 let (partial, complete) = extract_leading_messages(&buf[..cut], 24);
199 assert!(!complete);
200 assert_eq!(partial.len(), 2, "the cut message must remain absent");
201 }
202
203 #[test]
204 fn title_derivation_keeps_the_first_image_only_user_boundary() {
205 let tmp = tempdir().expect("tempdir");
206 for with_metadata in [false, true] {
207 let mut first = Message {
208 role: Role::User,
209 content: vec![ContentBlock::ImageUrl {
210 image_url: codewhale_models::ImageUrlContent {
211 url: "data:image/png;base64,AAAA".to_string(),
212 },
213 }],
214 };
215 if with_metadata {
216 first.content.push(ContentBlock::Text {
217 text: "<turn_meta>\nSession mode: Work\n</turn_meta>".to_string(),
218 cache_control: None,
219 });
220 }
221 let messages = vec![first, make_test_message("user", "A later request")];
222 assert_eq!(conversation_title_prompt(&messages), None);
223 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
224 assert_eq!(session.metadata.title, DEFAULT_SESSION_TITLE);
225 let imported = SavedSession::import_foreign(
226 container_with(messages, tmp.path()),
227 tmp.path().to_path_buf(),
228 "test-model".to_string(),
229 )
230 .expect("import succeeds");
231 assert_eq!(imported.metadata.title, DEFAULT_SESSION_TITLE);
232 }
233 }
234
235 #[test]
236 fn strip_thinking_tags_removes_common_inline_blocks() {
237 let text = "Before <think>private</think> middle <reasoning>hidden</reasoning> after";
238 let cleaned = strip_thinking_tags(text);
239 assert_eq!(cleaned, "Before middle after");
240 assert_eq!(strip_thinking_tags("plain answer"), "plain answer");
241 }
242
243 #[test]
244 fn test_format_age() {
245 let now = Utc::now();
246 assert_eq!(format_age(&now), "just now");
247
248 let hour_ago = now - chrono::Duration::hours(2);
249 assert_eq!(format_age(&hour_ago), "2h ago");
250
251 let day_ago = now - chrono::Duration::days(3);
252 assert_eq!(format_age(&day_ago), "3d ago");
253 }
254
255 #[test]
256 fn session_titles_never_keep_terminal_controls_or_bidi_format_chars() {
257 let raw = "Ev\u{1b}]0;PWNED\u{7}il\u{202e}R\u{200b}Z\u{9d}0;X\u{9c}After\u{2066}B\u{2069} 会議 🐳";
258 assert_eq!(
259 sanitize_session_title(raw),
260 "Ev]0;PWNEDilRZ0;XAfterB 会議 🐳"
261 );
262 // Every rename surface goes through normalize_session_title.
263 assert_eq!(
264 normalize_session_title(raw).unwrap(),
265 "Ev]0;PWNEDilRZ0;XAfterB 会議 🐳"
266 );
267 // A title that is nothing but controls is an empty title.
268 assert!(normalize_session_title("\u{1b}\u{7}\u{200b}").is_err());
269 // The listing line re-sanitizes titles saved before this policy.
270 assert_eq!(truncate_title(raw, 40), "Ev]0;PWNEDilRZ0;XAfterB 会議 🐳");
271 }
272
273 #[test]
274 fn format_session_line_includes_absolute_updated_timestamp() {
275 let mut session = create_saved_session(
276 &[make_test_message("user", "Find Friday work")],
277 "test-model",
278 Path::new("/tmp/project"),
279 100,
280 None,
281 );
282 session.metadata.updated_at = DateTime::parse_from_rfc3339("2026-06-01T12:34:00Z")
283 .expect("timestamp")
284 .with_timezone(&Utc);
285
286 let line = format_session_line(&session.metadata);
287
288 assert!(
289 line.contains("2026-06-01 12:34 UTC"),
290 "session list should include an absolute timestamp, got {line:?}"
291 );
292 }
293
294 #[test]
295 fn test_update_session() {
296 let tmp = tempdir().expect("tempdir");
297
298 let messages = vec![make_test_message("user", "Hello")];
299 let session = create_saved_session(&messages, "test-model", tmp.path(), 50, None);
300
301 let new_messages = vec![
302 make_test_message("user", "Hello"),
303 make_test_message("assistant", "Hi!"),
304 ];
305
306 let updated = update_session(session, &new_messages, 100, None);
307 assert_eq!(updated.messages.len(), 2);
308 assert_eq!(updated.metadata.total_tokens, 100);
309 }
310
311 #[test]
312 fn save_load_round_trip_preserves_all_messages_for_cache_fidelity() {
313 #[derive(serde::Deserialize)]
314 struct LegacySession {
315 messages: Vec<Message>,
316 }
317
318 let tmp = tempdir().expect("tempdir");
319 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
320 // Covers the old 500-message cap boundary and well beyond.
321 for count in [0, 1, 500, 501, 600, 1000] {
322 let original: Vec<_> = (0..count)
323 .map(|i| {
324 make_test_message(
325 if i % 2 == 0 { "user" } else { "assistant" },
326 &format!("round-trip message {i}"),
327 )
328 })
329 .collect();
330
331 let mut session = create_saved_session(&original, "test-model", tmp.path(), 0, None);
332 let expected_journal = session.journal.clone();
333 session.compact_for_persistence_queue();
334 let path = manager.save_session(&session).expect("save");
335 let legacy: LegacySession =
336 serde_json::from_slice(&fs::read(path).expect("read")).expect("legacy reader");
337 let loaded = manager.load_session(&session.metadata.id).expect("load");
338
339 assert_eq!(
340 legacy.messages, original,
341 "legacy messages for count={count}"
342 );
343 assert_eq!(
344 loaded.journal, expected_journal,
345 "journal for count={count}"
346 );
347 assert_eq!(
348 loaded.messages.len(),
349 count,
350 "count preserved for count={count}"
351 );
352 assert_eq!(
353 loaded.messages, original,
354 "every message byte-identical after round-trip for count={count}"
355 );
356 }
357 }
358
359 #[test]
360 fn test_checkpoint_round_trip_and_clear() {
361 let tmp = tempdir().expect("tempdir");
362 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
363 let messages = vec![make_test_message("user", "checkpoint me")];
364 let mut session = create_saved_session(&messages, "test-model", tmp.path(), 12, None);
365 session.work_state = Some(SessionWorkState {
366 todos: crate::tools::todo::TodoListSnapshot {
367 items: vec![crate::tools::todo::TodoItem {
368 id: 1,
369 content: "verify checkpoint durability".to_string(),
370 status: crate::tools::todo::TodoStatus::InProgress,
371 }],
372 completion_pct: 0,
373 in_progress_id: Some(1),
374 },
375 ..SessionWorkState::default()
376 });
377 let expected_messages = session.messages.clone();
378 let expected_journal = session.journal.clone();
379 session.compact_for_persistence_queue();
380
381 let path = manager.save_checkpoint(&session).expect("save checkpoint");
382 assert_eq!(
383 path.file_name().and_then(|n| n.to_str()),
384 Some(format!("{}.json", session.metadata.id).as_str()),
385 "checkpoint file must be keyed by session id"
386 );
387 let loaded = manager
388 .load_session_checkpoint(&session.metadata.id)
389 .expect("load checkpoint")
390 .expect("checkpoint exists");
391 assert_eq!(loaded.metadata.id, session.metadata.id);
392 assert_eq!(loaded.messages, expected_messages);
393 assert_eq!(loaded.journal, expected_journal);
394 assert_eq!(
395 loaded.work_state, session.work_state,
396 "work state must survive the checkpoint round trip"
397 );
398
399 manager
400 .clear_session_checkpoint(&session.metadata.id)
401 .expect("clear checkpoint");
402 assert!(
403 manager
404 .load_session_checkpoint(&session.metadata.id)
405 .expect("load checkpoint")
406 .is_none()
407 );
408 }
409
410 #[test]
411 fn graph_backed_work_state_remains_readable_by_legacy_shape() {
412 #[derive(serde::Deserialize)]
413 struct LegacyWorkState {
414 #[serde(default)]
415 todos: crate::tools::todo::TodoListSnapshot,
416 #[serde(default)]
417 plan: crate::tools::plan::PlanSnapshot,
418 }
419
420 let fixture = include_bytes!("../../tests/fixtures/work_graph_session_v1_reader.json");
421 let current: SavedSession = serde_json::from_slice(fixture).expect("current reader");
422 let state = current.work_state.expect("fixture Work state");
423 let legacy: LegacyWorkState = serde_json::from_value(
424 serde_json::from_slice::<serde_json::Value>(fixture)
425 .expect("fixture JSON")["work_state"]
426 .clone(),
427 )
428 .expect("v1 reader ignores graph");
429 assert_eq!(legacy.todos, state.todos);
430 assert_eq!(legacy.plan, state.plan);
431 let graph = state.graph.expect("fixture graph");
432 crate::work_graph::validate(&graph).expect("valid fixture graph");
433 assert_eq!(crate::work_graph::project_todos(&graph), state.todos);
434 assert_eq!(crate::work_graph::project_plan(&graph), state.plan);
435 }
436
437 #[test]
438 fn first_graph_write_archives_exact_legacy_session_once() {
439 let tmp = tempdir().expect("tempdir");
440 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
441 let mut session = create_saved_session(
442 &[make_test_message("user", "archive before import")],
443 "test-model",
444 tmp.path(),
445 0,
446 None,
447 );
448 let plan = crate::tools::plan::PlanSnapshot {
449 items: vec![crate::tools::plan::PlanItemArg {
450 step: "Import".to_string(),
451 status: crate::tools::plan::StepStatus::Pending,
452 }],
453 ..crate::tools::plan::PlanSnapshot::default()
454 };
455 let todos = crate::tools::todo::TodoListSnapshot::default();
456 session.work_state = Some(SessionWorkState {
457 graph: None,
458 todos: todos.clone(),
459 plan: plan.clone(),
460 });
461 let path = manager.save_session(&session).expect("save legacy session");
462 let legacy_bytes = fs::read(&path).expect("read legacy bytes");
463
464 let graph = crate::work_graph::import_legacy(&session.metadata.id, &plan, &todos)
465 .expect("import graph");
466 session.work_state = Some(SessionWorkState {
467 graph: Some(graph),
468 todos,
469 plan,
470 });
471 manager.save_session(&session).expect("first graph write");
472 let archive = manager
473 .sessions_dir
474 .join(WORK_GRAPH_IMPORT_ARCHIVE_DIR)
475 .join(path.file_name().expect("session filename"));
476 assert_eq!(fs::read(&archive).expect("archive exists"), legacy_bytes);
477
478 session.metadata.title = "later graph write".to_string();
479 manager.save_session(&session).expect("second graph write");
480 assert_eq!(
481 fs::read(&archive).expect("archive still exists"),
482 legacy_bytes,
483 "later graph writes must not replace the pre-import receipt"
484 );
485 }
486
487 #[test]
488 fn checkpoints_are_independent_per_session() {
489 let tmp = tempdir().expect("tempdir");
490 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
491 let first = create_saved_session(
492 &[make_test_message("user", "session one")],
493 "test-model",
494 tmp.path(),
495 0,
496 None,
497 );
498 let second = create_saved_session(
499 &[make_test_message("user", "session two")],
500 "test-model",
501 tmp.path(),
502 0,
503 None,
504 );
505
506 manager.save_checkpoint(&first).expect("save first");
507 manager.save_checkpoint(&second).expect("save second");
508 manager
509 .clear_session_checkpoint(&first.metadata.id)
510 .expect("clear first");
511
512 assert!(
513 manager
514 .load_session_checkpoint(&first.metadata.id)
515 .expect("load first")
516 .is_none(),
517 "clearing one session must remove only that session's file"
518 );
519 let survivor = manager
520 .load_session_checkpoint(&second.metadata.id)
521 .expect("load second")
522 .expect("second checkpoint survives");
523 assert_eq!(survivor.metadata.id, second.metadata.id);
524 }
525
526 #[test]
527 fn list_checkpoints_includes_legacy_slot_and_skips_offline_queue() {
528 let tmp = tempdir().expect("tempdir");
529 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
530 let session = create_saved_session(
531 &[make_test_message("user", "list me")],
532 "test-model",
533 tmp.path(),
534 0,
535 None,
536 );
537 manager.save_checkpoint(&session).expect("save checkpoint");
538 let checkpoints = tmp.path().join("sessions").join("checkpoints");
539 fs::write(checkpoints.join("latest.json"), "{}").expect("write legacy slot");
540 fs::write(checkpoints.join("offline_queue.json"), "{}").expect("write legacy queue");
541 fs::write(
542 checkpoints.join(format!("{}.offline_queue.json", session.metadata.id)),
543 "{}",
544 )
545 .expect("write per-session queue");
546
547 let refs = manager.list_checkpoints().expect("list checkpoints");
548 assert_eq!(refs.len(), 2, "offline queue must not be a candidate");
549 assert!(
550 refs.iter()
551 .any(|r| r.source == CheckpointSource::Session(session.metadata.id.clone()))
552 );
553 assert!(refs.iter().any(|r| r.source == CheckpointSource::Legacy));
554 }
555
556 /// A session owned by a *prior* process instance with a crash-recovery
557 /// checkpoint on disk: the foreign boot-owner stamp keeps
558 /// `session_from_prior_instance` true (the save keeps the original
559 /// owner), and the checkpoint is the durable interrupted sign.
560 fn write_prior_interrupted_session(
561 manager: &SessionManager,
562 id: &str,
563 workspace: &Path,
564 ) -> SavedSession {
565 let mut session = create_saved_session(
566 &[make_test_message("user", "still working")],
567 "test-model",
568 workspace,
569 0,
570 None,
571 );
572 session.metadata.id = id.to_string();
573 session.metadata.title = format!("prior-{id}");
574 manager
575 .record_session_boot_owner(id, "boot_other_instance")
576 .expect("stamp foreign owner");
577 manager.save_session(&session).expect("save session");
578 manager.save_checkpoint(&session).expect("save checkpoint");
579 session
580 }
581
582 #[test]
583 fn interrupted_workspace_session_returns_newest_prior_checkpoint() {
584 let tmp = tempdir().expect("tempdir");
585 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
586 let workspace = tmp.path().join("ws");
587 fs::create_dir_all(&workspace).expect("workspace");
588
589 write_prior_interrupted_session(&manager, "sess-old", &workspace);
590 // Distinct checkpoint mtimes make newest-first deterministic.
591 std::thread::sleep(std::time::Duration::from_millis(20));
592 write_prior_interrupted_session(&manager, "sess-new", &workspace);
593
594 // A checkpointed session this instance created is current work, not
595 // prior work: `save_session` stamps the current boot id, and the
596 // checkpoint write keeps it because the record already exists.
597 let own = create_saved_session(
598 &[make_test_message("user", "mine")],
599 "test-model",
600 &workspace,
601 0,
602 None,
603 );
604 manager.save_session(&own).expect("save own");
605 manager.save_checkpoint(&own).expect("checkpoint own");
606
607 // A checkpointed session in another workspace stays invisible.
608 let other_workspace = tmp.path().join("other-ws");
609 fs::create_dir_all(&other_workspace).expect("other workspace");
610 write_prior_interrupted_session(&manager, "sess-elsewhere", &other_workspace);
611
612 assert_eq!(
613 manager
614 .interrupted_workspace_session(&workspace, Some(own.metadata.id.as_str()))
615 .map(|meta| meta.id),
616 Some("sess-new".to_string())
617 );
618 // Excluding the newest surfaces the next interrupted session.
619 assert_eq!(
620 manager
621 .interrupted_workspace_session(&workspace, Some("sess-new"))
622 .map(|meta| meta.id),
623 Some("sess-old".to_string())
624 );
625 assert_eq!(
626 manager
627 .interrupted_workspace_session(&other_workspace, None)
628 .map(|meta| meta.id),
629 Some("sess-elsewhere".to_string())
630 );
631 }
632
633 fn hold_live_lease(manager: &SessionManager, id: &str) -> fs::File {
634 manager.hold_live_lease_elsewhere(id)
635 }
636
637 #[test]
638 fn interrupted_workspace_session_skips_a_session_live_elsewhere() {
639 let tmp = tempdir().expect("tempdir");
640 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
641 let workspace = tmp.path().join("ws");
642 fs::create_dir_all(&workspace).expect("workspace");
643 write_prior_interrupted_session(&manager, "sess-crashed", &workspace);
644 std::thread::sleep(std::time::Duration::from_millis(20));
645 write_prior_interrupted_session(&manager, "sess-running", &workspace);
646
647 let lease = hold_live_lease(&manager, "sess-running");
648 assert_eq!(
649 manager
650 .interrupted_workspace_session(&workspace, None)
651 .map(|meta| meta.id),
652 Some("sess-crashed".to_string()),
653 "a checkpoint another terminal is refreshing is not a crash"
654 );
655 drop(lease);
656 assert_eq!(
657 manager
658 .interrupted_workspace_session(&workspace, None)
659 .map(|meta| meta.id),
660 Some("sess-running".to_string())
661 );
662 }
663
664 /// Attaching a session that crashed mid-turn opens the interrupted turn
665 /// from its crash checkpoint; a stale checkpoint never replaces a newer
666 /// saved document.
667 #[test]
668 fn attach_promotes_the_sessions_newer_crash_checkpoint() {
669 let _env = crate::test_support::lock_test_env();
670 let tmp = tempdir().expect("tempdir");
671 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
672 let workspace = tmp.path().join("ws");
673 fs::create_dir_all(&workspace).expect("workspace");
674 let user = |text: &str| Message {
675 role: Role::User,
676 content: vec![ContentBlock::Text {
677 text: text.to_string(),
678 cache_control: None,
679 }],
680 };
681 let saved = create_saved_session(&[user("first")], "test-model", &workspace, 0, None);
682 let id = saved.metadata.id.clone();
683 manager.save_session(&saved).expect("save session");
684 let mut interrupted = saved.clone();
685 interrupted.messages.push(user("in-flight turn"));
686 interrupted.metadata.updated_at = saved.metadata.updated_at + chrono::Duration::seconds(5);
687 manager
688 .save_checkpoint(&interrupted)
689 .expect("save checkpoint");
690
691 let (recovery, lease) = manager.attach_session(&id).expect("attach");
692 assert_eq!(
693 recovery.session.messages.len(),
694 2,
695 "the interrupted turn is part of the attached session"
696 );
697 assert!(
698 manager
699 .load_session_checkpoint(&id)
700 .expect("load checkpoint")
701 .is_none(),
702 "the recovered checkpoint is consumed"
703 );
704 drop(lease);
705
706 let mut stale = saved.clone();
707 stale.metadata.updated_at = saved.metadata.updated_at - chrono::Duration::seconds(5);
708 manager
709 .save_checkpoint(&stale)
710 .expect("save stale checkpoint");
711 let (recovery, _lease) = manager.attach_session(&id).expect("attach again");
712 assert_eq!(
713 recovery.session.messages.len(),
714 2,
715 "a stale checkpoint never replaces the newer document"
716 );
717 }
718
719 /// Attaching to a session another process has open is refused, naming
720 /// it, rather than giving the document a second autosaving writer.
721 #[test]
722 fn attach_refuses_a_session_open_in_another_process() {
723 let _env = crate::test_support::lock_test_env();
724 let tmp = tempdir().expect("tempdir");
725 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
726 let workspace = tmp.path().join("ws");
727 fs::create_dir_all(&workspace).expect("workspace");
728 write_prior_interrupted_session(&manager, "sess-open", &workspace);
729
730 let lease = hold_live_lease(&manager, "sess-open");
731 let error = manager
732 .reserve_session_for_attach("sess-open")
733 .expect_err("a session open elsewhere is refused");
734 assert_eq!(error.kind(), io::ErrorKind::ResourceBusy);
735 assert!(error.to_string().contains("sess-open"), "{error}");
736 assert!(!is_live_session("sess-open"), "nothing was claimed");
737
738 drop(lease);
739 let (_, reserved) = manager
740 .attach_session("sess-open")
741 .expect("attach once the other window has closed");
742 assert!(
743 !try_lock_elsewhere(&manager, "sess-open"),
744 "the attach holds the lease from the start"
745 );
746 reserved.commit();
747 assert!(is_live_session("sess-open"));
748 assert!(!try_lock_elsewhere(&manager, "sess-open"));
749 set_live_session(None);
750 }
751
752 /// Switching from session A to B must not give up A until B is applied:
753 /// a reservation that is dropped (B failed to load or apply) leaves A
754 /// claimed and leased, and B free.
755 #[test]
756 fn a_failed_attach_keeps_the_current_sessions_lease() {
757 let _env = crate::test_support::lock_test_env();
758 let tmp = tempdir().expect("tempdir");
759 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
760 let workspace = tmp.path().join("ws");
761 fs::create_dir_all(&workspace).expect("workspace");
762 write_prior_interrupted_session(&manager, "sess-a", &workspace);
763 write_prior_interrupted_session(&manager, "sess-b", &workspace);
764 manager.claim_live_session("sess-a");
765 assert!(!try_lock_elsewhere(&manager, "sess-a"), "A is leased");
766
767 let reserved = manager
768 .reserve_session_for_attach("sess-b")
769 .expect("reserve B");
770 assert!(is_live_session("sess-a"), "A stays claimed while B loads");
771 assert!(!try_lock_elsewhere(&manager, "sess-a"), "and leased");
772 drop(reserved);
773 assert!(is_live_session("sess-a"));
774 assert!(!try_lock_elsewhere(&manager, "sess-a"), "A is still leased");
775 assert!(try_lock_elsewhere(&manager, "sess-b"), "B was released");
776
777 // A B whose document cannot be loaded is refused the same way.
778 let b_path = manager.validated_session_path("sess-b").expect("path");
779 fs::write(&b_path, "{ not json").expect("corrupt B");
780 manager
781 .attach_session("sess-b")
782 .expect_err("a corrupt B fails to attach");
783 assert!(is_live_session("sess-a"));
784 assert!(!try_lock_elsewhere(&manager, "sess-a"), "A is still leased");
785 assert!(try_lock_elsewhere(&manager, "sess-b"), "B was released");
786 set_live_session(None);
787 }
788
789 /// `/load` takes the same contract as `resume`: a managed record and a
790 /// foreign file naming a session open in another window are both
791 /// refused, and neither is claimed.
792 #[test]
793 fn load_of_a_session_open_elsewhere_is_refused_for_managed_and_foreign_files() {
794 let _env = crate::test_support::lock_test_env();
795 let tmp = tempdir().expect("tempdir");
796 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
797 let workspace = tmp.path().join("ws");
798 fs::create_dir_all(&workspace).expect("workspace");
799 let session = write_prior_interrupted_session(&manager, "sess-load", &workspace);
800 let managed = manager.validated_session_path("sess-load").expect("path");
801 let foreign = tmp.path().join("exported.json");
802 fs::write(&foreign, serde_json::to_vec(&session).expect("json")).expect("export");
803
804 let lease = hold_live_lease(&manager, "sess-load");
805 for path in [&managed, &foreign] {
806 let error = manager
807 .attach_session_file(session.clone(), path)
808 .expect_err("a session open elsewhere is not loaded");
809 assert_eq!(
810 error.kind(),
811 io::ErrorKind::ResourceBusy,
812 "{}",
813 path.display()
814 );
815 }
816 assert!(!is_live_session("sess-load"), "nothing was claimed");
817
818 drop(lease);
819 for path in [&managed, &foreign] {
820 let (loaded, reserved) = manager
821 .attach_session_file(session.clone(), path)
822 .expect("loads once the other window has closed");
823 assert_eq!(loaded.metadata.id, "sess-load");
824 assert!(
825 !try_lock_elsewhere(&manager, "sess-load"),
826 "the load holds the lease"
827 );
828 drop(reserved);
829 }
830 set_live_session(None);
831 }
832
833 /// A lease that cannot be taken is an error, not an unguarded attach.
834 #[cfg(unix)]
835 #[test]
836 fn attach_fails_closed_when_the_lease_cannot_be_taken() {
837 let _env = crate::test_support::lock_test_env();
838 let tmp = tempdir().expect("tempdir");
839 let sessions_dir = tmp.path().join("sessions");
840 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
841 let workspace = tmp.path().join("ws");
842 fs::create_dir_all(&workspace).expect("workspace");
843 write_prior_interrupted_session(&manager, "sess-x", &workspace);
844 // The lease directory is a regular file: no lock file can be opened.
845 let _ = fs::remove_dir_all(sessions_dir.join(LATE_USAGE_DIR));
846 fs::write(sessions_dir.join(LATE_USAGE_DIR), "").expect("block lease dir");
847
848 let error = manager
849 .attach_session("sess-x")
850 .expect_err("no lease, no attach");
851 assert!(error.to_string().contains("live lease"), "{error}");
852 assert!(!is_live_session("sess-x"), "nothing was claimed");
853 }
854
855 /// A liveness probe from another process briefly holds the lease lock. A
856 /// claim that lost that race once must take the lease on a later save,
857 /// not run unleased for the whole session.
858 #[test]
859 fn a_claim_that_lost_a_probe_race_takes_the_lease_later() {
860 let _env = crate::test_support::lock_test_env();
861 let tmp = tempdir().expect("tempdir");
862 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
863
864 let probe = hold_live_lease(&manager, "sess-a");
865 manager.claim_live_session("sess-a");
866 drop(probe);
867 assert!(
868 try_lock_elsewhere(&manager, "sess-a"),
869 "the first claim lost"
870 );
871
872 manager.claim_live_session("sess-a");
873 assert!(
874 !try_lock_elsewhere(&manager, "sess-a"),
875 "the next claim takes the lease"
876 );
877 set_live_session(None);
878 }
879
880 /// Whether another open file description can lock `id`'s lease now.
881 fn try_lock_elsewhere(manager: &SessionManager, id: &str) -> bool {
882 let lease = open_private_read_file(&manager.live_lease_path(id, false).expect("path"))
883 .expect("lease file");
884 crate::runtime_threads::try_lock_file_exclusive(&lease).expect("lock")
885 }
886
887 #[test]
888 fn interrupted_workspace_session_ignores_settled_sessions() {
889 let tmp = tempdir().expect("tempdir");
890 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
891 let workspace = tmp.path().join("ws");
892 fs::create_dir_all(&workspace).expect("workspace");
893
894 // Prior-instance session that settled cleanly: no checkpoint, so it
895 // is not interrupted even though it is prior work.
896 manager
897 .record_session_boot_owner("sess-done", "boot_other_instance")
898 .expect("stamp foreign owner");
899 write_session_record(&manager, "sess-done", &workspace, Utc::now());
900
901 assert!(
902 manager
903 .interrupted_workspace_session(&workspace, None)
904 .is_none()
905 );
906
907 // Excluding the only interrupted session leaves nothing to report.
908 write_prior_interrupted_session(&manager, "sess-prior", &workspace);
909 assert!(
910 manager
911 .interrupted_workspace_session(&workspace, Some("sess-prior"))
912 .is_none()
913 );
914 }
915
916 #[test]
917 fn session_recovery_hint_names_the_interrupted_prior_session() {
918 let _lock = crate::test_support::lock_test_env();
919 let tmp = tempdir().expect("tempdir");
920 let home = tmp.path().join("home");
921 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
922 let _codewhale_home =
923 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join("codewhale"));
924 let workspace = tmp.path().join("ws");
925 fs::create_dir_all(&workspace).expect("workspace");
926
927 let manager = SessionManager::default_location().expect("default manager");
928 assert!(
929 session_recovery_hint(&workspace, None).is_none(),
930 "clean store must leave the prompt untouched"
931 );
932
933 write_prior_interrupted_session(&manager, "sess-prior", &workspace);
934 let hint = session_recovery_hint(&workspace, Some("sess-live"))
935 .expect("hint for interrupted prior session");
936 assert!(hint.contains("prior-sess-prior"), "{hint}");
937 assert!(hint.contains("session_search"), "{hint}");
938 assert!(hint.contains("/resume"), "{hint}");
939
940 // The live session's own checkpoint is never reported as prior work.
941 assert!(session_recovery_hint(&workspace, Some("sess-prior")).is_none());
942 }
943
944 #[test]
945 fn legacy_migration_never_overwrites_existing_per_session_checkpoint() {
946 let tmp = tempdir().expect("tempdir");
947 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
948 let mut session = create_saved_session(
949 &[make_test_message("user", "original")],
950 "test-model",
951 tmp.path(),
952 0,
953 None,
954 );
955 manager.save_checkpoint(&session).expect("save checkpoint");
956
957 session.messages = vec![make_test_message("user", "stale legacy copy")];
958 let written = manager
959 .write_session_checkpoint_if_absent(&session)
960 .expect("migration attempt");
961 assert!(!written, "migration must not overwrite an existing file");
962 let loaded = manager
963 .load_session_checkpoint(&session.metadata.id)
964 .expect("load")
965 .expect("checkpoint exists");
966 assert_eq!(
967 loaded.messages,
968 vec![make_test_message("user", "original")],
969 "existing per-session checkpoint content must be preserved"
970 );
971 }
972
973 #[test]
974 fn workspace_scope_matches_subdirectories_in_same_git_checkout() {
975 let tmp = tempdir().expect("tempdir");
976 let repo = tmp.path().join("repo");
977 let nested = repo.join("crates").join("tui");
978 fs::create_dir_all(&nested).expect("mkdir nested");
979 fs::write(repo.join(".git"), "gitdir: .git/worktrees/repo").expect("write git marker");
980
981 assert!(workspace_scope_matches(&repo, &nested));
982 }
983
984 #[test]
985 fn workspace_scope_rejects_sibling_git_checkouts() {
986 let tmp = tempdir().expect("tempdir");
987 let first = tmp.path().join("repo-a");
988 let second = tmp.path().join("repo-b");
989 fs::create_dir_all(&first).expect("mkdir first");
990 fs::create_dir_all(&second).expect("mkdir second");
991 fs::write(first.join(".git"), "gitdir: .git/worktrees/a").expect("write first marker");
992 fs::write(second.join(".git"), "gitdir: .git/worktrees/b").expect("write second marker");
993
994 assert!(!workspace_scope_matches(&first, &second));
995 }
996
997 #[test]
998 fn test_offline_queue_round_trip_and_clear() {
999 let tmp = tempdir().expect("tempdir");
1000 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1001
1002 let state = OfflineQueueState {
1003 messages: vec![QueuedSessionMessage {
1004 display: "queued message".to_string(),
1005 skill_instruction: Some("Use skill".to_string()),
1006 skill_provenance: None,
1007 }],
1008 draft: Some(QueuedSessionMessage {
1009 display: "draft message".to_string(),
1010 skill_instruction: None,
1011 skill_provenance: None,
1012 }),
1013 ..OfflineQueueState::default()
1014 };
1015
1016 manager
1017 .save_offline_queue_state(&state, Some("test-session"))
1018 .expect("save queue state");
1019 let loaded = manager
1020 .load_offline_queue_state("test-session")
1021 .expect("load queue state")
1022 .expect("queue state exists");
1023 assert_eq!(loaded.messages.len(), 1);
1024 assert_eq!(loaded.messages[0].display, "queued message");
1025 assert!(loaded.draft.is_some());
1026
1027 manager
1028 .clear_offline_queue_state_for("test-session")
1029 .expect("clear queue state");
1030 assert!(
1031 manager
1032 .load_offline_queue_state("test-session")
1033 .expect("load queue state")
1034 .is_none()
1035 );
1036
1037 // A queue with no owning session has nowhere to be restored to, so it
1038 // is refused rather than written where another session would find it.
1039 let unowned = manager.save_offline_queue_state(&state, None);
1040 assert!(unowned.is_err(), "unowned queue must not be parked");
1041 }
1042
1043 fn parked(text: &str) -> OfflineQueueState {
1044 OfflineQueueState {
1045 messages: vec![QueuedSessionMessage {
1046 display: text.to_string(),
1047 skill_instruction: None,
1048 skill_provenance: None,
1049 }],
1050 ..OfflineQueueState::default()
1051 }
1052 }
1053
1054 #[test]
1055 fn offline_queues_are_keyed_per_session() {
1056 // Replaces the #487 single-slot test, which pinned the shared
1057 // `checkpoints/offline_queue.json`: two concurrent Codewhale
1058 // instances raced on it and the loser's unsent text was destroyed.
1059 // Queues are keyed per session for the same reason checkpoints are.
1060 let tmp = tempdir().expect("tempdir");
1061 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1062
1063 manager
1064 .save_offline_queue_state(&parked("A text"), Some("session-A"))
1065 .expect("park A");
1066 manager
1067 .save_offline_queue_state(&parked("B text"), Some("session-B"))
1068 .expect("park B");
1069
1070 let a = manager
1071 .load_offline_queue_state("session-A")
1072 .expect("load A")
1073 .expect("A still parked");
1074 assert_eq!(a.messages[0].display, "A text");
1075 assert_eq!(a.session_id.as_deref(), Some("session-A"));
1076 let b = manager
1077 .load_offline_queue_state("session-B")
1078 .expect("load B")
1079 .expect("B still parked");
1080 assert_eq!(b.messages[0].display, "B text");
1081
1082 // Clearing one session's queue leaves the other's alone.
1083 manager
1084 .clear_offline_queue_state_for("session-A")
1085 .expect("clear A");
1086 assert!(
1087 manager
1088 .load_offline_queue_state("session-A")
1089 .expect("load A")
1090 .is_none()
1091 );
1092 assert!(
1093 manager
1094 .load_offline_queue_state("session-B")
1095 .expect("load B")
1096 .is_some(),
1097 "clearing one session must never delete another's unsent text"
1098 );
1099
1100 // A session with nothing parked reads back nothing — it can never
1101 // inherit, or destroy, a sibling's queue.
1102 assert!(
1103 manager
1104 .load_offline_queue_state("session-C")
1105 .expect("load C")
1106 .is_none()
1107 );
1108 }
1109
1110 #[test]
1111 fn legacy_global_queue_is_adopted_only_by_its_own_session() {
1112 let tmp = tempdir().expect("tempdir");
1113 let sessions_dir = tmp.path().join("sessions");
1114 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1115 let checkpoints = sessions_dir.join("checkpoints");
1116 fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
1117 let legacy = checkpoints.join("offline_queue.json");
1118 let mut state = parked("text from the old global queue");
1119 state.session_id = Some("session-A".to_string());
1120 fs::write(
1121 &legacy,
1122 serde_json::to_string_pretty(&state).expect("serialize"),
1123 )
1124 .expect("write legacy queue");
1125
1126 // A different session must not inherit it, and must not delete it.
1127 assert!(
1128 manager
1129 .load_offline_queue_state("session-B")
1130 .expect("load B")
1131 .is_none()
1132 );
1133 assert!(legacy.exists(), "another session's text must survive");
1134
1135 // Its own session adopts it, and the global file is retired only
1136 // after the per-session copy is durably written.
1137 let adopted = manager
1138 .load_offline_queue_state("session-A")
1139 .expect("load A")
1140 .expect("adopted");
1141 assert_eq!(
1142 adopted.messages[0].display,
1143 "text from the old global queue"
1144 );
1145 assert!(!legacy.exists(), "adopted legacy queue is retired");
1146 assert!(
1147 checkpoints.join("session-A.offline_queue.json").exists(),
1148 "adoption writes the per-session file"
1149 );
1150 let again = manager
1151 .load_offline_queue_state("session-A")
1152 .expect("reload A")
1153 .expect("still parked");
1154 assert_eq!(again.messages[0].display, "text from the old global queue");
1155 }
1156
1157 #[test]
1158 fn test_session_context_references_round_trip() {
1159 let tmp = tempdir().expect("tempdir");
1160 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1161 let mut session = create_saved_session(
1162 &[make_test_message("user", "read @src/main.rs")],
1163 "deepseek-v4-pro",
1164 tmp.path(),
1165 0,
1166 None,
1167 );
1168 session.context_references.push(SessionContextReference {
1169 message_index: 0,
1170 reference: ContextReference {
1171 kind: ContextReferenceKind::File,
1172 source: ContextReferenceSource::AtMention,
1173 badge: "file".to_string(),
1174 label: "src/main.rs".to_string(),
1175 target: tmp.path().join("src/main.rs").display().to_string(),
1176 included: true,
1177 expanded: true,
1178 detail: Some("included".to_string()),
1179 },
1180 });
1181
1182 let path = manager.save_session(&session).expect("save session");
1183 let loaded = manager
1184 .load_session(&session.metadata.id)
1185 .expect("load session");
1186 assert!(path.exists());
1187 assert_eq!(loaded.context_references, session.context_references);
1188 }
1189
1190 #[test]
1191 fn test_checkpoint_rejects_newer_schema() {
1192 let tmp = tempdir().expect("tempdir");
1193 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1194 let checkpoints = tmp.path().join("sessions").join("checkpoints");
1195 fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
1196 let path = checkpoints.join("latest.json");
1197 fs::write(
1198 &path,
1199 r#"{
1200 "schema_version": 999,
1201 "metadata": {
1202 "id": "sid",
1203 "title": "bad",
1204 "created_at": "2026-01-01T00:00:00Z",
1205 "updated_at": "2026-01-01T00:00:00Z",
1206 "message_count": 0,
1207 "total_tokens": 0,
1208 "model": "m",
1209 "workspace": "/tmp",
1210 "mode": null
1211 },
1212 "messages": [],
1213 "system_prompt": null
1214 }"#,
1215 )
1216 .expect("write checkpoint");
1217
1218 let err = manager
1219 .load_legacy_checkpoint()
1220 .expect_err("should reject schema");
1221 assert!(err.to_string().contains("newer than supported"));
1222
1223 // The same guard applies to per-session checkpoint files.
1224 fs::rename(&path, checkpoints.join("sid.json")).expect("rename to per-session file");
1225 let err = manager
1226 .load_session_checkpoint("sid")
1227 .expect_err("should reject schema");
1228 assert!(err.to_string().contains("newer than supported"));
1229 }
1230
1231 #[test]
1232 fn test_load_session_rejects_newer_schema() {
1233 let tmp = tempdir().expect("tempdir");
1234 let sessions_dir = tmp.path().join("sessions");
1235 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1236
1237 let id = "future-session";
1238 let path = sessions_dir.join(format!("{id}.json"));
1239 fs::write(
1240 &path,
1241 r#"{
1242 "schema_version": 999,
1243 "metadata": {
1244 "id": "future-session",
1245 "title": "future",
1246 "created_at": "2026-01-01T00:00:00Z",
1247 "updated_at": "2026-01-01T00:00:00Z",
1248 "message_count": 0,
1249 "total_tokens": 0,
1250 "model": "m",
1251 "workspace": "/tmp",
1252 "mode": null
1253 },
1254 "messages": [],
1255 "system_prompt": null
1256 }"#,
1257 )
1258 .expect("write session");
1259
1260 let err = manager.load_session(id).expect_err("should reject schema");
1261 assert!(
1262 err.to_string().contains("newer than supported"),
1263 "unexpected error: {err}"
1264 );
1265 }
1266
1267 /// Regression for #337: metadata extraction skips the (potentially
1268 /// huge) `messages` array — it must succeed even when the messages
1269 /// array is megabytes long, and it must NOT confuse a `"metadata"`
1270 /// substring inside a message body for the real top-level key.
1271 #[test]
1272 fn extract_top_level_metadata_skips_huge_messages_array() {
1273 // Build a session JSON with a large `messages` payload that
1274 // contains the literal string `"metadata"` in a user message —
1275 // a naive `find("\"metadata\"")` would mis-target this.
1276 let big_text = format!(
1277 r#"this message references "metadata" inside it, repeated:{}"#,
1278 "x".repeat(20_000)
1279 );
1280 let json = format!(
1281 r#"{{
1282 "schema_version": 1,
1283 "metadata": {{
1284 "id": "abc-123",
1285 "title": "Real Session",
1286 "created_at": "2026-01-01T00:00:00Z",
1287 "updated_at": "2026-01-02T00:00:00Z",
1288 "message_count": 12,
1289 "total_tokens": 4096,
1290 "model": "deepseek-v4-flash",
1291 "workspace": "/tmp"
1292 }},
1293 "messages": [
1294 {{ "role": "user", "content": [ {{ "Text": {{ "text": {big_text:?} }} }} ] }}
1295 ]
1296 }}"#
1297 );
1298
1299 let extracted =
1300 extract_top_level_metadata(json.as_bytes()).expect("metadata extractable from prefix");
1301 assert_eq!(extracted.id, "abc-123");
1302 assert_eq!(extracted.title, "Real Session");
1303 assert_eq!(extracted.message_count, 12);
1304 assert_eq!(extracted.total_tokens, 4096);
1305 }
1306
1307 /// A 64 KB read prefix routinely ends inside a multi-byte character in a
1308 /// CJK or emoji transcript. The metadata ahead of the cut is intact and
1309 /// must still be read from the prefix, not force a full-file read.
1310 #[test]
1311 fn extract_top_level_metadata_survives_a_prefix_cut_inside_a_character() {
1312 let json = format!(
1313 r#"{{"schema_version":1,"metadata":{{"id":"cjk-1","title":"会话","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z","message_count":3,"total_tokens":10,"model":"m","workspace":"/tmp"}},"messages":[{{"role":"user","content":"{}"}}]}}"#,
1314 "中文".repeat(100)
1315 );
1316 let bytes = json.as_bytes();
1317 let body_start = json.find("中文").expect("body");
1318 // One byte into a three-byte character.
1319 let cut = &bytes[..body_start + 1];
1320 assert!(
1321 std::str::from_utf8(cut).is_err(),
1322 "the cut splits a character"
1323 );
1324
1325 let extracted = extract_top_level_metadata(cut).expect("metadata from the cut prefix");
1326 assert_eq!(extracted.id, "cjk-1");
1327 assert_eq!(extracted.title, "会话");
1328 }
1329
1330 /// Only a cut at the end of the prefix is trimmed. An invalid byte ahead
1331 /// of it is a damaged file, left to the full read to report.
1332 #[test]
1333 fn extract_top_level_metadata_rejects_invalid_utf8_inside_the_prefix() {
1334 let json = r#"{"schema_version":1,"metadata":{"id":"bad-1","title":"t","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z","message_count":3,"total_tokens":10,"model":"m","workspace":"/tmp"},"messages":[{"role":"user","content":"AB"}]}"#;
1335 let mut bytes = json.as_bytes().to_vec();
1336 let body = json.find("AB").expect("body");
1337 bytes[body] = 0xFF;
1338
1339 assert!(extract_top_level_metadata(&bytes).is_none());
1340 }
1341
1342 #[test]
1343 fn extract_top_level_metadata_handles_braces_inside_strings() {
1344 // A title containing `{` and `}` inside the metadata block must
1345 // not throw off the brace counter.
1346 let json = r#"{
1347 "metadata": {
1348 "id": "x",
1349 "title": "weird { title } with braces",
1350 "created_at": "2026-01-01T00:00:00Z",
1351 "updated_at": "2026-01-01T00:00:00Z",
1352 "message_count": 0,
1353 "total_tokens": 0,
1354 "model": "m",
1355 "workspace": "/tmp"
1356 },
1357 "messages": []
1358 }"#;
1359 let extracted = extract_top_level_metadata(json.as_bytes())
1360 .expect("brace-in-string survives the scanner");
1361 assert_eq!(extracted.title, "weird { title } with braces");
1362 }
1363
1364 #[test]
1365 fn saved_session_deserializes_without_artifacts_as_empty_registry() {
1366 let json = r#"{
1367 "schema_version": 1,
1368 "metadata": {
1369 "id": "legacy-session",
1370 "title": "legacy",
1371 "created_at": "2026-05-08T00:00:00Z",
1372 "updated_at": "2026-05-08T00:00:00Z",
1373 "message_count": 0,
1374 "total_tokens": 0,
1375 "model": "deepseek-v4-pro",
1376 "workspace": "/tmp"
1377 },
1378 "messages": [],
1379 "system_prompt": null
1380 }"#;
1381
1382 let session: SavedSession = serde_json::from_str(json).expect("legacy session loads");
1383 assert!(session.artifacts.is_empty());
1384 assert!(session.last_auto_route.is_none());
1385 assert!(session.metadata.parent_session_id.is_none());
1386 assert!(session.metadata.forked_from_message_count.is_none());
1387 }
1388
1389 #[test]
1390 fn fork_lineage_metadata_round_trips_and_formats() {
1391 let tmp = tempdir().expect("tempdir");
1392 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1393 let parent = create_saved_session(
1394 &[
1395 make_test_message("user", "try approach A"),
1396 make_test_message("assistant", "A looks viable"),
1397 ],
1398 "deepseek-v4-pro",
1399 Path::new("/tmp"),
1400 42,
1401 None,
1402 );
1403 let mut forked = create_saved_session(
1404 &parent.messages,
1405 &parent.metadata.model,
1406 &parent.metadata.workspace,
1407 parent.metadata.total_tokens,
1408 None,
1409 );
1410 forked.metadata.mark_forked_from(&parent.metadata);
1411
1412 manager.save_session(&forked).expect("save fork");
1413 let loaded = manager
1414 .load_session(&forked.metadata.id)
1415 .expect("load fork");
1416
1417 assert_eq!(
1418 loaded.metadata.parent_session_id.as_deref(),
1419 Some(parent.metadata.id.as_str())
1420 );
1421 assert_eq!(loaded.metadata.forked_from_message_count, Some(2));
1422 let line = format_session_line(&loaded.metadata);
1423 assert!(line.contains("fork"));
1424 assert!(!line.contains(parent.metadata.id.as_str()));
1425 }
1426
1427 #[test]
1428 fn save_and_load_session_preserves_artifact_metadata() {
1429 let tmp = tempdir().expect("tempdir");
1430 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1431 let mut session = create_saved_session(
1432 &[make_test_message("user", "run tests")],
1433 "deepseek-v4-pro",
1434 Path::new("/tmp"),
1435 0,
1436 None,
1437 );
1438 session.artifacts.push(crate::artifacts::ArtifactRecord {
1439 id: "art_call_big".to_string(),
1440 kind: crate::artifacts::ArtifactKind::ToolOutput,
1441 session_id: session.metadata.id.clone(),
1442 tool_call_id: "call-big".to_string(),
1443 tool_name: "exec_shell".to_string(),
1444 created_at: Utc::now(),
1445 byte_size: 512_000,
1446 preview: "cargo test output".to_string(),
1447 storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"),
1448 });
1449
1450 manager.save_session(&session).expect("save");
1451 let loaded = manager.load_session(&session.metadata.id).expect("load");
1452
1453 assert_eq!(loaded.artifacts, session.artifacts);
1454 }
1455
1456 // ---- #406 prune_sessions_older_than ----
1457 //
1458 // The helper is a building block for the auto-archive design: it
1459 // removes session files older than a threshold while leaving fresh
1460 // ones (and the checkpoint directory) alone. Tests cover the empty
1461 // case, the all-fresh case, the all-stale case, and the mixed case.
1462
1463 fn write_session_with_updated_at(
1464 manager: &SessionManager,
1465 id: &str,
1466 updated_at: DateTime<Utc>,
1467 ) {
1468 // Build a minimal SavedSession by hand so the test isn't tied
1469 // to whatever the helper functions emit; we just need a
1470 // metadata block whose `updated_at` matches the requested
1471 // value.
1472 write_session_record(manager, id, Path::new("/tmp"), updated_at);
1473 }
1474
1475 #[test]
1476 fn retention_archives_past_the_cap_and_never_unlinks_transcripts() {
1477 // #6136: the cap retires transcripts into the archive; it must not
1478 // delete what the user never asked to delete.
1479 let tmp = tempdir().expect("tempdir");
1480 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
1481 let mut ids = Vec::new();
1482 for index in 0..(MAX_SESSIONS + 3) {
1483 let id = Uuid::new_v4().to_string();
1484 write_session_with_updated_at(
1485 &manager,
1486 &id,
1487 Utc::now() - chrono::Duration::minutes((MAX_SESSIONS + 3 - index) as i64),
1488 );
1489 ids.push(id);
1490 }
1491 manager.cleanup_old_sessions().expect("retention");
1492
1493 let listed = manager.list_sessions().expect("sessions");
1494 assert_eq!(listed.len(), MAX_SESSIONS + 3, "nothing is unlinked");
1495 let archived: Vec<&str> = listed
1496 .iter()
1497 .filter(|session| session.archived)
1498 .map(|session| session.id.as_str())
1499 .collect();
1500 assert_eq!(
1501 archived.len(),
1502 3,
1503 "exactly the overflow is archived: {archived:?}"
1504 );
1505 for id in &ids[..3] {
1506 assert!(archived.contains(&id.as_str()), "{id} must be archived");
1507 assert!(
1508 manager.validated_session_path(id).expect("path").exists(),
1509 "the transcript file survives retention"
1510 );
1511 }
1512 for id in &ids[3..] {
1513 assert!(
1514 !archived.contains(&id.as_str()),
1515 "{id} is inside the cap and must stay active"
1516 );
1517 }
1518 }
1519
1520 #[test]
1521 fn empty_stubs_are_capped_apart_and_never_evict_transcripts() {
1522 // #6137: auto-created "New Session" stubs must not occupy (or evict
1523 // from) the transcript cap.
1524 let tmp = tempdir().expect("tempdir");
1525 let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager");
1526 for index in 0..MAX_SESSIONS {
1527 write_session_with_updated_at(
1528 &manager,
1529 &Uuid::new_v4().to_string(),
1530 Utc::now() - chrono::Duration::minutes((MAX_SESSIONS + 20 - index) as i64),
1531 );
1532 }
1533 let mut stub_ids = Vec::new();
1534 for index in 0..(MAX_EMPTY_SESSION_STUBS + 4) {
1535 let id = Uuid::new_v4().to_string();
1536 write_empty_session_record(
1537 &manager,
1538 &id,
1539 Path::new("/tmp"),
1540 Utc::now()
1541 - chrono::Duration::minutes((MAX_EMPTY_SESSION_STUBS + 4 - index) as i64),
1542 );
1543 stub_ids.push(id);
1544 }
1545 manager.cleanup_old_sessions().expect("retention");
1546
1547 let listed = manager.list_sessions().expect("sessions");
1548 assert_eq!(
1549 listed
1550 .iter()
1551 .filter(|session| !is_empty_auto_created_session(session) && !session.archived)
1552 .count(),
1553 MAX_SESSIONS,
1554 "stubs never push a transcript out of the cap"
1555 );
1556 assert!(
1557 listed
1558 .iter()
1559 .filter(|session| !is_empty_auto_created_session(session))
1560 .all(|session| !session.archived),
1561 "no transcript is archived while only stubs are over their cap"
1562 );
1563 assert_eq!(
1564 listed
1565 .iter()
1566 .filter(|session| is_empty_auto_created_session(session))
1567 .count(),
1568 MAX_EMPTY_SESSION_STUBS,
1569 "stub retention keeps only the newest stubs"
1570 );
1571 for id in &stub_ids[..4] {
1572 assert!(
1573 !manager.validated_session_path(id).expect("path").exists(),
1574 "{id} is an old stub and must be removed"
1575 );
1576 }
1577 for id in &stub_ids[4..] {
1578 assert!(
1579 manager.validated_session_path(id).expect("path").exists(),
1580 "{id} is among the newest stubs and must stay"
1581 );
1582 }
1583 }
1583 lines RUST