"""Tests for Claude Agent SDK client adapter.""" from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from app_factory.core.claude_client import ClaudeSDKClient class _FakeOptions: def __init__(self, **kwargs): self.kwargs = kwargs class _FakeProcessError(RuntimeError): def __init__(self, message: str, stderr: str): super().__init__(message) self.stderr = stderr class TestClaudeSDKClient: @pytest.mark.asyncio async def test_complete_uses_result_text_and_usage_tokens(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions async def _fake_query(*, prompt, options): yield SimpleNamespace( content=[SimpleNamespace(text="intermediate answer")], model="claude-sonnet-4-6", usage=None, is_error=False, ) yield SimpleNamespace( result="final answer", usage={"input_tokens": 12, "output_tokens": 34}, is_error=False, ) client._query = _fake_query result = await client.complete( "hello", model="claude-sonnet-4-6", system_prompt="You are a tester.", max_turns=1, ) assert result.text == "final answer" assert result.input_tokens == 12 assert result.output_tokens == 34 @pytest.mark.asyncio async def test_complete_raises_on_error_result(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions async def _fake_query(*, prompt, options): yield SimpleNamespace( result="something went wrong", usage={"input_tokens": 1, "output_tokens": 1}, is_error=True, ) client._query = _fake_query with pytest.raises(RuntimeError, match="something went wrong"): await client.complete("hello") @pytest.mark.asyncio async def test_complete_falls_back_to_content_blocks(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions async def _fake_query(*, prompt, options): yield SimpleNamespace( content=[SimpleNamespace(text="first"), SimpleNamespace(text="second")], model="claude-sonnet-4-6", usage={"cache_creation_input_tokens": 7, "output_tokens": 2}, is_error=False, ) client._query = _fake_query result = await client.complete("hello") assert result.text == "first\nsecond" assert result.input_tokens == 7 assert result.output_tokens == 2 @pytest.mark.asyncio async def test_complete_rejects_error_subtype(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions async def _fake_query(*, prompt, options): yield SimpleNamespace( subtype="error_during_execution", is_error=False, result=None, usage={"input_tokens": 0, "output_tokens": 0}, ) client._query = _fake_query with pytest.raises(RuntimeError, match="error_during_execution"): await client.complete("hello") @pytest.mark.asyncio async def test_complete_ignores_user_content_blocks(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions async def _fake_query(*, prompt, options): # Simulates a user/system message content block that should not be treated as model output. yield SimpleNamespace( content=[SimpleNamespace(text="[Request interrupted by user]")], is_error=False, usage=None, ) yield SimpleNamespace( result="actual assistant result", subtype="success", is_error=False, usage={"input_tokens": 1, "output_tokens": 2}, ) client._query = _fake_query result = await client.complete("hello") assert result.text == "actual assistant result" @pytest.mark.asyncio async def test_complete_wraps_query_exception_with_hint(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions async def _fake_query(*, prompt, options): raise RuntimeError("Command failed with exit code 1") yield # pragma: no cover client._query = _fake_query with pytest.raises(RuntimeError, match="Hint: verify Claude auth is available"): await client.complete("hello") @pytest.mark.asyncio async def test_complete_passes_debug_to_stderr_flag(self): client = ClaudeSDKClient(enable_debug=True) client._options_cls = _FakeOptions captured_options = None async def _fake_query(*, prompt, options): nonlocal captured_options captured_options = options yield SimpleNamespace( result="ok", subtype="success", is_error=False, usage={"input_tokens": 0, "output_tokens": 0}, ) client._query = _fake_query await client.complete("hello") assert captured_options is not None assert captured_options.kwargs["extra_args"] == {"debug-to-stderr": None} assert hasattr(captured_options.kwargs["debug_stderr"], "fileno") @pytest.mark.asyncio async def test_complete_omits_debug_stderr_without_debug_flag(self): client = ClaudeSDKClient(enable_debug=False) client._options_cls = _FakeOptions captured_options = None async def _fake_query(*, prompt, options): nonlocal captured_options captured_options = options yield SimpleNamespace( result="ok", subtype="success", is_error=False, usage={"input_tokens": 0, "output_tokens": 0}, ) client._query = _fake_query await client.complete("hello") assert captured_options is not None assert "extra_args" not in captured_options.kwargs assert "debug_stderr" not in captured_options.kwargs @pytest.mark.asyncio async def test_complete_includes_exception_stderr_detail(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions async def _fake_query(*, prompt, options): raise _FakeProcessError( "Command failed with exit code 1", "permission denied writing ~/.claude.json", ) yield # pragma: no cover client._query = _fake_query with pytest.raises(RuntimeError, match="permission denied writing ~/.claude.json"): await client.complete("hello") @pytest.mark.asyncio async def test_complete_uses_fallback_home_when_default_home_not_writable(self, tmp_path): client = ClaudeSDKClient() client._options_cls = _FakeOptions captured_options = None cwd_dir = tmp_path / "workspace" cwd_dir.mkdir(parents=True, exist_ok=True) expected_home = cwd_dir / ".app_factory" / "claude_home" expected_home.mkdir(parents=True, exist_ok=True) client._claude_home_is_writable = lambda home: False client._prepare_fallback_claude_home = ( lambda source_home, fallback_home: expected_home ) async def _fake_query(*, prompt, options): nonlocal captured_options captured_options = options yield SimpleNamespace( result="ok", subtype="success", is_error=False, usage={"input_tokens": 0, "output_tokens": 0}, ) client._query = _fake_query await client.complete("hello", cwd=str(cwd_dir)) assert captured_options is not None assert captured_options.kwargs["env"]["HOME"] == str(expected_home) @pytest.mark.asyncio async def test_complete_retries_rate_limit_event_then_succeeds(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions attempts = 0 async def _fake_query(*, prompt, options): nonlocal attempts attempts += 1 if attempts == 1: raise RuntimeError("Unknown message type: rate_limit_event") yield SimpleNamespace( result="ok", subtype="success", is_error=False, usage={"input_tokens": 2, "output_tokens": 3}, ) client._query = _fake_query with patch("app_factory.core.claude_client.asyncio.sleep", new=AsyncMock()) as mock_sleep: result = await client.complete("hello") assert result.text == "ok" assert attempts == 2 mock_sleep.assert_awaited_once_with(0.2) @pytest.mark.asyncio async def test_complete_retries_rate_limit_event_exhausts_then_fails(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions attempts = 0 async def _fake_query(*, prompt, options): nonlocal attempts attempts += 1 raise RuntimeError("Unknown message type: rate_limit_event") yield # pragma: no cover client._query = _fake_query with patch("app_factory.core.claude_client.asyncio.sleep", new=AsyncMock()) as mock_sleep: with pytest.raises(RuntimeError, match="rate_limit_event"): await client.complete("hello") assert attempts == 4 assert mock_sleep.await_args_list == [call(0.2), call(0.8), call(4.0)] @pytest.mark.asyncio async def test_complete_emits_observability_events(self): client = ClaudeSDKClient() client._options_cls = _FakeOptions mock_observability = MagicMock() async def _fake_query(*, prompt, options): yield SimpleNamespace( content=[ SimpleNamespace( id="toolu_1", name="Bash", input={"command": "echo hi", "api_key": "secret"}, ), SimpleNamespace(tool_use_id="toolu_1", content="done", is_error=False), SimpleNamespace(text="Tool finished"), ], model="claude-sonnet-4-6", session_id="session-123", usage=None, is_error=False, ) yield SimpleNamespace( subtype="success", duration_ms=250, duration_api_ms=200, is_error=False, num_turns=1, session_id="session-123", usage={"input_tokens": 3, "output_tokens": 4}, result="ok", ) client._query = _fake_query result = await client.complete( "hello", observability=mock_observability, agent_name="pm_agent", task_id="expand_prd", ) assert result.text == "ok" event_types = [ call.kwargs["event_type"] for call in mock_observability.log_claude_event.call_args_list ] assert "request_start" in event_types assert "tool_use" in event_types assert "tool_result" in event_types assert "result_message" in event_types assert "request_complete" in event_types tool_use_calls = [ c.kwargs for c in mock_observability.log_claude_event.call_args_list if c.kwargs.get("event_type") == "tool_use" ] assert tool_use_calls assert tool_use_calls[0]["payload"]["tool_input"]["api_key"] == "[REDACTED]"