mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 15:44:49 +08:00
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
248 lines
7.9 KiB
Rust
248 lines
7.9 KiB
Rust
use std::fs;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
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);
|
|
|
|
#[test]
|
|
fn resumed_binary_accepts_slash_commands_with_arguments() {
|
|
// given
|
|
let temp_dir = unique_temp_dir("resume-slash-commands");
|
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
|
|
let session_path = temp_dir.join("session.jsonl");
|
|
let export_path = temp_dir.join("notes.txt");
|
|
|
|
let mut session = Session::new();
|
|
session
|
|
.push_user_text("ship the slash command harness")
|
|
.expect("session write should succeed");
|
|
session
|
|
.save_to_path(&session_path)
|
|
.expect("session should persist");
|
|
|
|
// when
|
|
let output = run_claw(
|
|
&temp_dir,
|
|
&[
|
|
"--resume",
|
|
session_path.to_str().expect("utf8 path"),
|
|
"/export",
|
|
export_path.to_str().expect("utf8 path"),
|
|
"/clear",
|
|
"--confirm",
|
|
],
|
|
);
|
|
|
|
// then
|
|
assert!(
|
|
output.status.success(),
|
|
"stdout:\n{}\n\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
assert!(stdout.contains("Export"));
|
|
assert!(stdout.contains("wrote transcript"));
|
|
assert!(stdout.contains(export_path.to_str().expect("utf8 path")));
|
|
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"));
|
|
assert!(export.contains("ship the slash command harness"));
|
|
|
|
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]
|
|
fn status_command_applies_cli_flags_end_to_end() {
|
|
// given
|
|
let temp_dir = unique_temp_dir("status-command-flags");
|
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
|
|
// when
|
|
let output = run_claw(
|
|
&temp_dir,
|
|
&[
|
|
"--model",
|
|
"sonnet",
|
|
"--permission-mode",
|
|
"read-only",
|
|
"status",
|
|
],
|
|
);
|
|
|
|
// then
|
|
assert!(
|
|
output.status.success(),
|
|
"stdout:\n{}\n\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
assert!(stdout.contains("Status"));
|
|
assert!(stdout.contains("Model claude-sonnet-4-6"));
|
|
assert!(stdout.contains("Permission mode read-only"));
|
|
}
|
|
|
|
#[test]
|
|
fn resumed_config_command_loads_settings_files_end_to_end() {
|
|
// given
|
|
let temp_dir = unique_temp_dir("resume-config");
|
|
let project_dir = temp_dir.join("project");
|
|
let config_home = temp_dir.join("home").join(".claw");
|
|
fs::create_dir_all(project_dir.join(".claw")).expect("project config dir should exist");
|
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
|
|
|
let session_path = project_dir.join("session.jsonl");
|
|
Session::new()
|
|
.with_persistence_path(&session_path)
|
|
.save_to_path(&session_path)
|
|
.expect("session should persist");
|
|
|
|
fs::write(config_home.join("settings.json"), r#"{"model":"haiku"}"#)
|
|
.expect("user config should write");
|
|
fs::write(
|
|
project_dir.join(".claw").join("settings.local.json"),
|
|
r#"{"model":"opus"}"#,
|
|
)
|
|
.expect("local config should write");
|
|
|
|
// when
|
|
let output = run_claw_with_env(
|
|
&project_dir,
|
|
&[
|
|
"--resume",
|
|
session_path.to_str().expect("utf8 path"),
|
|
"/config",
|
|
"model",
|
|
],
|
|
&[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
|
|
);
|
|
|
|
// then
|
|
assert!(
|
|
output.status.success(),
|
|
"stdout:\n{}\n\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
assert!(stdout.contains("Config"));
|
|
assert!(stdout.contains("Loaded files 2"));
|
|
assert!(stdout.contains(
|
|
config_home
|
|
.join("settings.json")
|
|
.to_str()
|
|
.expect("utf8 path")
|
|
));
|
|
assert!(stdout.contains(
|
|
project_dir
|
|
.join(".claw")
|
|
.join("settings.local.json")
|
|
.to_str()
|
|
.expect("utf8 path")
|
|
));
|
|
assert!(stdout.contains("Merged section: model"));
|
|
assert!(stdout.contains("opus"));
|
|
}
|
|
|
|
#[test]
|
|
fn resume_latest_restores_the_most_recent_managed_session() {
|
|
// given
|
|
let temp_dir = unique_temp_dir("resume-latest");
|
|
let project_dir = temp_dir.join("project");
|
|
let sessions_dir = project_dir.join(".claw").join("sessions");
|
|
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
|
|
|
|
let older_path = sessions_dir.join("session-older.jsonl");
|
|
let newer_path = sessions_dir.join("session-newer.jsonl");
|
|
|
|
let mut older = Session::new().with_persistence_path(&older_path);
|
|
older
|
|
.push_user_text("older session")
|
|
.expect("older session write should succeed");
|
|
older
|
|
.save_to_path(&older_path)
|
|
.expect("older session should persist");
|
|
|
|
let mut newer = Session::new().with_persistence_path(&newer_path);
|
|
newer
|
|
.push_user_text("newer session")
|
|
.expect("newer session write should succeed");
|
|
newer
|
|
.push_user_text("resume me")
|
|
.expect("newer session write should succeed");
|
|
newer
|
|
.save_to_path(&newer_path)
|
|
.expect("newer session should persist");
|
|
|
|
// when
|
|
let output = run_claw(&project_dir, &["--resume", "latest", "/status"]);
|
|
|
|
// then
|
|
assert!(
|
|
output.status.success(),
|
|
"stdout:\n{}\n\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
assert!(stdout.contains("Status"));
|
|
assert!(stdout.contains("Messages 2"));
|
|
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
|
|
}
|
|
|
|
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
|
run_claw_with_env(current_dir, args, &[])
|
|
}
|
|
|
|
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
|
command.current_dir(current_dir).args(args);
|
|
for (key, value) in envs {
|
|
command.env(key, value);
|
|
}
|
|
command.output().expect("claw should launch")
|
|
}
|
|
|
|
fn unique_temp_dir(label: &str) -> PathBuf {
|
|
let millis = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("clock should be after epoch")
|
|
.as_millis();
|
|
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
std::env::temp_dir().join(format!(
|
|
"claw-{label}-{}-{millis}-{counter}",
|
|
std::process::id()
|
|
))
|
|
}
|