Procházet zdrojové kódy

Merge remote-tracking branch 'origin/omx-issue-9202-release-harness'

# Conflicts:
#	rust/crates/rusty-claude-cli/src/main.rs
Yeachan-Heo před 2 měsíci
rodič
revize
c0be23b4f6

+ 96 - 9
rust/crates/rusty-claude-cli/src/main.rs

@@ -571,25 +571,72 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
 }
 
 fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
-    let (session_path, commands) = match args.first() {
-        None => (PathBuf::from(LATEST_SESSION_REFERENCE), Vec::new()),
+    let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
+        None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
         Some(first) if first.trim_start().starts_with('/') => {
-            (PathBuf::from(LATEST_SESSION_REFERENCE), args.to_vec())
+            (PathBuf::from(LATEST_SESSION_REFERENCE), args)
         }
-        Some(first) => (PathBuf::from(first), args[1..].to_vec()),
+        Some(first) => (PathBuf::from(first), &args[1..]),
     };
-    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 command_tokens {
+        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);
@@ -5118,6 +5165,46 @@ mod tests {
         assert!(error.contains("claw --help"));
     }
 
+    #[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()
+    ))
+}