fix(learn): claude-cli streams output with idle timeout (#373)

## Description

`headroom learn` with the claude-cli backend used `subprocess.run` with
a hard 120s wall-clock cap and no liveness signal. A successful long
analysis and a hung connection looked identical — exit 0 with "0
recommendations" was the only user-visible signal when the LLM call
timed out, which silently hides genuine learnings.

This PR makes the CLI backend timeout-aware, with progress detection for
claude-cli and configurable wall-clock caps for every backend.

Fixes #(issue number)

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- **Streaming claude-cli with idle timeout**: invoke `claude -p
--output-format stream-json --verbose` and run a watchdog loop that
drains stdout/stderr via reader threads. Each stream-json event resets
an idle deadline. Kill the process if no output for
`HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS` (default 60s) or if total elapsed
exceeds `HEADROOM_LEARN_CLI_TIMEOUT_SECS` (default 300s, was 120s). The
final `type:"result"` event carries the assistant response, which is
then parsed as JSON. Reader threads (rather than `select`) are used so
the watchdog works on Windows where `select` does not support pipe
handles.
- **Bumped default `_CLI_TIMEOUT` from 120s to 300s** as the hard cap
for all CLI backends. The previous 120s was too tight for large digests
on slower networks.
- **Env-var overrides** via new helper `_resolve_timeout_secs(env_var,
default)`:
- `HEADROOM_LEARN_CLI_TIMEOUT_SECS` — hard wall-clock cap (all CLI
backends)
- `HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS` — idle cap (streaming
claude-cli only)
- Non-positive or non-integer values log a warning and fall back to
defaults, so a typo can't disable the timeout.
- **gemini-cli and codex-cli** keep `subprocess.run(timeout=hard_cap)`
since they do not emit progress events. They benefit from the bumped
default and the env-var override.
- **CHANGELOG.md** updated under `[Unreleased]` → `### Fixed`.

## 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 (existing repro: 16k-call digest that
previously timed out at 120s)

New test coverage in `tests/test_learn/test_analyzer.py`:

