diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index ebb252e1..90120eff 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -93,8 +93,19 @@ impl SessionStore { } pub fn resolve_reference(&self, reference: &str) -> Result { + self.resolve_reference_excluding(reference, None) + } + + /// Resolve a session reference, optionally excluding a session by ID. + /// When the reference is an alias, the excluded session is skipped + /// so /resume latest returns the previous session, not the current one. + pub fn resolve_reference_excluding( + &self, + reference: &str, + exclude_id: Option<&str>, + ) -> Result { if is_session_reference_alias(reference) { - let latest = self.latest_session()?; + let latest = self.latest_session_excluding(exclude_id)?; return Ok(SessionHandle { id: latest.id, path: latest.path, @@ -158,12 +169,45 @@ impl SessionStore { } pub fn latest_session(&self) -> Result { - if let Some(latest) = self.list_sessions()?.into_iter().next() { + self.latest_session_excluding(None) + } + + /// Find the most recent session, optionally excluding a session by ID + /// and skipping sessions with 0 messages. Used by /resume latest to skip + /// the current empty session and find the previous session with actual + /// conversation history. + pub fn latest_session_excluding( + &self, + exclude_id: Option<&str>, + ) -> Result { + let exclude = exclude_id.unwrap_or(""); + // First: look in the current workspace's session namespace + if let Some(latest) = self + .list_sessions()? + .into_iter() + .find(|s| s.id != exclude && s.message_count > 0) + { return Ok(latest); } - if let Some(latest) = self.scan_global_sessions()?.into_iter().next() { + // Fallback: scan all workspace namespaces under ~/.claw/sessions/ + // and project-local .claw/sessions/ so /resume latest finds sessions + // from other workspaces. + if let Some(latest) = self + .scan_global_sessions()? + .into_iter() + .find(|s| s.id != exclude && s.message_count > 0) + { return Ok(latest); } + // Distinguish between "no sessions at all" and "sessions exist but + // all are empty" so the user gets a clear signal about what to do. + let has_any_session = self.list_sessions()?.iter().any(|s| s.id != exclude) + || self.scan_global_sessions()?.iter().any(|s| s.id != exclude); + if has_any_session { + return Err(SessionControlError::Format(format_all_sessions_empty( + &self.sessions_root, + ))); + } Err(SessionControlError::Format(format_no_managed_sessions( &self.sessions_root, ))) @@ -204,28 +248,41 @@ impl SessionStore { &self, reference: &str, ) -> Result { - match self.load_session(reference) { - Ok(loaded) => Ok(loaded), - Err(SessionControlError::WorkspaceMismatch { expected, actual }) - if is_session_reference_alias(reference) => + self.load_session_excluding(reference, None) + } + + /// Like `load_session_loose` but also excludes a session by ID. + /// Used by /resume latest to skip the current empty session and find + /// the previous session with actual conversation history. + pub fn load_session_excluding( + &self, + reference: &str, + exclude_id: Option<&str>, + ) -> Result { + let handle = self.resolve_reference_excluding(reference, exclude_id)?; + let session = Session::load_from_path(&handle.path)?; + // For alias references, allow cross-workspace resume + if is_session_reference_alias(reference) { + if let Err(SessionControlError::WorkspaceMismatch { + expected: _, + actual, + }) = self.validate_loaded_session(&handle.path, &session) { - let handle = self.resolve_reference(reference)?; - let session = Session::load_from_path(&handle.path)?; eprintln!( " Note: resuming session from a different workspace (origin: {})", actual.display() ); - let _ = expected; // suppress unused warning - Ok(LoadedManagedSession { - handle: SessionHandle { - id: session.session_id.clone(), - path: handle.path, - }, - session, - }) } - Err(other) => Err(other), + } else { + self.validate_loaded_session(&handle.path, &session)?; } + Ok(LoadedManagedSession { + handle: SessionHandle { + id: session.session_id.clone(), + path: handle.path, + }, + session, + }) } pub fn fork_session( @@ -726,6 +783,16 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String { ) } +fn format_all_sessions_empty(sessions_root: &Path) -> String { + let fingerprint_dir = sessions_root + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(""); + format!( + "all sessions are empty (0 messages) in .claw/sessions/{fingerprint_dir}/\nThis usually means a fresh `claw` session is running but no messages have been sent yet.\nWait for a response in your other session, then try `--resume {LATEST_SESSION_REFERENCE}` again." + ) +} + fn format_legacy_session_missing_workspace_root( session_path: &Path, workspace_root: &Path, @@ -1220,6 +1287,114 @@ mod tests { fs::remove_dir_all(base).expect("temp dir should clean up"); } + #[test] + fn latest_session_returns_all_empty_error_when_sessions_exist_but_have_no_messages() { + // given — create sessions with 0 messages (empty) + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + + let empty_handle = store.create_handle("empty-session"); + Session::new() + .with_persistence_path(empty_handle.path.clone()) + .save_to_path(&empty_handle.path) + .expect("empty session should save"); + + // when — latest_session should fail with the "all sessions empty" message + let result = store.latest_session(); + assert!( + result.is_err(), + "latest_session should fail when all sessions are empty" + ); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("all sessions are empty"), + "error should mention 'all sessions are empty', got: {err_msg}" + ); + assert!( + err_msg.contains("0 messages"), + "error should mention '0 messages', got: {err_msg}" + ); + + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + + #[test] + fn latest_session_excluding_skips_excluded_id_and_returns_previous() { + // given — two sessions WITH messages, newest excluded + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + let older = persist_session_via_store(&store, "older work"); + wait_for_next_millisecond(); + let newer = persist_session_via_store(&store, "newer work"); + + // when — exclude the newest session + let latest = store + .latest_session_excluding(Some(&newer.session_id)) + .expect("latest excluding newest should resolve"); + + // then — the older session wins because the newest is skipped + assert_eq!( + latest.id, older.session_id, + "excluded id must be skipped, returning the previous session" + ); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + + #[test] + fn latest_session_filters_out_zero_message_sessions() { + // given — one empty (0-message) session and one non-empty session + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + + let empty_handle = store.create_handle("empty-session"); + Session::new() + .with_persistence_path(empty_handle.path.clone()) + .save_to_path(&empty_handle.path) + .expect("empty session should save"); + wait_for_next_millisecond(); + let non_empty = persist_session_via_store(&store, "real conversation"); + + // when + let latest = store.latest_session().expect("latest should resolve"); + + // then — the non-empty session wins; the 0-message one is filtered out + assert_eq!( + latest.id, non_empty.session_id, + "0-message session must be filtered out, non-empty session wins" + ); + assert!( + latest.message_count > 0, + "resolved session must have messages" + ); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + + #[test] + fn resolve_reference_excluding_latest_skips_excluded_id() { + // given — two sessions WITH messages + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + let older = persist_session_via_store(&store, "older work"); + wait_for_next_millisecond(); + let newer = persist_session_via_store(&store, "newer work"); + + // when — resolve the "latest" alias while excluding the newest session + let handle = store + .resolve_reference_excluding("latest", Some(&newer.session_id)) + .expect("latest alias excluding newest should resolve"); + + // then — the excluded id is skipped, so the older session resolves + assert_eq!( + handle.id, older.session_id, + "excluded id must be skipped when resolving the latest alias" + ); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + #[test] fn session_exists_and_delete_are_scoped_to_workspace_store() { // given diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 40d3c69e..27aae614 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -8431,7 +8431,8 @@ impl LiveCli { return Ok(false); }; - let (handle, session) = load_session_reference(&session_ref)?; + let (handle, session) = + load_session_reference_excluding(&session_ref, Some(&self.session.id))?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); let runtime = build_runtime( @@ -9127,17 +9128,18 @@ fn latest_managed_session() -> Result Result<(SessionHandle, Session), Box> { + load_session_reference_excluding(reference, None) +} + +fn load_session_reference_excluding( + reference: &str, + exclude_id: Option<&str>, ) -> Result<(SessionHandle, Session), Box> { let store = current_session_store()?; - // For alias references ("latest", "last", "recent"), allow cross-workspace - // resume so /resume latest finds the most recent session globally. - // For explicit references, workspace validation is enforced. - let result = if runtime::session_control::is_session_reference_alias(reference) { - store.load_session_loose(reference) - } else { - store.load_session(reference) - }; - let loaded = result.map_err(|e| Box::new(e) as Box)?; + let loaded = store + .load_session_excluding(reference, exclude_id) + .map_err(|e| Box::new(e) as Box)?; Ok(( SessionHandle { id: loaded.handle.id, @@ -18386,16 +18388,26 @@ UU conflicted.rs", std::env::set_current_dir(&workspace).expect("switch cwd"); let older = create_managed_session_handle("session-older").expect("older handle"); - Session::new() - .with_persistence_path(older.path.clone()) - .save_to_path(&older.path) - .expect("older session should save"); + { + let mut session = Session::new().with_persistence_path(older.path.clone()); + session + .push_user_text("older session message") + .expect("older message should save"); + session + .save_to_path(&older.path) + .expect("older session should save"); + } std::thread::sleep(Duration::from_millis(20)); let newer = create_managed_session_handle("session-newer").expect("newer handle"); - Session::new() - .with_persistence_path(newer.path.clone()) - .save_to_path(&newer.path) - .expect("newer session should save"); + { + let mut session = Session::new().with_persistence_path(newer.path.clone()); + session + .push_user_text("newer session message") + .expect("newer message should save"); + session + .save_to_path(&newer.path) + .expect("newer session should save"); + } let resolved = resolve_session_reference("latest").expect("latest session should resolve"); assert_eq!(