headroom/tests/test_learn
gglucass c5563d3a7d
fix(learn): include stdout in CLI failure messages, not just stderr (#3080)
## Description

`headroom learn` reports CLI backend failures using **stderr only**.
`claude -p --output-format stream-json --verbose` writes *nothing* to
stderr when the run fails at the API layer, so the failure a user
actually sees is a message that stops at the colon:

```text
LLM analysis failed: `claude -p --output-format stream-json --verbose` failed (exit 1):
```

The reason is not missing, it is discarded. Claude Code still emits a
final `result` event on stdout whose `result` field is the
human-readable cause, and the streaming path has already parsed it into
`final_result` one line above the `raise`. This makes a whole class of
failures undiagnosable for users and maintainers alike: a usage limit,
an unreachable local proxy, and an expired login all render identically
as an empty message.

Reported by a desktop user who could only tell us "sometimes i have this
LLM analysis failed" with nothing after the colon.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Add `_failure_detail(stderr, stdout, *, result_text=None)` in
`headroom/learn/analyzer.py`. Prefers the already-parsed `result` text,
falls back to the **tail** of stdout (CLI backends emit the error last,
after their whole event log), keeps stderr when present, and returns
`"(no output captured)"` so the message is never a dangling colon.
- Use it in `_call_claude_cli_streaming` (streaming claude-cli path) and
in `_call_cli_llm` (the `subprocess.run` backends, gemini-cli /
codex-cli), so the same blind spot is closed for every CLI backend
rather than only the one that was reported.
- Existing truncation behaviour is unchanged: each stream is still
capped at `_MAX_SNIPPET_LEN`.

Complements #3016, which makes an analysis failure propagate instead of
being swallowed as success; that PR fixes *whether* the user learns a
failure happened, this one fixes *what* the failure says. No overlapping
lines.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run --frozen --extra dev pytest tests/test_learn/ -q
247 passed, 4 skipped in 27.08s

$ uvx ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!

$ uvx ruff format --check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
2 files already formatted

$ uv run --frozen --extra dev mypy headroom/learn/analyzer.py
Success: no issues found in 1 source file
```

New tests:
`test_claude_cli_nonzero_exit_includes_api_error_from_stdout`,
`test_claude_cli_nonzero_exit_with_no_output_says_so`,
`test_claude_cli_nonzero_exit_keeps_stderr_when_present`,
`test_codex_nonzero_exit_includes_stdout_when_stderr_empty`.

## Real Behavior Proof

- Environment: macOS 15.6 (Darwin 24.6.0), Claude Code 2.1.228, Python
3.10.18, headroom on this branch.
- Exact command / steps: forced an API-layer failure in the exact
command the analyzer runs, capturing the streams separately: `echo "say
hi" | claude -p --output-format stream-json --verbose --settings
'{"env":{"ANTHROPIC_BASE_URL":"http://127.0.0.1:9"}}' > out.txt 2>
err.txt; echo "EXIT=$?"; wc -c err.txt; tail -c 400 out.txt`
- Observed result: `EXIT=1`, `err.txt` is **0 bytes**, and the reason
appears only in the last stdout line: `"terminal_reason":"api_error",
..., "result":"API Error: Connection refused — a firewall or proxy may
be blocking it (ConnectionRefused)"`. A second run with `--bare`
produced the same shape with `"result":"Not logged in · Please run
/login"`. Before this change both surface as `failed (exit 1):` with
nothing after the colon; after it, the `result` text is in the message.
The unit tests encode this exact stream shape (stdout `result` event,
empty stderr, exit 1).
- Not tested: real usage-limit and 429 responses, which I cannot provoke
on demand. They travel the same code path as the reproduced `api_error`
case (final `result` event on stdout, empty stderr), so they are covered
by construction rather than by observation. Windows and the gemini-cli
backend were not exercised manually; the shared helper is covered by
unit tests for both the streaming and `subprocess.run` paths.

## Runtime Rollout Safety

- Rollout-managed feature(s): None. This touches only the error text
raised by `headroom learn`'s CLI backends; no rollout-gated feature,
flag, or runtime component is involved.
- Minimum rollout channel: N/A, not rollout-gated. Ships with the
package like any other library fix.
- Stable/default behavior changed: Yes, narrowly. The message text of an
existing `RuntimeError` on a non-zero CLI exit now includes the
stdout/`result` reason alongside stderr. No control flow, exit code,
public API, or return value changes: the same exception is raised in the
same cases.
- Kill switch / disable path: None needed. Nothing is enabled or newly
executed, so there is nothing to switch off; the only behavioral surface
is the string inside an exception that was already being raised.
- Unsafe override required: No.
- Qualification impact: None. No qualification-gated path, model, or
provider behavior is touched. Callers that pattern-match this message on
`"failed (exit N)"` still match, since that prefix is unchanged.
- Rollback path: Revert this commit. The previous stderr-only message
returns with no migration, state, or config to undo.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 20:20:45 -07:00
..
__init__.py Add headroom learn: offline failure learning for coding agents 2026-02-27 21:19:03 -08:00
test_analyzer.py fix(learn): include stdout in CLI failure messages, not just stderr (#3080) 2026-08-17 20:20:45 -07:00
test_claude_config_dir.py fix(learn): honor CLAUDE_CONFIG_DIR when locating Claude logs and memory (#1642) 2026-07-01 23:22:16 -05:00
test_error_classification.py fix(learn): don't shadow TIMEOUT/CONNECTION with the generic RUNTIME_ERROR (#2099) 2026-07-13 10:50:54 -04:00
test_gemini_scanner.py fix(learn/gemini): stop double-counting session tokens (#2230) 2026-08-11 23:37:58 -05:00
test_integration.py fix(learn): stop classifying a successful exit code 0 as an error (#2289) 2026-08-11 23:45:32 -05:00
test_loop_weighting.py fix: remove rtk and lean-ctx CLI context tools (#2677) 2026-07-30 22:59:41 -07:00
test_opencode_scanner.py fix(learn): detect the active OpenCode database (#2587) 2026-07-26 19:50:59 -07:00
test_plugin_encoding.py Fix headroom learn crashing/no-op on Windows from missing UTF-8 encoding (#1239) 2026-06-21 10:37:37 -07:00
test_registry.py feat: add first-class OpenCode support (wrap, learn, mcp install) (#559) 2026-06-25 13:38:58 -05:00
test_scanner.py fix(learn): treat unreadable candidate paths as absent in project decode (#2446) 2026-07-22 06:16:44 -07:00
test_subagent_scanning.py fix(learn/claude): don't abort the whole scan on a null message line (#2299) 2026-07-16 14:36:11 -07:00
test_writer.py feat(learn): write per-project learnings to CLAUDE.local.md by default (#1115) 2026-06-22 15:05:06 -05:00