- `test_claude_cli_streams_and_parses_result_event` — happy path, fake
Popen yields system/assistant/result events
- `test_claude_cli_parses_fenced_result` — markdown fences in the result
event still parse
- `test_claude_cli_idle_timeout_kills_hang` —
`HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS=1` + a hanging stdout iterator
triggers the idle watchdog
- `test_claude_cli_hard_cap_kills_continuous_chatter` — continuous
events with a low hard cap fire the wall-clock kill (proves idle reset
alone can't keep a runaway alive)
- `test_claude_cli_missing_result_event_raises` — graceful failure when
no `result` event is emitted
- `test_claude_cli_nonzero_exit_raises` /
`test_claude_cli_unparseable_result_raises_with_context` /
`test_claude_cli_not_installed_raises` — error paths
- Parallel codex-cli error coverage (timeout-honors-env-override
included) so the wall-clock path is exercised
- `TestResolveTimeoutSecs` — unset / empty / non-integer / non-positive
/ valid override

## Test Output

```
$ uv run pytest tests/test_learn/test_analyzer.py
============================== 67 passed in 2.14s ==============================

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

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

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- The contract assumed for claude-cli stream-json output is: each line
is a JSON object with a `type` field; the final event has
`type:"result"` with a string `result` field carrying the assistant
text. This matches the documented Anthropic CLI behavior. If the
contract changes upstream, `_call_claude_cli_streaming` raises a clear
"did not emit a final \`result\` event" error rather than silently
succeeding.
- Backwards-compatible for users without env-var configuration: behavior
just becomes "longer hard cap, plus idle watchdog for claude-cli",
neither of which can falsely succeed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gglucass 2026-06-11 18:55:19 +02:00 committed by GitHub
parent 8f374263d3
commit 9bff5752bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 498 additions and 44 deletions

View file

@ -175,6 +175,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
resolution, made PyPI publish failures block GitHub Releases unless
`PYPI_SKIP=true`, and added an sdist `LICENSE` invariant.
- **`headroom learn` with claude-cli no longer fails silently on slow
networks or large digests.** The CLI backend timeout was a hard 120s
wall-clock cap with no liveness signal: a successful long analysis and
a hung connection looked identical, and exit 0 with "no recommendations"
was the only user-visible signal. Two changes:
(1) **Streaming + idle timeout for claude-cli**: the command now uses
`--output-format stream-json --verbose` and a watchdog thread reads
events as they arrive. The process is killed only after
`HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS` (default 60s) of zero output, or
after `HEADROOM_LEARN_CLI_TIMEOUT_SECS` (default 300s, was 120s) total.
Long-but-active analyses run to completion; genuine hangs are caught
fast. The final `type:"result"` event carries the assistant response.
Drains stdout/stderr via reader threads so the watchdog works on
Windows too. (2) **Env-var overrides for all CLI backends**:
`HEADROOM_LEARN_CLI_TIMEOUT_SECS` is honored by gemini-cli and
codex-cli as the wall-clock timeout; idle override applies only to the
streaming claude-cli path.
- **`Learned: error recovery` section in MEMORY.md no longer bloats with
stale, one-shot, or contradictory entries.** The matchers paired up
unrelated tool calls (e.g. `state.rs` and `lib.rs` in the same dir

View file

@ -17,8 +17,12 @@ from __future__ import annotations
import json
import logging
import os
import queue
import shutil
import subprocess
import threading
import time
import typing
from .models import (
AnalysisResult,
@ -43,9 +47,11 @@ _MODEL_DEFAULTS: list[tuple[str, str]] = [
_MAX_DIGEST_TOKENS = 80_000 # Budget for the digest (leave room for prompt + output)
# CLI tools to try when no API key is set (checked in order).
# Each entry: (binary_name, model_identifier, command_prefix)
# Each entry: (binary_name, model_identifier, command_prefix). The claude-cli
# command uses stream-json output so the analyzer can detect progress and
# enforce an idle (rather than wall-clock-only) timeout — see _call_cli_llm.
_CLI_BACKENDS: list[tuple[str, str, list[str]]] = [
("claude", "claude-cli", ["claude", "-p"]),
("claude", "claude-cli", ["claude", "-p", "--output-format", "stream-json", "--verbose"]),
("gemini", "gemini-cli", ["gemini", "-p"]),
("codex", "codex-cli", ["codex", "exec"]),
]
@ -55,7 +61,35 @@ _CLI_MODEL_IDS: set[str] = {model for _, model, _ in _CLI_BACKENDS}
_USER_PROMPT_PREFIX = "Analyze these coding agent sessions and return JSON recommendations:\n\n" # Shared by _call_cli_llm and _call_llm
_MAX_SNIPPET_LEN = 2000 # Max chars of CLI output (stdout/stderr) in error messages
_CLI_TIMEOUT = 120 # Subprocess timeout for CLI backends, in seconds
# Hard wall-clock cap for CLI backends (seconds). Override with
# HEADROOM_LEARN_CLI_TIMEOUT_SECS for slow networks or large digests.
_CLI_TIMEOUT = 300
# Idle cap (seconds) for streaming claude-cli: kill if no output arrives for
# this long. Lets us catch genuine hangs quickly while letting long-but-active
# analyses run to completion. Override with HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS.
_CLI_IDLE_TIMEOUT = 60
def _resolve_timeout_secs(env_var: str, default: int) -> int:
"""Resolve a positive-integer timeout from *env_var* or fall back to *default*.
Invalid or non-positive values are logged and ignored so a typo in env
config can't accidentally disable the timeout.
"""
raw = os.environ.get(env_var)
if raw is None or raw == "":
return default
try:
value = int(raw)
except ValueError:
logger.warning("Invalid %s=%r — using default %ds", env_var, raw, default)
return default
if value <= 0:
logger.warning(
"Invalid %s=%r (must be positive) — using default %ds", env_var, raw, default
)
return default
return value
def _detect_default_model() -> str:
@ -413,9 +447,12 @@ def _call_cli_llm(digest: str, model: str) -> dict:
OS ``ARG_MAX`` limits and argument-injection risks.
CLI invocations:
claude-cli echo <prompt> | claude -p
gemini-cli echo <prompt> | gemini -p
codex-cli echo <prompt> | codex exec
claude-cli claude -p --output-format stream-json --verbose (idle-timeout)
gemini-cli gemini -p (wall-clock timeout)
codex-cli codex exec (wall-clock timeout)
The claude-cli path streams JSON events, letting the analyzer kill genuine
hangs while letting long-but-active analyses run to completion.
Args:
digest: Token-efficient session digest to analyze.
@ -437,6 +474,11 @@ def _call_cli_llm(digest: str, model: str) -> dict:
raise ValueError(f"Unknown CLI model: {model}")
prompt = _SYSTEM_PROMPT + "\n\n" + _USER_PROMPT_PREFIX + digest
hard_cap = _resolve_timeout_secs("HEADROOM_LEARN_CLI_TIMEOUT_SECS", _CLI_TIMEOUT)
if model == "claude-cli":
idle_cap = _resolve_timeout_secs("HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS", _CLI_IDLE_TIMEOUT)
return _call_claude_cli_streaming(cmd, prompt, hard_cap=hard_cap, idle_cap=idle_cap)
try:
result = subprocess.run(
@ -444,7 +486,7 @@ def _call_cli_llm(digest: str, model: str) -> dict:
input=prompt,
capture_output=True,
text=True,
timeout=_CLI_TIMEOUT,
timeout=hard_cap,
)
except FileNotFoundError:
raise RuntimeError(
@ -453,9 +495,9 @@ def _call_cli_llm(digest: str, model: str) -> dict:
) from None
except subprocess.TimeoutExpired:
raise RuntimeError(
f"`{' '.join(cmd)}` did not respond within {_CLI_TIMEOUT}s. "
"Check network connectivity or try a different backend with "
"--model <litellm-model-name>."
f"`{' '.join(cmd)}` did not respond within {hard_cap}s. "
"Check network connectivity, raise HEADROOM_LEARN_CLI_TIMEOUT_SECS, "
"or try a different backend with --model <litellm-model-name>."
) from None
if result.returncode != 0:
@ -478,6 +520,155 @@ def _call_cli_llm(digest: str, model: str) -> dict:
) from exc
def _call_claude_cli_streaming(
cmd: list[str], prompt: str, *, hard_cap: int, idle_cap: int
) -> dict:
"""Run claude-cli with stream-json output and an idle-timeout watchdog.
Each line of stdout is one JSON event from claude (system/assistant/user/
result). Any line resets the idle deadline. The process is killed if no
output arrives for *idle_cap* seconds, or if total elapsed exceeds
*hard_cap* seconds. The final ``type:"result"`` event carries the assistant
response, which is then parsed as JSON.
Threads (rather than ``select``) drain stdout/stderr so the watchdog works
on Windows too, where ``select`` does not support pipe handles.
"""
try:
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1, # line-buffered
)
except FileNotFoundError:
raise RuntimeError(
f"`{cmd[0]}` not found in PATH. Install it or use a different backend "
"with --model <litellm-model-name>."
) from None
assert proc.stdin is not None and proc.stdout is not None and proc.stderr is not None
try:
proc.stdin.write(prompt)
finally:
try:
proc.stdin.close()
except BrokenPipeError: # pragma: no cover — defensive, claude exits before stdin drain
pass
events: queue.Queue[tuple[str, str | None]] = queue.Queue()
def _pump(stream: typing.IO[str], tag: str) -> None:
try:
for line in stream:
events.put((tag, line))
except Exception as exc: # pragma: no cover — defensive
logger.debug("stream pump (%s) errored: %s", tag, exc)
finally:
events.put((tag, None)) # EOF marker
threading.Thread(target=_pump, args=(proc.stdout, "stdout"), daemon=True).start()
threading.Thread(target=_pump, args=(proc.stderr, "stderr"), daemon=True).start()
start = time.monotonic()
last_activity = start
stdout_lines: list[str] = []
stderr_lines: list[str] = []
final_result: str | None = None
eofs = 0
def _kill(reason: str) -> None:
proc.kill()
try:
proc.wait(timeout=5)
except (
subprocess.TimeoutExpired
): # pragma: no cover — defensive, kill normally returns fast
pass
logger.debug("claude-cli killed: %s", reason)
while eofs < 2:
elapsed = time.monotonic() - start
if elapsed > hard_cap:
_kill(f"hard cap {hard_cap}s exceeded")
raise RuntimeError(
f"`{' '.join(cmd)}` exceeded the {hard_cap}s hard cap. "
"Raise HEADROOM_LEARN_CLI_TIMEOUT_SECS for slower networks or "
"larger digests, or try a different backend with "
"--model <litellm-model-name>."
)
idle_elapsed = time.monotonic() - last_activity
if idle_elapsed > idle_cap:
_kill(f"idle cap {idle_cap}s exceeded")
raise RuntimeError(
f"`{' '.join(cmd)}` produced no output for {idle_cap}s. "
"Check network connectivity, raise "
"HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS, or try a different "
"backend with --model <litellm-model-name>."
)
# Block up to 1s waiting for the next event, then re-check deadlines.
try:
tag, line = events.get(timeout=1.0)
except queue.Empty:
continue
if line is None:
eofs += 1
continue
last_activity = time.monotonic()
if tag == "stdout":
stdout_lines.append(line)
event = _parse_stream_event(line)
if event is not None and event.get("type") == "result":
# Last result event wins if multiple are emitted.
result_text = event.get("result")
if isinstance(result_text, str):
final_result = result_text
else:
stderr_lines.append(line)
proc.wait()
if proc.returncode != 0:
stderr_blob = "".join(stderr_lines)[:_MAX_SNIPPET_LEN]
raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {proc.returncode}):\n{stderr_blob}")
stderr_blob = "".join(stderr_lines)
if stderr_blob.strip():
logger.debug("CLI stderr (exit 0): %s", stderr_blob[:_MAX_SNIPPET_LEN])
if final_result is None:
stdout_snippet = "".join(stdout_lines)[:_MAX_SNIPPET_LEN]
raise RuntimeError(
f"`{' '.join(cmd)}` did not emit a final `result` event. "
f"First {_MAX_SNIPPET_LEN} chars of stdout:\n{stdout_snippet}"
)
try:
return _strip_fenced_json(final_result)
except json.JSONDecodeError as exc:
snippet = final_result[:_MAX_SNIPPET_LEN]
raise RuntimeError(
f"`{' '.join(cmd)}` returned unparseable output. "
f"First {_MAX_SNIPPET_LEN} chars:\n{snippet}"
) from exc
def _parse_stream_event(line: str) -> dict | None:
"""Parse one line of claude-cli stream-json output, returning None on junk."""
line = line.strip()
if not line:
return None
try:
parsed = json.loads(line)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
def _call_llm(digest: str, model: str) -> dict:
"""Call LLM with the session digest and return parsed JSON.

View file

@ -1,7 +1,9 @@
"""Tests for session analyzer — digest builder and LLM-based analysis."""
import io
import json
import subprocess
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
@ -14,6 +16,7 @@ from headroom.learn.analyzer import (
_call_llm,
_detect_default_model,
_parse_llm_response,
_resolve_timeout_secs,
_strip_fenced_json,
)
from headroom.learn.models import (
@ -590,21 +593,157 @@ class TestStripFencedJson:
_strip_fenced_json("not json at all")
def _fake_claude_popen(
*,
stdout_lines: list[str],
stderr_lines: list[str] | None = None,
returncode: int = 0,
stdout_delay: float = 0.0,
) -> MagicMock:
"""Build a Popen mock factory for the streaming claude-cli path.
Returns a MagicMock that, when called as ``Popen(cmd, ...)``, yields a
fake process whose stdout/stderr behave like line-iterable text streams.
Each stdout line is sleep(*stdout_delay*)-gated to let tests simulate slow
or hung processes.
"""
if stderr_lines is None:
stderr_lines = []
def _make_iter(lines: list[str], delay: float):
def _gen():
for line in lines:
if delay:
time.sleep(delay)
yield line
return _gen()
factory = MagicMock()
def _construct(*args, **kwargs):
proc = MagicMock()
proc.stdin = io.StringIO()
proc.stdout = _make_iter(stdout_lines, stdout_delay)
proc.stderr = _make_iter(stderr_lines, 0.0)
proc.returncode = returncode
proc.wait = MagicMock(return_value=returncode)
proc.kill = MagicMock()
proc.poll = MagicMock(return_value=returncode)
return proc
factory.side_effect = _construct
return factory
def _stream_event(event_type: str, **fields) -> str:
return json.dumps({"type": event_type, **fields}) + "\n"
def _result_event(text: str) -> str:
return _stream_event("result", subtype="success", is_error=False, result=text)
class TestCallCliLlm:
@patch("headroom.learn.analyzer.subprocess.run")
def test_claude_cli_success(self, mock_run: MagicMock):
mock_run.return_value = MagicMock(
returncode=0,
stdout='{"context_file_rules": [], "memory_file_rules": []}',
stderr="",
)
result = _call_cli_llm("test digest", "claude-cli")
def test_claude_cli_streams_and_parses_result_event(self):
stdout = [
_stream_event("system", subtype="init"),
_stream_event("assistant", message={"content": "thinking..."}),
_result_event('{"context_file_rules": [], "memory_file_rules": []}'),
]
with patch(
"headroom.learn.analyzer.subprocess.Popen", _fake_claude_popen(stdout_lines=stdout)
) as popen:
result = _call_cli_llm("test digest", "claude-cli")
assert result == {"context_file_rules": [], "memory_file_rules": []}
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert cmd == ["claude", "-p"]
# Prompt passed via stdin, not as an argument
assert mock_run.call_args.kwargs.get("input") is not None
cmd = popen.call_args[0][0]
assert cmd == ["claude", "-p", "--output-format", "stream-json", "--verbose"]
def test_claude_cli_parses_fenced_result(self):
stdout = [
_result_event('```json\n{"context_file_rules": [], "memory_file_rules": []}\n```'),
]
with patch(
"headroom.learn.analyzer.subprocess.Popen", _fake_claude_popen(stdout_lines=stdout)
):
result = _call_cli_llm("test digest", "claude-cli")
assert result == {"context_file_rules": [], "memory_file_rules": []}
def test_claude_cli_idle_timeout_kills_hang(self, monkeypatch):
import threading as _threading
monkeypatch.setenv("HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS", "1")
# An iterator that never yields and never EOFs — simulates a hung CLI.
# The pump thread blocks in __next__, so no events reach the watchdog.
blocked = _threading.Event() # never set
class _HangingStream:
def __iter__(self):
return self
def __next__(self):
blocked.wait(timeout=10)
raise StopIteration
def _construct(*args, **kwargs):
proc = MagicMock()
proc.stdin = io.StringIO()
proc.stdout = _HangingStream()
proc.stderr = _HangingStream()
proc.returncode = 0
proc.wait = MagicMock(return_value=0)
proc.kill = MagicMock(side_effect=lambda: blocked.set())
proc.poll = MagicMock(return_value=None)
return proc
popen = MagicMock(side_effect=_construct)
with patch("headroom.learn.analyzer.subprocess.Popen", popen):
with pytest.raises(RuntimeError, match="produced no output"):
_call_cli_llm("test digest", "claude-cli")
def test_claude_cli_hard_cap_kills_continuous_chatter(self, monkeypatch):
monkeypatch.setenv("HEADROOM_LEARN_CLI_TIMEOUT_SECS", "1")
monkeypatch.setenv("HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS", "10")
# Continuous output every 50ms so idle never fires; hard cap should.
chatter = [_stream_event("assistant", message={"i": i}) for i in range(1000)]
popen = _fake_claude_popen(stdout_lines=chatter, stdout_delay=0.05)
with patch("headroom.learn.analyzer.subprocess.Popen", popen):
with pytest.raises(RuntimeError, match="exceeded the 1s hard cap"):
_call_cli_llm("test digest", "claude-cli")
def test_claude_cli_missing_result_event_raises(self):
stdout = [_stream_event("assistant", message={"content": "no result"})]
with patch(
"headroom.learn.analyzer.subprocess.Popen", _fake_claude_popen(stdout_lines=stdout)
):
with pytest.raises(RuntimeError, match="did not emit a final `result` event"):
_call_cli_llm("test digest", "claude-cli")
def test_claude_cli_nonzero_exit_raises(self):
popen = _fake_claude_popen(
stdout_lines=[],
stderr_lines=["Error: auth required\n"],
returncode=1,
)
with patch("headroom.learn.analyzer.subprocess.Popen", popen):
with pytest.raises(RuntimeError, match="failed.*exit 1"):
_call_cli_llm("test digest", "claude-cli")
def test_claude_cli_unparseable_result_raises_with_context(self):
stdout = [_result_event("This is not JSON at all")]
with patch(
"headroom.learn.analyzer.subprocess.Popen", _fake_claude_popen(stdout_lines=stdout)
):
with pytest.raises(RuntimeError, match="unparseable output"):
_call_cli_llm("test digest", "claude-cli")
def test_claude_cli_not_installed_raises(self):
popen = MagicMock(side_effect=FileNotFoundError("No such file or directory: 'claude'"))
with patch("headroom.learn.analyzer.subprocess.Popen", popen):
with pytest.raises(RuntimeError, match="not found in PATH"):
_call_cli_llm("test digest", "claude-cli")
@patch("headroom.learn.analyzer.subprocess.run")
def test_codex_cli_uses_exec(self, mock_run: MagicMock):
@ -630,17 +769,17 @@ class TestCallCliLlm:
assert cmd == ["gemini", "-p"]
@patch("headroom.learn.analyzer.subprocess.run")
def test_cli_nonzero_exit_raises(self, mock_run: MagicMock):
def test_codex_nonzero_exit_raises(self, mock_run: MagicMock):
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Error: auth required",
)
with pytest.raises(RuntimeError, match="failed.*exit 1"):
_call_cli_llm("test digest", "claude-cli")
_call_cli_llm("test digest", "codex-cli")
@patch("headroom.learn.analyzer.subprocess.run")
def test_cli_stderr_truncated_in_error(self, mock_run: MagicMock):
def test_codex_stderr_truncated_in_error(self, mock_run: MagicMock):
long_stderr = "x" * 5000
mock_run.return_value = MagicMock(
returncode=1,
@ -648,8 +787,7 @@ class TestCallCliLlm:
stderr=long_stderr,
)
with pytest.raises(RuntimeError) as exc_info:
_call_cli_llm("test digest", "claude-cli")
# Full 5000-char stderr should not appear in the error message
_call_cli_llm("test digest", "codex-cli")
assert long_stderr not in str(exc_info.value)
def test_unknown_cli_model_raises(self):
@ -657,36 +795,144 @@ class TestCallCliLlm:
_call_cli_llm("test digest", "unknown-cli")
@patch("headroom.learn.analyzer.subprocess.run")
def test_fenced_output_parsed(self, mock_run: MagicMock):
mock_run.return_value = MagicMock(
returncode=0,
stdout='```json\n{"context_file_rules": [], "memory_file_rules": []}\n```',
stderr="",
)
result = _call_cli_llm("test digest", "claude-cli")
assert result == {"context_file_rules": [], "memory_file_rules": []}
@patch("headroom.learn.analyzer.subprocess.run")
def test_cli_not_installed_raises(self, mock_run: MagicMock):
def test_codex_not_installed_raises(self, mock_run: MagicMock):
mock_run.side_effect = FileNotFoundError("No such file or directory: 'codex'")
with pytest.raises(RuntimeError, match="not found in PATH"):
_call_cli_llm("test digest", "codex-cli")
@patch("headroom.learn.analyzer.subprocess.run")
def test_timeout_raises_runtime_error(self, mock_run: MagicMock):
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["claude", "-p"], timeout=120)
def test_codex_timeout_raises_runtime_error(self, mock_run: MagicMock):
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["codex", "exec"], timeout=300)
with pytest.raises(RuntimeError, match="did not respond within"):
_call_cli_llm("test digest", "claude-cli")
_call_cli_llm("test digest", "codex-cli")
@patch("headroom.learn.analyzer.subprocess.run")
def test_unparseable_output_raises_with_context(self, mock_run: MagicMock):
def test_codex_timeout_honors_env_override(self, mock_run: MagicMock, monkeypatch):
monkeypatch.setenv("HEADROOM_LEARN_CLI_TIMEOUT_SECS", "42")
mock_run.return_value = MagicMock(returncode=0, stdout="{}", stderr="")
_call_cli_llm("test digest", "codex-cli")
assert mock_run.call_args.kwargs["timeout"] == 42
@patch("headroom.learn.analyzer.subprocess.run")
def test_codex_unparseable_output_raises_with_context(self, mock_run: MagicMock):
mock_run.return_value = MagicMock(
returncode=0,
stdout="This is not JSON at all",
stderr="",
)
with pytest.raises(RuntimeError, match="unparseable output"):
_call_cli_llm("test digest", "claude-cli")
_call_cli_llm("test digest", "codex-cli")
class TestParseStreamEvent:
def test_returns_none_for_empty_line(self):
from headroom.learn.analyzer import _parse_stream_event
assert _parse_stream_event("") is None
assert _parse_stream_event(" \n") is None
def test_returns_none_for_invalid_json(self):
from headroom.learn.analyzer import _parse_stream_event
assert _parse_stream_event("not json at all") is None
assert _parse_stream_event("{unclosed") is None
def test_returns_none_for_non_dict_json(self):
from headroom.learn.analyzer import _parse_stream_event
assert _parse_stream_event('"a string"') is None
assert _parse_stream_event("[1, 2, 3]") is None
def test_parses_valid_event(self):
from headroom.learn.analyzer import _parse_stream_event
assert _parse_stream_event('{"type": "result", "result": "x"}') == {
"type": "result",
"result": "x",
}
class TestClaudeCliEdgeCases:
"""Coverage for less-traveled branches in the streaming claude-cli path."""
def test_non_string_result_field_falls_through_to_missing(self):
# `result` event present but the `result` field is a dict, not a string.
# The watchdog should not store it as final_result, so the path raises
# the "did not emit a final result event" error.
stdout = [_stream_event("result", subtype="success", result={"unexpected": "shape"})]
with patch(
"headroom.learn.analyzer.subprocess.Popen", _fake_claude_popen(stdout_lines=stdout)
):
with pytest.raises(RuntimeError, match="did not emit a final `result` event"):
_call_cli_llm("test digest", "claude-cli")
def test_stderr_on_success_is_logged_not_raised(self, caplog):
import logging
stdout = [_result_event('{"context_file_rules": [], "memory_file_rules": []}')]
stderr_warning = "deprecation: --foo will be removed in v2\n"
popen = _fake_claude_popen(stdout_lines=stdout, stderr_lines=[stderr_warning])
with caplog.at_level(logging.DEBUG, logger="headroom.learn.analyzer"):
with patch("headroom.learn.analyzer.subprocess.Popen", popen):
result = _call_cli_llm("test digest", "claude-cli")
assert result == {"context_file_rules": [], "memory_file_rules": []}
assert any("CLI stderr (exit 0)" in rec.message for rec in caplog.records)
def test_non_result_stdout_lines_are_buffered_into_snippet_on_failure(self):
# If only assistant/system events arrive (no result), the missing-result
# error should include a snippet from stdout.
stdout = [
_stream_event("system", subtype="init"),
_stream_event("assistant", message={"content": "thinking..."}),
]
with patch(
"headroom.learn.analyzer.subprocess.Popen", _fake_claude_popen(stdout_lines=stdout)
):
with pytest.raises(RuntimeError) as exc_info:
_call_cli_llm("test digest", "claude-cli")
message = str(exc_info.value)
assert "did not emit a final `result` event" in message
assert "thinking" in message # stdout snippet was included
def test_resolve_timeout_logs_warning_for_invalid(self, caplog, monkeypatch):
import logging
monkeypatch.setenv("HEADROOM_LEARN_CLI_TIMEOUT_SECS", "abc")
with caplog.at_level(logging.WARNING, logger="headroom.learn.analyzer"):
assert _resolve_timeout_secs("HEADROOM_LEARN_CLI_TIMEOUT_SECS", 300) == 300
assert any(
"Invalid HEADROOM_LEARN_CLI_TIMEOUT_SECS" in rec.message for rec in caplog.records
)
def test_resolve_timeout_logs_warning_for_non_positive(self, caplog, monkeypatch):
import logging
monkeypatch.setenv("HEADROOM_LEARN_CLI_TIMEOUT_SECS", "-5")
with caplog.at_level(logging.WARNING, logger="headroom.learn.analyzer"):
assert _resolve_timeout_secs("HEADROOM_LEARN_CLI_TIMEOUT_SECS", 300) == 300
assert any("must be positive" in rec.message for rec in caplog.records)
class TestResolveTimeoutSecs:
def test_uses_default_when_unset(self, monkeypatch):
monkeypatch.delenv("HEADROOM_LEARN_CLI_TIMEOUT_SECS", raising=False)
assert _resolve_timeout_secs("HEADROOM_LEARN_CLI_TIMEOUT_SECS", 300) == 300
def test_uses_default_when_empty(self, monkeypatch):
monkeypatch.setenv("HEADROOM_LEARN_CLI_TIMEOUT_SECS", "")
assert _resolve_timeout_secs("HEADROOM_LEARN_CLI_TIMEOUT_SECS", 300) == 300
def test_uses_default_for_non_integer(self, monkeypatch):
monkeypatch.setenv("HEADROOM_LEARN_CLI_TIMEOUT_SECS", "not-a-number")
assert _resolve_timeout_secs("HEADROOM_LEARN_CLI_TIMEOUT_SECS", 300) == 300
def test_uses_default_for_non_positive(self, monkeypatch):
monkeypatch.setenv("HEADROOM_LEARN_CLI_TIMEOUT_SECS", "0")
assert _resolve_timeout_secs("HEADROOM_LEARN_CLI_TIMEOUT_SECS", 300) == 300
def test_returns_overridden_value(self, monkeypatch):
monkeypatch.setenv("HEADROOM_LEARN_CLI_TIMEOUT_SECS", "777")
assert _resolve_timeout_secs("HEADROOM_LEARN_CLI_TIMEOUT_SECS", 300) == 777
class TestCallLlmRouting: