diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 8113d2a0..5febf841 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1755,12 +1755,26 @@ fn parse_direct_slash_cli_action( } Ok(Some(command)) => Err({ let _ = command; - format!( - // #738: newline before remediation so split_error_hint populates hint field - "slash command {command_name} is interactive-only.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.", - command_name = rest[0], - latest = LATEST_SESSION_REFERENCE, - ) + let command_name = &rest[0]; + // #829: only suggest --resume when the command is actually + // resume-safe. Non-resume-safe commands (e.g. /commit, /pr) + // previously suggested --resume, which just re-triggered + // interactive_only on a second invocation. + let bare_name = command_name.trim_start_matches('/'); + let is_resume_safe = commands::resume_supported_slash_commands() + .iter() + .any(|spec| spec.name == bare_name); + if is_resume_safe { + format!( + // #738: newline before remediation so split_error_hint populates hint field + "interactive_only: slash command {command_name} requires a live session.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}`.", + latest = LATEST_SESSION_REFERENCE, + ) + } else { + format!( + "interactive_only: slash command {command_name} requires a live REPL session.\nStart `claw` and run it there." + ) + } }), Ok(None) => Err(format!("unknown subcommand: {}", rest[0])), Err(error) => Err(error.to_string()), @@ -13906,8 +13920,15 @@ mod tests { ); let error = parse_args(&["/status".to_string()]) .expect_err("/status should remain REPL-only when invoked directly"); - assert!(error.contains("interactive-only")); - assert!(error.contains("claw --resume SESSION.jsonl /status")); + // #829: prefix changed from "interactive-only" to "interactive_only:" + assert!( + error.contains("interactive_only:"), + "expected interactive_only: prefix, got: {error}" + ); + assert!( + error.contains("claw --resume SESSION.jsonl /status"), + "expected --resume suggestion for resume-safe /status, got: {error}" + ); } #[test] @@ -13929,8 +13950,9 @@ mod tests { for alias in ["/plugin", "/plugins", "/marketplace"] { let error = parse_args(&[alias.to_string()]) .expect_err("valid plugin slash aliases are local/interactive, never prompts"); + // #829: prefix changed from "interactive-only" to "interactive_only:" assert!( - error.contains("interactive-only"), + error.contains("interactive_only:") || error.contains("interactive-only"), "{alias} should reject as an interactive plugin command outside the REPL, got: {error}" ); } diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 5b5e0707..240884bf 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -4015,3 +4015,42 @@ fn approve_deny_outside_repl_emits_interactive_only() { ); } } + +// #829: interactive_only hint must NOT suggest --resume for non-resume-safe commands +#[test] +fn non_resume_safe_interactive_only_hint_omits_resume_suggestion() { + let root = unique_temp_dir("non-resume-hint-829"); + std::fs::create_dir_all(&root).expect("create temp dir"); + // /commit, /pr, /issue, /bughunter, /ultraplan are not resume-safe + for cmd in &["/commit", "/pr", "/issue", "/bughunter", "/ultraplan"] { + let output = run_claw(&root, &["--output-format", "json", cmd], &[]); + let stdout = String::from_utf8_lossy(&output.stdout); + let j: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|_| panic!("{cmd} must emit JSON (#829), got: {stdout:?}")); + assert_eq!( + j["error_kind"], "interactive_only", + "{cmd} must emit interactive_only (#829): {j}" + ); + let hint = j["hint"].as_str().unwrap_or(""); + assert!( + !hint.contains("--resume"), + "{cmd} hint must not suggest --resume for non-resume-safe command (#829): hint={hint:?}" + ); + } +} + +// #829: resume-safe commands should still suggest --resume in the hint +#[test] +fn resume_safe_interactive_only_hint_includes_resume_suggestion() { + let root = unique_temp_dir("resume-hint-829"); + std::fs::create_dir_all(&root).expect("create temp dir"); + let output = run_claw(&root, &["--output-format", "json", "/diff"], &[]); + let stdout = String::from_utf8_lossy(&output.stdout); + let j: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|_| panic!("/diff must emit JSON (#829), got: {stdout:?}")); + let hint = j["hint"].as_str().unwrap_or(""); + assert!( + hint.contains("--resume"), + "/diff hint must suggest --resume (it is resume-safe) (#829): hint={hint:?}" + ); +}