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