445 lines
15 KiB
Python
445 lines
15 KiB
Python
"""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 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_returns_partial_state() -> None:
|
|
"""On success the node returns {} (partial state update — DONE comes from graph)."""
|
|
pusher, dispatcher = _fake_seams()
|
|
node = make_dispatch_node(
|
|
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
|
|
)
|
|
result = node(_VALID_STATE)
|
|
|
|
# Remote implementation returns {} on success (graph topology marks DONE).
|
|
assert isinstance(result, dict)
|
|
|
|
|
|
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
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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()
|