diff --git a/ROADMAP.md b/ROADMAP.md index 0900c86a..be1184b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7727,3 +7727,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 779. **Resumed `/skills ` invocation returned bare prose → `error_kind:"unknown"` + `hint:null` after #776** — dogfooded 2026-05-27 on `fded4f6b` (pinpoint by Gaebal-gajae). Sibling of #777: the `/skills` invoke-dispatch guard emitted a single-line prose error identical in structure to the pre-#777 plugins mutation guard. After #776's classify/split it fell to `unknown+null` because no `interactive_only:` prefix was present. Fix: replaced with `interactive_only: /skills {skill_name} invocation requires a live session.\n...hint...` format. Integration test `resume_skills_invocation_is_typed_interactive_only_779` added. 40 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `fded4f6b`, 2026-05-27. 780. **`classify_error_kind` arm ordering bug: `"failed to restore session: legacy session is missing workspace binding: ..."` classified as `session_load_failed` instead of `legacy_session_no_workspace_binding`** — dogfooded 2026-05-27 on `364e7909`. The full error message from `resume_session` prepends `"failed to restore session: "` before `"legacy session is missing workspace binding: ..."`. The `contains("failed to restore session")` arm at line 278 matched first, returning `session_load_failed`; the more specific `legacy_session_no_workspace_binding` arm at line 282 was never reached. Same shadowing existed for `no_managed_sessions`. Fix: reordered the three arms — specific cases (`no_managed_sessions`, `legacy_session_no_workspace_binding`) before the generic `session_load_failed` catch-all. Unit test updated to assert corrected discriminants, plus new assertion covering the full prefixed message that exposed the bug. 40 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori classifier-ordering probe on `364e7909`, 2026-05-27. + +781. **`api_http_error` was a single bucket for all HTTP errors; 401 auth and 429 rate-limit returned `hint:null` with no distinction** — dogfooded 2026-05-27 on `d9844cfe`. `classify_error_kind` had a single `api_http_error` arm for all API failures. 401 Unauthorized and 429 rate-limit errors emitted `error_kind:"api_http_error"` + `hint:null`, making it impossible for automation to distinguish auth misconfiguration from transient rate-limiting. Fixes: (1) added `api_auth_error` sub-classifier arm for 401/Unauthorized/authentication_error messages; (2) added `api_rate_limit_error` arm for 429/rate_limit messages; (3) added `fallback_hint_for_error_kind()` that derives a stable hint from the error kind when `split_error_hint` returns `None` (API layer never emits `\n`-delimited hints); (4) main JSON error emission path now calls `fallback_hint_for_error_kind` as fallback. Auth errors now return `api_auth_error` + env-var hint; rate-limit returns `api_rate_limit_error` + retry hint. Unit tests updated. 40 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori API error opacity probe on `d9844cfe`, 2026-05-27. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index eb24c042..4a144870 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -222,7 +222,9 @@ fn main() { // fields and add the stable status/error_kind/action contract used // by non-interactive command guards. let kind = classify_error_kind(&message); - let (short_reason, hint) = split_error_hint(&message); + let (short_reason, inline_hint) = split_error_hint(&message); + // #781: fall back to a kind-derived hint when the message has no \n-delimited hint + let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); eprintln!( "{}", serde_json::json!({ @@ -301,6 +303,20 @@ fn classify_error_kind(message: &str) -> &'static str { "unsupported_resumed_command" } else if message.contains("confirmation required") { "confirmation_required" + } else if (message.contains("api failed") || message.contains("api returned")) + && (message.contains("401") + || message.contains("Unauthorized") + || message.contains("authentication_error")) + { + // #781: sub-classify auth failures so wrappers can distinguish from rate-limit / server errors + "api_auth_error" + } else if (message.contains("api failed") || message.contains("api returned")) + && (message.contains("429") + || message.contains("rate_limit") + || message.contains("rate limit")) + { + // #781: sub-classify rate-limit failures + "api_rate_limit_error" } else if message.contains("api failed") || message.contains("api returned") { "api_http_error" } else if message.contains("mcpServers") { @@ -365,6 +381,24 @@ fn split_error_hint(message: &str) -> (String, Option) { } } +/// #781: derive a stable fallback hint from a classified error kind when the error +/// message itself has no `\n`-delimited hint. Returns `None` for kinds where the +/// message is self-explanatory or no canonical remediation exists. +fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> { + match kind { + "api_auth_error" => { + Some("Check that ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN is set and valid.") + } + "api_rate_limit_error" => { + Some("You have hit the API rate limit. Wait and retry, or reduce request frequency.") + } + "missing_credentials" => { + Some("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN before running claw.") + } + _ => None, + } +} + /// Read piped stdin content when stdin is not a terminal. /// /// Returns `None` when stdin is attached to a terminal (interactive REPL use), @@ -13034,8 +13068,19 @@ mod tests { classify_error_kind("confirmation required before running destructive operation"), "confirmation_required" ); + // #781: 429 and 401 now sub-classify; generic 5xx/other still api_http_error assert_eq!( classify_error_kind("api returned unexpected status 429"), + "api_rate_limit_error" + ); + assert_eq!( + classify_error_kind( + "api returned 401 Unauthorized (authentication_error): invalid x-api-key" + ), + "api_auth_error" + ); + assert_eq!( + classify_error_kind("api returned 500 Internal Server Error"), "api_http_error" ); assert_eq!(