From 113145a42a1878936b02a63f5dd8cfdf4abab834 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 09:06:28 +0900 Subject: [PATCH] fix(#787): --resume with directory path returns session_path_is_directory kind + hint; wire fallback_hint_for_error_kind into both resume error emission sites --- ROADMAP.md | 2 + rust/crates/rusty-claude-cli/src/main.rs | 25 ++++++++- .../tests/output_format_contract.rs | 52 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 42c858da..a065bde5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7739,3 +7739,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 785. **`claw dump` (typo/near-miss for dump-manifests) returned `error_kind:"unknown"` — no classifier arm for `"unknown subcommand:"` prose prefix** — dogfooded 2026-05-27 on `e628b4bb`. Any unknown top-level subcommand that triggers the suggestion path emitted `"unknown subcommand: .\nDid you mean "` but `classify_error_kind` had no arm for that prefix; all fell to the `"unknown"` catch-all. The hint was non-null (the suggestion text was extracted by `split_error_hint`) but `error_kind` was undifferentiated. Fix: added `starts_with("unknown subcommand:")` → `"unknown_subcommand"` arm. Unit test assertion + integration test `unknown_subcommand_returns_typed_kind_785` using `claw dump` as the trigger. 44 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori subcommand-classifier probe on `e628b4bb`, 2026-05-27. 786. **`claw dump-manifests --manifests-dir` (missing value) and `--manifests-dir=` (empty) both returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `87f43347` (pinpoint by Gaebal-gajae). Both missing `--manifests-dir` branches in `parse_dump_manifests_args` emitted plain `"--manifests-dir requires a path"` with no typed prefix; `classify_error_kind` had no matching arm so they fell to `"unknown"`. Fix: both branches now use `missing_flag_value:` prefix + `\n` usage hint. Integration test `dump_manifests_missing_dir_has_typed_kind_and_hint_786` covers both cases. 45 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `87f43347`, 2026-05-27. + +787. **`claw --resume /tmp` (directory path) returned `error_kind:"session_load_failed"` + `hint:null`; resume error emission sites didn't apply `fallback_hint_for_error_kind`** — dogfooded 2026-05-27 on `22b423b6`. Two gaps: (1) the OS error `"Is a directory (os error 21)"` had no classifier arm, falling to generic `session_load_failed`; (2) both resume error emission paths (session load at line 3338, command execution at line 3484) called `split_error_hint` but not `fallback_hint_for_error_kind`, so API-layer errors with no `\n` always got `hint:null`. Fix: added `session_path_is_directory` classifier arm for `"Is a directory"` / `"os error 21"` messages; added `fallback_hint_for_error_kind` fallback to both resume error sites; added `session_path_is_directory` and `session_load_failed` to `fallback_hint_for_error_kind`. Unit test + integration test `resume_directory_path_returns_typed_kind_and_hint_787`. 46 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori resume-path probe on `22b423b6`, 2026-05-27. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 72563baf..1eab83b5 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -283,6 +283,9 @@ fn classify_error_kind(message: &str) -> &'static str { // error message is "failed to restore session: legacy session is missing workspace // binding: ...", so the specific arm must be checked first. "legacy_session_no_workspace_binding" + } else if message.contains("Is a directory") || message.contains("os error 21") { + // #787: --resume given a directory path instead of a .jsonl file + "session_path_is_directory" } else if message.contains("failed to restore session") { "session_load_failed" } else if message.contains("unsupported ACP invocation") { @@ -401,6 +404,13 @@ fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> { "missing_credentials" => { Some("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN before running claw.") } + // #787: session load failures have no \n-delimited hint from the OS error path + "session_load_failed" => Some( + "Pass a path to a .jsonl session file, not a directory. Managed sessions live in .claw/sessions/.", + ), + "session_path_is_directory" => Some( + "--resume expects a .jsonl session file path, not a directory. Run `claw --output-format json /session list` to list managed sessions.", + ), _ => None, } } @@ -3327,7 +3337,10 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu // #77: classify session load errors for downstream consumers let full_message = format!("failed to restore session: {error}"); let kind = classify_error_kind(&full_message); - let (short_reason, hint) = split_error_hint(&full_message); + let (short_reason, inline_hint) = split_error_hint(&full_message); + // #787: fall back to kind-derived hint when message has no \n delimiter + let hint = + inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); eprintln!( "{}", serde_json::json!({ @@ -3472,7 +3485,10 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu // hardcoded "resume_command_error" + prose in the error field let full_error = error.to_string(); let error_kind = classify_error_kind(&full_error); - let (short_reason, hint) = split_error_hint(&full_error); + let (short_reason, inline_hint) = split_error_hint(&full_error); + // #787: fall back to kind-derived hint when error has no \n delimiter + let hint = inline_hint + .or_else(|| fallback_hint_for_error_kind(error_kind).map(String::from)); eprintln!( "{}", serde_json::json!({ @@ -13009,6 +13025,11 @@ mod tests { classify_error_kind("failed to restore session: file not found"), "session_load_failed" ); + // #787: directory-as-session-path gets its own kind (precedes generic session_load_failed) + assert_eq!( + classify_error_kind("failed to restore session: Is a directory (os error 21)"), + "session_path_is_directory" + ); assert_eq!( classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"), "cli_parse" 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 417aa09d..b39cfc25 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -2612,3 +2612,55 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() { .expect("missing_flag_value must have hint (#786)"); assert!(!h2.is_empty(), "hint must not be empty"); } + +#[test] +fn resume_directory_path_returns_typed_kind_and_hint_787() { + // #787: `claw --resume /tmp` (directory instead of .jsonl file) returned + // error_kind:"session_load_failed" + hint:null. The OS error "Is a directory (os error 21)" + // had no \n delimiter so split_error_hint returned None, and the resume error path + // didn't call fallback_hint_for_error_kind. + // Fix: (1) added session_path_is_directory classifier arm for os error 21; + // (2) wired fallback_hint_for_error_kind into both resume error emission sites. + let root = unique_temp_dir("resume-dir-787"); + fs::create_dir_all(&root).expect("temp dir"); + std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .output() + .ok(); + + // Pass the root directory itself as the session path + let output = run_claw( + &root, + &[ + "--output-format", + "json", + "--resume", + root.to_str().unwrap(), + "/status", + ], + &[], + ); + assert!( + !output.status.success(), + "resume with directory should fail" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let j: serde_json::Value = stderr + .lines() + .find(|l| l.trim_start().starts_with('{')) + .and_then(|l| serde_json::from_str(l).ok()) + .expect("resume with directory should emit JSON error"); + assert_eq!( + j["error_kind"], "session_path_is_directory", + "directory resume path should return session_path_is_directory, got {:?}", + j["error_kind"] + ); + let hint = j["hint"] + .as_str() + .expect("session_path_is_directory must have hint (#787)"); + assert!( + hint.contains(".jsonl") || hint.contains("session") || hint.contains("file"), + "hint should explain expected path format, got: {hint:?}" + ); +}