"""Unit tests for WS3: agent_team.nodes.dispatch_invoker + graph/coordinator wiring. Tests cover: * :func:`make_dispatch_node` — factory contract, fail-closed on missing fields (parks on missing thread_id/diff/scope), injection of owner/repo at factory time (never read from state), scope list flattening, happy-path returns empty dict (partial state update) and calls dispatcher. * :func:`agent_team.graph.build_graph` with ``dispatch_node`` wired — verifies the DISPATCH_NODE constant is exported and the graph compiles with/without it. * :func:`agent_team.coordinator.default_dispatch_node_factory` — env-var binding and fail-closed when required vars are unset. * :class:`agent_team.coordinator.Coordinator` ``dispatch_node_wiring`` seam — accepted at __init__, NOT called in setup() when build_verify_wiring is None (DISPATCH_NODE requires VERIFY_NODE/APPROVED_ROUTE to exist). All network + subprocess calls are replaced with injected fakes; no model, no git, no GitHub, no subprocess. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock import pytest from agent_team.nodes.dispatch_invoker import DispatchNodeFactory, make_dispatch_node from agent_team.task_model import Phase, TaskStatus _FAKE_RUN_ID = "27990718108" @pytest.fixture(autouse=True) def _stub_run_locator(monkeypatch: pytest.MonkeyPatch) -> None: """Stub the real ``gh run list`` locator so no test shells out / sleeps. ``dispatch_apply_verify`` resolves the dispatched run via ``_default_run_locator`` when no ``locator`` is injected; the real one polls ``gh`` with retry sleeps. Replace it module-wide with a fast fake returning a fixed run id so every ``make_dispatch_node`` call (incl. factory paths) stays hermetic. """ from agent_team import dispatcher as _dispatcher monkeypatch.setattr( _dispatcher, "_default_run_locator", lambda: lambda **_kw: _FAKE_RUN_ID, ) # --------------------------------------------------------------------------- # # Fake BranchPusher / WorkflowDispatcher (the two injectable seams in dispatcher) # --------------------------------------------------------------------------- # def _make_fake_pusher() -> tuple[list[dict[str, Any]], Any]: calls: list[dict[str, Any]] = [] def _pusher( *, owner: str, repo: str, base: str, head_branch: str, diff_text: str ) -> None: calls.append(dict(owner=owner, repo=repo, base=base, head_branch=head_branch)) return calls, _pusher def _make_fake_workflow_dispatcher() -> tuple[list[dict[str, Any]], Any]: calls: list[dict[str, Any]] = [] def _dispatcher(*, owner: str, repo: str, inputs: dict[str, str], ref: str) -> None: calls.append(dict(owner=owner, repo=repo, ref=ref, inputs=inputs)) return calls, _dispatcher def _fake_seams() -> tuple[Any, Any]: """Return (fake_pusher, fake_dispatcher) that silence all subprocess calls.""" _, pusher = _make_fake_pusher() _, dispatcher = _make_fake_workflow_dispatcher() return pusher, dispatcher _VALID_STATE: dict[str, Any] = { "thread_id": "task-abc", "candidate_diff": ( "diff --git a/f.py b/f.py\n--- a/f.py\n+++ b/f.py\n@@ -1 +1 @@\n-old\n+new" ), "plan": {"scope": ["agent_team/"]}, "status": "active", "current_phase": "verify", } # --------------------------------------------------------------------------- # # make_dispatch_node — happy path # --------------------------------------------------------------------------- # def test_dispatch_node_happy_path_persists_run_id() -> None: """On success the node persists the located run identity into state. The dispatched run's id (plus the dispatched-at watermark and correlation tag) flows into PipelineState so the verifier's read-only fetcher polls THIS task's run and the pure-code gate binds its verdict to it. """ pusher, dispatcher = _fake_seams() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) result = node(_VALID_STATE) assert isinstance(result, dict) assert result["run_id"] == _FAKE_RUN_ID assert result["dispatched_at"] # stamped assert result["ci_correlation_tag"] == "task-abc" def test_dispatch_node_injects_owner_repo_at_factory_time() -> None: """owner/repo come from factory args — model output in state cannot redirect them.""" pusher_calls, pusher = _make_fake_pusher() _, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="trusted-org", repo="trusted-repo", pusher=pusher, dispatcher=dispatcher ) node(_VALID_STATE) assert pusher_calls, "pusher should have been called" assert pusher_calls[0]["owner"] == "trusted-org" assert pusher_calls[0]["repo"] == "trusted-repo" def test_dispatch_node_passes_task_id_to_head_branch() -> None: pusher_calls, pusher = _make_fake_pusher() _, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", base="main", pusher=pusher, dispatcher=dispatcher ) node(_VALID_STATE) assert pusher_calls, "pusher should have been called" # build_dispatch_inputs derives head_branch from task_id. assert "task-abc" in pusher_calls[0]["head_branch"] assert pusher_calls[0]["base"] == "main" def test_dispatch_node_passes_scope_to_workflow_dispatcher() -> None: _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) node(_VALID_STATE) assert disp_calls, "workflow dispatcher should have been called" wf_inputs = disp_calls[0]["inputs"] assert "agent_team/" in str(wf_inputs) def test_dispatch_node_flattens_scope_list_to_string() -> None: """Multi-entry scope list is newline-joined into declared_scope.""" _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) state = dict(_VALID_STATE, plan={"scope": ["agent_team/", "tests/"]}) node(state) wf_inputs = disp_calls[0]["inputs"] scope_str = str(wf_inputs) assert "agent_team/" in scope_str def test_dispatch_node_unresolved_run_id_persists_watermark_and_verify_fails_closed( monkeypatch: pytest.MonkeyPatch, ) -> None: """The ``if not result.run_id`` branch: dispatch fired but the run could not be correlated. The node must NOT park (the dispatch succeeded) — it persists the dispatched-at watermark + correlation tag with ``run_id=None`` so the downstream verifier gate fails closed (BLOCK/park), never fabricating a pass. """ from agent_team import dispatcher as _dispatcher # Override the autouse locator stub: this run cannot be located -> None. monkeypatch.setattr(_dispatcher, "_default_run_locator", lambda: lambda **_kw: None) pusher, dispatcher = _fake_seams() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) result = node(_VALID_STATE) # Not parked: the dispatch itself succeeded; only correlation failed. assert result.get("status") != TaskStatus.PARKED.value assert result["run_id"] is None # The watermark + correlation tag are still persisted for the CI-watch timeout # and for audit, even though the run id is unresolved. assert result["dispatched_at"] assert result["ci_correlation_tag"] == "task-abc" # Feed the dispatch output into the verifier: a None run_id has nothing for # the gate to bind to, so it BLOCKs and parks — fail closed, never a pass. from agent_team.nodes.build_verify_subgraph import make_verify_node from agent_team.nodes.verifier import VerifierConfig verify_state: dict[str, Any] = { "thread_id": "task-abc", "candidate_diff": _VALID_STATE["candidate_diff"], "diff_hash": "deadbeef", "ci_results": None, "run_id": result["run_id"], # None, carried from dispatch } def _pass_fetcher(_s: Any) -> Any: # Even a 'success' CI result cannot rescue a missing run_id: the gate has # no run identity to bind the verdict to. return {"run_id": "whatever", "conclusion": "success", "diff_hash": "deadbeef"} verify_node = make_verify_node( VerifierConfig(expected_run_id=None, allowed_scope=["agent_team/"]), ci_result_fetcher=_pass_fetcher, ) out = verify_node(verify_state) assert out["status"] == TaskStatus.PARKED.value assert out["ci_results"]["gate_decision"] == "block" # --------------------------------------------------------------------------- # # make_dispatch_node — fail closed (never dispatches incomplete input) # --------------------------------------------------------------------------- # def test_dispatch_node_parks_on_missing_thread_id() -> None: _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) state = dict(_VALID_STATE, thread_id="") result = node(state) assert result.get("status") == TaskStatus.PARKED.value assert result.get("current_phase") == Phase.PARKED.value assert not disp_calls # dispatcher never called def test_dispatch_node_parks_on_missing_diff() -> None: _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) state = dict(_VALID_STATE, candidate_diff="") result = node(state) assert result.get("status") == TaskStatus.PARKED.value assert not disp_calls def test_dispatch_node_parks_on_whitespace_only_diff() -> None: _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) state = dict(_VALID_STATE, candidate_diff=" \n ") result = node(state) assert result.get("status") == TaskStatus.PARKED.value assert not disp_calls def test_dispatch_node_parks_on_empty_scope() -> None: _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) state = dict(_VALID_STATE, plan={"scope": []}) result = node(state) assert result.get("status") == TaskStatus.PARKED.value assert not disp_calls def test_dispatch_node_parks_on_none_plan() -> None: _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) state = dict(_VALID_STATE, plan=None) result = node(state) assert result.get("status") == TaskStatus.PARKED.value assert not disp_calls def test_dispatch_node_parks_on_non_dict_plan() -> None: _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) state = dict(_VALID_STATE, plan="not-a-dict") result = node(state) assert result.get("status") == TaskStatus.PARKED.value assert not disp_calls def test_dispatch_node_parks_on_dispatcher_error() -> None: """A DispatcherError from the underlying dispatcher parks the task (fail safe).""" from agent_team.dispatcher import DispatcherError def _bad_pusher(**_kwargs: Any) -> None: raise DispatcherError("invalid owner/repo 'x'/'y'") _, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=_bad_pusher, dispatcher=dispatcher ) result = node(_VALID_STATE) assert result.get("status") == TaskStatus.PARKED.value def test_dispatch_node_parks_on_unexpected_exception() -> None: """Any unexpected error parks the task rather than crashing the graph.""" def _exploding_pusher(**_kwargs: Any) -> None: raise RuntimeError("network timeout") _, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( owner="org", repo="repo", pusher=_exploding_pusher, dispatcher=dispatcher ) result = node(_VALID_STATE) assert result.get("status") == TaskStatus.PARKED.value # --------------------------------------------------------------------------- # # DispatchNodeFactory type alias export # --------------------------------------------------------------------------- # def test_dispatch_node_factory_type_is_exported() -> None: from agent_team.nodes import dispatch_invoker assert hasattr(dispatch_invoker, "DispatchNodeFactory") def test_dispatch_node_factory_satisfies_zero_arg_contract() -> None: """DispatchNodeFactory is a zero-arg callable that returns a node callable.""" pusher, dispatcher = _fake_seams() factory: DispatchNodeFactory = lambda: make_dispatch_node( # noqa: E731 owner="o", repo="r", pusher=pusher, dispatcher=dispatcher ) node = factory() assert callable(node) result = node(_VALID_STATE) assert isinstance(result, dict) # --------------------------------------------------------------------------- # # graph.build_graph — DISPATCH_NODE constant and compile-time wiring # --------------------------------------------------------------------------- # def test_build_graph_dispatch_node_constant_is_exported() -> None: from agent_team import graph as graph_mod assert hasattr(graph_mod, "DISPATCH_NODE") assert graph_mod.DISPATCH_NODE == "dispatch_node" def test_build_graph_without_dispatch_node_compiles() -> None: """Default (no dispatch_node) builds fine — backward-compatible.""" from langgraph.checkpoint.memory import MemorySaver from agent_team.graph import build_graph graph = build_graph(MemorySaver()) assert graph is not None def test_build_graph_with_dispatch_node_no_build_verify_raises() -> None: """dispatch_node without build_verify raises ValueError (APPROVED_ROUTE requires P3).""" from langgraph.checkpoint.memory import MemorySaver from agent_team.graph import build_graph def _fake_dispatch(state: Any) -> Any: return {} with pytest.raises(ValueError, match="dispatch_node requires build_verify"): build_graph(MemorySaver(), dispatch_node=_fake_dispatch) # --------------------------------------------------------------------------- # # coordinator.default_dispatch_node_factory # --------------------------------------------------------------------------- # def test_default_dispatch_node_factory_raises_without_env_vars( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("AGENT_TEAM_REPO_OWNER", raising=False) monkeypatch.delenv("AGENT_TEAM_REPO_NAME", raising=False) from agent_team.coordinator import default_dispatch_node_factory with pytest.raises(RuntimeError, match="AGENT_TEAM_REPO_OWNER"): default_dispatch_node_factory() def test_default_dispatch_node_factory_raises_when_only_owner_set( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "org") monkeypatch.delenv("AGENT_TEAM_REPO_NAME", raising=False) from agent_team.coordinator import default_dispatch_node_factory with pytest.raises(RuntimeError): default_dispatch_node_factory() def test_default_dispatch_node_factory_returns_callable_with_env_vars( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "test-org") monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "test-repo") monkeypatch.delenv("AGENT_TEAM_BASE_BRANCH", raising=False) from agent_team.coordinator import default_dispatch_node_factory node = default_dispatch_node_factory() assert callable(node) def test_default_dispatch_node_factory_reads_base_branch_from_env( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "org") monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "repo") monkeypatch.setenv("AGENT_TEAM_BASE_BRANCH", "develop") from agent_team.coordinator import default_dispatch_node_factory node = default_dispatch_node_factory() assert callable(node) def test_default_dispatch_node_factory_in_all() -> None: from agent_team import coordinator assert "default_dispatch_node_factory" in coordinator.__all__ # --------------------------------------------------------------------------- # # Coordinator.dispatch_node_wiring seam # --------------------------------------------------------------------------- # def test_coordinator_accepts_dispatch_node_wiring_param() -> None: """Coordinator.__init__ accepts dispatch_node_wiring=None without error.""" from agent_team.coordinator import Coordinator from agent_team.transport.base import Transport transport = MagicMock(spec=Transport) coord = Coordinator( db_path=":memory:", transport=transport, dispatch_node_wiring=None, ) assert coord is not None def test_coordinator_dispatch_node_wiring_without_build_verify_raises( tmp_path: Any, ) -> None: """setup() raises ValueError when dispatch_node_wiring is set but build_verify_wiring is None. build_graph enforces that dispatch_node requires build_verify (APPROVED_ROUTE lives inside the P3 subgraph). Passing dispatch_node_wiring alone is a configuration error caught at setup time. """ from langgraph.checkpoint.memory import MemorySaver from agent_team.coordinator import Coordinator from agent_team.transport.base import Transport pusher, dispatcher = _fake_seams() def _factory() -> Any: return make_dispatch_node( owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher ) def _stub_clarify_node() -> Any: return MagicMock() def _stub_checkpointer(db_path: Any) -> Any: return MemorySaver() transport = MagicMock(spec=Transport) coord = Coordinator( db_path=tmp_path / "test.db", transport=transport, build_clarify_node=_stub_clarify_node, build_checkpointer=_stub_checkpointer, dispatch_node_wiring=_factory, # build_verify_wiring=None (default) ) with pytest.raises(ValueError, match="dispatch_node requires build_verify"): coord.setup()