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.
310 lines
9.9 KiB
Python
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)
|