"""Unit tests for agent_team.transport.claude_code_adapter (§3.3.1, §7.1 P4).""" from __future__ import annotations from typing import Any import pytest from agent_team.transport.base import ( GITHUB_MARKER_TEMPLATE, Transport, ) from agent_team.transport.claude_code_adapter import ( VIA, ClaudeCodeAdapter, ClaudeCodeDeliveryError, build_channel_ref, parse_channel_ref, render_prompt, ) def _question_set(**overrides: Any): from agent_team.transport.base import QuestionSet defaults: dict[str, Any] = { "thread_id": "t1", "question_id": "q1", "turn": 0, "questions": ["Proceed with the dependency bump?"], } defaults.update(overrides) return QuestionSet(**defaults) class _RecordingSink: """A fake delivery sink that records its call and returns a session id.""" def __init__(self, session_id: str = "sess-abc") -> None: self.session_id = session_id self.calls: list[dict[str, Any]] = [] def __call__(self, *, session_hint: str, prompt: str) -> str: self.calls.append({"session_hint": session_hint, "prompt": prompt}) return self.session_id # --------------------------------------------------------------------------- # # Contract / typing # --------------------------------------------------------------------------- # def test_adapter_is_a_transport_subclass() -> None: assert issubclass(ClaudeCodeAdapter, Transport) def test_adapter_instantiates_without_abstract_error() -> None: # All abstract methods are implemented, so construction must succeed. adapter = ClaudeCodeAdapter(_RecordingSink()) assert isinstance(adapter, Transport) def test_via_constant_value() -> None: assert VIA == "claude_code" # --------------------------------------------------------------------------- # # channel_ref helpers # --------------------------------------------------------------------------- # def test_build_channel_ref_embeds_session_and_question() -> None: ref = build_channel_ref("sess-xyz", "q9") assert ref == "claude-session:sess-xyz:q9" def test_parse_channel_ref_roundtrips_build() -> None: ref = build_channel_ref("sess-xyz", "q9") assert parse_channel_ref(ref) == ("sess-xyz", "q9") def test_parse_channel_ref_rejects_foreign_ref() -> None: # A Slack ts or GitHub comment id is not a Claude-Code ref. assert parse_channel_ref("1718000000.001100") is None assert parse_channel_ref("issuecomment-12345") is None # --------------------------------------------------------------------------- # # render_prompt # --------------------------------------------------------------------------- # def test_render_prompt_embeds_question_marker() -> None: prompt = render_prompt( question_id="abc123", turn=2, question_set=_question_set(questions=["a?", "b?"]), deadline="2026-06-18T00:00:00Z", ) assert GITHUB_MARKER_TEMPLATE.format(question_id="abc123") in prompt assert "turn 2" in prompt assert "1. a?" in prompt assert "2. b?" in prompt assert "2026-06-18T00:00:00Z" in prompt def test_render_prompt_surfaces_context_when_present() -> None: prompt = render_prompt( question_id="q1", turn=0, question_set=_question_set( context={"repo": "sea-haven/widgets", "summary": "bump urllib3"} ), deadline="d", ) assert "repo: sea-haven/widgets" in prompt assert "summary: bump urllib3" in prompt def test_render_prompt_omits_absent_context_keys() -> None: prompt = render_prompt( question_id="q1", turn=0, question_set=_question_set(context={}), deadline="d", ) assert "repo:" not in prompt assert "summary:" not in prompt # --------------------------------------------------------------------------- # # post_question # --------------------------------------------------------------------------- # def test_post_question_returns_channel_ref_with_question_id() -> None: sink = _RecordingSink(session_id="sess-42") adapter = ClaudeCodeAdapter(sink, session_hint="mac-1") ref = adapter.post_question( thread_id="t1", question_id="q1", turn=0, question_set=_question_set(), deadline="2026-06-18T00:00:00Z", ) assert ref == "claude-session:sess-42:q1" # The channel_ref alone must round-trip the question_id (§3.3.1). assert parse_channel_ref(ref) == ("sess-42", "q1") def test_post_question_passes_rendered_prompt_and_hint_to_sink() -> None: sink = _RecordingSink() adapter = ClaudeCodeAdapter(sink, session_hint="mac-7") adapter.post_question( thread_id="t1", question_id="qZ", turn=1, question_set=_question_set(question_id="qZ", questions=["go?"]), deadline="d", ) assert len(sink.calls) == 1 call = sink.calls[0] assert call["session_hint"] == "mac-7" assert GITHUB_MARKER_TEMPLATE.format(question_id="qZ") in call["prompt"] assert "go?" in call["prompt"] def test_post_question_default_sink_raises() -> None: adapter = ClaudeCodeAdapter() # no delivery injected with pytest.raises(ClaudeCodeDeliveryError): adapter.post_question( thread_id="t1", question_id="q1", turn=0, question_set=_question_set(), deadline="d", ) @pytest.mark.parametrize("bad_session", ["", " ", None]) def test_post_question_blank_session_id_is_failed_post(bad_session: Any) -> None: def sink(*, session_hint: str, prompt: str) -> Any: return bad_session adapter = ClaudeCodeAdapter(sink) with pytest.raises(ClaudeCodeDeliveryError): adapter.post_question( thread_id="t1", question_id="q1", turn=0, question_set=_question_set(), deadline="d", ) def test_post_question_strips_session_id_whitespace() -> None: adapter = ClaudeCodeAdapter(lambda *, session_hint, prompt: " sess-w ") ref = adapter.post_question( thread_id="t1", question_id="q1", turn=0, question_set=_question_set(), deadline="d", ) assert ref == "claude-session:sess-w:q1" # --------------------------------------------------------------------------- # # parse_answer # --------------------------------------------------------------------------- # def test_parse_answer_explicit_question_id() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) parsed = adapter.parse_answer({"question_id": "q1", "answer": "yes"}) assert parsed == ("q1", "yes", VIA) def test_parse_answer_recovers_question_id_from_channel_ref() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) parsed = adapter.parse_answer( {"channel_ref": build_channel_ref("sess-1", "q7"), "answer": "ship it"} ) assert parsed == ("q7", "ship it", VIA) def test_parse_answer_recovers_question_id_from_prompt_marker() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) marker = GITHUB_MARKER_TEMPLATE.format(question_id="qM") parsed = adapter.parse_answer({"prompt": f"{marker}\nanswered inline", "answer": 3}) assert parsed == ("qM", 3, VIA) def test_parse_answer_recovers_question_id_from_marker_in_answer_text() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) marker = GITHUB_MARKER_TEMPLATE.format(question_id="qInline") parsed = adapter.parse_answer({"answer": f"my reply {marker}"}) assert parsed[0] == "qInline" assert parsed[2] == VIA def test_parse_answer_explicit_id_takes_priority_over_ref() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) parsed = adapter.parse_answer( { "question_id": "explicit", "channel_ref": build_channel_ref("sess-1", "fromref"), "answer": "x", } ) assert parsed[0] == "explicit" def test_parse_answer_value_and_text_fallbacks() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) assert adapter.parse_answer({"question_id": "q", "value": "v"})[1] == "v" assert adapter.parse_answer({"question_id": "q", "text": "t"})[1] == "t" def test_parse_answer_answer_key_wins_over_value_and_text() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) parsed = adapter.parse_answer( {"question_id": "q", "answer": "A", "value": "V", "text": "T"} ) assert parsed[1] == "A" def test_parse_answer_preserves_structured_answer() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) payload = {"question_id": "q", "answer": {"choice": 2, "note": "ok"}} parsed = adapter.parse_answer(payload) assert parsed[1] == {"choice": 2, "note": "ok"} def test_parse_answer_missing_answer_is_none() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) parsed = adapter.parse_answer({"question_id": "q"}) assert parsed == ("q", None, VIA) def test_parse_answer_rejects_unmappable_payload() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) with pytest.raises(ValueError): adapter.parse_answer({"answer": "no id anywhere"}) def test_parse_answer_rejects_non_mapping() -> None: adapter = ClaudeCodeAdapter(_RecordingSink()) with pytest.raises(ValueError): adapter.parse_answer("not a dict") # --------------------------------------------------------------------------- # # Round-trip: post then parse the resulting ref # --------------------------------------------------------------------------- # def test_post_then_parse_roundtrip_via_channel_ref() -> None: sink = _RecordingSink(session_id="sess-rt") adapter = ClaudeCodeAdapter(sink) ref = adapter.post_question( thread_id="t1", question_id="qRT", turn=0, question_set=_question_set(question_id="qRT"), deadline="d", ) # An inbound answer that echoes only the channel_ref still maps back. parsed = adapter.parse_answer({"channel_ref": ref, "answer": "done"}) assert parsed == ("qRT", "done", VIA)