From 901ce4851bb68c2ffbb36739656dce82bbeb19e2 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 2 Apr 2026 10:03:07 +0000 Subject: [PATCH] Preserve resumable history when clearing CLI sessions PARITY.md and the current Rust CLI UX both pointed at session-management polish as a worthwhile parity lane. The existing /clear flow reset the live REPL without telling the user how to get back, and the resumed /clear path overwrote the saved session file in place with no recovery handle. This change keeps the existing clear semantics but makes them safer and more legible. Live clears now print the previous session id and a resume hint, while resumed clears write a sibling backup before resetting the requested session file and report both the backup path and the new session id. Constraint: Keep /clear compatible with follow-on commands in the same --resume invocation Rejected: Switch resumed /clear to a brand-new primary session path | would break the expected in-place reset semantics for chained resume commands Confidence: high Scope-risk: narrow Directive: Preserve explicit recovery hints in /clear output if session lifecycle behavior changes again Tested: cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test resume_slash_commands Tested: cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --bin claw clear_command_requires_explicit_confirmation_flag Not-tested: Manual interactive REPL /clear run --- rust/crates/rusty-claude-cli/src/main.rs | 43 +++++++++++++++++-- .../tests/resume_slash_commands.rs | 20 ++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index f23fa60..3c1e0f9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1,4 +1,11 @@ -#![allow(dead_code, unused_imports, unused_variables, clippy::unneeded_struct_pattern, clippy::unnecessary_wraps, clippy::unused_self)] +#![allow( + dead_code, + unused_imports, + unused_variables, + clippy::unneeded_struct_pattern, + clippy::unnecessary_wraps, + clippy::unused_self +)] mod init; mod input; mod render; @@ -1293,12 +1300,17 @@ fn run_resume_command( ), }); } + let backup_path = write_session_clear_backup(session, session_path)?; + let previous_session_id = session.session_id.clone(); let cleared = Session::new(); + let new_session_id = cleared.session_id.clone(); cleared.save_to_path(session_path)?; Ok(ResumeCommandOutcome { session: cleared, message: Some(format!( - "Cleared resumed session file {}.", + "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", + backup_path.display(), + backup_path.display(), session_path.display() )), }) @@ -1974,6 +1986,7 @@ impl LiveCli { return Ok(false); } + let previous_session = self.session.clone(); let session_state = Session::new(); self.session = create_managed_session_handle(&session_state.session_id)?; self.runtime = build_runtime( @@ -1988,10 +2001,13 @@ impl LiveCli { None, )?; println!( - "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", + "Session cleared\n Mode fresh session\n Previous session {}\n Resume previous /resume {}\n Preserved model {}\n Permission mode {}\n New session {}\n Session file {}", + previous_session.id, + previous_session.id, self.model, self.permission_mode.as_str(), self.session.id, + self.session.path.display(), ); Ok(true) } @@ -2514,6 +2530,27 @@ fn format_session_modified_age(modified_epoch_millis: u128) -> String { } } +fn write_session_clear_backup( + session: &Session, + session_path: &Path, +) -> Result> { + let backup_path = session_clear_backup_path(session_path); + session.save_to_path(&backup_path)?; + Ok(backup_path) +} + +fn session_clear_backup_path(session_path: &Path) -> PathBuf { + let timestamp = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(0, |duration| duration.as_millis()); + let file_name = session_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("session.jsonl"); + session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak")) +} + fn render_repl_help() -> String { [ "REPL".to_string(), diff --git a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs index 99ebce9..ccef95f 100644 --- a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs +++ b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs @@ -5,6 +5,7 @@ use std::process::{Command, Output}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use runtime::ContentBlock; use runtime::Session; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -51,7 +52,12 @@ fn resumed_binary_accepts_slash_commands_with_arguments() { assert!(stdout.contains("Export")); assert!(stdout.contains("wrote transcript")); assert!(stdout.contains(export_path.to_str().expect("utf8 path"))); - assert!(stdout.contains("Cleared resumed session file")); + assert!(stdout.contains("Session cleared")); + assert!(stdout.contains("Mode resumed session reset")); + assert!(stdout.contains("Previous session")); + assert!(stdout.contains("Resume previous claw --resume")); + assert!(stdout.contains("Backup ")); + assert!(stdout.contains("Session file ")); let export = fs::read_to_string(&export_path).expect("export file should exist"); assert!(export.contains("# Conversation Export")); @@ -59,6 +65,18 @@ fn resumed_binary_accepts_slash_commands_with_arguments() { let restored = Session::load_from_path(&session_path).expect("cleared session should load"); assert!(restored.messages.is_empty()); + + let backup_path = stdout + .lines() + .find_map(|line| line.strip_prefix(" Backup ")) + .map(PathBuf::from) + .expect("clear output should include backup path"); + let backup = Session::load_from_path(&backup_path).expect("backup session should load"); + assert_eq!(backup.messages.len(), 1); + assert!(matches!( + backup.messages[0].blocks.first(), + Some(ContentBlock::Text { text }) if text == "ship the slash command harness" + )); } #[test]