This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/tests/test_invoker.py
Adam Moussa 21f2fe54c1 fix(agent-team): builder uses agentic invoker config (read-only tools + turn headroom)
The Plane-2 builder (default_diff_builder) called claude_invoke with no
overrides, inheriting the subscription invoker's single-shot defaults
(max_turns=1, allowed_tools=[]). Diff synthesis is agentic, so the call
died with 'Reached maximum number of turns (1)' and every task failed at
phase=build.

- invoker.py: thread allowed_tools through subscription_invoker and
  _collect_subscription_text (default None -> []), so callers can opt in;
  single-shot reasoning nodes are unchanged.
- builders.py: default_diff_builder now passes max_turns=8, a read-only
  tool allowlist (Read/Grep/Glob), and budget_usd=4.0. No write tools --
  the builder returns the diff as data and performs no repo writes (D2/D11).
- Tests: builder agentic-config passthrough; invoker allowed_tools thread +
  tool-less default guard (so future nodes must opt in explicitly).

Closes #60
2026-06-24 13:13:28 -04:00

374 lines
13 KiB
Python

"""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