返回 CodeWhale
session_lifecycle_regression_tests.rs
根目录 / crates / tui / src / commands / session_lifecycle_regression_tests.rs
1 //! Regression coverage retained from the pre-FEAT-023 lifecycle implementation.
2 //!
3 //! These tests dispatch through the public command seam so moving host logic
4 //! behind `CommandSessionLifecycleContext` cannot silently reduce the existing
5 //! persistence, reset, and deferred-load guarantees.
6
7 use std::time::Instant;
8
9 use tempfile::TempDir;
10
11 use crate::commands::CommandResult;
12 use crate::config::Config;
13 use crate::reasoning_preference::ReasoningEffort;
14 use crate::session_manager::create_saved_session_with_id_and_mode;
15 use crate::test_support::SealedHome;
16 use crate::tui::app::{App, AppAction, TuiOptions, TurnCacheRecord};
17 use crate::tui::history::HistoryCell;
18 use codewhale_config::AppMode;
19 use codewhale_models::Role;
20
21 fn dispatch_lifecycle(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult {
22 let command = match arg {
23 Some(arg) => format!("/{name} {arg}"),
24 None => format!("/{name}"),
25 };
26 crate::commands::execute(&command, app)
27 }
28
29 fn save(app: &mut App, path: Option<&str>) -> CommandResult {
30 dispatch_lifecycle(app, "save", path)
31 }
32
33 fn fork(app: &mut App) -> CommandResult {
34 dispatch_lifecycle(app, "fork", None)
35 }
36
37 fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult {
38 dispatch_lifecycle(app, "new", arg)
39 }
40
41 fn load(app: &mut App, path: Option<&str>) -> CommandResult {
42 dispatch_lifecycle(app, "load", path)
43 }
44
45 fn compact(app: &mut App, arg: Option<&str>) -> CommandResult {
46 dispatch_lifecycle(app, "compact", arg)
47 }
48
49 fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult {
50 dispatch_lifecycle(app, "sessions", arg)
51 }
52
53 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
54 let options = TuiOptions {
55 skills_dir: tmpdir.path().join("skills"),
56 memory_path: tmpdir.path().join("memory.md"),
57 notes_path: tmpdir.path().join("notes.txt"),
58 mcp_config_path: tmpdir.path().join("mcp.json"),
59 ..crate::test_support::test_tui_options(tmpdir.path())
60 };
61 App::new(options, &Config::default())
62 }
63
64 #[test]
65 fn test_save_creates_file_and_sets_session_id() {
66 let tmpdir = TempDir::new().unwrap();
67 let _home = SealedHome::new();
68 let mut app = create_test_app_with_tmpdir(&tmpdir);
69 let save_path = tmpdir.path().join("test_session.json");
70
71 let result = save(&mut app, Some(save_path.to_str().unwrap()));
72 assert!(result.message.is_some());
73 let msg = result.message.unwrap();
74 assert!(msg.contains("Session saved to"));
75 assert!(msg.contains("ID:"));
76 assert!(app.current_session_id.is_some());
77 assert!(save_path.exists());
78 }
79
80 #[test]
81 fn save_preserves_artifact_registry() {
82 let tmpdir = TempDir::new().unwrap();
83 let _home = SealedHome::new();
84 let mut app = create_test_app_with_tmpdir(&tmpdir);
85 let save_path = tmpdir.path().join("artifact_session.json");
86 app.session_artifacts
87 .push(crate::artifacts::ArtifactRecord {
88 id: "art_call_big".to_string(),
89 kind: crate::artifacts::ArtifactKind::ToolOutput,
90 session_id: "artifact-session".to_string(),
91 tool_call_id: "call-big".to_string(),
92 tool_name: "exec_shell".to_string(),
93 created_at: chrono::Utc::now(),
94 byte_size: 512_000,
95 preview: "cargo test output".to_string(),
96 storage_path: tmpdir.path().join("call-big.txt"),
97 });
98
99 let result = save(&mut app, Some(save_path.to_str().unwrap()));
100
101 assert!(!result.is_error);
102 let saved: crate::session_manager::SavedSession =
103 serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap();
104 assert_eq!(saved.artifacts, app.session_artifacts);
105 }
106
107 #[test]
108 fn save_preserves_latest_auto_route_receipt() {
109 let tmpdir = TempDir::new().unwrap();
110 let _home = SealedHome::new();
111 let mut app = create_test_app_with_tmpdir(&tmpdir);
112 let save_path = tmpdir.path().join("auto_route_session.json");
113 let receipt = crate::model_routing::AutoRouteReceipt {
114 tier: crate::model_routing::AutoRouteTier::Fast,
115 pair: crate::model_routing::AutoRoutePair {
116 strong: crate::config::ZAI_GLM_5_2_MODEL.to_string(),
117 fast: Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()),
118 },
119 scope: crate::model_routing::AutoRouteScope::ResolvedProvider,
120 data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic,
121 reason: crate::model_routing::AutoRouteReason::LocalFallback(
122 crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault,
123 ),
124 decision: None,
125 router_failure: None,
126 };
127 app.set_model_selection("auto".to_string());
128 app.last_effective_provider = Some(crate::config::ProviderKind::Zai);
129 app.last_effective_provider_identity = Some("zai".to_string());
130 app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string());
131 app.last_auto_route_receipt = Some(receipt.clone());
132 app.last_effective_reasoning_effort =
133 Some(crate::reasoning_preference::EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable);
134
135 let result = save(&mut app, Some(save_path.to_str().unwrap()));
136
137 assert!(!result.is_error);
138 let saved: crate::session_manager::SavedSession =
139 serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap();
140 let route = saved.last_auto_route.expect("latest Auto route");
141 assert_eq!(route.provider, crate::config::ProviderKind::Zai);
142 assert_eq!(route.provider_identity, "zai");
143 assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
144 assert_eq!(route.receipt, receipt);
145 assert_eq!(
146 route.effective_reasoning_effort,
147 Some(crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable)
148 );
149 }
150
151 #[test]
152 fn fork_saves_parent_and_switches_to_child_session() {
153 let tmpdir = TempDir::new().unwrap();
154 let _home = SealedHome::new();
155 let mut app = create_test_app_with_tmpdir(&tmpdir);
156 app.set_provider_identity(crate::config::ProviderKind::Custom, "lm-studio");
157 app.current_session_id = Some("parent-session".to_string());
158 let mut cached_parent = create_saved_session_with_id_and_mode(
159 "parent-session".to_string(),
160 &[],
161 &app.model,
162 &app.workspace,
163 0,
164 None,
165 Some(app.mode.label()),
166 )
167 .metadata;
168 cached_parent.title = "Custom Parent".to_string();
169 cached_parent.created_at = "2026-01-02T03:04:05Z"
170 .parse()
171 .expect("fixed parent timestamp");
172 app.current_session_metadata = Some(cached_parent.clone());
173 app.session_title = Some(cached_parent.title.clone());
174 app.api_messages_mut().push(codewhale_models::Message {
175 role: Role::User,
176 content: vec![codewhale_models::ContentBlock::Text {
177 text: "try another path".to_string(),
178 cache_control: None,
179 }],
180 });
181 {
182 let mut todos = app.todos.try_lock().expect("todos lock");
183 todos.add(
184 "preserve fork Work".to_string(),
185 crate::tools::todo::TodoStatus::InProgress,
186 );
187 }
188 {
189 let mut plan = app.plan_state.try_lock().expect("plan lock");
190 plan.update(crate::tools::plan::UpdatePlanArgs {
191 objective: Some("Fork without Work drift".to_string()),
192 ..crate::tools::plan::UpdatePlanArgs::default()
193 });
194 }
195 app.cycle_effort();
196 let expected_work = app
197 .work_state_snapshot()
198 .expect("Work snapshot")
199 .expect("graph-backed Work state");
200 assert!(
201 expected_work.graph.is_some(),
202 "fork fixture must use a graph"
203 );
204
205 let result = fork(&mut app);
206
207 assert!(!result.is_error, "{:?}", result.message);
208 let new_id = app.current_session_id.clone().expect("fork session id");
209 assert_ne!(new_id, "parent-session");
210 assert!(result.message.as_deref().unwrap_or("").contains("Forked"));
211 assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
212
213 let manager = crate::session_manager::SessionManager::default_location().unwrap();
214 let parent = manager
215 .load_session("parent-session")
216 .expect("parent saved");
217 let child = manager.load_session(&new_id).expect("child saved");
218 assert_eq!(parent.messages.len(), 1);
219 assert_eq!(parent.metadata.model_provider, "custom");
220 assert_eq!(
221 parent.metadata.model_provider_id.as_deref(),
222 Some("lm-studio")
223 );
224 assert_eq!(parent.metadata.title, cached_parent.title);
225 assert_eq!(parent.metadata.created_at, cached_parent.created_at);
226 assert_eq!(
227 child.metadata.parent_session_id.as_deref(),
228 Some("parent-session")
229 );
230 assert_eq!(child.metadata.forked_from_message_count, Some(1));
231 assert_eq!(child.metadata.model_provider, "custom");
232 assert_eq!(
233 child.metadata.model_provider_id.as_deref(),
234 Some("lm-studio")
235 );
236 assert_eq!(parent.work_state.as_ref(), Some(&expected_work));
237 assert_eq!(child.work_state.as_ref(), Some(&expected_work));
238 let cached_child = app
239 .current_session_metadata
240 .as_ref()
241 .expect("child metadata cached");
242 assert_eq!(cached_child.id, child.metadata.id);
243 assert_eq!(cached_child.title, child.metadata.title);
244 assert_eq!(cached_child.created_at, child.metadata.created_at);
245 assert_eq!(
246 cached_child.parent_session_id,
247 child.metadata.parent_session_id
248 );
249 assert_eq!(
250 app.session_title.as_deref(),
251 Some(child.metadata.title.as_str())
252 );
253 }
254
255 #[test]
256 fn fork_rejects_active_runtime_without_switching_sessions() {
257 let tmpdir = TempDir::new().unwrap();
258 let _home = SealedHome::new();
259 let mut app = create_test_app_with_tmpdir(&tmpdir);
260 app.current_session_id = Some("parent-session".to_string());
261 app.api_messages_mut().push(codewhale_models::Message {
262 role: Role::User,
263 content: vec![codewhale_models::ContentBlock::Text {
264 text: "still running".to_string(),
265 cache_control: None,
266 }],
267 });
268 app.is_loading = true;
269
270 let result = fork(&mut app);
271
272 assert!(result.is_error);
273 assert!(result.action.is_none());
274 assert_eq!(app.current_session_id.as_deref(), Some("parent-session"));
275 assert_eq!(app.api_messages.len(), 1);
276 }
277
278 #[test]
279 fn new_session_from_resumed_state_creates_distinct_empty_session() {
280 let tmpdir = TempDir::new().unwrap();
281 let _home = SealedHome::new();
282 let mut app = create_test_app_with_tmpdir(&tmpdir);
283 app.current_session_id = Some("old-session".to_string());
284 app.session_title = Some("Old Session".to_string());
285 app.api_messages_mut().push(codewhale_models::Message {
286 role: Role::User,
287 content: vec![codewhale_models::ContentBlock::Text {
288 text: "continue this thread".to_string(),
289 cache_control: None,
290 }],
291 });
292 app.add_message(HistoryCell::System {
293 content: "old transcript".to_string(),
294 });
295 app.system_prompt = Some(codewhale_models::SystemPrompt::Text(
296 "old prompt".to_string(),
297 ));
298 app.session.total_tokens = 123;
299 app.session.session_cost = 1.25;
300
301 let result = new_session(&mut app, None);
302
303 assert!(!result.is_error, "{:?}", result.message);
304 let new_id = app.current_session_id.clone().expect("new session id");
305 assert_ne!(new_id, "old-session");
306 assert_eq!(app.session_title.as_deref(), Some("New Session"));
307 assert!(app.api_messages.is_empty());
308 assert!(app.history.is_empty());
309 assert!(app.system_prompt.is_none());
310 assert_eq!(app.session.total_tokens, 0);
311 assert_eq!(app.session.session_cost, 0.0);
312 assert!(
313 result
314 .message
315 .as_deref()
316 .unwrap_or_default()
317 .contains("/resume")
318 );
319 match result.action {
320 Some(AppAction::SyncSession {
321 session_id,
322 messages,
323 system_prompt,
324 ..
325 }) => {
326 assert_eq!(session_id.as_deref(), Some(new_id.as_str()));
327 assert!(messages.is_empty());
328 assert!(system_prompt.is_none());
329 }
330 other => panic!("expected SyncSession action, got {other:?}"),
331 }
332 }
333
334 #[test]
335 fn new_session_forgets_denials_and_session_grants() {
336 conversation_reset_forgets_approvals("new");
337 }
338
339 #[test]
340 fn clear_session_forgets_denials_and_session_grants() {
341 conversation_reset_forgets_approvals("clear");
342 }
343
344 fn conversation_reset_forgets_approvals(command: &str) {
345 // UX-8: a Deny used to outlive `/new` for the whole process ("Restart
346 // Codewhale to reconsider it"); a fresh conversation starts clean.
347 let tmpdir = TempDir::new().unwrap();
348 let _home = SealedHome::new();
349 let mut app = create_test_app_with_tmpdir(&tmpdir);
350 app.current_session_id = Some("old-session".to_string());
351 app.approval_session_denied
352 .insert("shell:rm -rf build:call-1".to_string());
353 app.approval_session_approved
354 .insert("shell:git status".to_string());
355 // A child agent's pending approval card from the old conversation.
356 let child_id = "agent:agent_a:approval:boot:1";
357 crate::tui::ui::push_approval_request_view(
358 &mut app,
359 child_id,
360 "exec_shell",
361 "agent_a wants to run 'exec_shell'",
362 &serde_json::json!({"command": "cargo build"}),
363 "k",
364 "g",
365 None,
366 crate::config::ApprovalDefaultSelection::Deny,
367 None,
368 );
369 crate::tui::pending_requests::record(
370 &mut app,
371 child_id,
372 crate::tui::pending_requests::PendingChildRequest {
373 agent_id: "agent_a".to_string(),
374 tool_name: "exec_shell".to_string(),
375 description: String::new(),
376 input: serde_json::json!({}),
377 approval_key: "k".to_string(),
378 approval_grouping_key: "g".to_string(),
379 intent_summary: None,
380 requested_at: std::time::Instant::now(),
381 },
382 );
383
384 let result = dispatch_lifecycle(&mut app, command, None);
385
386 assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
387 assert!(
388 app.pending_child_requests.is_empty() && app.view_stack.is_empty(),
389 "an old conversation's child approval card must not follow /{command}"
390 );
391 assert!(
392 app.approval_session_denied.is_empty(),
393 "a denied call must prompt again after /{command}"
394 );
395 assert!(
396 app.approval_session_approved.is_empty(),
397 "an approve-for-session grant must not follow the user into /{command}"
398 );
399 }
400
401 #[test]
402 fn new_session_blocks_unsent_input_without_force() {
403 let tmpdir = TempDir::new().unwrap();
404 let _home = SealedHome::new();
405 let mut app = create_test_app_with_tmpdir(&tmpdir);
406 app.current_session_id = Some("old-session".to_string());
407 app.input = "draft text".to_string();
408
409 let result = new_session(&mut app, None);
410
411 assert!(result.is_error);
412 assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
413 assert_eq!(app.input, "draft text");
414 assert!(result.action.is_none());
415 assert!(
416 result
417 .message
418 .as_deref()
419 .unwrap_or_default()
420 .contains("/new --force")
421 );
422 }
423
424 #[test]
425 fn new_session_force_discards_unsent_input() {
426 let tmpdir = TempDir::new().unwrap();
427 let _home = SealedHome::new();
428 let mut app = create_test_app_with_tmpdir(&tmpdir);
429 app.current_session_id = Some("old-session".to_string());
430 app.input = "draft text".to_string();
431
432 let result = new_session(&mut app, Some("--force"));
433
434 assert!(!result.is_error, "{:?}", result.message);
435 assert_ne!(app.current_session_id.as_deref(), Some("old-session"));
436 assert!(app.input.is_empty());
437 assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
438 }
439
440 #[test]
441 fn new_session_blocks_in_flight_turn_without_force() {
442 let tmpdir = TempDir::new().unwrap();
443 let _home = SealedHome::new();
444 let mut app = create_test_app_with_tmpdir(&tmpdir);
445 app.current_session_id = Some("old-session".to_string());
446 app.is_loading = true;
447
448 let result = new_session(&mut app, None);
449
450 assert!(result.is_error);
451 assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
452 assert!(result.action.is_none());
453 }
454
455 #[test]
456 fn new_session_force_cannot_detach_an_in_flight_turn() {
457 let tmpdir = TempDir::new().unwrap();
458 let _home = SealedHome::new();
459 let mut app = create_test_app_with_tmpdir(&tmpdir);
460 app.current_session_id = Some("old-session".to_string());
461 app.api_messages_mut().push(codewhale_models::Message {
462 role: Role::User,
463 content: vec![],
464 });
465 app.is_loading = true;
466 app.runtime_turn_status = Some("in_progress".to_string());
467
468 let result = new_session(&mut app, Some("--force"));
469
470 assert!(result.is_error);
471 assert!(result.action.is_none());
472 assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
473 assert_eq!(app.api_messages.len(), 1);
474 assert!(
475 result
476 .message
477 .as_deref()
478 .is_some_and(|message| message.contains("only discards draft or queued input"))
479 );
480 }
481
482 #[test]
483 fn load_rejects_an_active_runtime_before_reading_or_mutating() {
484 let tmpdir = TempDir::new().unwrap();
485 let _home = SealedHome::new();
486 let mut app = create_test_app_with_tmpdir(&tmpdir);
487 app.current_session_id = Some("old-session".to_string());
488 app.api_messages_mut().push(codewhale_models::Message {
489 role: Role::User,
490 content: vec![],
491 });
492 app.task_panel.push(crate::tui::app::TaskPanelEntry {
493 exit_code: None,
494 id: "queued-late-producer".to_string(),
495 status: "queued".to_string(),
496 prompt_summary: "queued".to_string(),
497 duration_ms: None,
498 kind: crate::tui::app::TaskPanelEntryKind::Background,
499 stale: false,
500 elapsed_since_output_ms: None,
501 owner_agent_id: None,
502 owner_agent_name: None,
503 current_tool: None,
504 role: None,
505 files_touched: 0,
506 });
507
508 let result = load(&mut app, Some("does-not-exist.json"));
509
510 assert!(result.is_error);
511 assert!(result.action.is_none());
512 assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
513 assert_eq!(app.api_messages.len(), 1);
514 assert!(
515 result
516 .message
517 .as_deref()
518 .is_some_and(|message| message.contains("runtime work is active"))
519 );
520 }
521
522 #[test]
523 fn test_save_with_default_path_uses_managed_sessions_dir() {
524 let tmpdir = TempDir::new().unwrap();
525 // The seal points the managed sessions directory inside its own temp dir
526 // rather than the real user home.
527 let home = SealedHome::new();
528 let sessions_dir = home.codewhale_home().join("sessions");
529 std::fs::create_dir_all(&sessions_dir).unwrap();
530 let mut app = create_test_app_with_tmpdir(&tmpdir);
531 let result = save(&mut app, None);
532 assert!(result.message.is_some());
533 let msg = result.message.unwrap();
534 // Give it a moment to ensure file is written
535 std::thread::sleep(std::time::Duration::from_millis(10));
536 let entries: Vec<_> = if sessions_dir.exists() {
537 std::fs::read_dir(&sessions_dir)
538 .unwrap()
539 .filter_map(|e| e.ok())
540 .filter(|e| e.file_name().to_string_lossy().ends_with(".json"))
541 .collect()
542 } else {
543 Vec::new()
544 };
545 // Session should be saved to the managed dir, not the workspace root.
546 assert!(
547 !entries.is_empty(),
548 "expected session file in {sessions_dir:?}, got none; msg: {msg}"
549 );
550 let session_id = app
551 .current_session_id
552 .as_deref()
553 .expect("current session id");
554 assert!(sessions_dir.join(format!("{session_id}.json")).exists());
555 }
556
557 #[test]
558 fn test_save_serialization_error() {
559 let tmpdir = TempDir::new().unwrap();
560 let _home = SealedHome::new();
561 let mut app = create_test_app_with_tmpdir(&tmpdir);
562 // This should work normally since SavedSession is serializable
563 // Testing error path would require mocking, which is complex
564 let save_path = tmpdir.path().join("test.json");
565 let result = save(&mut app, Some(save_path.to_str().unwrap()));
566 assert!(result.message.is_some());
567 }
568
569 #[test]
570 fn test_load_without_path_returns_error() {
571 let tmpdir = TempDir::new().unwrap();
572 let _home = SealedHome::new();
573 let mut app = create_test_app_with_tmpdir(&tmpdir);
574 let result = load(&mut app, None);
575 assert!(result.message.is_some());
576 assert!(result.message.unwrap().contains("Usage: /load"));
577 }
578
579 #[test]
580 fn test_load_nonexistent_file_returns_error() {
581 let tmpdir = TempDir::new().unwrap();
582 let _home = SealedHome::new();
583 let mut app = create_test_app_with_tmpdir(&tmpdir);
584 let result = load(&mut app, Some("nonexistent.json"));
585 assert!(result.message.is_some());
586 assert!(result.message.unwrap().contains("Failed to read"));
587 }
588
589 #[test]
590 fn test_load_invalid_json_returns_error() {
591 let tmpdir = TempDir::new().unwrap();
592 let _home = SealedHome::new();
593 let mut app = create_test_app_with_tmpdir(&tmpdir);
594 let bad_file = tmpdir.path().join("bad.json");
595 std::fs::write(&bad_file, "not valid json").unwrap();
596 let result = load(&mut app, Some(bad_file.to_str().unwrap()));
597 assert!(result.message.is_some());
598 assert!(result.message.unwrap().contains("Failed to parse"));
599 }
600
601 #[test]
602 fn test_load_valid_session_defers_state_restore_to_event_loop() {
603 let tmpdir = TempDir::new().unwrap();
604 let _home = SealedHome::new();
605 let mut app1 = create_test_app_with_tmpdir(&tmpdir);
606 // Set up some state to save
607 app1.api_messages_mut().push(codewhale_models::Message {
608 role: Role::User,
609 content: vec![codewhale_models::ContentBlock::Text {
610 text: "Hello".to_string(),
611 cache_control: None,
612 }],
613 });
614 app1.session.total_tokens = 500;
615 app1.set_mode(AppMode::Plan);
616 let save_path = tmpdir.path().join("test.json");
617 save(&mut app1, Some(save_path.to_str().unwrap()));
618
619 // Create new app and load
620 let mut app2 = create_test_app_with_tmpdir(&tmpdir);
621 app2.system_prompt = Some(codewhale_models::SystemPrompt::Text(
622 "stale prompt from prior session".to_string(),
623 ));
624 app2.session_context_references
625 .push(crate::session_manager::SessionContextReference {
626 message_index: 0,
627 reference: codewhale_core::ContextReference {
628 kind: codewhale_core::ContextReferenceKind::File,
629 source: codewhale_core::ContextReferenceSource::AtMention,
630 badge: "file".to_string(),
631 label: "stale.rs".to_string(),
632 target: tmpdir.path().join("stale.rs").display().to_string(),
633 included: true,
634 expanded: true,
635 detail: None,
636 },
637 });
638 let result = load(&mut app2, Some(save_path.to_str().unwrap()));
639 assert_eq!(result.message, None);
640 assert!(app2.api_messages.is_empty());
641 assert_eq!(app2.session.total_tokens, 0);
642 assert!(app2.current_session_id.is_none());
643 assert!(app2.system_prompt.is_some());
644 assert_eq!(app2.session_context_references.len(), 1);
645 assert!(matches!(
646 result.action,
647 Some(AppAction::LoadSession(path)) if path == save_path
648 ));
649 }
650
651 #[test]
652 fn explicit_save_persists_work_state_and_load_defers_application() {
653 let tmpdir = TempDir::new().unwrap();
654 let _home = SealedHome::new();
655 let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
656 {
657 let mut todos = saved_app.todos.try_lock().expect("todos lock");
658 todos.add(
659 "persist me".to_string(),
660 crate::tools::todo::TodoStatus::InProgress,
661 );
662 }
663 {
664 let mut plan = saved_app.plan_state.try_lock().expect("plan lock");
665 plan.update(crate::tools::plan::UpdatePlanArgs {
666 objective: Some("Resume exactly".to_string()),
667 ..crate::tools::plan::UpdatePlanArgs::default()
668 });
669 }
670 let expected = saved_app.work_state_snapshot().expect("snapshot");
671 let save_path = tmpdir.path().join("work_state.json");
672 let saved = save(&mut saved_app, Some(save_path.to_str().unwrap()));
673 assert!(!saved.is_error, "{:?}", saved.message);
674
675 let mut loaded_app = create_test_app_with_tmpdir(&tmpdir);
676 let loaded = load(&mut loaded_app, Some(save_path.to_str().unwrap()));
677 assert!(!loaded.is_error, "{:?}", loaded.message);
678 assert_eq!(loaded_app.work_state_snapshot().expect("snapshot"), None);
679 assert!(matches!(
680 loaded.action,
681 Some(AppAction::LoadSession(path)) if path == save_path
682 ));
683 let saved_session: crate::session_manager::SavedSession =
684 serde_json::from_str(&std::fs::read_to_string(&save_path).expect("saved session file"))
685 .expect("saved session JSON");
686 assert_eq!(saved_session.work_state, expected);
687 }
688
689 #[test]
690 fn new_session_is_all_or_nothing_when_work_state_is_busy() {
691 let tmpdir = TempDir::new().unwrap();
692 let _home = SealedHome::new();
693 let mut app = create_test_app_with_tmpdir(&tmpdir);
694 app.api_messages_mut().push(codewhale_models::Message {
695 role: Role::User,
696 content: vec![],
697 });
698 app.current_session_id = Some("current-session".to_string());
699 let todos = app.todos.clone();
700 let _held = todos.try_lock().expect("hold todos lock");
701
702 let result = new_session(&mut app, Some("--force"));
703
704 assert!(result.is_error);
705 assert_eq!(app.api_messages.len(), 1);
706 assert_eq!(app.current_session_id.as_deref(), Some("current-session"));
707 assert!(result.action.is_none());
708 }
709
710 #[test]
711 fn load_auto_model_session_defers_model_restore_to_event_loop() {
712 let tmpdir = TempDir::new().unwrap();
713 let _home = SealedHome::new();
714 let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
715 saved_app.set_model_selection("auto".to_string());
716 saved_app.last_effective_model = Some("deepseek-v4-flash".to_string());
717 saved_app.last_effective_reasoning_effort = Some(
718 crate::reasoning_preference::EffectiveReasoningEffort::Tier(ReasoningEffort::Low),
719 );
720 let save_path = tmpdir.path().join("auto_model.json");
721 save(&mut saved_app, Some(save_path.to_str().unwrap()));
722
723 let mut app = create_test_app_with_tmpdir(&tmpdir);
724 app.set_model_selection("deepseek-v4-flash".to_string());
725 app.reasoning_effort = ReasoningEffort::High;
726 let result = load(&mut app, Some(save_path.to_str().unwrap()));
727
728 assert!(!result.is_error);
729 assert!(!app.auto_model);
730 assert_eq!(app.model, "deepseek-v4-flash");
731 assert_eq!(app.reasoning_effort, ReasoningEffort::High);
732 assert!(matches!(
733 result.action,
734 Some(AppAction::LoadSession(path)) if path == save_path
735 ));
736 }
737
738 #[test]
739 fn load_defers_artifact_registry_restore_to_event_loop() {
740 let tmpdir = TempDir::new().unwrap();
741 let _home = SealedHome::new();
742 let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
743 saved_app
744 .session_artifacts
745 .push(crate::artifacts::ArtifactRecord {
746 id: "art_call_big".to_string(),
747 kind: crate::artifacts::ArtifactKind::ToolOutput,
748 session_id: "artifact-session".to_string(),
749 tool_call_id: "call-big".to_string(),
750 tool_name: "exec_shell".to_string(),
751 created_at: chrono::Utc::now(),
752 byte_size: 128,
753 preview: "checking crate".to_string(),
754 storage_path: tmpdir.path().join("call-big.txt"),
755 });
756 let save_path = tmpdir.path().join("artifact_load.json");
757 save(&mut saved_app, Some(save_path.to_str().unwrap()));
758
759 let mut app = create_test_app_with_tmpdir(&tmpdir);
760 app.session_artifacts
761 .push(crate::artifacts::ArtifactRecord {
762 id: "art_stale".to_string(),
763 kind: crate::artifacts::ArtifactKind::ToolOutput,
764 session_id: "stale-session".to_string(),
765 tool_call_id: "stale".to_string(),
766 tool_name: "exec_shell".to_string(),
767 created_at: chrono::Utc::now(),
768 byte_size: 1,
769 preview: "stale".to_string(),
770 storage_path: tmpdir.path().join("stale.txt"),
771 });
772
773 let result = load(&mut app, Some(save_path.to_str().unwrap()));
774
775 assert!(!result.is_error);
776 assert_eq!(app.session_artifacts.len(), 1);
777 assert_eq!(app.session_artifacts[0].id, "art_stale");
778 assert!(matches!(
779 result.action,
780 Some(AppAction::LoadSession(path)) if path == save_path
781 ));
782 }
783
784 #[test]
785 fn load_defers_telemetry_reset_to_event_loop() {
786 let tmpdir = TempDir::new().unwrap();
787 let _home = SealedHome::new();
788 let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
789 saved_app
790 .api_messages_mut()
791 .push(codewhale_models::Message {
792 role: Role::User,
793 content: vec![codewhale_models::ContentBlock::Text {
794 text: "checkpoint".to_string(),
795 cache_control: None,
796 }],
797 });
798 saved_app.session.total_tokens = 500;
799 let save_path = tmpdir.path().join("checkpoint.json");
800 save(&mut saved_app, Some(save_path.to_str().unwrap()));
801
802 let mut app = create_test_app_with_tmpdir(&tmpdir);
803 app.session.session_cost = 1.25;
804 app.session.session_cost_cny = 9.13;
805 app.session.subagent_cost = 0.75;
806 app.session.subagent_cost_cny = 5.48;
807 app.session
808 .subagent_usage_sources
809 .insert(crate::cost_status::usage_source_fingerprint(
810 "response-test",
811 ));
812 app.session.displayed_cost_high_water = 2.0;
813 app.session.displayed_cost_high_water_cny = 14.61;
814 app.session.last_prompt_tokens = Some(120);
815 app.session.last_completion_tokens = Some(35);
816 app.session.last_prompt_cache_hit_tokens = Some(80);
817 app.session.last_prompt_cache_miss_tokens = Some(40);
818 app.session.last_reasoning_replay_tokens = Some(12);
819 app.push_turn_cache_record(TurnCacheRecord {
820 provider: None,
821 provider_identity: None,
822 model: None,
823 auto_model: false,
824 input_tokens: 120,
825 output_tokens: 35,
826 cache_hit_tokens: Some(80),
827 cache_miss_tokens: Some(40),
828 reasoning_replay_tokens: Some(12),
829 cache_write_tokens: None,
830 reasoning_tokens: None,
831 cost_audit: None,
832 recorded_at: Instant::now(),
833 });
834
835 let result = load(&mut app, Some(save_path.to_str().unwrap()));
836
837 assert_eq!(result.message, None);
838 assert_eq!(app.session.total_tokens, 0);
839 assert_eq!(app.session.session_cost, 1.25);
840 assert_eq!(app.session.session_cost_cny, 9.13);
841 assert_eq!(app.session.subagent_cost, 0.75);
842 assert_eq!(app.session.subagent_cost_cny, 5.48);
843 assert_eq!(app.session.turn_cache_history.len(), 1);
844 assert!(matches!(
845 result.action,
846 Some(AppAction::LoadSession(path)) if path == save_path
847 ));
848 }
849
850 #[test]
851 fn test_compact_toggles_state() {
852 let tmpdir = TempDir::new().unwrap();
853 let _home = SealedHome::new();
854 let mut app = create_test_app_with_tmpdir(&tmpdir);
855
856 let result = compact(&mut app, None);
857 assert!(result.message.is_some());
858 let msg = result.message.unwrap();
859 assert!(msg.contains("Making room"), "{msg}");
860 assert!(matches!(
861 result.action,
862 Some(AppAction::CompactContext { focus: None })
863 ));
864 }
865
866 #[test]
867 fn compact_command_forwards_a_trimmed_focus_argument() {
868 let tmpdir = TempDir::new().unwrap();
869 let _home = SealedHome::new();
870 let mut app = create_test_app_with_tmpdir(&tmpdir);
871
872 let result = compact(&mut app, Some(" the auth refactor "));
873 assert!(matches!(
874 result.action,
875 Some(AppAction::CompactContext { focus: Some(ref focus) }) if focus == "the auth refactor"
876 ));
877 assert!(
878 result
879 .message
880 .as_deref()
881 .is_some_and(|msg| msg.contains("focus: the auth refactor")),
882 "{result:?}"
883 );
884
885 // Whitespace-only arguments behave like no focus at all.
886 let blank = compact(&mut app, Some(" "));
887 assert!(matches!(
888 blank.action,
889 Some(AppAction::CompactContext { focus: None })
890 ));
891 }
892
893 #[test]
894 fn test_sessions_pushes_picker_view() {
895 let tmpdir = TempDir::new().unwrap();
896 let _home = SealedHome::new();
897 let mut app = create_test_app_with_tmpdir(&tmpdir);
898 let initial_kind = app.view_stack.top_kind();
899
900 let result = sessions(&mut app, None);
901 assert_eq!(result.message, None);
902 assert!(result.action.is_none());
903 // View should have changed (session picker should be on top)
904 assert_ne!(app.view_stack.top_kind(), initial_kind);
905 }
906
907 #[test]
908 fn test_sessions_show_subcommand_pushes_picker_view() {
909 // `/sessions show` and `/sessions list` are explicit aliases
910 // for the no-arg picker form. Verify they don't fall through
911 // to the prune branch.
912 let tmpdir = TempDir::new().unwrap();
913 let _home = SealedHome::new();
914 let mut app = create_test_app_with_tmpdir(&tmpdir);
915 let initial_kind = app.view_stack.top_kind();
916 let result = sessions(&mut app, Some("show"));
917 assert_eq!(result.message, None);
918 assert_ne!(app.view_stack.top_kind(), initial_kind);
919 }
920
921 #[test]
922 fn test_sessions_prune_requires_days_argument() {
923 let tmpdir = TempDir::new().unwrap();
924 let _home = SealedHome::new();
925 let mut app = create_test_app_with_tmpdir(&tmpdir);
926 let result = sessions(&mut app, Some("prune"));
927 assert!(result.is_error);
928 assert!(
929 result.message.as_deref().unwrap_or("").contains("usage"),
930 "expected usage hint: {:?}",
931 result.message
932 );
933 }
934
935 #[test]
936 fn test_sessions_prune_rejects_non_positive_days() {
937 let tmpdir = TempDir::new().unwrap();
938 let _home = SealedHome::new();
939 let mut app = create_test_app_with_tmpdir(&tmpdir);
940 for bad in ["0", "-3", "abc", "3.14"] {
941 let result = sessions(&mut app, Some(&format!("prune {bad}")));
942 assert!(result.is_error, "expected error for `{bad}`");
943 }
944 }
945
946 #[test]
947 fn test_sessions_unknown_subcommand_errors() {
948 let tmpdir = TempDir::new().unwrap();
949 let _home = SealedHome::new();
950 let mut app = create_test_app_with_tmpdir(&tmpdir);
951 let result = sessions(&mut app, Some("teleport"));
952 assert!(result.is_error);
953 assert!(
954 result
955 .message
956 .as_deref()
957 .unwrap_or("")
958 .contains("unknown subcommand"),
959 "expected unknown-subcommand error: {:?}",
960 result.message
961 );
962 }
963
964 #[test]
965 fn branch_snapshot_roundtrip_preserves_siblings_ids_stamps_and_engine_projection() {
966 let root = TempDir::new().unwrap();
967 let _home = SealedHome::new();
968 let mut app = create_test_app_with_tmpdir(&root);
969 let message = |text: &str| codewhale_models::Message {
970 role: Role::User,
971 content: vec![codewhale_models::ContentBlock::Text {
972 text: text.into(),
973 cache_control: None,
974 }],
975 };
976 for text in ["root", "chosen", "abandoned"] {
977 app.push_api_message(message(text));
978 }
979 assert!(!save(&mut app, None).is_error);
980 let id = app.current_session_id.clone().unwrap();
981 let manager = crate::session_manager::SessionManager::default_location().unwrap();
982 let initial = manager.load_session(&id).unwrap();
983 let original = initial.journal.as_ref().unwrap().entries.clone();
984 let chosen = original[1].id.clone();
985 let result = dispatch_lifecycle(&mut app, "branch", Some(&chosen));
986 assert!(!result.is_error, "{:?}", result.message);
987 let Some(AppAction::SyncSession {
988 messages,
989 session_id,
990 ..
991 }) = result.action
992 else {
993 panic!("branch must synchronize the existing engine");
994 };
995 assert_eq!(session_id.as_deref(), Some(id.as_str()));
996 assert_eq!(messages, vec![message("root"), message("chosen")]);
997 assert_eq!(app.api_messages.as_ref(), &messages);
998 assert!(
999 !app.history
1000 .iter()
1001 .any(|cell| format!("{cell:?}").contains("abandoned"))
1002 );
1003 app.push_api_message(message("new path"));
1004 for _ in 0..2 {
1005 let snapshot = crate::tui::ui::build_session_snapshot(&mut app, &manager).unwrap();
1006 manager.save_session_owned(snapshot).unwrap();
1007 }
1008 let saved = manager.load_session(&id).unwrap();
1009 let journal = saved.journal.as_ref().unwrap();
1010 assert_eq!(
1011 &journal.entries[..3],
1012 original.as_slice(),
1013 "all old IDs, stamps and content survive"
1014 );
1015 assert_eq!(journal.entries.len(), 4, "a second save appends nothing");
1016 assert_eq!(
1017 journal.entries[3].parent_id.as_deref(),
1018 Some(chosen.as_str())
1019 );
1020 assert_eq!(journal.leaves().len(), 2);
1021 assert_eq!(
1022 saved.messages,
1023 vec![message("root"), message("chosen"), message("new path")]
1024 );
1025 // Restoring and forking must retain the tree as well.
1026 app.restore_api_messages(saved.messages.clone(), &saved);
1027 let forked = fork(&mut app);
1028 assert!(!forked.is_error, "{:?}", forked.message);
1029 let child = manager
1030 .load_session(app.current_session_id.as_deref().unwrap())
1031 .unwrap();
1032 assert_eq!(child.journal.as_ref().unwrap().entries, journal.entries);
1033 assert_eq!(child.metadata.spawn_depth, 1);
1034 let snapshot = crate::tui::ui::build_session_snapshot(&mut app, &manager).unwrap();
1035 assert_eq!(snapshot.journal.unwrap().entries, journal.entries);
1036 }
1037
1037 lines RUST