mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-27 03:35:00 +08:00
fix: #179 — JSON mode now fully suppresses argparse stderr + preserves real error message
Dogfood discovered #178 had two residual gaps: 1. Stderr pollution: argparse usage + error text still leaked to stderr even in JSON mode (envelope was correct on stdout, but stderr noise broke the 'machine-first protocol' contract — claws capturing both streams got dual output) 2. Generic error message: envelope carried 'invalid command or argument (argparse rejection)' instead of argparse's actual text like 'the following arguments are required: session_id' or 'invalid choice: typo (choose from ...)' Before #179: $ claw load-session --output-format json [stdout] {"error": {"message": "invalid command or argument (argparse rejection)"}} [stderr] usage: main.py load-session [-h] ... main.py load-session: error: the following arguments are required: session_id [exit 1] After #179: $ claw load-session --output-format json [stdout] {"error": {"message": "the following arguments are required: session_id"}} [stderr] (empty) [exit 1] Implementation: - New _ArgparseError exception class captures argparse's real message - main() monkey-patches parser.error (+ all subparser.error) in JSON mode to raise _ArgparseError instead of print-to-stderr + sys.exit(2) - _emit_parse_error_envelope() now receives the real message verbatim - Text mode path unchanged: still uses original argparse print+exit behavior Contract: - JSON mode: stdout carries envelope with argparse's actual error; stderr silent - Text mode: unchanged — argparse usage to stderr, exit 2 - Parse errors still error.kind='parse', retryable=false Test additions (5 new, 14 total in test_parse_error_envelope.py): - TestParseErrorStderrHygiene (5): - test_json_mode_stderr_is_silent_on_unknown_command - test_json_mode_stderr_is_silent_on_missing_arg - test_json_mode_envelope_carries_real_argparse_message - test_json_mode_envelope_carries_invalid_choice_details (verifies valid-choices list) - test_text_mode_stderr_preserved_on_unknown_command (backward compat) Operational impact: Claws capturing both stdout and stderr no longer get garbled output. The envelope message now carries discoverability info (valid command list, missing-arg name) that claws can use for retry/recovery without probing the CLI a second time. Test results: 201 → 206 passing, 3 skipped unchanged, zero regression. Pinpoint discovered via dogfood at 2026-04-22 20:30 KST (cycle #20).
This commit is contained in:
@@ -157,3 +157,83 @@ class TestParseErrorSchemaCompliance:
|
||||
)
|
||||
envelope = json.loads(result.stdout)
|
||||
assert envelope['error']['retryable'] is False
|
||||
|
||||
|
||||
class TestParseErrorStderrHygiene:
|
||||
"""#179: JSON mode must fully suppress argparse stderr output.
|
||||
|
||||
Before #179: stderr leaked argparse usage + error text even when --output-format json.
|
||||
After #179: stderr is silent; envelope carries the real error message verbatim.
|
||||
"""
|
||||
|
||||
def test_json_mode_stderr_is_silent_on_unknown_command(self) -> None:
|
||||
"""Unknown command in JSON mode: stderr empty."""
|
||||
result = subprocess.run(
|
||||
CLI + ['nonexistent-cmd', '--output-format', 'json'],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.stderr == '', (
|
||||
f"JSON mode stderr must be empty; got:\n{result.stderr!r}"
|
||||
)
|
||||
|
||||
def test_json_mode_stderr_is_silent_on_missing_arg(self) -> None:
|
||||
"""Missing required arg in JSON mode: stderr empty (no argparse usage leak)."""
|
||||
result = subprocess.run(
|
||||
CLI + ['load-session', '--output-format', 'json'],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.stderr == '', (
|
||||
f"JSON mode stderr must be empty on missing arg; got:\n{result.stderr!r}"
|
||||
)
|
||||
|
||||
def test_json_mode_envelope_carries_real_argparse_message(self) -> None:
|
||||
"""#179: envelope.error.message contains argparse's actual text, not generic rejection."""
|
||||
result = subprocess.run(
|
||||
CLI + ['load-session', '--output-format', 'json'],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
envelope = json.loads(result.stdout)
|
||||
# Real argparse message: 'the following arguments are required: session_id'
|
||||
msg = envelope['error']['message']
|
||||
assert 'session_id' in msg, (
|
||||
f"envelope.error.message must carry real argparse text mentioning missing arg; got: {msg!r}"
|
||||
)
|
||||
assert 'required' in msg.lower(), (
|
||||
f"envelope.error.message must indicate what is required; got: {msg!r}"
|
||||
)
|
||||
|
||||
def test_json_mode_envelope_carries_invalid_choice_details(self) -> None:
|
||||
"""#179: unknown command envelope includes valid-choice list from argparse."""
|
||||
result = subprocess.run(
|
||||
CLI + ['typo-command', '--output-format', 'json'],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
envelope = json.loads(result.stdout)
|
||||
msg = envelope['error']['message']
|
||||
assert 'invalid choice' in msg.lower(), (
|
||||
f"envelope must mention 'invalid choice'; got: {msg!r}"
|
||||
)
|
||||
# Should include at least one valid command name for discoverability
|
||||
assert 'bootstrap' in msg or 'summary' in msg, (
|
||||
f"envelope must include valid choices for discoverability; got: {msg!r}"
|
||||
)
|
||||
|
||||
def test_text_mode_stderr_preserved_on_unknown_command(self) -> None:
|
||||
"""Text mode: argparse stderr behavior unchanged (backward compat)."""
|
||||
result = subprocess.run(
|
||||
CLI + ['nonexistent-cmd'],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Text mode still dumps argparse help to stderr
|
||||
assert 'invalid choice' in result.stderr
|
||||
assert result.returncode == 2
|
||||
|
||||
Reference in New Issue
Block a user