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) proc = MagicMock() proc.communicate.return_value = ("plain output", "") proc.returncode = 0 proc.poll.return_value = 0 with patch("rein_aharness.adapter.subprocess.Popen", return_value=proc) as popen: response = adapter.execute_prompt("do it", config) assert response.content == "plain output" argv = popen.call_args.args[0] assert "--output-format" not in argv proc.communicate.assert_called_once() def test_blocking_cancel_kills_process_and_raises(tmp_path) -> None: from rein_aharness.execution_cancel import ExecutionCancel, ExecutionCancelled cancel = ExecutionCancel() adapter = AgenticClaudeCodeAdapter(workdir=tmp_path, cancel=cancel) config = RunConfig(timeout_seconds=30) proc = MagicMock() proc.poll.return_value = None def _communicate(input=None, timeout=None): # noqa: A002 cancel.cancel("lease-loss") raise RuntimeError("subprocess interrupted") proc.communicate.side_effect = _communicate with patch("rein_aharness.adapter.subprocess.Popen", return_value=proc): with pytest.raises(ExecutionCancelled, match="lease-loss"): adapter.execute_prompt("do it", config) proc.kill.assert_called_once() def test_streaming_cancel_refuses_nonzero_exit(tmp_path) -> None: from rein_aharness.execution_cancel import ExecutionCancel, ExecutionCancelled cancel = ExecutionCancel() adapter = AgenticClaudeCodeAdapter( workdir=tmp_path, on_tool_event=lambda e: None, cancel=cancel ) config = RunConfig(timeout_seconds=30) proc = _fake_proc([]) proc.poll.return_value = None def _wait(timeout=None): cancel.cancel("signal") return 1 proc.wait.side_effect = _wait with patch("rein_aharness.adapter.subprocess.Popen", return_value=proc): with pytest.raises(ExecutionCancelled, match="signal"): adapter.execute_prompt("do it", config)