rein-aharness/tests/test_adapter.py
tegwick 6c4f00e2c2 Add real-time per-tool-call audit streaming (HARNESS-WP-0002-T03)
Claude Code executes its own tools internally in --print mode -- there
is no way for a caller to externally dispatch individual tool calls
without abandoning that self-contained agent model. What
--output-format stream-json --include-hook-events does allow: observing
each tool_use/tool_result/hook event in real time.

- adapter.py: AgenticClaudeCodeAdapter gains an optional on_tool_event
  callback; streaming mode (Popen + background reader thread) is used
  only when set, blocking subprocess.run path is unchanged otherwise.
- runner.py: run_task gains emit_tool_events/on_tool_event, collecting
  events onto RunResult.tool_events and posting a tool_call hub event
  per tool when reporting is enabled.
- cli.py: --stream-tool-events flag on `run`, prints each event as a
  tagged JSON line ahead of the unchanged final result block.

Live-verified against the real claude CLI: 5 real tool events streamed
correctly (2x Bash, 1x Write) plus Stop hook lifecycle events, real
commit landed, final result block unchanged. 13 new tests
(test_adapter.py + 2 in test_runner.py), all passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:49:36 +02:00

121 lines
4.8 KiB
Python

import json
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from llm_connect.exceptions import LLMSubprocessError, LLMTimeoutError
from llm_connect.models import RunConfig
from rein_aharness.adapter import AgenticClaudeCodeAdapter, _is_tool_event
def _fake_proc(lines: list[str], returncode: int = 0, stderr: str = "") -> MagicMock:
proc = MagicMock()
proc.stdin = MagicMock()
proc.stdout = iter(lines)
proc.stderr = MagicMock()
proc.stderr.read.return_value = stderr
proc.wait.return_value = returncode
return proc
def test_is_tool_event_true_for_tool_use() -> None:
event = {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}}
assert _is_tool_event(event) is True
def test_is_tool_event_true_for_tool_result() -> None:
event = {"type": "user", "message": {"content": [{"type": "tool_result", "content": "x"}]}}
assert _is_tool_event(event) is True
def test_is_tool_event_true_for_hook_lifecycle() -> None:
assert _is_tool_event({"type": "system", "subtype": "hook_started"}) is True
assert _is_tool_event({"type": "system", "subtype": "hook_response"}) is True
def test_is_tool_event_false_for_plain_text() -> None:
event = {"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}
assert _is_tool_event(event) is False
def test_is_tool_event_false_for_init() -> None:
assert _is_tool_event({"type": "system", "subtype": "init"}) is False
def test_build_command_adds_stream_json_when_callback_set(tmp_path) -> None:
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path, on_tool_event=lambda e: None)
cmd = adapter._build_command(RunConfig(timeout_seconds=60))
assert "--output-format" in cmd
assert "stream-json" in cmd
assert "--include-hook-events" in cmd
def test_build_command_omits_stream_json_without_callback(tmp_path) -> None:
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path)
cmd = adapter._build_command(RunConfig(timeout_seconds=60))
assert "--output-format" not in cmd
assert "--include-hook-events" not in cmd
def test_execute_streaming_invokes_callback_only_for_tool_events(tmp_path) -> None:
events: list[dict] = []
lines = [
json.dumps({"type": "system", "subtype": "init"}) + "\n",
json.dumps(
{"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "t1", "name": "Read"}]}}
)
+ "\n",
json.dumps(
{"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "hi"}]}}
)
+ "\n",
json.dumps({"type": "assistant", "message": {"content": [{"type": "text", "text": "Done."}]}}) + "\n",
json.dumps({"type": "result", "subtype": "success"}) + "\n",
]
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path, on_tool_event=events.append)
config = RunConfig(timeout_seconds=30)
with patch("rein_aharness.adapter.subprocess.Popen", return_value=_fake_proc(lines)):
response = adapter.execute_prompt("do it", config)
assert response.content == "Done."
assert response.metadata["tool_event_count"] == 2
assert len(events) == 2
assert events[0]["message"]["content"][0]["type"] == "tool_use"
assert events[1]["message"]["content"][0]["type"] == "tool_result"
def test_execute_streaming_raises_on_nonzero_exit(tmp_path) -> None:
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path, on_tool_event=lambda e: None)
config = RunConfig(timeout_seconds=30)
proc = _fake_proc([json.dumps({"type": "result", "subtype": "error"}) + "\n"], returncode=1, stderr="boom")
with patch("rein_aharness.adapter.subprocess.Popen", return_value=proc):
with pytest.raises(LLMSubprocessError, match="claude CLI exited"):
adapter.execute_prompt("do it", config)
def test_execute_streaming_raises_timeout(tmp_path) -> None:
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path, on_tool_event=lambda e: None)
config = RunConfig(timeout_seconds=1)
proc = _fake_proc([])
proc.wait.side_effect = [subprocess.TimeoutExpired(cmd="claude", timeout=1), 0]
with patch("rein_aharness.adapter.subprocess.Popen", return_value=proc):
with pytest.raises(LLMTimeoutError):
adapter.execute_prompt("do it", config)
proc.kill.assert_called_once()
def test_execute_blocking_path_unchanged_without_callback(tmp_path) -> None:
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path)
config = RunConfig(timeout_seconds=30)
fake_result = MagicMock(returncode=0, stdout="plain output", stderr="")
with patch("rein_aharness.adapter.subprocess.run", return_value=fake_result) as run:
response = adapter.execute_prompt("do it", config)
assert response.content == "plain output"
argv = run.call_args.args[0]
assert "--output-format" not in argv