mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-26 20:14:57 +08:00
Compare commits
1 Commits
feat/jobdo
...
feat/jobdo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c09172b9f |
25
ROADMAP.md
25
ROADMAP.md
@@ -6565,30 +6565,7 @@ main.py: error: the following arguments are required: command
|
||||
|
||||
---
|
||||
|
||||
## Pinpoint #247. `classify_error_kind()` misses prompt-related parse errors — "empty prompt" and "prompt subcommand requires" classified as `unknown` instead of `cli_parse`; JSON envelope also drops the `Run claw --help for usage` hint **[CLOSED 2026-04-22 cycle #34]**
|
||||
|
||||
**Status.** CLOSED. Fix landed on `feat/jobdori-247-classify-prompt-errors` (cycle #34, Jobdori, 2026-04-22 22:4x KST). Two atomic edits in `rust/crates/rusty-claude-cli/src/main.rs` + one unit test + four integration tests. Verified on the compiled `claw` binary: both prompt-related parse errors now classify as `cli_parse`, and JSON envelopes for the bare-`claw prompt` path now carry the same `Run \`claw --help\` for usage.` hint as text mode. Regression guard locks in that the existing `unrecognized argument` hint/kind path is untouched.
|
||||
|
||||
**What landed.**
|
||||
1. `classify_error_kind()` gained two explicit branches for `prompt subcommand requires` and `empty prompt:`, both routed to `cli_parse`. Patterns are specific enough that generic prompt-adjacent messages still fall through to `unknown` (locked by unit test).
|
||||
2. JSON error path in `main()` now synthesizes the `Run \`claw --help\` for usage.` hint when `kind == "cli_parse"` AND the message itself did not already embed one (prevents duplication on the `empty prompt: … (run \`claw --help\`)` path which carries guidance inline).
|
||||
3. Regression tests added: one unit test (`classify_error_kind_covers_prompt_parse_errors_247`) + four integration tests in `tests/output_format_contract.rs` covering bare `claw prompt`, `claw ""`, `claw " "`, and the `doctor --foo` unrecognized-argument regression guard.
|
||||
|
||||
**Cross-channel parity after fix.**
|
||||
|
||||
```
|
||||
$ claw --output-format json prompt
|
||||
{"error":"prompt subcommand requires a prompt string","hint":"Run `claw --help` for usage.","kind":"cli_parse","type":"error"}
|
||||
|
||||
$ claw --output-format json ""
|
||||
{"error":"empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string","hint":null,"kind":"cli_parse","type":"error"}
|
||||
```
|
||||
|
||||
Text mode remains unchanged (still prints `[error-kind: cli_parse]` + trailer). Both channels now carry `kind == cli_parse` and the hint content is either explicit (JSON field) or embedded (inline in `error`), closing the typed-envelope asymmetry flagged in the pinpoint.
|
||||
|
||||
**Original gap (preserved for history below).**
|
||||
|
||||
## Pinpoint #247 (original). `classify_error_kind()` misses prompt-related parse errors — "empty prompt" and "prompt subcommand requires" classified as `unknown` instead of `cli_parse`; JSON envelope also drops the `Run claw --help for usage` hint
|
||||
## Pinpoint #247. `classify_error_kind()` misses prompt-related parse errors — "empty prompt" and "prompt subcommand requires" classified as `unknown` instead of `cli_parse`; JSON envelope also drops the `Run claw --help for usage` hint
|
||||
|
||||
**Gap.** Typed-error contract (§4.44) specifies an enumerated error kind set: `filesystem | auth | session | parse | runtime | mcp | delivery | usage | policy | unknown`. The `classify_error_kind()` function at `rust/crates/rusty-claude-cli/src/main.rs:246-280` uses substring matching to map error messages to these kinds. Two common prompt-related parse errors are NOT matched and fall through to `unknown`:
|
||||
|
||||
|
||||
@@ -213,16 +213,7 @@ fn main() {
|
||||
// #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, mut hint) = split_error_hint(&message);
|
||||
// #247: JSON envelope was losing the `Run claw --help for usage.`
|
||||
// trailer that text-mode stderr includes. When the error is a
|
||||
// cli_parse and the message itself carried no embedded hint,
|
||||
// synthesize the trailer so typed-error consumers get the same
|
||||
// actionable pointer that text-mode users see. Cross-channel
|
||||
// consistency is a §4.44 typed-envelope contract requirement.
|
||||
if hint.is_none() && kind == "cli_parse" && !short_reason.contains("`claw --help`") {
|
||||
hint = Some("Run `claw --help` for usage.".to_string());
|
||||
}
|
||||
let (short_reason, hint) = split_error_hint(&message);
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
@@ -273,11 +264,15 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
"no_managed_sessions"
|
||||
} else if message.contains("unrecognized argument") || message.contains("unknown option") {
|
||||
"cli_parse"
|
||||
} else if message.contains("prompt subcommand requires") {
|
||||
// #247: `claw prompt` with no argument — a parse error, not `unknown`.
|
||||
} else if is_unknown_verb_option_error(message) {
|
||||
// #248: verb-qualified option rejections like `unknown system-prompt option:`,
|
||||
// `unknown export option:`, `unknown dump-manifests option:` are parse errors,
|
||||
// not `unknown`. Without this they leak out of the typed-error contract the
|
||||
// same way #247 did for prompt-related messages.
|
||||
"cli_parse"
|
||||
} else if message.starts_with("empty prompt:") {
|
||||
// #247: `claw ""` or `claw " "` — a parse error, not `unknown`.
|
||||
} else if message.contains("unknown subcommand:") || message.contains("unknown slash command") {
|
||||
// #248 companion: subcommand-level mistypes are also parse failures,
|
||||
// not mystery `unknown` errors. `parse_command_token` emits these.
|
||||
"cli_parse"
|
||||
} else if message.contains("invalid model syntax") {
|
||||
"invalid_model_syntax"
|
||||
@@ -294,6 +289,42 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// #248: Returns true when the error message is a verb-qualified "unknown
|
||||
/// … option:" rejection produced by a subcommand option parser.
|
||||
///
|
||||
/// These messages have the shape `unknown <verb> option: <arg>` and are
|
||||
/// emitted from subcommand-specific option parsing paths (system-prompt,
|
||||
/// export, dump-manifests, etc.). They are parse errors by construction:
|
||||
/// the CLI successfully identified the verb but failed to recognize one of
|
||||
/// its flags. Left unclassified they were leaking out as `kind: "unknown"`,
|
||||
/// which defeats typed-error dispatch for claws that route on parse failures.
|
||||
///
|
||||
/// Detection is kept intentionally narrow (must start with `"unknown "`,
|
||||
/// must contain `" option:"`) so generic text happening to contain either
|
||||
/// substring in isolation does not get hijacked.
|
||||
pub fn is_unknown_verb_option_error(message: &str) -> bool {
|
||||
// #248: matches only the shape `unknown <verb> option: <arg>` where <verb>
|
||||
// is a single token (no spaces). Verb-qualified rejections look like:
|
||||
// "unknown system-prompt option: --json"
|
||||
// "unknown export option: --bogus"
|
||||
// but NOT:
|
||||
// "unknown option: --foo" (missing verb)
|
||||
// "unknown subcommand: foo" (wrong category)
|
||||
if !message.starts_with("unknown ") || !message.contains(" option:") {
|
||||
return false;
|
||||
}
|
||||
// Ensure there's a non-empty, space-free verb between `unknown ` and ` option:`.
|
||||
if let Some(before_option) = message.split(" option:").next() {
|
||||
if before_option.len() <= 8 {
|
||||
return false; // "unknown " only, no verb
|
||||
}
|
||||
let verb = &before_option[8..]; // Everything after "unknown "
|
||||
!verb.contains(' ') // Single-token verb (no spaces)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// #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
|
||||
@@ -9030,7 +9061,7 @@ 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,
|
||||
classify_error_kind, is_unknown_verb_option_error,
|
||||
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,
|
||||
@@ -10450,29 +10481,68 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_error_kind_covers_prompt_parse_errors_247() {
|
||||
// #247: prompt-related parse errors must classify as `cli_parse`,
|
||||
// not fall through to `unknown`. Regression guard for ROADMAP #247
|
||||
// (typed-error contract drift found in cycle #33 dogfood).
|
||||
fn classify_error_kind_covers_verb_qualified_unknown_options_248() {
|
||||
// #248: verb-qualified unknown-option rejections must classify as
|
||||
// `cli_parse`, matching how the unqualified `unknown option` path
|
||||
// already classifies. Regression guard — without this, typed-error
|
||||
// dispatch on subcommand option errors was leaking to `unknown`.
|
||||
assert_eq!(
|
||||
classify_error_kind("prompt subcommand requires a prompt string"),
|
||||
classify_error_kind("unknown system-prompt option: --json"),
|
||||
"cli_parse",
|
||||
"bare `claw prompt` must surface as cli_parse so typed-error consumers can dispatch"
|
||||
"system-prompt verb option errors must be cli_parse"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind(
|
||||
"empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string"
|
||||
),
|
||||
classify_error_kind("unknown export option: --bogus"),
|
||||
"cli_parse",
|
||||
"`claw \"\"` must surface as cli_parse, not unknown"
|
||||
"export verb option errors must be cli_parse"
|
||||
);
|
||||
// Sanity: the new patterns must be specific enough not to hijack
|
||||
// genuinely unknown errors that happen to contain the word `prompt`.
|
||||
assert_eq!(
|
||||
classify_error_kind("some random prompt-adjacent failure we don't recognize"),
|
||||
classify_error_kind("unknown dump-manifests option: --bogus"),
|
||||
"cli_parse",
|
||||
"dump-manifests verb option errors must be cli_parse"
|
||||
);
|
||||
// Subcommand-level mistypes also classify as cli_parse now.
|
||||
assert_eq!(
|
||||
classify_error_kind("unknown subcommand: staatus"),
|
||||
"cli_parse",
|
||||
"unknown subcommand must be cli_parse"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind("unknown slash command outside the REPL: /blargh"),
|
||||
"cli_parse",
|
||||
"unknown direct slash command must be cli_parse"
|
||||
);
|
||||
// Specificity guard: text that merely mentions `unknown` or `option`
|
||||
// without the verb-qualified shape must still fall through. The
|
||||
// detector requires `starts_with("unknown ")` AND `contains(" option:")`.
|
||||
assert_eq!(
|
||||
classify_error_kind("something totally unknown happened while processing options"),
|
||||
"unknown",
|
||||
"generic prompt-containing text should still fall through to unknown"
|
||||
"loose prose mentioning unknown/options must still fall through"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind("unknown runtime failure: widget exploded"),
|
||||
"unknown",
|
||||
"`unknown <anything>` without ` option:` must not get hijacked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_unknown_verb_option_error_only_matches_verb_qualified_shape_248() {
|
||||
// #248: lock the detector's scope. Must match only the exact shape
|
||||
// `unknown <verb> option: <arg>`.
|
||||
assert!(is_unknown_verb_option_error(
|
||||
"unknown system-prompt option: --json"
|
||||
));
|
||||
assert!(is_unknown_verb_option_error("unknown export option: --bogus"));
|
||||
assert!(is_unknown_verb_option_error(
|
||||
"unknown dump-manifests option: --bogus"
|
||||
));
|
||||
// Negatives.
|
||||
assert!(!is_unknown_verb_option_error("unknown subcommand: foo"));
|
||||
assert!(!is_unknown_verb_option_error("unknown option: --foo"));
|
||||
assert!(!is_unknown_verb_option_error("something is unknown option:"));
|
||||
assert!(!is_unknown_verb_option_error(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -388,114 +388,6 @@ fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
|
||||
/// #247 regression helper: run claw expecting a non-zero exit and return
|
||||
/// the JSON error envelope parsed from stderr. Asserts exit != 0 and that
|
||||
/// the envelope includes `type: "error"` at the very least.
|
||||
fn assert_json_error_envelope(current_dir: &Path, args: &[&str]) -> Value {
|
||||
let output = run_claw(current_dir, args, &[]);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"command unexpectedly succeeded; stdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
// The JSON envelope is written to stderr for error cases (see main.rs).
|
||||
let envelope: Value = serde_json::from_slice(&output.stderr).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"stderr should be a JSON error envelope but failed to parse: {err}\nstderr bytes:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
envelope["type"], "error",
|
||||
"envelope should carry type=error"
|
||||
);
|
||||
envelope
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_subcommand_without_arg_emits_cli_parse_envelope_with_hint_247() {
|
||||
// #247: `claw prompt` with no argument must classify as `cli_parse`
|
||||
// (not `unknown`) and the JSON envelope must carry the same actionable
|
||||
// `Run claw --help for usage.` hint that text-mode stderr appends.
|
||||
let root = unique_temp_dir("247-prompt-no-arg");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let envelope = assert_json_error_envelope(&root, &["--output-format", "json", "prompt"]);
|
||||
assert_eq!(
|
||||
envelope["kind"], "cli_parse",
|
||||
"prompt subcommand without arg should classify as cli_parse, envelope: {envelope}"
|
||||
);
|
||||
assert_eq!(
|
||||
envelope["error"], "prompt subcommand requires a prompt string",
|
||||
"short reason should match the raw error, envelope: {envelope}"
|
||||
);
|
||||
assert_eq!(
|
||||
envelope["hint"],
|
||||
"Run `claw --help` for usage.",
|
||||
"JSON envelope must carry the same help-runbook hint as text mode, envelope: {envelope}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_positional_arg_emits_cli_parse_envelope_247() {
|
||||
// #247: `claw ""` must classify as `cli_parse`, not `unknown`. The
|
||||
// message itself embeds a ``run `claw --help`` pointer so the explicit
|
||||
// hint field is allowed to remain null to avoid duplication — what
|
||||
// matters for the typed-error contract is that `kind == cli_parse`.
|
||||
let root = unique_temp_dir("247-empty-arg");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let envelope = assert_json_error_envelope(&root, &["--output-format", "json", ""]);
|
||||
assert_eq!(
|
||||
envelope["kind"], "cli_parse",
|
||||
"empty-prompt error should classify as cli_parse, envelope: {envelope}"
|
||||
);
|
||||
let short = envelope["error"]
|
||||
.as_str()
|
||||
.expect("error field should be a string");
|
||||
assert!(
|
||||
short.starts_with("empty prompt:"),
|
||||
"short reason should preserve the original empty-prompt message, got: {short}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_positional_arg_emits_cli_parse_envelope_247() {
|
||||
// #247: same rule for `claw " "` — any whitespace-only prompt must
|
||||
// flow through the empty-prompt path and classify as `cli_parse`.
|
||||
let root = unique_temp_dir("247-whitespace-arg");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let envelope = assert_json_error_envelope(&root, &["--output-format", "json", " "]);
|
||||
assert_eq!(
|
||||
envelope["kind"], "cli_parse",
|
||||
"whitespace-only prompt should classify as cli_parse, envelope: {envelope}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecognized_argument_still_classifies_as_cli_parse_247_regression_guard() {
|
||||
// #247 regression guard: the new empty-prompt / prompt-subcommand
|
||||
// patterns must NOT hijack the existing #77 unrecognized-argument
|
||||
// classification. `claw doctor --foo` must still surface as cli_parse
|
||||
// with the runbook hint present.
|
||||
let root = unique_temp_dir("247-unrecognized-arg");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let envelope =
|
||||
assert_json_error_envelope(&root, &["--output-format", "json", "doctor", "--foo"]);
|
||||
assert_eq!(
|
||||
envelope["kind"], "cli_parse",
|
||||
"unrecognized-argument must remain cli_parse, envelope: {envelope}"
|
||||
);
|
||||
assert_eq!(
|
||||
envelope["hint"],
|
||||
"Run `claw --help` for usage.",
|
||||
"unrecognized-argument hint should stay intact, envelope: {envelope}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Value {
|
||||
let output = run_claw(current_dir, args, envs);
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user