Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1406,6 +1406,20 @@ def _claude_cli_error(stdout: str) -> str:
return "unspecified error"


def _claude_cli_failure_detail(cli_error: str, stderr: str) -> str:
"""Build the error string for a non-zero ``claude -p`` exit.

The stdout JSON envelope carries the CLI's structured failure (rate limits,
auth, etc.). stderr often holds hook teardown noise instead (#2692), so the
envelope leads — but when stderr also has content, append a truncated copy
so diagnostics are not lost entirely.
"""
stderr = stderr.strip()
if cli_error and stderr:
return f"{cli_error}; stderr: {stderr[:200]}"
return cli_error or stderr or "(no stderr, no error envelope)"


# A JSON Schema pinning the top-level shape graphify consumes. Passed to
# `claude -p --json-schema` (structured output) so the CLI CONSTRAINS the model
# to emit the object directly instead of relying on it CHOOSING to honour a
Expand Down Expand Up @@ -1573,7 +1587,7 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo
)
cli_error = _claude_cli_error(proc.stdout)
if proc.returncode != 0:
detail = proc.stderr.strip() or cli_error or "(no stderr, no error envelope)"
detail = _claude_cli_failure_detail(cli_error, proc.stderr)
raise RuntimeError(f"claude -p exited {proc.returncode}: {detail[:500]}")
if cli_error:
raise RuntimeError(f"claude -p reported an error: {cli_error[:500]}")
Expand Down Expand Up @@ -2634,7 +2648,7 @@ def _rec(inp, out) -> None:
)
cli_error = _claude_cli_error(proc.stdout)
if proc.returncode != 0:
detail = proc.stderr.strip() or cli_error or "(no stderr, no error envelope)"
detail = _claude_cli_failure_detail(cli_error, proc.stderr)
raise RuntimeError(f"claude -p exited {proc.returncode}: {detail[:500]}")
if cli_error:
# Without this the error text is returned as the model's reply and
Expand Down
47 changes: 47 additions & 0 deletions tests/test_claude_cli_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ def test_raises_on_nonzero_exit():
}


def test_claude_cli_failure_detail_prefers_envelope_and_keeps_stderr():
detail = llm._claude_cli_failure_detail(
"API Error: Rate limit reached",
"SessionEnd hook failed",
)
assert detail.startswith("API Error: Rate limit reached; stderr:")
assert "SessionEnd hook failed" in detail


def test_claude_cli_failure_detail_stderr_only_when_no_envelope():
assert llm._claude_cli_failure_detail("", "auth failed") == "auth failed"


def test_nonzero_exit_surfaces_envelope_error_when_stderr_empty():
# The CLI reports API failures in the stdout JSON envelope, not on stderr.
# Without reading it the user gets a bare "exited 1: " and no cause (#2554).
Expand Down Expand Up @@ -152,6 +165,40 @@ def test_call_llm_raises_on_error_envelope():
llm._call_llm("dummy", backend="claude-cli")


def test_nonzero_exit_prefers_envelope_error_over_stderr_hook_noise():
# SessionEnd hook teardown often fills stderr on failure; the real cause
# (rate limit, auth, etc.) lives in the stdout JSON envelope (#2692).
hook_noise = "SessionEnd hook [notify]: failed: Hook cancelled"
completed = MagicMock(
returncode=1,
stdout=json.dumps(_ERROR_ENVELOPE),
stderr=hook_noise,
)
with patch("shutil.which", return_value="/fake/bin/claude"), \
patch("subprocess.run", return_value=completed):
with pytest.raises(RuntimeError, match="Rate limit reached") as exc:
llm._call_claude_cli("dummy", max_tokens=8192)
msg = str(exc.value)
assert msg.index("Rate limit reached") < msg.index(hook_noise)
assert f"stderr: {hook_noise}" in msg


def test_call_llm_nonzero_exit_prefers_envelope_error_over_stderr():
hook_noise = "SessionEnd hook [notify]: failed: Hook cancelled"
completed = MagicMock(
returncode=1,
stdout=json.dumps(_ERROR_ENVELOPE),
stderr=hook_noise,
)
with patch("shutil.which", return_value="/fake/bin/claude"), \
patch("subprocess.run", return_value=completed):
with pytest.raises(RuntimeError, match="Rate limit reached") as exc:
llm._call_llm("dummy", backend="claude-cli")
msg = str(exc.value)
assert msg.index("Rate limit reached") < msg.index(hook_noise)
assert f"stderr: {hook_noise}" in msg


def test_call_llm_nonzero_exit_surfaces_envelope_error():
completed = MagicMock(
returncode=1, stdout=json.dumps(_ERROR_ENVELOPE), stderr="",
Expand Down
Loading