"""Unit tests for agent_team.invoker (§3.1) — all mocked, no network/SDK. These tests prove the module imports cleanly without ``claude_agent_sdk`` or ``anthropic`` installed, and exercise each billing path through the injected SDK seams (``_query``/``_options_cls``/``_client``) so nothing real is called. """ from __future__ import annotations import pytest from agent_team import billing, invoker from agent_team.billing import BillingMode, ClaudeResult @pytest.fixture(autouse=True) def _restore_invoker(): """Restore the module-global billing invoker after each test.""" original = billing._invoker yield billing._invoker = original # --------------------------------------------------------------------------- # # Fakes mirroring the Agent SDK / anthropic message shapes # --------------------------------------------------------------------------- # class _FakeTextBlock: def __init__(self, text: str) -> None: self.text = text # Class names mirror the real Agent SDK message types — the invoker dispatches # on ``type(msg).__name__``, so these MUST be named AssistantMessage / # ResultMessage to be recognised. class AssistantMessage: def __init__(self, text: str) -> None: self.content = [_FakeTextBlock(text)] class ResultMessage: def __init__(self, result: str, cost: float = 0.42) -> None: self.result = result self.total_cost_usd = cost self.usage = {"input_tokens": 11, "output_tokens": 7} def _fake_options(**kwargs): """Stand-in for ClaudeAgentOptions: just record the kwargs.""" return dict(kwargs) def _make_fake_query(messages): """Build an async ``query(prompt=..., options=...)`` yielding ``messages``.""" async def _query(*, prompt, options): for msg in messages: yield msg return _query def _make_raising_query(messages, exc): """Async ``query`` that yields ``messages`` then raises ``exc`` mid-stream. Mirrors the Agent SDK terminating an over-cap stream by *raising* after the assistant turn(s) — e.g. ``Exception("Reached maximum number of turns (1)")``. """ async def _query(*, prompt, options): for msg in messages: yield msg raise exc return _query class _FakeAnthropicUsage: def __init__(self) -> None: self.input_tokens = 12 self.output_tokens = 5 def model_dump(self) -> dict: return {"input_tokens": self.input_tokens, "output_tokens": self.output_tokens} class _FakeAnthropicMessage: def __init__(self, text: str) -> None: self.content = [_FakeTextBlock(text)] self.usage = _FakeAnthropicUsage() class _FakeMessages: def __init__(self, text: str) -> None: self._text = text self.calls: list[dict] = [] def create(self, **kwargs): self.calls.append(kwargs) return _FakeAnthropicMessage(self._text) class _FakeAnthropicClient: def __init__(self, text: str) -> None: self.messages = _FakeMessages(text) # --------------------------------------------------------------------------- # # Clean import without the SDKs # --------------------------------------------------------------------------- # def test_module_imports_without_sdks() -> None: import importlib import sys # claude_agent_sdk / anthropic are not installed in this env. assert "claude_agent_sdk" not in sys.modules mod = importlib.import_module("agent_team.invoker") assert hasattr(mod, "subscription_invoker") assert hasattr(mod, "api_invoker") # --------------------------------------------------------------------------- # # Subscription path # --------------------------------------------------------------------------- # def test_subscription_invoker_returns_result( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok") messages = [ AssistantMessage("partial "), ResultMessage("final answer"), ] result = invoker.subscription_invoker( "hello", mode=BillingMode.SUBSCRIPTION, _query=_make_fake_query(messages), _options_cls=_fake_options, ) assert isinstance(result, ClaudeResult) assert result.text == "final answer" assert result.mode is BillingMode.SUBSCRIPTION # usage populated from ResultMessage cost + usage dict. assert result.usage["total_cost_usd"] == pytest.approx(0.42) assert result.usage["input_tokens"] == 11 # raw carries the collected message stream. assert result.raw == messages def _make_capturing_query(messages, captured: dict): """Async ``query`` that records the ``options`` it was handed, then yields.""" async def _query(*, prompt, options): captured["options"] = options for msg in messages: yield msg return _query def test_subscription_invoker_threads_allowed_tools( monkeypatch: pytest.MonkeyPatch, ) -> None: # Issue #60: an agentic caller (the builder) opts in to tools; the allowlist # must reach the Agent SDK options, not be swallowed by **kw. monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok") captured: dict = {} invoker.subscription_invoker( "build it", mode=BillingMode.SUBSCRIPTION, allowed_tools=["Read", "Grep"], _query=_make_capturing_query([ResultMessage("ok")], captured), _options_cls=_fake_options, ) assert captured["options"]["allowed_tools"] == ["Read", "Grep"] def test_subscription_invoker_defaults_to_no_tools( monkeypatch: pytest.MonkeyPatch, ) -> None: # The single-shot reasoning nodes (clarifier/planner/fixer/verifier) must # keep their tool-less default — this is the opt-in guard for future nodes. monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok") captured: dict = {} invoker.subscription_invoker( "reason", mode=BillingMode.SUBSCRIPTION, _query=_make_capturing_query([ResultMessage("ok")], captured), _options_cls=_fake_options, ) assert captured["options"]["allowed_tools"] == [] def test_subscription_invoker_falls_back_to_assistant_text( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok") messages = [AssistantMessage("a"), AssistantMessage("b")] result = invoker.subscription_invoker( "hi", mode=BillingMode.SUBSCRIPTION, _query=_make_fake_query(messages), _options_cls=_fake_options, ) assert result.text == "a\nb" def test_subscription_invoker_salvages_text_on_turn_cap( monkeypatch: pytest.MonkeyPatch, ) -> None: """A single-shot turn-cap error returns the collected text, not a crash. With ``max_turns=1`` the SDK raises ``Reached maximum number of turns`` after the assistant emitted its answer. The invoker must salvage that text (the JSON the planner needs) rather than propagate the exception — which is what crashed the coordinator before this fix. """ monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok") messages = [AssistantMessage('{"summary": "ok", "phases": []}')] result = invoker.subscription_invoker( "plan this", mode=BillingMode.SUBSCRIPTION, _query=_make_raising_query( messages, Exception("Reached maximum number of turns (1)") ), _options_cls=_fake_options, ) assert isinstance(result, ClaudeResult) assert result.text == '{"summary": "ok", "phases": []}' def test_subscription_invoker_turn_cap_with_no_text_reraises( monkeypatch: pytest.MonkeyPatch, ) -> None: """If the cap is hit before ANY text was produced, the error propagates. There is nothing to salvage, so the caller must see a real failure (which the coordinator turns into a failed task) rather than an empty reply. """ monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok") with pytest.raises(Exception, match="maximum number of turns"): invoker.subscription_invoker( "plan this", mode=BillingMode.SUBSCRIPTION, _query=_make_raising_query( [], Exception("Reached maximum number of turns (1)") ), _options_cls=_fake_options, ) def test_subscription_invoker_non_turn_error_always_propagates( monkeypatch: pytest.MonkeyPatch, ) -> None: """A non-turn-cap error (auth/network/SDK bug) is never swallowed.""" monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok") with pytest.raises(RuntimeError, match="upstream 500"): invoker.subscription_invoker( "plan this", mode=BillingMode.SUBSCRIPTION, _query=_make_raising_query( [AssistantMessage("partial")], RuntimeError("upstream 500") ), _options_cls=_fake_options, ) def test_subscription_invoker_requires_oauth_token( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) with pytest.raises(RuntimeError, match="secrev.env"): invoker.subscription_invoker( "hi", mode=BillingMode.SUBSCRIPTION, _query=_make_fake_query([]), _options_cls=_fake_options, ) # --------------------------------------------------------------------------- # # API path # --------------------------------------------------------------------------- # def test_api_invoker_returns_result() -> None: client = _FakeAnthropicClient("api text") result = invoker.api_invoker("ask", mode=BillingMode.API, _client=client) assert result.text == "api text" assert result.mode is BillingMode.API assert result.usage == {"input_tokens": 12, "output_tokens": 5} # The model + prompt were threaded into the SDK call. assert client.messages.calls[0]["model"] == invoker.API_MODEL assert client.messages.calls[0]["messages"] == [{"role": "user", "content": "ask"}] # --------------------------------------------------------------------------- # # Bedrock path # --------------------------------------------------------------------------- # def test_bedrock_raises_not_implemented() -> None: with pytest.raises(NotImplementedError, match="BEDROCK"): invoker.real_invoker("hi", mode=BillingMode.BEDROCK) # --------------------------------------------------------------------------- # # real_invoker dispatch — SUBSCRIPTION + API branches forward kwargs to the leaf # --------------------------------------------------------------------------- # def test_real_invoker_dispatches_subscription_branch( monkeypatch: pytest.MonkeyPatch, ) -> None: """``real_invoker`` routes SUBSCRIPTION to ``subscription_invoker`` (kwargs fwd).""" monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-tok") messages = [AssistantMessage("partial "), ResultMessage("sub final")] result = invoker.real_invoker( "hello", mode=BillingMode.SUBSCRIPTION, _query=_make_fake_query(messages), _options_cls=_fake_options, ) assert isinstance(result, ClaudeResult) assert result.mode is BillingMode.SUBSCRIPTION assert result.text == "sub final" # the ResultMessage leaf ran assert result.raw == messages def test_real_invoker_dispatches_api_branch() -> None: """``real_invoker`` routes API to ``api_invoker`` with the injected client.""" client = _FakeAnthropicClient("api branch text") result = invoker.real_invoker("ask", mode=BillingMode.API, _client=client) assert result.mode is BillingMode.API assert result.text == "api branch text" # the anthropic-client leaf ran # The prompt was threaded through to the injected client. assert client.messages.calls[0]["messages"] == [{"role": "user", "content": "ask"}] # --------------------------------------------------------------------------- # # Binding into the billing seam # --------------------------------------------------------------------------- # def test_bind_subscription_invoker_sets_billing_invoker() -> None: invoker.bind_subscription_invoker() assert billing._invoker is invoker.real_invoker def test_bound_invoker_drives_claude_invoke( monkeypatch: pytest.MonkeyPatch, ) -> None: # Bind a fake invoker so claude_invoke routes through it end to end. captured: dict = {} def fake(prompt: str, *, mode: BillingMode, **kw): captured["prompt"] = prompt captured["mode"] = mode return ClaudeResult(text="routed", mode=mode) invoker.bind_invoker(fake) result = billing.claude_invoke("q", mode=BillingMode.API) assert result.text == "routed" assert captured["mode"] is BillingMode.API assert captured["prompt"] == "q" def test_bind_invoker_defaults_to_real_invoker() -> None: invoker.bind_invoker() assert billing._invoker is invoker.real_invoker