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