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_claude_code_adapter.py
Adam Moussa 15a416d31a Add Plane-2 leaf scaffold (pipeline graph, nodes, HITL, transports, CI)
Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto
the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean.

Built (pre-deployment scaffold only — nothing provisioned/enabled):
- LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable)
- nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier
- §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder
- transports: slack / github / claude_code adapters
- ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness
- ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled

KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up):
- builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete)
- §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency
- operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap
- ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.)
- P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven

Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning,
/sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
2026-06-17 15:16:12 -04:00

310 lines
9.9 KiB
Python

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