Selaa lähdekoodia

Prevent resumed slash commands from dropping release-critical arguments

The release harness advertised resumed slash commands like /export <file> and /clear --confirm, but argv parsing split every slash-prefixed token into a new command. That made the claw binary reject legitimate resumed command sequences and quietly miss the caller-provided export target.

This change teaches --resume parsing to keep command arguments attached, including absolute export paths, and locks the behavior with both parser regressions and a binary-level smoke test that exercises the real claw resume path.

Constraint: Keep the scope to a high-confidence release-path fix that fits a ~1 hour hardening pass

Rejected: Broad REPL or network end-to-end coverage expansion | too slow and too wide for the release-confidence target

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: If new resume-supported commands accept slash-prefixed literals, extend the resume parser heuristics and add binary coverage for them

Tested: cargo test --workspace; cargo test -p rusty-claude-cli --test resume_slash_commands; cargo test -p rusty-claude-cli parses_resume_flag_with_absolute_export_path -- --exact

Not-tested: cargo clippy --workspace --all-targets -- -D warnings currently fails on pre-existing runtime/conversation/session lints outside this change
Yeachan-Heo 2 kuukautta sitten
vanhempi
commit
b3b14cff79

+ 92 - 7
rust/crates/rusty-claude-cli/src/main.rs

@@ -425,19 +425,65 @@ fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
         .first()
         .ok_or_else(|| "missing session path for --resume".to_string())
         .map(PathBuf::from)?;
-    let commands = args[1..].to_vec();
-    if commands
-        .iter()
-        .any(|command| !command.trim_start().starts_with('/'))
-    {
-        return Err("--resume trailing arguments must be slash commands".to_string());
+    let mut commands = Vec::new();
+    let mut current_command = String::new();
+
+    for token in &args[1..] {
+        if token.trim_start().starts_with('/') {
+            if resume_command_can_absorb_token(&current_command, token) {
+                current_command.push(' ');
+                current_command.push_str(token);
+                continue;
+            }
+            if !current_command.is_empty() {
+                commands.push(current_command);
+            }
+            current_command = token.clone();
+            continue;
+        }
+
+        if current_command.is_empty() {
+            return Err("--resume trailing arguments must be slash commands".to_string());
+        }
+
+        current_command.push(' ');
+        current_command.push_str(token);
+    }
+
+    if !current_command.is_empty() {
+        commands.push(current_command);
     }
+
     Ok(CliAction::ResumeSession {
         session_path,
         commands,
     })
 }
 
+fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool {
+    matches!(
+        SlashCommand::parse(current_command),
+        Some(SlashCommand::Export { path: None })
+    ) && !looks_like_slash_command_token(token)
+}
+
+fn looks_like_slash_command_token(token: &str) -> bool {
+    let trimmed = token.trim_start();
+    let Some(name) = trimmed.strip_prefix('/').and_then(|value| {
+        value
+            .split_whitespace()
+            .next()
+            .map(str::trim)
+            .filter(|value| !value.is_empty())
+    }) else {
+        return false;
+    };
+
+    slash_command_specs()
+        .iter()
+        .any(|spec| spec.name == name || spec.aliases.contains(&name))
+}
+
 fn dump_manifests() {
     let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
     let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
@@ -3286,7 +3332,6 @@ impl AnthropicRuntimeClient {
             progress_reporter,
         })
     }
-
 }
 
 fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
@@ -4573,6 +4618,46 @@ mod tests {
         );
     }
 
+    #[test]
+    fn parses_resume_flag_with_slash_command_arguments() {
+        let args = vec![
+            "--resume".to_string(),
+            "session.jsonl".to_string(),
+            "/export".to_string(),
+            "notes.txt".to_string(),
+            "/clear".to_string(),
+            "--confirm".to_string(),
+        ];
+        assert_eq!(
+            parse_args(&args).expect("args should parse"),
+            CliAction::ResumeSession {
+                session_path: PathBuf::from("session.jsonl"),
+                commands: vec![
+                    "/export notes.txt".to_string(),
+                    "/clear --confirm".to_string()
+                ],
+            }
+        );
+    }
+
+    #[test]
+    fn parses_resume_flag_with_absolute_export_path() {
+        let args = vec![
+            "--resume".to_string(),
+            "session.jsonl".to_string(),
+            "/export".to_string(),
+            "/tmp/notes.txt".to_string(),
+            "/status".to_string(),
+        ];
+        assert_eq!(
+            parse_args(&args).expect("args should parse"),
+            CliAction::ResumeSession {
+                session_path: PathBuf::from("session.jsonl"),
+                commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()],
+            }
+        );
+    }
+
     #[test]
     fn filtered_tool_specs_respect_allowlist() {
         let allowed = ["read_file", "grep_search"]

+ 71 - 0
rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs

@@ -0,0 +1,71 @@
+use std::fs;
+use std::path::PathBuf;
+use std::process::Command;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use runtime::Session;
+
+static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
+
+#[test]
+fn resumed_binary_accepts_slash_commands_with_arguments() {
+    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");
+
+    let output = Command::new(env!("CARGO_BIN_EXE_claw"))
+        .current_dir(&temp_dir)
+        .args([
+            "--resume",
+            session_path.to_str().expect("utf8 path"),
+            "/export",
+            export_path.to_str().expect("utf8 path"),
+            "/clear",
+            "--confirm",
+        ])
+        .output()
+        .expect("claw should launch");
+
+    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("Cleared resumed 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());
+}
+
+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()
+    ))
+}