From e459a727e9b7a445466586c9e91192420937a9fc Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 2 Jun 2026 15:49:21 -0500 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20session=20resume=20=E2=80=94=20skip?= =?UTF-8?q?=20current=20empty=20session,=20unify=20cross-workspace=20loadi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements to the /resume command: 1. /resume latest now skips the current empty session When a new session is created on startup (with 0 messages), /resume latest previously returned that empty session. Now it skips sessions with message_count == 0 and excludes the current session ID via the new exclude_id parameter, so it finds the previous session with actual conversation history. 2. Unified load_session_excluding() replaces load_session_loose() The previous load_session_loose() only handled cross-workspace resume for aliases. The new load_session_excluding() combines the loose workspace validation logic with the exclude_id parameter, simplifying the call chain and ensuring all resume paths skip the current empty session when appropriate. 3. All existing session scanning paths (global root + project-local .claw/sessions/) are already in place from prior commits, and now the exclude_id filter is applied consistently across both local and global session scans. Changes: - session_control.rs: Add resolve_reference_excluding() that delegates from resolve_reference(), adding optional exclude_id filtering for alias references. - session_control.rs: Add latest_session_excluding() that delegates from latest_session(), filtering out excluded session IDs and sessions with 0 messages in both local and global scan paths. - session_control.rs: Add load_session_excluding() that replaces load_session_loose(), combining cross-workspace alias handling with the exclude_id parameter. - main.rs: Add load_session_reference_excluding() that delegates from load_session_reference(), using the new store method. - main.rs: Wire LiveCli::resume_session() to pass the current session ID as the exclude_id so /resume latest skips the current empty session. Co-Authored-By: Claude Opus 4.8 --- rust/crates/runtime/src/session_control.rs | 84 +++++++++++++++++----- rust/crates/rusty-claude-cli/src/main.rs | 22 +++--- 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index e6c3f6c0..896ad915 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,10 +169,34 @@ 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); } Err(SessionControlError::Format(format_no_managed_sessions( @@ -204,28 +239,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( diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 5febf841..7ee36c88 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6364,7 +6364,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( @@ -7059,17 +7060,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, From 41034bb3f35348f09b24bd4b134652a3ee97501a Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 3 Jun 2026 13:44:20 -0500 Subject: [PATCH 2/3] fix: address CI test failure and add empty-session error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix latest_session_alias_resolves_most_recent_managed_session test: the test created sessions with 0 messages, which are now filtered out by the message_count > 0 check in latest_session_excluding(). Updated the test to call push_user_text() before saving so sessions have at least one message and are findable by /resume latest. - Add distinct error message when all sessions are empty (0 messages). Previously, the same "no managed sessions found" message was returned whether there were zero sessions or all sessions had 0 messages. Now: - No sessions at all → "no managed sessions found in {path}. Start claw to create a session..." - Sessions exist but all empty → "all sessions are empty (0 messages) in {path}. This usually means a fresh claw session is running but no messages have been sent yet. Wait for a response in your other session, then try --resume latest again." - Add test for the all-sessions-empty error path. Addresses reviewer feedback on #3216. --- rust/crates/runtime/src/session_control.rs | 54 ++++++++++++++++++++++ rust/crates/rusty-claude-cli/src/main.rs | 26 +++++++---- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 896ad915..b00dfce1 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -199,6 +199,21 @@ impl SessionStore { { 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, ))) @@ -774,6 +789,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, @@ -1229,6 +1254,35 @@ 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 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 7ee36c88..f3ba3112 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -15377,16 +15377,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!( From 346772a8b309fb5cd0c030049211571288eaeca8 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 4 Jun 2026 09:17:42 -0500 Subject: [PATCH 3/3] test: add exclude_id and 0-message filtering coverage; cargo fmt --- rust/crates/runtime/src/session_control.rs | 97 +++++++++++++++++++--- 1 file changed, 85 insertions(+), 12 deletions(-) diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 961d48d9..90120eff 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -201,18 +201,12 @@ impl SessionStore { } // 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); + 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), - )); + return Err(SessionControlError::Format(format_all_sessions_empty( + &self.sessions_root, + ))); } Err(SessionControlError::Format(format_no_managed_sessions( &self.sessions_root, @@ -1308,7 +1302,10 @@ mod tests { // 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"); + 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"), @@ -1322,6 +1319,82 @@ mod tests { 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