From 187aebd74fb3fa9a826bbf73b629bde970941bdb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 16:36:54 +0900 Subject: [PATCH] fix: /approve and /deny outside REPL emit interactive_only error_kind (#828) /approve, /yes, /deny, /no (and /y, /n) are valid REPL-only slash commands. Outside the REPL they were falling through to format_unknown_direct_slash_command -> error_kind:unknown_slash_command. Fix: intercept them in the SlashCommand::Unknown arm and emit interactive_only: prefix so classify_error_kind returns the correct kind. One new test: approve_deny_outside_repl_emits_interactive_only (covers /approve, /yes, /deny, /no) 572 tests pass, 1 pre-existing worker_boot failure unrelated. --- ROADMAP.md | 6 +++++ rust/crates/rusty-claude-cli/src/main.rs | 15 ++++++++++++- .../tests/output_format_contract.rs | 22 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a8f55c68..121073d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7888,3 +7888,9 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) **Required fix shape.** Introduce a typed `error_kind` for unrecognized slash commands (e.g. `unknown_slash_command` or `command_not_found`). Update the JSON emit in the `--resume` unknown-command handler to use the typed kind. Add regression coverage asserting the typed kind. **Acceptance.** `claw --resume latest --output-format json /bogus` exits rc=2, stdout `error_kind:"unknown_slash_command"` (or similar typed constant), stderr empty. [SCOPE: claw-code] + +828. **`/approve` and `/deny` outside REPL emit `unknown_slash_command` instead of `interactive_only`** — dogfooded 2026-05-29 16:05 on `main` `9d05573f`. `claw --output-format json /approve` exited rc=1 with `error_kind:"unknown_slash_command"` — these are valid REPL-only slash commands but are not `SlashCommand` enum variants, so they fell through to `format_unknown_direct_slash_command`. Machine consumers saw the wrong error class. + + **Fix applied.** `SlashCommand::Unknown` arm now special-cases `approve | yes | y | deny | no | n` and emits `interactive_only:` prefix before falling through to `format_unknown_direct_slash_command`. Both `error_kind` and hint are correct. + + **Acceptance.** `claw --output-format json /approve` exits rc=1, stdout `error_kind:"interactive_only"`, stderr empty. [SCOPE: claw-code] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 057a973d..8113d2a0 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1739,7 +1739,20 @@ fn parse_direct_slash_cli_action( }), } } - Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)), + Ok(Some(SlashCommand::Unknown(name))) => { + // #828: /approve and /deny are valid REPL-only slash commands that + // are not SlashCommand enum variants (they require an active tool + // call in the REPL to be meaningful). Emit interactive_only so + // machine consumers see the correct error_kind instead of + // unknown_slash_command. + if matches!(name.as_str(), "approve" | "yes" | "y" | "deny" | "no" | "n") { + Err(format!( + "interactive_only: /{name} requires an active tool call in the REPL.\nStart `claw` and use /{name} to approve or deny a pending tool execution." + )) + } else { + Err(format_unknown_direct_slash_command(&name)) + } + } Ok(Some(command)) => Err({ let _ = command; format!( 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 8d7ff7b1..5b5e0707 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -3993,3 +3993,25 @@ fn direct_unknown_slash_command_emits_typed_error_kind() { "direct unknown slash JSON must have empty stderr (#827)" ); } + +// #828: /approve and /deny outside REPL must emit interactive_only, not unknown_slash_command +#[test] +fn approve_deny_outside_repl_emits_interactive_only() { + let root = unique_temp_dir("approve-deny-828"); + std::fs::create_dir_all(&root).expect("create temp dir"); + for cmd in &["/approve", "/yes", "/deny", "/no"] { + let output = run_claw(&root, &["--output-format", "json", cmd], &[]); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let j: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|_| panic!("{cmd} must emit JSON (#828), got: {stdout:?}")); + assert_eq!( + j["error_kind"], "interactive_only", + "{cmd} outside REPL must emit interactive_only (#828): {j}" + ); + assert!( + stderr.is_empty(), + "{cmd} JSON must have empty stderr (#828): {stderr:?}" + ); + } +}