返回 CodeWhale
session.rs
根目录 / crates / tui / src / commands / groups / session / session.rs
1 //! Session commands: save, load, compact, export
2
3 use std::path::PathBuf;
4
5 use crate::session_manager::{
6 create_saved_session_with_id_and_mode, create_saved_session_with_mode,
7 };
8 use crate::tui::app::{App, AppAction};
9 use crate::tui::session_picker::SessionPickerView;
10
11 use super::CommandResult;
12
13 /// Save session to file.
14 ///
15 /// When an explicit path is given, the session is exported there
16 /// (user-visible explicit export). Without a path, v0.8.44 saves
17 /// into the managed session directory (`~/.codewhale/sessions`
18 /// or legacy `~/.deepseek/sessions`) so repo-local `session_*.json`
19 /// artifacts are no longer created by default.
20 pub fn save(app: &mut App, path: Option<&str>) -> CommandResult {
21 let explicit_save_path = path.map(PathBuf::from);
22
23 let messages = app.api_messages.clone();
24 let mut session = create_saved_session_with_mode(
25 &messages,
26 &app.model,
27 &app.workspace,
28 u64::from(app.session.total_tokens),
29 app.system_prompt.as_ref(),
30 Some(app.mode.label()),
31 );
32 session
33 .metadata
34 .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
35 app.sync_cost_to_metadata(&mut session.metadata);
36 session.context_references = app.session_context_references.clone();
37 session.artifacts = app.session_artifacts.clone();
38 session.work_state = match app.work_state_snapshot() {
39 Ok(state) => state,
40 Err(err) => return CommandResult::error(format!("Failed to snapshot Work state: {err}")),
41 };
42 session.last_auto_route = app.auto_route_for_persistence();
43 let save_path = explicit_save_path.unwrap_or_else(|| {
44 let dir = crate::session_manager::default_sessions_dir()
45 .unwrap_or_else(|_| app.workspace.clone());
46 dir.join(format!("{}.json", session.metadata.id))
47 });
48
49 let sessions_dir = save_path
50 .parent()
51 .filter(|p| !p.as_os_str().is_empty())
52 .map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf);
53
54 match std::fs::create_dir_all(&sessions_dir) {
55 Ok(()) => {
56 let json = match serde_json::to_string_pretty(&session) {
57 Ok(j) => j,
58 Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")),
59 };
60 match crate::utils::write_atomic(&save_path, json.as_bytes()) {
61 Ok(()) => {
62 app.current_session_id = Some(session.metadata.id.clone());
63 app.current_session_metadata = Some(session.metadata.clone());
64 app.session_title = Some(session.metadata.title.clone());
65 if let Err(err) = app.publish_pending_work_state() {
66 return CommandResult::error(format!(
67 "Session saved, but Work views were not published: {err}"
68 ));
69 }
70 CommandResult::message(format!(
71 "Session saved to {} (ID: {})",
72 save_path.display(),
73 crate::session_manager::truncate_id(&session.metadata.id)
74 ))
75 }
76 Err(e) => CommandResult::error(format!("Failed to save session: {e}")),
77 }
78 }
79 Err(e) => CommandResult::error(format!("Failed to create directory: {e}")),
80 }
81 }
82
83 /// Fork the active conversation into a new saved sibling session and switch to it.
84 pub fn fork(app: &mut App) -> CommandResult {
85 if app.session_transition_blocked() {
86 return CommandResult::error(
87 "Cannot fork a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first.",
88 );
89 }
90 if app.api_messages.is_empty() {
91 return CommandResult::error("Nothing to fork. Send or load a message first.");
92 }
93
94 let manager = match crate::session_manager::SessionManager::default_location() {
95 Ok(manager) => manager,
96 Err(err) => {
97 return CommandResult::error(format!("could not open sessions directory: {err}"));
98 }
99 };
100
101 let parent_id = app
102 .current_session_id
103 .clone()
104 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
105 let mut parent = create_saved_session_with_id_and_mode(
106 parent_id,
107 &app.api_messages,
108 &app.model,
109 &app.workspace,
110 u64::from(app.session.total_tokens),
111 app.system_prompt.as_ref(),
112 Some(app.mode.label()),
113 );
114 parent
115 .metadata
116 .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
117 if let Some(cached) = app
118 .current_session_metadata
119 .as_ref()
120 .filter(|metadata| metadata.id == parent.metadata.id)
121 {
122 parent.metadata.created_at = cached.created_at;
123 parent.metadata.title.clone_from(&cached.title);
124 parent
125 .metadata
126 .parent_session_id
127 .clone_from(&cached.parent_session_id);
128 parent.metadata.forked_from_message_count = cached.forked_from_message_count;
129 }
130 app.sync_cost_to_metadata(&mut parent.metadata);
131 parent.context_references = app.session_context_references.clone();
132 parent.artifacts = app.session_artifacts.clone();
133 let work_state = match app.work_state_snapshot() {
134 Ok(state) => state,
135 Err(err) => return CommandResult::error(format!("Failed to snapshot Work state: {err}")),
136 };
137 parent.work_state = work_state.clone();
138 parent.last_auto_route = app.auto_route_for_persistence();
139
140 if let Err(err) = manager.save_session(&parent) {
141 return CommandResult::error(format!("Failed to save parent session: {err}"));
142 }
143
144 let mut forked = create_saved_session_with_mode(
145 &app.api_messages,
146 &app.model,
147 &app.workspace,
148 u64::from(app.session.total_tokens),
149 app.system_prompt.as_ref(),
150 Some(app.mode.label()),
151 );
152 forked
153 .metadata
154 .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
155 forked.metadata.copy_cost_from(&parent.metadata);
156 forked.metadata.mark_forked_from(&parent.metadata);
157 forked.context_references = app.session_context_references.clone();
158 forked.artifacts = app.session_artifacts.clone();
159 forked.work_state = work_state;
160 forked.last_auto_route = app.auto_route_for_persistence();
161
162 if let Err(err) = manager.save_session(&forked) {
163 return CommandResult::error(format!("Failed to save forked session: {err}"));
164 }
165 if let Err(err) = app.publish_pending_work_state() {
166 return CommandResult::error(format!(
167 "Sessions saved, but Work views were not published: {err}"
168 ));
169 }
170
171 app.current_session_id = Some(forked.metadata.id.clone());
172 app.current_session_metadata = Some(forked.metadata.clone());
173 app.session_title = Some(forked.metadata.title.clone());
174 let fork_id = forked.metadata.id.clone();
175 let parent_label = crate::session_manager::truncate_id(&parent.metadata.id).to_string();
176 let fork_label = crate::session_manager::truncate_id(&fork_id).to_string();
177
178 CommandResult::with_message_and_action(
179 format!("Forked session {parent_label} -> {fork_label}"),
180 AppAction::SyncSession {
181 session_id: Some(fork_id),
182 messages: app.api_messages.clone(),
183 system_prompt: app.system_prompt.clone(),
184 model: app.model.clone(),
185 workspace: app.workspace.clone(),
186 mode: app.mode,
187 },
188 )
189 }
190
191 /// Start a fresh saved session from the current TUI state.
192 pub fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult {
193 let force = match arg.map(str::trim).filter(|s| !s.is_empty()) {
194 None => false,
195 Some("--force" | "force") => true,
196 Some(other) => {
197 return CommandResult::error(format!(
198 "Usage: /new [--force]\n\nUnknown argument: {other}"
199 ));
200 }
201 };
202
203 if app.session_transition_blocked() {
204 return CommandResult::error(
205 "Cannot start a new session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work. `/new --force` only discards draft or queued input.",
206 );
207 }
208
209 if !force {
210 let blockers = new_session_blockers(app);
211 if !blockers.is_empty() {
212 return CommandResult::error(format!(
213 "Cannot start a new session while {}. Run `/new --force` to discard pending work and start a fresh session.",
214 blockers.join(", ")
215 ));
216 }
217 }
218
219 let new_id = uuid::Uuid::new_v4().to_string();
220 if !super::super::core::reset_conversation_state(app) {
221 return CommandResult::error(
222 "Could not start a new session because Work state is busy; retry in a moment.",
223 );
224 }
225 app.clear_input();
226 app.session_artifacts.clear();
227 app.session_context_references.clear();
228 app.tool_evidence.clear();
229 app.current_session_id = Some(new_id.clone());
230 app.current_session_metadata = None;
231 app.session_title = Some("New Session".to_string());
232 app.scroll_to_bottom();
233
234 CommandResult::with_message_and_action(
235 format!(
236 "Started new session {} (New Session). Previous sessions remain available via /resume.",
237 crate::session_manager::truncate_id(&new_id)
238 ),
239 AppAction::SyncSession {
240 session_id: Some(new_id),
241 messages: Vec::new(),
242 system_prompt: None,
243 model: app.model.clone(),
244 workspace: app.workspace.clone(),
245 mode: app.mode,
246 },
247 )
248 }
249
250 fn new_session_blockers(app: &App) -> Vec<&'static str> {
251 let mut blockers = Vec::new();
252 if !app.input.trim().is_empty() {
253 blockers.push("the composer has unsent text");
254 }
255 if !app.queued_messages.is_empty() || app.queued_draft.is_some() {
256 blockers.push("queued messages are pending");
257 }
258 blockers
259 }
260
261 /// Load session from file
262 pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
263 if app.session_transition_blocked() {
264 return CommandResult::error(
265 "Cannot load a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first.",
266 );
267 }
268 let load_path = if let Some(p) = path {
269 if p.contains('/') || p.contains('\\') {
270 PathBuf::from(p)
271 } else {
272 app.workspace.join(p)
273 }
274 } else {
275 return CommandResult::error("Usage: /load <path>");
276 };
277
278 let content = match std::fs::read_to_string(&load_path) {
279 Ok(c) => c,
280 Err(e) => {
281 return CommandResult::error(format!("Failed to read session file: {e}"));
282 }
283 };
284
285 let _session: crate::session_manager::SavedSession = match serde_json::from_str(&content) {
286 Ok(s) => s,
287 Err(e) => {
288 return CommandResult::error(format!("Failed to parse session file: {e}"));
289 }
290 };
291
292 // The command layer only validates the file shape. The event loop reloads
293 // Config once and applies the session plus route atomically before it
294 // rebuilds or syncs the engine.
295 // Success is reported only after the event loop re-reads live Config and
296 // atomically applies the session route. Emitting it here would leave a
297 // false receipt in the current transcript if that final validation fails.
298 CommandResult::action(crate::tui::app::AppAction::LoadSession(load_path))
299 }
300
301 /// Trigger context compaction. An optional argument becomes the summary
302 /// focus (`/compact the auth refactor`), forwarded into the successor brief.
303 pub fn compact(_app: &mut App, arg: Option<&str>) -> CommandResult {
304 let focus = arg
305 .map(str::trim)
306 .filter(|focus| !focus.is_empty())
307 .map(str::to_string);
308 let receipt = match focus.as_deref() {
309 Some(focus) => format!("Context compaction triggered (focus: {focus})..."),
310 None => "Context compaction triggered...".to_string(),
311 };
312 CommandResult::with_message_and_action(receipt, AppAction::CompactContext { focus })
313 }
314
315 /// Trigger agent-driven context purging.
316 pub fn purge(_app: &mut App) -> CommandResult {
317 CommandResult::with_message_and_action(
318 "Agent context purge triggered...".to_string(),
319 AppAction::PurgeContext,
320 )
321 }
322
323 /// Open the session picker UI, or run a sub-action like
324 /// `prune <days>` for housekeeping (#406 phase-1.5).
325 pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult {
326 let trimmed = arg.unwrap_or("").trim();
327 if trimmed.is_empty() {
328 app.view_stack
329 .push(SessionPickerView::new(&app.workspace, app.ui_locale));
330 return CommandResult::ok();
331 }
332
333 let mut parts = trimmed.split_whitespace();
334 let action = parts.next().unwrap_or("").to_ascii_lowercase();
335 match action.as_str() {
336 "prune" => prune(app, parts.next()),
337 "show" | "list" | "picker" => {
338 app.view_stack
339 .push(SessionPickerView::new(&app.workspace, app.ui_locale));
340 CommandResult::ok()
341 }
342 // `open` is what the sidebar Sessions rail dispatches (#2934): it
343 // opens the existing picker preselected on a session rather than
344 // resuming inline, so resume keeps its single implementation.
345 "open" => open_session(app, parts.next()),
346 "archive" => set_archived(app, parts.next(), true),
347 "unarchive" | "restore" => set_archived(app, parts.next(), false),
348 _ => CommandResult::error(format!(
349 "unknown subcommand `{action}`. usage: /sessions [show|open <id>|archive <id>|unarchive <id>|prune <days>]"
350 )),
351 }
352 }
353
354 /// Open the session picker with `session_id` preselected.
355 fn open_session(app: &mut App, session_id: Option<&str>) -> CommandResult {
356 let Some(session_id) = session_id.map(str::trim).filter(|id| !id.is_empty()) else {
357 return CommandResult::error("usage: /sessions open <session-id>");
358 };
359 app.view_stack.push(SessionPickerView::new_selecting(
360 &app.workspace,
361 app.ui_locale,
362 session_id,
363 ));
364 CommandResult::ok()
365 }
366
367 /// Archive or restore a saved session.
368 ///
369 /// Routes through [`crate::session_manager::SessionManager::set_session_archived`]
370 /// — the same writer the picker and `PATCH /v1/sessions/{id}` use — so all
371 /// three surfaces produce one durable lifecycle state.
372 fn set_archived(app: &mut App, session_id: Option<&str>, archived: bool) -> CommandResult {
373 let verb = if archived { "archive" } else { "unarchive" };
374 let Some(session_id) = session_id.map(str::trim).filter(|id| !id.is_empty()) else {
375 return CommandResult::error(format!("usage: /sessions {verb} <session-id>"));
376 };
377 let manager = match crate::session_manager::SessionManager::default_location() {
378 Ok(manager) => manager,
379 Err(err) => {
380 return CommandResult::error(format!("could not open sessions directory: {err}"));
381 }
382 };
383 // `Owner`: this is the in-process interactive surface, and the block below
384 // updates the live cached metadata in the same step.
385 match manager.set_session_archived(
386 session_id,
387 archived,
388 crate::session_manager::SessionMutator::Owner,
389 ) {
390 Ok(metadata) => {
391 // Atomic with the write, from the app's point of view: nothing can
392 // run between the manager call and this update, so the next
393 // autosave already sees the new lifecycle state.
394 if let Some(cached) = app.current_session_metadata.as_mut()
395 && cached.id == metadata.id
396 {
397 cached.archived = metadata.archived;
398 }
399 CommandResult::message(format!(
400 "{} session {} ({})",
401 if archived { "Archived" } else { "Restored" },
402 crate::session_manager::truncate_id(&metadata.id),
403 metadata.title
404 ))
405 }
406 Err(err) => CommandResult::error(format!("{verb} failed: {err}")),
407 }
408 }
409
410 /// Prune persisted sessions older than `<days>` from
411 /// `~/.deepseek/sessions/`. Wraps
412 /// [`crate::session_manager::SessionManager::prune_sessions_older_than`]
413 /// so users can run a safe cleanup without leaving the TUI. Skips
414 /// the checkpoint subdirectory (the helper guarantees that already).
415 fn prune(app: &mut App, days_arg: Option<&str>) -> CommandResult {
416 let days_str = match days_arg {
417 Some(s) => s,
418 None => {
419 return CommandResult::error(
420 "usage: /sessions prune <days> (e.g. `/sessions prune 30` to drop sessions older than 30 days)",
421 );
422 }
423 };
424 let days: u64 = match days_str.parse() {
425 Ok(n) if n > 0 => n,
426 _ => {
427 return CommandResult::error(format!(
428 "expected a positive integer number of days, got `{days_str}`"
429 ));
430 }
431 };
432
433 let manager = match crate::session_manager::SessionManager::default_location() {
434 Ok(m) => m,
435 Err(err) => {
436 return CommandResult::error(format!("could not open sessions directory: {err}"));
437 }
438 };
439
440 let max_age = std::time::Duration::from_secs(days.saturating_mul(24 * 60 * 60));
441 // Never prune the active session, even if its timestamp is stale (a
442 // just-resumed session isn't re-saved until its first post-resume write).
443 let keep = app.current_session_id.as_deref();
444 match manager.prune_sessions_older_than_keeping(max_age, keep) {
445 Ok(0) => CommandResult::message(format!("no sessions older than {days}d to prune")),
446 Ok(n) => CommandResult::message(format!(
447 "pruned {n} session{} older than {days}d",
448 if n == 1 { "" } else { "s" }
449 )),
450 Err(err) => CommandResult::error(format!("prune failed: {err}")),
451 }
452 }
453
454 #[cfg(test)]
455 mod tests {
456 use super::*;
457 use crate::config::Config;
458 use crate::test_support::EnvVarGuard;
459 use crate::tui::app::{App, AppMode, ReasoningEffort, TuiOptions, TurnCacheRecord};
460 use crate::tui::history::HistoryCell;
461 use std::time::Instant;
462 use tempfile::TempDir;
463
464 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
465 let options = TuiOptions {
466 skills_dir: tmpdir.path().join("skills"),
467 memory_path: tmpdir.path().join("memory.md"),
468 notes_path: tmpdir.path().join("notes.txt"),
469 mcp_config_path: tmpdir.path().join("mcp.json"),
470 ..crate::test_support::test_tui_options(tmpdir.path())
471 };
472 App::new(options, &Config::default())
473 }
474
475 #[test]
476 fn test_save_creates_file_and_sets_session_id() {
477 let tmpdir = TempDir::new().unwrap();
478 let mut app = create_test_app_with_tmpdir(&tmpdir);
479 let save_path = tmpdir.path().join("test_session.json");
480
481 let result = save(&mut app, Some(save_path.to_str().unwrap()));
482 assert!(result.message.is_some());
483 let msg = result.message.unwrap();
484 assert!(msg.contains("Session saved to"));
485 assert!(msg.contains("ID:"));
486 assert!(app.current_session_id.is_some());
487 assert!(save_path.exists());
488 }
489
490 #[test]
491 fn save_preserves_artifact_registry() {
492 let tmpdir = TempDir::new().unwrap();
493 let mut app = create_test_app_with_tmpdir(&tmpdir);
494 let save_path = tmpdir.path().join("artifact_session.json");
495 app.session_artifacts
496 .push(crate::artifacts::ArtifactRecord {
497 id: "art_call_big".to_string(),
498 kind: crate::artifacts::ArtifactKind::ToolOutput,
499 session_id: "artifact-session".to_string(),
500 tool_call_id: "call-big".to_string(),
501 tool_name: "exec_shell".to_string(),
502 created_at: chrono::Utc::now(),
503 byte_size: 512_000,
504 preview: "cargo test output".to_string(),
505 storage_path: tmpdir.path().join("call-big.txt"),
506 });
507
508 let result = save(&mut app, Some(save_path.to_str().unwrap()));
509
510 assert!(!result.is_error);
511 let saved: crate::session_manager::SavedSession =
512 serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap();
513 assert_eq!(saved.artifacts, app.session_artifacts);
514 }
515
516 #[test]
517 fn save_preserves_latest_auto_route_receipt() {
518 let tmpdir = TempDir::new().unwrap();
519 let mut app = create_test_app_with_tmpdir(&tmpdir);
520 let save_path = tmpdir.path().join("auto_route_session.json");
521 let receipt = crate::model_routing::AutoRouteReceipt {
522 tier: crate::model_routing::AutoRouteTier::Fast,
523 pair: crate::model_routing::AutoRoutePair {
524 strong: crate::config::ZAI_GLM_5_2_MODEL.to_string(),
525 fast: Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()),
526 },
527 scope: crate::model_routing::AutoRouteScope::ResolvedProvider,
528 data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic,
529 reason: crate::model_routing::AutoRouteReason::LocalHeuristic(
530 crate::model_routing::AutoRouteHeuristicReason::ShortRequest,
531 ),
532 };
533 app.set_model_selection("auto".to_string());
534 app.last_effective_provider = Some(crate::config::ApiProvider::Zai);
535 app.last_effective_provider_identity = Some("zai".to_string());
536 app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string());
537 app.last_auto_route_receipt = Some(receipt.clone());
538 app.last_effective_reasoning_effort =
539 Some(crate::tui::app::EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable);
540
541 let result = save(&mut app, Some(save_path.to_str().unwrap()));
542
543 assert!(!result.is_error);
544 let saved: crate::session_manager::SavedSession =
545 serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap();
546 let route = saved.last_auto_route.expect("latest Auto route");
547 assert_eq!(route.provider, crate::config::ApiProvider::Zai);
548 assert_eq!(route.provider_identity, "zai");
549 assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL);
550 assert_eq!(route.receipt, receipt);
551 assert_eq!(
552 route.effective_reasoning_effort,
553 Some(crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable)
554 );
555 }
556
557 #[test]
558 fn fork_saves_parent_and_switches_to_child_session() {
559 let tmpdir = TempDir::new().unwrap();
560 let _lock = crate::test_support::lock_test_env();
561 let home = tmpdir.path().join("home");
562 std::fs::create_dir_all(&home).unwrap();
563 let home_guard = EnvVarGuard::set("HOME", &home);
564 let previous_home = home_guard.previous();
565 let mut app = create_test_app_with_tmpdir(&tmpdir);
566 app.set_provider_identity(crate::config::ApiProvider::Custom, "lm-studio");
567 app.current_session_id = Some("parent-session".to_string());
568 let mut cached_parent = create_saved_session_with_id_and_mode(
569 "parent-session".to_string(),
570 &[],
571 &app.model,
572 &app.workspace,
573 0,
574 None,
575 Some(app.mode.label()),
576 )
577 .metadata;
578 cached_parent.title = "Custom Parent".to_string();
579 cached_parent.created_at = "2026-01-02T03:04:05Z"
580 .parse()
581 .expect("fixed parent timestamp");
582 app.current_session_metadata = Some(cached_parent.clone());
583 app.session_title = Some(cached_parent.title.clone());
584 app.api_messages.push(crate::models::Message {
585 role: "user".to_string(),
586 content: vec![crate::models::ContentBlock::Text {
587 text: "try another path".to_string(),
588 cache_control: None,
589 }],
590 });
591 {
592 let mut todos = app.todos.try_lock().expect("todos lock");
593 todos.add(
594 "preserve fork Work".to_string(),
595 crate::tools::todo::TodoStatus::InProgress,
596 );
597 }
598 {
599 let mut plan = app.plan_state.try_lock().expect("plan lock");
600 plan.update(crate::tools::plan::UpdatePlanArgs {
601 objective: Some("Fork without Work drift".to_string()),
602 ..crate::tools::plan::UpdatePlanArgs::default()
603 });
604 }
605 app.cycle_effort();
606 let expected_work = app
607 .work_state_snapshot()
608 .expect("Work snapshot")
609 .expect("graph-backed Work state");
610 assert!(
611 expected_work.graph.is_some(),
612 "fork fixture must use a graph"
613 );
614
615 let result = fork(&mut app);
616
617 assert!(!result.is_error, "{:?}", result.message);
618 let new_id = app.current_session_id.clone().expect("fork session id");
619 assert_ne!(new_id, "parent-session");
620 assert!(result.message.as_deref().unwrap_or("").contains("Forked"));
621 assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
622
623 let manager = crate::session_manager::SessionManager::default_location().unwrap();
624 let parent = manager
625 .load_session("parent-session")
626 .expect("parent saved");
627 let child = manager.load_session(&new_id).expect("child saved");
628 assert_eq!(parent.messages.len(), 1);
629 assert_eq!(parent.metadata.model_provider, "custom");
630 assert_eq!(
631 parent.metadata.model_provider_id.as_deref(),
632 Some("lm-studio")
633 );
634 assert_eq!(parent.metadata.title, cached_parent.title);
635 assert_eq!(parent.metadata.created_at, cached_parent.created_at);
636 assert_eq!(
637 child.metadata.parent_session_id.as_deref(),
638 Some("parent-session")
639 );
640 assert_eq!(child.metadata.forked_from_message_count, Some(1));
641 assert_eq!(child.metadata.model_provider, "custom");
642 assert_eq!(
643 child.metadata.model_provider_id.as_deref(),
644 Some("lm-studio")
645 );
646 assert_eq!(parent.work_state.as_ref(), Some(&expected_work));
647 assert_eq!(child.work_state.as_ref(), Some(&expected_work));
648 let cached_child = app
649 .current_session_metadata
650 .as_ref()
651 .expect("child metadata cached");
652 assert_eq!(cached_child.id, child.metadata.id);
653 assert_eq!(cached_child.title, child.metadata.title);
654 assert_eq!(cached_child.created_at, child.metadata.created_at);
655 assert_eq!(
656 cached_child.parent_session_id,
657 child.metadata.parent_session_id
658 );
659 assert_eq!(
660 app.session_title.as_deref(),
661 Some(child.metadata.title.as_str())
662 );
663 drop(home_guard);
664 assert_eq!(std::env::var_os("HOME"), previous_home);
665 }
666
667 #[test]
668 fn fork_rejects_active_runtime_without_switching_sessions() {
669 let tmpdir = TempDir::new().unwrap();
670 let mut app = create_test_app_with_tmpdir(&tmpdir);
671 app.current_session_id = Some("parent-session".to_string());
672 app.api_messages.push(crate::models::Message {
673 role: "user".to_string(),
674 content: vec![crate::models::ContentBlock::Text {
675 text: "still running".to_string(),
676 cache_control: None,
677 }],
678 });
679 app.is_loading = true;
680
681 let result = fork(&mut app);
682
683 assert!(result.is_error);
684 assert!(result.action.is_none());
685 assert_eq!(app.current_session_id.as_deref(), Some("parent-session"));
686 assert_eq!(app.api_messages.len(), 1);
687 }
688
689 #[test]
690 fn new_session_from_resumed_state_creates_distinct_empty_session() {
691 let tmpdir = TempDir::new().unwrap();
692 let mut app = create_test_app_with_tmpdir(&tmpdir);
693 app.current_session_id = Some("old-session".to_string());
694 app.session_title = Some("Old Session".to_string());
695 app.api_messages.push(crate::models::Message {
696 role: "user".to_string(),
697 content: vec![crate::models::ContentBlock::Text {
698 text: "continue this thread".to_string(),
699 cache_control: None,
700 }],
701 });
702 app.add_message(HistoryCell::System {
703 content: "old transcript".to_string(),
704 });
705 app.system_prompt = Some(crate::models::SystemPrompt::Text("old prompt".to_string()));
706 app.session.total_tokens = 123;
707 app.session.session_cost = 1.25;
708
709 let result = new_session(&mut app, None);
710
711 assert!(!result.is_error, "{:?}", result.message);
712 let new_id = app.current_session_id.clone().expect("new session id");
713 assert_ne!(new_id, "old-session");
714 assert_eq!(app.session_title.as_deref(), Some("New Session"));
715 assert!(app.api_messages.is_empty());
716 assert!(app.history.is_empty());
717 assert!(app.system_prompt.is_none());
718 assert_eq!(app.session.total_tokens, 0);
719 assert_eq!(app.session.session_cost, 0.0);
720 assert!(
721 result
722 .message
723 .as_deref()
724 .unwrap_or_default()
725 .contains("/resume")
726 );
727 match result.action {
728 Some(AppAction::SyncSession {
729 session_id,
730 messages,
731 system_prompt,
732 ..
733 }) => {
734 assert_eq!(session_id.as_deref(), Some(new_id.as_str()));
735 assert!(messages.is_empty());
736 assert!(system_prompt.is_none());
737 }
738 other => panic!("expected SyncSession action, got {other:?}"),
739 }
740 }
741
742 #[test]
743 fn new_session_blocks_unsent_input_without_force() {
744 let tmpdir = TempDir::new().unwrap();
745 let mut app = create_test_app_with_tmpdir(&tmpdir);
746 app.current_session_id = Some("old-session".to_string());
747 app.input = "draft text".to_string();
748
749 let result = new_session(&mut app, None);
750
751 assert!(result.is_error);
752 assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
753 assert_eq!(app.input, "draft text");
754 assert!(result.action.is_none());
755 assert!(
756 result
757 .message
758 .as_deref()
759 .unwrap_or_default()
760 .contains("/new --force")
761 );
762 }
763
764 #[test]
765 fn new_session_force_discards_unsent_input() {
766 let tmpdir = TempDir::new().unwrap();
767 let mut app = create_test_app_with_tmpdir(&tmpdir);
768 app.current_session_id = Some("old-session".to_string());
769 app.input = "draft text".to_string();
770
771 let result = new_session(&mut app, Some("--force"));
772
773 assert!(!result.is_error, "{:?}", result.message);
774 assert_ne!(app.current_session_id.as_deref(), Some("old-session"));
775 assert!(app.input.is_empty());
776 assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
777 }
778
779 #[test]
780 fn new_session_blocks_in_flight_turn_without_force() {
781 let tmpdir = TempDir::new().unwrap();
782 let mut app = create_test_app_with_tmpdir(&tmpdir);
783 app.current_session_id = Some("old-session".to_string());
784 app.is_loading = true;
785
786 let result = new_session(&mut app, None);
787
788 assert!(result.is_error);
789 assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
790 assert!(result.action.is_none());
791 }
792
793 #[test]
794 fn new_session_force_cannot_detach_an_in_flight_turn() {
795 let tmpdir = TempDir::new().unwrap();
796 let mut app = create_test_app_with_tmpdir(&tmpdir);
797 app.current_session_id = Some("old-session".to_string());
798 app.api_messages.push(crate::models::Message {
799 role: "user".to_string(),
800 content: vec![],
801 });
802 app.is_loading = true;
803 app.runtime_turn_status = Some("in_progress".to_string());
804
805 let result = new_session(&mut app, Some("--force"));
806
807 assert!(result.is_error);
808 assert!(result.action.is_none());
809 assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
810 assert_eq!(app.api_messages.len(), 1);
811 assert!(
812 result
813 .message
814 .as_deref()
815 .is_some_and(|message| message.contains("only discards draft or queued input"))
816 );
817 }
818
819 #[test]
820 fn load_rejects_an_active_runtime_before_reading_or_mutating() {
821 let tmpdir = TempDir::new().unwrap();
822 let mut app = create_test_app_with_tmpdir(&tmpdir);
823 app.current_session_id = Some("old-session".to_string());
824 app.api_messages.push(crate::models::Message {
825 role: "user".to_string(),
826 content: vec![],
827 });
828 app.task_panel.push(crate::tui::app::TaskPanelEntry {
829 id: "queued-late-producer".to_string(),
830 status: "queued".to_string(),
831 prompt_summary: "queued".to_string(),
832 duration_ms: None,
833 kind: crate::tui::app::TaskPanelEntryKind::Background,
834 stale: false,
835 elapsed_since_output_ms: None,
836 owner_agent_id: None,
837 owner_agent_name: None,
838 current_tool: None,
839 role: None,
840 files_touched: 0,
841 });
842
843 let result = load(&mut app, Some("does-not-exist.json"));
844
845 assert!(result.is_error);
846 assert!(result.action.is_none());
847 assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
848 assert_eq!(app.api_messages.len(), 1);
849 assert!(
850 result
851 .message
852 .as_deref()
853 .is_some_and(|message| message.contains("runtime work is active"))
854 );
855 }
856
857 #[test]
858 fn test_save_with_default_path_uses_managed_sessions_dir() {
859 let tmpdir = TempDir::new().unwrap();
860 let _lock = crate::test_support::lock_test_env();
861 // Set CODEWHALE_HOME so the managed sessions directory lands inside the
862 // temp dir rather than the real user home. Pre-create the directory so
863 // resolve_state_dir picks it up instead of falling back to legacy.
864 let home = tmpdir.path().join("home");
865 let sessions_dir = home.join("sessions");
866 std::fs::create_dir_all(&sessions_dir).unwrap();
867 let codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
868 let previous_codewhale_home = codewhale_home.previous();
869 let mut app = create_test_app_with_tmpdir(&tmpdir);
870 let result = save(&mut app, None);
871 assert!(result.message.is_some());
872 let msg = result.message.unwrap();
873 // Give it a moment to ensure file is written
874 std::thread::sleep(std::time::Duration::from_millis(10));
875 let entries: Vec<_> = if sessions_dir.exists() {
876 std::fs::read_dir(&sessions_dir)
877 .unwrap()
878 .filter_map(|e| e.ok())
879 .filter(|e| e.file_name().to_string_lossy().ends_with(".json"))
880 .collect()
881 } else {
882 Vec::new()
883 };
884 drop(codewhale_home);
885 // Session should be saved to the managed dir, not the workspace root.
886 assert!(
887 !entries.is_empty(),
888 "expected session file in {sessions_dir:?}, got none; msg: {msg}"
889 );
890 let session_id = app
891 .current_session_id
892 .as_deref()
893 .expect("current session id");
894 assert!(sessions_dir.join(format!("{session_id}.json")).exists());
895 assert_eq!(std::env::var_os("CODEWHALE_HOME"), previous_codewhale_home);
896 }
897
898 #[test]
899 fn test_save_serialization_error() {
900 let tmpdir = TempDir::new().unwrap();
901 let mut app = create_test_app_with_tmpdir(&tmpdir);
902 // This should work normally since SavedSession is serializable
903 // Testing error path would require mocking, which is complex
904 let save_path = tmpdir.path().join("test.json");
905 let result = save(&mut app, Some(save_path.to_str().unwrap()));
906 assert!(result.message.is_some());
907 }
908
909 #[test]
910 fn test_load_without_path_returns_error() {
911 let tmpdir = TempDir::new().unwrap();
912 let mut app = create_test_app_with_tmpdir(&tmpdir);
913 let result = load(&mut app, None);
914 assert!(result.message.is_some());
915 assert!(result.message.unwrap().contains("Usage: /load"));
916 }
917
918 #[test]
919 fn test_load_nonexistent_file_returns_error() {
920 let tmpdir = TempDir::new().unwrap();
921 let mut app = create_test_app_with_tmpdir(&tmpdir);
922 let result = load(&mut app, Some("nonexistent.json"));
923 assert!(result.message.is_some());
924 assert!(result.message.unwrap().contains("Failed to read"));
925 }
926
927 #[test]
928 fn test_load_invalid_json_returns_error() {
929 let tmpdir = TempDir::new().unwrap();
930 let mut app = create_test_app_with_tmpdir(&tmpdir);
931 let bad_file = tmpdir.path().join("bad.json");
932 std::fs::write(&bad_file, "not valid json").unwrap();
933 let result = load(&mut app, Some(bad_file.to_str().unwrap()));
934 assert!(result.message.is_some());
935 assert!(result.message.unwrap().contains("Failed to parse"));
936 }
937
938 #[test]
939 fn test_load_valid_session_defers_state_restore_to_event_loop() {
940 let tmpdir = TempDir::new().unwrap();
941 let mut app1 = create_test_app_with_tmpdir(&tmpdir);
942 // Set up some state to save
943 app1.api_messages.push(crate::models::Message {
944 role: "user".to_string(),
945 content: vec![crate::models::ContentBlock::Text {
946 text: "Hello".to_string(),
947 cache_control: None,
948 }],
949 });
950 app1.session.total_tokens = 500;
951 app1.set_mode(AppMode::Plan);
952 let save_path = tmpdir.path().join("test.json");
953 save(&mut app1, Some(save_path.to_str().unwrap()));
954
955 // Create new app and load
956 let mut app2 = create_test_app_with_tmpdir(&tmpdir);
957 app2.system_prompt = Some(crate::models::SystemPrompt::Text(
958 "stale prompt from prior session".to_string(),
959 ));
960 app2.session_context_references
961 .push(crate::session_manager::SessionContextReference {
962 message_index: 0,
963 reference: crate::tui::file_mention::ContextReference {
964 kind: crate::tui::file_mention::ContextReferenceKind::File,
965 source: crate::tui::file_mention::ContextReferenceSource::AtMention,
966 badge: "file".to_string(),
967 label: "stale.rs".to_string(),
968 target: tmpdir.path().join("stale.rs").display().to_string(),
969 included: true,
970 expanded: true,
971 detail: None,
972 },
973 });
974 let result = load(&mut app2, Some(save_path.to_str().unwrap()));
975 assert_eq!(result.message, None);
976 assert!(app2.api_messages.is_empty());
977 assert_eq!(app2.session.total_tokens, 0);
978 assert!(app2.current_session_id.is_none());
979 assert!(app2.system_prompt.is_some());
980 assert_eq!(app2.session_context_references.len(), 1);
981 assert!(matches!(
982 result.action,
983 Some(AppAction::LoadSession(path)) if path == save_path
984 ));
985 }
986
987 #[test]
988 fn explicit_save_persists_work_state_and_load_defers_application() {
989 let tmpdir = TempDir::new().unwrap();
990 let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
991 {
992 let mut todos = saved_app.todos.try_lock().expect("todos lock");
993 todos.add(
994 "persist me".to_string(),
995 crate::tools::todo::TodoStatus::InProgress,
996 );
997 }
998 {
999 let mut plan = saved_app.plan_state.try_lock().expect("plan lock");
1000 plan.update(crate::tools::plan::UpdatePlanArgs {
1001 objective: Some("Resume exactly".to_string()),
1002 ..crate::tools::plan::UpdatePlanArgs::default()
1003 });
1004 }
1005 let expected = saved_app.work_state_snapshot().expect("snapshot");
1006 let save_path = tmpdir.path().join("work_state.json");
1007 let saved = save(&mut saved_app, Some(save_path.to_str().unwrap()));
1008 assert!(!saved.is_error, "{:?}", saved.message);
1009
1010 let mut loaded_app = create_test_app_with_tmpdir(&tmpdir);
1011 let loaded = load(&mut loaded_app, Some(save_path.to_str().unwrap()));
1012 assert!(!loaded.is_error, "{:?}", loaded.message);
1013 assert_eq!(loaded_app.work_state_snapshot().expect("snapshot"), None);
1014 assert!(matches!(
1015 loaded.action,
1016 Some(AppAction::LoadSession(path)) if path == save_path
1017 ));
1018 let saved_session: crate::session_manager::SavedSession =
1019 serde_json::from_str(&std::fs::read_to_string(&save_path).expect("saved session file"))
1020 .expect("saved session JSON");
1021 assert_eq!(saved_session.work_state, expected);
1022 }
1023
1024 #[test]
1025 fn new_session_is_all_or_nothing_when_work_state_is_busy() {
1026 let tmpdir = TempDir::new().unwrap();
1027 let mut app = create_test_app_with_tmpdir(&tmpdir);
1028 app.api_messages.push(crate::models::Message {
1029 role: "user".to_string(),
1030 content: vec![],
1031 });
1032 app.current_session_id = Some("current-session".to_string());
1033 let todos = app.todos.clone();
1034 let _held = todos.try_lock().expect("hold todos lock");
1035
1036 let result = new_session(&mut app, Some("--force"));
1037
1038 assert!(result.is_error);
1039 assert_eq!(app.api_messages.len(), 1);
1040 assert_eq!(app.current_session_id.as_deref(), Some("current-session"));
1041 assert!(result.action.is_none());
1042 }
1043
1044 #[test]
1045 fn load_auto_model_session_defers_model_restore_to_event_loop() {
1046 let tmpdir = TempDir::new().unwrap();
1047 let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
1048 saved_app.set_model_selection("auto".to_string());
1049 saved_app.last_effective_model = Some("deepseek-v4-flash".to_string());
1050 saved_app.last_effective_reasoning_effort = Some(
1051 crate::tui::app::EffectiveReasoningEffort::Tier(ReasoningEffort::Low),
1052 );
1053 let save_path = tmpdir.path().join("auto_model.json");
1054 save(&mut saved_app, Some(save_path.to_str().unwrap()));
1055
1056 let mut app = create_test_app_with_tmpdir(&tmpdir);
1057 app.set_model_selection("deepseek-v4-flash".to_string());
1058 app.reasoning_effort = ReasoningEffort::High;
1059 let result = load(&mut app, Some(save_path.to_str().unwrap()));
1060
1061 assert!(!result.is_error);
1062 assert!(!app.auto_model);
1063 assert_eq!(app.model, "deepseek-v4-flash");
1064 assert_eq!(app.reasoning_effort, ReasoningEffort::High);
1065 assert!(matches!(
1066 result.action,
1067 Some(AppAction::LoadSession(path)) if path == save_path
1068 ));
1069 }
1070
1071 #[test]
1072 fn load_defers_artifact_registry_restore_to_event_loop() {
1073 let tmpdir = TempDir::new().unwrap();
1074 let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
1075 saved_app
1076 .session_artifacts
1077 .push(crate::artifacts::ArtifactRecord {
1078 id: "art_call_big".to_string(),
1079 kind: crate::artifacts::ArtifactKind::ToolOutput,
1080 session_id: "artifact-session".to_string(),
1081 tool_call_id: "call-big".to_string(),
1082 tool_name: "exec_shell".to_string(),
1083 created_at: chrono::Utc::now(),
1084 byte_size: 128,
1085 preview: "checking crate".to_string(),
1086 storage_path: tmpdir.path().join("call-big.txt"),
1087 });
1088 let save_path = tmpdir.path().join("artifact_load.json");
1089 save(&mut saved_app, Some(save_path.to_str().unwrap()));
1090
1091 let mut app = create_test_app_with_tmpdir(&tmpdir);
1092 app.session_artifacts
1093 .push(crate::artifacts::ArtifactRecord {
1094 id: "art_stale".to_string(),
1095 kind: crate::artifacts::ArtifactKind::ToolOutput,
1096 session_id: "stale-session".to_string(),
1097 tool_call_id: "stale".to_string(),
1098 tool_name: "exec_shell".to_string(),
1099 created_at: chrono::Utc::now(),
1100 byte_size: 1,
1101 preview: "stale".to_string(),
1102 storage_path: tmpdir.path().join("stale.txt"),
1103 });
1104
1105 let result = load(&mut app, Some(save_path.to_str().unwrap()));
1106
1107 assert!(!result.is_error);
1108 assert_eq!(app.session_artifacts.len(), 1);
1109 assert_eq!(app.session_artifacts[0].id, "art_stale");
1110 assert!(matches!(
1111 result.action,
1112 Some(AppAction::LoadSession(path)) if path == save_path
1113 ));
1114 }
1115
1116 #[test]
1117 fn load_defers_telemetry_reset_to_event_loop() {
1118 let tmpdir = TempDir::new().unwrap();
1119 let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
1120 saved_app.api_messages.push(crate::models::Message {
1121 role: "user".to_string(),
1122 content: vec![crate::models::ContentBlock::Text {
1123 text: "checkpoint".to_string(),
1124 cache_control: None,
1125 }],
1126 });
1127 saved_app.session.total_tokens = 500;
1128 let save_path = tmpdir.path().join("checkpoint.json");
1129 save(&mut saved_app, Some(save_path.to_str().unwrap()));
1130
1131 let mut app = create_test_app_with_tmpdir(&tmpdir);
1132 app.session.session_cost = 1.25;
1133 app.session.session_cost_cny = 9.13;
1134 app.session.subagent_cost = 0.75;
1135 app.session.subagent_cost_cny = 5.48;
1136 app.session
1137 .subagent_cost_event_seqs
1138 .insert(("turn-test".to_string(), 42));
1139 app.session.displayed_cost_high_water = 2.0;
1140 app.session.displayed_cost_high_water_cny = 14.61;
1141 app.session.last_prompt_tokens = Some(120);
1142 app.session.last_completion_tokens = Some(35);
1143 app.session.last_prompt_cache_hit_tokens = Some(80);
1144 app.session.last_prompt_cache_miss_tokens = Some(40);
1145 app.session.last_reasoning_replay_tokens = Some(12);
1146 app.push_turn_cache_record(TurnCacheRecord {
1147 provider: None,
1148 provider_identity: None,
1149 model: None,
1150 auto_model: false,
1151 input_tokens: 120,
1152 output_tokens: 35,
1153 cache_hit_tokens: Some(80),
1154 cache_miss_tokens: Some(40),
1155 reasoning_replay_tokens: Some(12),
1156 cache_write_tokens: None,
1157 reasoning_tokens: None,
1158 cost_audit: None,
1159 recorded_at: Instant::now(),
1160 });
1161
1162 let result = load(&mut app, Some(save_path.to_str().unwrap()));
1163
1164 assert_eq!(result.message, None);
1165 assert_eq!(app.session.total_tokens, 0);
1166 assert_eq!(app.session.session_cost, 1.25);
1167 assert_eq!(app.session.session_cost_cny, 9.13);
1168 assert_eq!(app.session.subagent_cost, 0.75);
1169 assert_eq!(app.session.subagent_cost_cny, 5.48);
1170 assert_eq!(app.session.turn_cache_history.len(), 1);
1171 assert!(matches!(
1172 result.action,
1173 Some(AppAction::LoadSession(path)) if path == save_path
1174 ));
1175 }
1176
1177 #[test]
1178 fn test_compact_toggles_state() {
1179 let tmpdir = TempDir::new().unwrap();
1180 let mut app = create_test_app_with_tmpdir(&tmpdir);
1181
1182 let result = compact(&mut app, None);
1183 assert!(result.message.is_some());
1184 let msg = result.message.unwrap();
1185 assert!(msg.contains("compaction") || msg.contains("Compact"));
1186 assert!(matches!(
1187 result.action,
1188 Some(AppAction::CompactContext { focus: None })
1189 ));
1190 }
1191
1192 #[test]
1193 fn compact_command_forwards_a_trimmed_focus_argument() {
1194 let tmpdir = TempDir::new().unwrap();
1195 let mut app = create_test_app_with_tmpdir(&tmpdir);
1196
1197 let result = compact(&mut app, Some(" the auth refactor "));
1198 assert!(matches!(
1199 result.action,
1200 Some(AppAction::CompactContext { focus: Some(ref focus) }) if focus == "the auth refactor"
1201 ));
1202 assert!(
1203 result
1204 .message
1205 .as_deref()
1206 .is_some_and(|msg| msg.contains("focus: the auth refactor")),
1207 "{result:?}"
1208 );
1209
1210 // Whitespace-only arguments behave like no focus at all.
1211 let blank = compact(&mut app, Some(" "));
1212 assert!(matches!(
1213 blank.action,
1214 Some(AppAction::CompactContext { focus: None })
1215 ));
1216 }
1217
1218 #[test]
1219 fn test_sessions_pushes_picker_view() {
1220 let tmpdir = TempDir::new().unwrap();
1221 let mut app = create_test_app_with_tmpdir(&tmpdir);
1222 let initial_kind = app.view_stack.top_kind();
1223
1224 let result = sessions(&mut app, None);
1225 assert_eq!(result.message, None);
1226 assert!(result.action.is_none());
1227 // View should have changed (session picker should be on top)
1228 assert_ne!(app.view_stack.top_kind(), initial_kind);
1229 }
1230
1231 #[test]
1232 fn test_sessions_show_subcommand_pushes_picker_view() {
1233 // `/sessions show` and `/sessions list` are explicit aliases
1234 // for the no-arg picker form. Verify they don't fall through
1235 // to the prune branch.
1236 let tmpdir = TempDir::new().unwrap();
1237 let mut app = create_test_app_with_tmpdir(&tmpdir);
1238 let initial_kind = app.view_stack.top_kind();
1239 let result = sessions(&mut app, Some("show"));
1240 assert_eq!(result.message, None);
1241 assert_ne!(app.view_stack.top_kind(), initial_kind);
1242 }
1243
1244 #[test]
1245 fn test_sessions_prune_requires_days_argument() {
1246 let tmpdir = TempDir::new().unwrap();
1247 let mut app = create_test_app_with_tmpdir(&tmpdir);
1248 let result = sessions(&mut app, Some("prune"));
1249 assert!(result.is_error);
1250 assert!(
1251 result.message.as_deref().unwrap_or("").contains("usage"),
1252 "expected usage hint: {:?}",
1253 result.message
1254 );
1255 }
1256
1257 #[test]
1258 fn test_sessions_prune_rejects_non_positive_days() {
1259 let tmpdir = TempDir::new().unwrap();
1260 let mut app = create_test_app_with_tmpdir(&tmpdir);
1261 for bad in ["0", "-3", "abc", "3.14"] {
1262 let result = sessions(&mut app, Some(&format!("prune {bad}")));
1263 assert!(result.is_error, "expected error for `{bad}`");
1264 }
1265 }
1266
1267 #[test]
1268 fn test_sessions_unknown_subcommand_errors() {
1269 let tmpdir = TempDir::new().unwrap();
1270 let mut app = create_test_app_with_tmpdir(&tmpdir);
1271 let result = sessions(&mut app, Some("teleport"));
1272 assert!(result.is_error);
1273 assert!(
1274 result
1275 .message
1276 .as_deref()
1277 .unwrap_or("")
1278 .contains("unknown subcommand"),
1279 "expected unknown-subcommand error: {:?}",
1280 result.message
1281 );
1282 }
1283 }
1284
1284 lines RUST