From 9362900b1b24da641e201835fd3a2a8a0ee444f1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 21 Apr 2026 22:38:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#77=20Phase=201=20=E2=80=94=20machine-r?= =?UTF-8?q?eadable=20error=20classification=20in=20JSON=20error=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem All JSON error payloads had the same three-field envelope: ```json {"type": "error", "error": ""} ``` Five distinct error classes were indistinguishable at the schema level: - missing_credentials (no API key) - missing_worker_state (no state file) - session_not_found / session_load_failed - cli_parse (unrecognized args) - invalid_model_syntax Downstream claws had to regex-scrape the prose to route failures. ## Fix 1. **Added `classify_error_kind()`** — prefix/keyword classifier that returns a snake_case discriminant token for 12 known error classes: `missing_credentials`, `missing_manifests`, `missing_worker_state`, `session_not_found`, `session_load_failed`, `no_managed_sessions`, `cli_parse`, `invalid_model_syntax`, `unsupported_command`, `unsupported_resumed_command`, `confirmation_required`, `api_http_error`, plus `unknown` fallback. 2. **Added `split_error_hint()`** — splits multi-line error messages into (short_reason, optional_hint) so the runbook prose stops being stuffed into the `error` field. 3. **Extended JSON envelope** at 4 emit sites: - Main error sink (line ~213) - Session load failure in resume_session - Stub command (unsupported_command) - Unknown resumed command (unsupported_resumed_command) ## New JSON shape ```json { "type": "error", "error": "short reason (first line)", "kind": "missing_credentials", "hint": "Hint: export ANTHROPIC_API_KEY..." } ``` `kind` is always present. `hint` is null when no runbook follows. `error` now carries only the short reason, not the full multi-line prose. ## Tests Added 2 new regression tests: - `classify_error_kind_returns_correct_discriminants` — all 9 known classes + fallback - `split_error_hint_separates_reason_from_runbook` — with and without hints All 179 rusty-claude-cli tests pass. Full workspace green. Closes ROADMAP #77 Phase 1. --- rust/crates/rusty-claude-cli/src/main.rs | 95 +++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 8500259..bed1686 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -210,11 +210,17 @@ fn main() { .any(|w| w[0] == "--output-format" && w[1] == "json") || argv.iter().any(|a| a == "--output-format=json"); if json_output { + // #77: classify error by prefix so downstream claws can route without + // regex-scraping the prose. Split short-reason from hint-runbook. + let kind = classify_error_kind(&message); + let (short_reason, hint) = split_error_hint(&message); eprintln!( "{}", serde_json::json!({ "type": "error", - "error": message, + "error": short_reason, + "kind": kind, + "hint": hint, }) ); } else if message.contains("`claw --help`") { @@ -230,6 +236,55 @@ Run `claw --help` for usage." } } +/// #77: Classify a stringified error message into a machine-readable kind. +/// +/// Returns a snake_case token that downstream consumers can switch on instead +/// of regex-scraping the prose. The classification is best-effort prefix/keyword +/// matching against the error messages produced throughout the CLI surface. +fn classify_error_kind(message: &str) -> &'static str { + // Check specific patterns first (more specific before generic) + if message.contains("missing Anthropic credentials") { + "missing_credentials" + } else if message.contains("Manifest source files are missing") { + "missing_manifests" + } else if message.contains("no worker state file found") { + "missing_worker_state" + } else if message.contains("session not found") { + "session_not_found" + } else if message.contains("failed to restore session") { + "session_load_failed" + } else if message.contains("no managed sessions found") { + "no_managed_sessions" + } else if message.contains("unrecognized argument") || message.contains("unknown option") { + "cli_parse" + } else if message.contains("invalid model syntax") { + "invalid_model_syntax" + } else if message.contains("is not yet implemented") { + "unsupported_command" + } else if message.contains("unsupported resumed command") { + "unsupported_resumed_command" + } else if message.contains("confirmation required") { + "confirmation_required" + } else if message.contains("api failed") || message.contains("api returned") { + "api_http_error" + } else { + "unknown" + } +} + +/// #77: Split a multi-line error message into (short_reason, optional_hint). +/// +/// The short_reason is the first line (up to the first newline), and the hint +/// is the remaining text or `None` if there's no newline. This prevents the +/// runbook prose from being stuffed into the `error` field that downstream +/// parsers expect to be the short reason alone. +fn split_error_hint(message: &str) -> (String, Option) { + match message.split_once('\n') { + Some((short, hint)) => (short.to_string(), Some(hint.trim().to_string())), + None => (message.to_string(), None), + } +} + /// Read piped stdin content when stdin is not a terminal. /// /// Returns `None` when stdin is attached to a terminal (interactive REPL use), @@ -2576,11 +2631,17 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu Ok(loaded) => loaded, Err(error) => { if output_format == CliOutputFormat::Json { + // #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); eprintln!( "{}", serde_json::json!({ "type": "error", - "error": format!("failed to restore session: {error}"), + "error": short_reason, + "kind": kind, + "hint": hint, }) ); } else { @@ -2632,6 +2693,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu serde_json::json!({ "type": "error", "error": format!("/{cmd_root} is not yet implemented in this build"), + "kind": "unsupported_command", "command": raw_command, }) ); @@ -2650,6 +2712,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu serde_json::json!({ "type": "error", "error": format!("unsupported resumed command: {raw_command}"), + "kind": "unsupported_resumed_command", "command": raw_command, }) ); @@ -8945,10 +9008,12 @@ mod tests { format_resume_report, format_status_report, format_tool_call_start, format_tool_result, format_ultraplan_report, format_unknown_slash_command, format_unknown_slash_command_message, format_user_visible_api_error, + classify_error_kind, merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args, parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, parse_history_count, permission_policy, print_help_to, push_output_block, render_config_report, render_diff_report, render_diff_report_for, render_memory_report, + split_error_hint, render_help_topic, render_prompt_history_report, render_repl_help, render_resume_usage, render_session_markdown, resolve_model_alias, resolve_model_alias_with_config, resolve_repl_model, resolve_session_reference, response_to_events, @@ -10348,6 +10413,32 @@ mod tests { ); } + #[test] + fn classify_error_kind_returns_correct_discriminants() { + // #77: error kind classification for JSON error payloads + assert_eq!(classify_error_kind("missing Anthropic credentials; export ..."), "missing_credentials"); + assert_eq!(classify_error_kind("no worker state file found at /tmp/..."), "missing_worker_state"); + assert_eq!(classify_error_kind("session not found: abc123"), "session_not_found"); + assert_eq!(classify_error_kind("failed to restore session: no managed sessions found"), "session_load_failed"); + assert_eq!(classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"), "cli_parse"); + assert_eq!(classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."), "invalid_model_syntax"); + assert_eq!(classify_error_kind("unsupported resumed command: /blargh"), "unsupported_resumed_command"); + assert_eq!(classify_error_kind("api failed after 3 attempts: ..."), "api_http_error"); + assert_eq!(classify_error_kind("something completely unknown"), "unknown"); + } + + #[test] + fn split_error_hint_separates_reason_from_runbook() { + // #77: short reason / hint separation for JSON error payloads + let (short, hint) = split_error_hint("missing credentials\nHint: export ANTHROPIC_API_KEY"); + assert_eq!(short, "missing credentials"); + assert_eq!(hint, Some("Hint: export ANTHROPIC_API_KEY".to_string())); + + let (short, hint) = split_error_hint("simple error with no hint"); + assert_eq!(short, "simple error with no hint"); + assert_eq!(hint, None); + } + #[test] fn parses_bare_export_subcommand_targeting_latest_session() { // given