diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index f08ae700..3f28dc0e 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -225,7 +225,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: Some( "[list|exists |switch |fork [branch-name]|delete [--force]]", ), - resume_supported: false, + resume_supported: true, }, SlashCommandSpec { name: "plugin", @@ -4596,16 +4596,16 @@ mod tests { })) ); assert_eq!( - SlashCommand::parse("/session exists abc123"), + SlashCommand::parse("/session switch abc123"), Ok(Some(SlashCommand::Session { - action: Some("exists".to_string()), + action: Some("switch".to_string()), target: Some("abc123".to_string()) })) ); assert_eq!( - SlashCommand::parse("/session switch abc123"), + SlashCommand::parse("/session exists abc123"), Ok(Some(SlashCommand::Session { - action: Some("switch".to_string()), + action: Some("exists".to_string()), target: Some("abc123".to_string()) })) ); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index d552b938..058ec0a9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -4157,7 +4157,6 @@ fn run_resume_command( | SlashCommand::Resume { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } - | SlashCommand::Session { .. } | SlashCommand::Login | SlashCommand::Logout | SlashCommand::Vim @@ -6102,6 +6101,140 @@ fn confirm_session_deletion(session_id: &str) -> bool { matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") } +fn session_details_json(sessions: &[ManagedSessionSummary]) -> Vec { + sessions + .iter() + .map(|session| { + serde_json::json!({ + "id": session.id, + "path": session.path.display().to_string(), + "message_count": session.message_count, + "updated_at_ms": session.updated_at_ms, + "modified_epoch_millis": session.modified_epoch_millis, + "parent_session_id": session.parent_session_id, + "branch_name": session.branch_name, + "lifecycle": session.lifecycle.json_value(), + }) + }) + .collect() +} + +fn session_exists_json( + target: &str, + active_session_id: &str, +) -> Result> { + let handle = create_managed_session_handle(target)?; + let resolved = resolve_session_reference(target).ok(); + let exists = resolved.is_some(); + let resolved_id = resolved + .as_ref() + .map(|handle| handle.id.as_str()) + .unwrap_or(target); + Ok(serde_json::json!({ + "kind": "session_exists", + "session_id": resolved_id, + "requested": target, + "exists": exists, + "active": resolved_id == active_session_id, + "path": resolved + .as_ref() + .map(|handle| handle.path.display().to_string()), + "candidate_path": handle.path.display().to_string(), + })) +} + +fn run_resumed_session_command( + session_path: &Path, + session: &Session, + action: Option<&str>, + target: Option<&str>, +) -> Result> { + match action { + None | Some("list") => { + let sessions = list_managed_sessions().unwrap_or_default(); + let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let active_id = session.session_id.clone(); + let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(text), + json: Some(serde_json::json!({ + "kind": "session_list", + "sessions": session_ids, + "session_details": session_details_json(&sessions), + "active": active_id, + })), + }) + } + Some("exists") => { + let Some(target) = target else { + return Err("/session exists requires a session id".into()); + }; + let value = session_exists_json(target, &session.session_id)?; + let exists = value + .get("exists") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Session exists\n Session {}\n Exists {}", + target, + if exists { "yes" } else { "no" } + )), + json: Some(value), + }) + } + Some("delete") => { + let Some(target) = target else { + return Err("/session delete requires a session id".into()); + }; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "delete: confirmation required; rerun with /session delete {target} --force" + )), + json: Some(serde_json::json!({ + "kind": "error", + "error": "confirmation required", + "hint": format!("rerun with /session delete {target} --force"), + "session_id": target, + })), + }) + } + Some("delete-force") => { + let Some(target) = target else { + return Err("/session delete requires a session id".into()); + }; + let handle = resolve_session_reference(target)?; + if handle.id == session.session_id || handle.path == session_path { + return Err(format!( + "delete: refusing to delete the active session '{}'. Resume or switch to another session first.", + handle.id + ) + .into()); + } + delete_managed_session(&handle.path)?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Session deleted\n Deleted session {}\n File {}", + handle.id, + handle.path.display(), + )), + json: Some(serde_json::json!({ + "kind": "session_delete", + "deleted": true, + "session_id": handle.id, + "path": handle.path.display().to_string(), + })), + }) + } + Some("switch" | "fork") => Err("unsupported resumed slash command".into()), + Some(other) => Err(format!("unsupported resumed /session action: {other}").into()), + } +} + fn render_session_list(active_session_id: &str) -> Result> { let sessions = list_managed_sessions()?; let mut lines = vec![ @@ -13565,6 +13698,68 @@ UU conflicted.rs", std::fs::remove_dir_all(workspace).expect("workspace should clean up"); } + #[test] + fn resumed_session_exists_and_delete_have_json_contracts() { + let _guard = cwd_guard(); + let workspace = temp_workspace("resume-session-json-contracts"); + std::fs::create_dir_all(&workspace).expect("workspace should create"); + let previous = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&workspace).expect("switch cwd"); + + let active = create_managed_session_handle("session-active").expect("active handle"); + let active_session = Session::new() + .with_workspace_root(workspace.clone()) + .with_persistence_path(active.path.clone()); + active_session + .save_to_path(&active.path) + .expect("active session should save"); + let saved = create_managed_session_handle("session-saved").expect("saved handle"); + Session::new() + .with_workspace_root(workspace.clone()) + .with_persistence_path(saved.path.clone()) + .save_to_path(&saved.path) + .expect("saved session should save"); + + let exists_command = SlashCommand::parse("/session exists session-saved") + .expect("parse should succeed") + .expect("command should exist"); + let exists = run_resume_command(&active.path, &active_session, &exists_command) + .expect("exists should run") + .json + .expect("exists should return json"); + assert_eq!(exists["kind"], "session_exists"); + assert_eq!(exists["session_id"], "session-saved"); + assert_eq!(exists["exists"], true); + assert_eq!(exists["active"], false); + assert!(exists["path"].as_str().is_some()); + + let missing_command = SlashCommand::parse("/session exists missing-session") + .expect("parse should succeed") + .expect("command should exist"); + let missing = run_resume_command(&active.path, &active_session, &missing_command) + .expect("missing exists should run") + .json + .expect("missing exists should return json"); + assert_eq!(missing["kind"], "session_exists"); + assert_eq!(missing["exists"], false); + assert_eq!(missing["session_id"], "missing-session"); + assert!(missing["candidate_path"].as_str().is_some()); + + let delete_command = SlashCommand::parse("/session delete session-saved --force") + .expect("parse should succeed") + .expect("command should exist"); + let deleted = run_resume_command(&active.path, &active_session, &delete_command) + .expect("delete should run") + .json + .expect("delete should return json"); + assert_eq!(deleted["kind"], "session_delete"); + assert_eq!(deleted["deleted"], true); + assert!(!saved.path.exists(), "saved session should be deleted"); + + std::env::set_current_dir(previous).expect("restore cwd"); + std::fs::remove_dir_all(workspace).expect("workspace should clean up"); + } + #[test] fn latest_session_alias_resolves_most_recent_managed_session() { let _guard = cwd_guard();