"""Tests for the transport ABC, registry, and AnthropicTransport.""" import pytest from types import SimpleNamespace from agent.transports.base import ProviderTransport from agent.transports.types import NormalizedResponse from agent.transports import get_transport, register_transport, _REGISTRY # ── ABC contract tests ────────────────────────────────────────────────── class TestProviderTransportABC: """Verify the ABC contract is enforceable.""" def test_cannot_instantiate_abc(self): with pytest.raises(TypeError): ProviderTransport() def test_concrete_must_implement_all_abstract(self): class Incomplete(ProviderTransport): @property def api_mode(self): return "test" with pytest.raises(TypeError): Incomplete() def test_minimal_concrete(self): class Minimal(ProviderTransport): @property def api_mode(self): return "test_minimal" def convert_messages(self, messages, **kw): return messages def convert_tools(self, tools): return tools def build_kwargs(self, model, messages, tools=None, **params): return {"model": model, "messages": messages} def normalize_response(self, response, **kw): return NormalizedResponse(content="ok", tool_calls=None, finish_reason="stop") t = Minimal() assert t.api_mode == "test_minimal" assert t.validate_response(None) is True # default assert t.extract_cache_stats(None) is None # default assert t.map_finish_reason("end_turn") == "end_turn" # default passthrough # ── Registry tests ─────────────────────────────────────────────────────── class TestTransportRegistry: def test_get_unregistered_returns_none(self): assert get_transport("nonexistent_mode") is None def test_anthropic_registered_on_import(self): import agent.transports.anthropic # noqa: F401 t = get_transport("anthropic_messages") assert t is not None assert t.api_mode == "anthropic_messages" def test_discovers_missing_transport_when_registry_partially_populated(self): """Importing one transport directly must not hide other valid api_modes.""" import agent.transports.chat_completions # noqa: F401 t = get_transport("codex_responses") assert t is not None assert t.api_mode == "codex_responses" def test_register_and_get(self): class DummyTransport(ProviderTransport): @property def api_mode(self): return "dummy_test" def convert_messages(self, messages, **kw): return messages def convert_tools(self, tools): return tools def build_kwargs(self, model, messages, tools=None, **params): return {} def normalize_response(self, response, **kw): return NormalizedResponse(content=None, tool_calls=None, finish_reason="stop") register_transport("dummy_test", DummyTransport) t = get_transport("dummy_test") assert t.api_mode == "dummy_test" # Cleanup _REGISTRY.pop("dummy_test", None) # ── AnthropicTransport tests ──────────────────────────────────────────── class TestAnthropicTransport: @pytest.fixture def transport(self): import agent.transports.anthropic # noqa: F401 return get_transport("anthropic_messages") def test_api_mode(self, transport): assert transport.api_mode == "anthropic_messages" def test_convert_tools_simple(self, transport): tools = [{ "type": "function", "function": { "name": "test_tool", "description": "A test", "parameters": {"type": "object", "properties": {}}, } }] result = transport.convert_tools(tools) assert len(result) == 1 assert result[0]["name"] == "test_tool" assert "input_schema" in result[0] def test_validate_response_none(self, transport): assert transport.validate_response(None) is False def test_validate_response_empty_content(self, transport): r = SimpleNamespace(content=[]) assert transport.validate_response(r) is False def test_validate_response_empty_content_with_end_turn_is_valid(self, transport): r = SimpleNamespace(content=[], stop_reason="end_turn") assert transport.validate_response(r) is True def test_validate_response_empty_content_with_tool_use_is_invalid(self, transport): r = SimpleNamespace(content=[], stop_reason="tool_use") assert transport.validate_response(r) is False def test_validate_response_empty_content_with_refusal_is_valid(self, transport): # Claude 4.5+ returns an empty content list with stop_reason="refusal" # when it declines to respond. It must validate so the response flows # through to normalize_response (which maps refusal → content_filter) # and the loop's refusal handler — instead of being rejected as an # "invalid response" and retried as a deterministic refusal. r = SimpleNamespace(content=[], stop_reason="refusal") assert transport.validate_response(r) is True def test_validate_response_valid(self, transport): r = SimpleNamespace(content=[SimpleNamespace(type="text", text="hello")]) assert transport.validate_response(r) is True def test_map_finish_reason(self, transport): assert transport.map_finish_reason("end_turn") == "stop" assert transport.map_finish_reason("tool_use") == "tool_calls" assert transport.map_finish_reason("max_tokens") == "length" assert transport.map_finish_reason("stop_sequence") == "stop" assert transport.map_finish_reason("refusal") == "content_filter" assert transport.map_finish_reason("model_context_window_exceeded") == "length" assert transport.map_finish_reason("unknown") == "stop" def test_extract_cache_stats_none_usage(self, transport): r = SimpleNamespace(usage=None) assert transport.extract_cache_stats(r) is None def test_extract_cache_stats_with_cache(self, transport): usage = SimpleNamespace(cache_read_input_tokens=100, cache_creation_input_tokens=50) r = SimpleNamespace(usage=usage) result = transport.extract_cache_stats(r) assert result == {"cached_tokens": 100, "creation_tokens": 50} def test_extract_cache_stats_zero(self, transport): usage = SimpleNamespace(cache_read_input_tokens=0, cache_creation_input_tokens=0) r = SimpleNamespace(usage=usage) assert transport.extract_cache_stats(r) is None def test_normalize_response_text(self, transport): """Test normalization of a simple text response.""" r = SimpleNamespace( content=[SimpleNamespace(type="text", text="Hello world")], stop_reason="end_turn", usage=SimpleNamespace(input_tokens=10, output_tokens=5), model="claude-sonnet-4-6", ) nr = transport.normalize_response(r) assert isinstance(nr, NormalizedResponse) assert nr.content == "Hello world" assert nr.tool_calls is None or nr.tool_calls == [] assert nr.finish_reason == "stop" def test_normalize_response_tool_calls(self, transport): """Test normalization of a tool-use response.""" r = SimpleNamespace( content=[ SimpleNamespace( type="tool_use", id="toolu_123", name="terminal", input={"command": "ls"}, ), ], stop_reason="tool_use", usage=SimpleNamespace(input_tokens=10, output_tokens=20), model="claude-sonnet-4-6", ) nr = transport.normalize_response(r) assert nr.finish_reason == "tool_calls" assert len(nr.tool_calls) == 1 tc = nr.tool_calls[0] assert tc.name == "terminal" assert tc.id == "toolu_123" assert '"command"' in tc.arguments def test_normalize_response_thinking(self, transport): """Test normalization preserves thinking content.""" r = SimpleNamespace( content=[ SimpleNamespace(type="thinking", thinking="Let me think..."), SimpleNamespace(type="text", text="The answer is 42"), ], stop_reason="end_turn", usage=SimpleNamespace(input_tokens=10, output_tokens=15), model="claude-sonnet-4-6", ) nr = transport.normalize_response(r) assert nr.content == "The answer is 42" assert nr.reasoning == "Let me think..." def test_build_kwargs_returns_dict(self, transport): """Test build_kwargs produces a usable kwargs dict.""" messages = [{"role": "user", "content": "Hello"}] kw = transport.build_kwargs( model="claude-sonnet-4-6", messages=messages, max_tokens=1024, ) assert isinstance(kw, dict) assert "model" in kw assert "max_tokens" in kw assert "messages" in kw def test_convert_messages_extracts_system(self, transport): """Test convert_messages separates system from messages.""" messages = [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hi"}, ] system, msgs = transport.convert_messages(messages) # System should be extracted assert system is not None # Messages should only have user assert len(msgs) >= 1