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_graph.py
Adam Moussa 8d31ef183d feat(agent-team): one Slack thread per task — root "Task received" message + threaded questions/milestones
WS Slack-UX Feature 1. A /new-task task now maps to ONE Slack thread instead of
several top-level messages.

- /new-task posts an immediate root "📥 Task received: …" ack and captures its
  ts (root_ts); this is the instant acknowledgement.
- root_ts is plumbed into start: new PipelineState/TaskRecord channel
  slack_thread_ts, seeded by graph.start_task and threaded through
  Coordinator.start_task. The NewTaskCallback is now (task_text, via, root_ts).
- All clarifier questions for the task post as THREADED REPLIES under root_ts
  (chat.postMessage thread_ts=root_ts), and each question's ledger channel_ref
  is set to root_ts (NOT the reply's own ts). Because answer-mapping resolves a
  reply via find_open_question_by_channel_ref(thread_ts), a reply in the root
  thread (thread_ts==root_ts) maps to the task's currently-open question with NO
  change to the mapping logic or the first-answer-wins CAS. The open-only
  partial-unique index still holds (one open question per task at a time).
- Lifecycle milestones (parked / plan-ready / needs-input) and follow-up
  questions thread under root_ts too; the notify sink gained an optional
  thread_ts kwarg (degrades to top-level on a sink that doesn't accept it).
  notify failures still never break tick.
- SlackTransport.post_question + the live poster accept/forward thread_ts.
- No root_ts (non-/new-task origin) ⇒ top-level posts exactly as before.

AUTHZ-01 (owner-allowlist-first, fail-closed) and the atomic open→answered
compare-and-set are unchanged.

Adds plumbing for the inbound-ack reactor seam used by Feature 2 (dormant until
a reactor is injected). Tests cover thread_ts forwarding, channel_ref=root_ts,
graph seeding, and coordinator threading.
2026-06-23 15:11:19 -04:00

635 lines
25 KiB
Python

"""Unit tests for agent_team.graph (Plane-2 P1 LangGraph wiring; §3.3, §7.1).
These exercise the P1 skeleton + human gate wiring:
* the pure node functions (intake/clarify-author/plan) in isolation,
* graph assembly + edge topology,
* the suspend-on-interrupt / resume-with-Command mechanic end to end,
* the thread_id-keyed driver seam (start/resume/get_state/pending_question),
* that the foundation contracts (PipelineState / Phase / TaskStatus /
QuestionSet) are imported verbatim and round-trip through the wiring.
An in-memory checkpointer is injected (the SQLite checkpointer is the
production store, D9, not constructed in pre-deploy scaffolding).
"""
from __future__ import annotations
import pytest
# InMemorySaver is the modern name; fall back to MemorySaver on older langgraph.
try: # pragma: no cover - import shim
from langgraph.checkpoint.memory import InMemorySaver as _Saver
except ImportError: # pragma: no cover - import shim
from langgraph.checkpoint.memory import MemorySaver as _Saver
from agent_team import graph as graph_mod
from agent_team.graph import (
CLARIFY,
INTAKE,
P1_PHASE_SEQUENCE,
PLAN,
build_checkpoint_serde,
build_graph,
build_sqlite_checkpointer,
clarify_node,
get_pipeline_state,
intake_node,
pending_question,
plan_node,
plan_phase,
resume_task,
start_task,
thread_config,
)
from agent_team.task_model import Phase, PipelineState, TaskStatus
from agent_team.transport import QuestionSet
@pytest.fixture()
def compiled():
"""A graph compiled with a fresh in-memory checkpointer per test."""
return build_graph(checkpointer=_Saver())
# --- Module surface / constants. -------------------------------------------
def test_node_name_constants_are_distinct() -> None:
assert len({INTAKE, CLARIFY, PLAN}) == 3
def test_p1_phase_sequence_stops_at_plan() -> None:
# P1 ends at an approved plan — no BUILD/VERIFY in the wired sequence (§7.1).
assert P1_PHASE_SEQUENCE == (Phase.INTAKE, Phase.CLARIFY, Phase.PLAN)
assert Phase.BUILD not in P1_PHASE_SEQUENCE
assert Phase.VERIFY not in P1_PHASE_SEQUENCE
# --- Pure node behaviour. ---------------------------------------------------
def test_intake_node_activates_and_advances_to_clarify() -> None:
out = intake_node(PipelineState(thread_id="t", current_phase=Phase.INTAKE.value))
assert out["status"] == TaskStatus.ACTIVE.value
assert out["current_phase"] == Phase.CLARIFY.value
assert out["updated_at"]
def test_plan_node_lands_approved_plan_and_finishes() -> None:
out = plan_node(PipelineState(thread_id="t", qa_history=[{"answer": "x"}]))
assert out["status"] == TaskStatus.DONE.value
assert out["current_phase"] == Phase.DONE.value
assert out["plan"]["approved"] is True
def test_plan_phase_counts_qa_turns() -> None:
state = PipelineState(qa_history=[{"answer": "a"}, {"answer": "b"}])
plan = plan_phase(state)
assert plan["qa_turns"] == 2
assert plan["approved"] is True
def test_plan_phase_handles_empty_history() -> None:
assert plan_phase(PipelineState())["qa_turns"] == 0
def test_clarify_node_suspends_rather_than_falling_through() -> None:
# Called bare (no running graph), interrupt() refuses to return a value:
# it raises because there is no runnable context to suspend into. This
# confirms clarify_node genuinely suspends rather than falling through to
# its post-interrupt return.
with pytest.raises(RuntimeError):
clarify_node(PipelineState(thread_id="t", transport="slack"))
# --- Graph assembly. --------------------------------------------------------
def test_build_graph_without_checkpointer_compiles() -> None:
# An uncheckpointed graph still compiles (used only for straight-through
# smoke paths); the driver requires a checkpointer for suspend/resume.
assert build_graph() is not None
def test_build_graph_with_checkpointer_compiles(compiled) -> None:
assert compiled is not None
def test_graph_nodes_present(compiled) -> None:
nodes = set(compiled.get_graph().nodes)
assert {INTAKE, CLARIFY, PLAN} <= nodes
# --- Suspend / resume end to end. ------------------------------------------
def test_start_task_suspends_on_human_gate(compiled) -> None:
thread_id, state = start_task(compiled, transport="slack")
# The task ran INTAKE then suspended at CLARIFY's interrupt().
assert "__interrupt__" in state
payload = pending_question(compiled, thread_id=thread_id)
assert payload is not None
assert payload["thread_id"] == thread_id
assert payload["transport"] == "slack"
assert payload["turn"] == 0
assert payload["deadline"]
def test_start_task_seeds_task_description_into_state(compiled) -> None:
# Regression: the intake description (Slack /new-task text, GitHub issue body)
# must reach the graph state so the clarifier can reason about it. It is
# seeded into the initial invoke and must persist through INTAKE into the
# suspended CLARIFY snapshot (intake_node returns only a partial state).
_thread_id, state = start_task(
compiled, transport="slack", task="build a login form"
)
assert state.get("task") == "build a login form"
def test_start_task_seeds_slack_thread_ts_into_state(compiled) -> None:
# One-thread-per-task: the root "📥 Task received" message ts must reach the
# graph state so every later question/notification threads under it. Like
# ``task`` it is seeded into the initial invoke and persists through INTAKE
# into the suspended CLARIFY snapshot.
_thread_id, state = start_task(
compiled, transport="slack", slack_thread_ts="1700000000.ROOT"
)
assert state.get("slack_thread_ts") == "1700000000.ROOT"
# It is also surfaced on the pending interrupt payload so the responder can
# thread the question post.
payload = pending_question(compiled, thread_id=_thread_id)
assert payload["slack_thread_ts"] == "1700000000.ROOT"
def test_start_task_default_slack_thread_ts_is_empty(compiled) -> None:
# A task with no root post (e.g. a GitHub-issue origin): slack_thread_ts is
# empty so questions post top-level exactly as before.
_thread_id, state = start_task(compiled, transport="slack")
assert state.get("slack_thread_ts") == ""
def test_pending_question_carries_foundation_questionset(compiled) -> None:
thread_id, _ = start_task(compiled, transport="slack")
payload = pending_question(compiled, thread_id=thread_id)
qset = payload["question_set"]
# Verbatim foundation contract — not a redefinition.
assert isinstance(qset, QuestionSet)
assert qset.thread_id == thread_id
assert qset.question_id == payload["question_id"]
assert qset.turn == 0
assert qset.questions # non-empty question-set
def test_resume_drives_task_to_done(compiled) -> None:
thread_id, _ = start_task(compiled, transport="slack")
final = resume_task(compiled, thread_id=thread_id, answer={"text": "do the thing"})
assert final["status"] == TaskStatus.DONE.value
assert final["current_phase"] == Phase.DONE.value
assert final["plan"]["approved"] is True
def test_answer_is_recorded_in_qa_history(compiled) -> None:
thread_id, _ = start_task(compiled, transport="slack")
answer = {"text": "scope is X"}
final = resume_task(compiled, thread_id=thread_id, answer=answer)
assert len(final["qa_history"]) == 1
assert final["qa_history"][0]["answer"] == answer
assert final["qa_history"][0]["turn"] == 0
def test_question_id_is_stable_across_resume(compiled) -> None:
# The clarifier node re-executes on resume; the question_id must NOT change
# between the id delivered at suspend (the ledger key) and the one recorded
# in qa_history, or the §3.3.1 identity contract breaks.
thread_id, _ = start_task(compiled, transport="slack")
delivered = pending_question(compiled, thread_id=thread_id)["question_id"]
final = resume_task(compiled, thread_id=thread_id, answer="ok")
assert final["qa_history"][0]["question_id"] == delivered
def test_no_pending_question_after_completion(compiled) -> None:
thread_id, _ = start_task(compiled, transport="slack")
resume_task(compiled, thread_id=thread_id, answer="ok")
assert pending_question(compiled, thread_id=thread_id) is None
def test_get_pipeline_state_reflects_suspend_then_done(compiled) -> None:
thread_id, _ = start_task(compiled, transport="slack")
mid = get_pipeline_state(compiled, thread_id=thread_id)
# Suspended ON the clarifier gate: INTAKE already advanced the phase to
# CLARIFY, and the clarifier's post-interrupt write (-> PLAN) has NOT yet
# committed because the node is paused at interrupt(). Task is mid-flight.
assert mid["current_phase"] == Phase.CLARIFY.value
assert mid["status"] == TaskStatus.ACTIVE.value
resume_task(compiled, thread_id=thread_id, answer="ok")
done = get_pipeline_state(compiled, thread_id=thread_id)
assert done["status"] == TaskStatus.DONE.value
assert done["current_phase"] == Phase.DONE.value
# --- Thread isolation (§3.3.1 P1 exit criterion (d)). ----------------------
def test_two_tasks_suspend_and_resume_independently(compiled) -> None:
t1, _ = start_task(compiled, transport="slack")
t2, _ = start_task(compiled, transport="github")
assert t1 != t2
p1 = pending_question(compiled, thread_id=t1)
p2 = pending_question(compiled, thread_id=t2)
assert p1["transport"] == "slack"
assert p2["transport"] == "github"
assert p1["question_id"] != p2["question_id"]
# Resume only t1; t2 must remain suspended on its own gate.
f1 = resume_task(compiled, thread_id=t1, answer="answer-1")
assert f1["status"] == TaskStatus.DONE.value
assert pending_question(compiled, thread_id=t2) is not None
f2 = resume_task(compiled, thread_id=t2, answer="answer-2")
assert f2["status"] == TaskStatus.DONE.value
assert f2["qa_history"][0]["answer"] == "answer-2"
def test_explicit_thread_id_is_honoured(compiled) -> None:
tid, _ = start_task(compiled, thread_id="fixed-thread", transport="slack")
assert tid == "fixed-thread"
assert pending_question(compiled, thread_id="fixed-thread") is not None
# --- Durable resume across a fresh graph object (P1 exit criterion (a)). ----
def test_resume_works_on_a_new_graph_over_shared_checkpointer() -> None:
# Simulates a process restart: a NEW compiled graph object built over the
# SAME checkpointer must resume a task suspended by the first graph object.
saver = _Saver()
g1 = build_graph(checkpointer=saver)
thread_id, _ = start_task(g1, transport="slack")
g2 = build_graph(checkpointer=saver) # "after restart"
assert pending_question(g2, thread_id=thread_id) is not None
final = resume_task(g2, thread_id=thread_id, answer="post-restart")
assert final["status"] == TaskStatus.DONE.value
assert final["qa_history"][0]["answer"] == "post-restart"
# --- Driver-seam helpers. ---------------------------------------------------
def test_thread_config_shape() -> None:
assert thread_config("abc") == {"configurable": {"thread_id": "abc"}}
def test_start_task_mints_unique_thread_ids(compiled) -> None:
t1, _ = start_task(compiled, transport="slack")
t2, _ = start_task(compiled, transport="slack")
assert t1 != t2
# --- Production checkpointer factory. --------------------------------------
def test_build_sqlite_checkpointer_missing_dep_raises_runtimeerror(
monkeypatch, tmp_path
) -> None:
# When the optional langgraph-checkpoint-sqlite package is absent, the
# factory must fail loudly with a clear RuntimeError, never silently run
# uncheckpointed. Force the ImportError path deterministically.
import builtins
real_import = builtins.__import__
def _blocking_import(name, *args, **kwargs):
if name == "langgraph.checkpoint.sqlite":
raise ImportError("blocked for test")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _blocking_import)
with pytest.raises(RuntimeError, match="SQLite checkpointer"):
build_sqlite_checkpointer(tmp_path / "state.db")
def test_build_sqlite_checkpointer_builds_when_dep_present(tmp_path) -> None:
# If the optional package IS installed, the factory returns a checkpointer
# over the DB path. Skip cleanly where it's absent (pre-deploy scaffolding).
pytest.importorskip("langgraph.checkpoint.sqlite")
cm = build_sqlite_checkpointer(tmp_path / "nested" / "state.db")
assert cm is not None
assert (tmp_path / "nested").is_dir()
# Contract: the factory returns a CONTEXT MANAGER (SqliteSaver.from_conn_string
# is a @contextmanager), so callers must enter it. Pin that here so a future
# change that returns a bare/un-entered object is caught (review FIX).
assert hasattr(cm, "__enter__") and hasattr(cm, "__exit__")
with cm as saver:
# The entered object is the real saver the graph compiles against.
assert hasattr(saver, "get_next_version")
# --- Checkpoint serializer / QuestionSet msgpack allowlist (D9). ------------
# QuestionSet rides in the clarifier interrupt payload and so is msgpack-encoded
# into every checkpoint. The default serializer deserializes it but logs
# "Deserializing unregistered type agent_team.transport.base.QuestionSet ... will
# be blocked in a future version" on each load, and the coming LangGraph default
# turns that warning into a hard block (breaking durable resume). These pin that
# build_checkpoint_serde registers the type so it round-trips silently AND is
# already block-clean.
_QSET_MSGPACK_KEY = ("agent_team.transport.base", "QuestionSet")
def _capture_serde_warnings():
"""Attach a capturing handler to the serde logger; return (handler, buffer)."""
import io
import logging
buf = io.StringIO()
handler = logging.StreamHandler(buf)
handler.setLevel(logging.WARNING)
logger = logging.getLogger("langgraph.checkpoint.serde.jsonplus")
logger.addHandler(handler)
logger.setLevel(logging.WARNING)
return logger, handler, buf
def _reset_serde_warning_dedup() -> None:
"""Clear the serializer's process-wide warn-once dedup set.
jsonplus dedups "unregistered type" warnings across the process lifetime, so
an earlier test (or the assertion below) could mask a regression. Clearing the
set makes each assertion observe the live behavior, not a stale dedup.
"""
from langgraph.checkpoint.serde import jsonplus as _jp
_jp._warned_unregistered_types.clear()
_jp._warned_blocked_types.clear()
def test_build_checkpoint_serde_allowlists_questionset() -> None:
# The serde must carry QuestionSet on its msgpack allowlist (an explicit
# collection, NOT the permissive ``True`` sentinel) — that is the registration
# path the warning recommends and what makes resume forward-compatible.
pytest.importorskip("langgraph.checkpoint.serde.jsonplus")
serde = build_checkpoint_serde()
allowed = serde._allowed_msgpack_modules
assert allowed is not True # not the warn-on-everything permissive default
assert _QSET_MSGPACK_KEY in allowed
def test_questionset_round_trips_through_serde_without_warning() -> None:
# The configured serializer must round-trip a QuestionSet AND emit no
# "unregistered type" warning on deserialize.
pytest.importorskip("langgraph.checkpoint.serde.jsonplus")
_reset_serde_warning_dedup()
logger, handler, buf = _capture_serde_warnings()
try:
serde = build_checkpoint_serde()
qset = QuestionSet(
thread_id="t-1",
question_id="q-1",
turn=0,
questions=["What is in scope?"],
context={"phase": Phase.CLARIFY.value},
)
encoded = serde.dumps_typed(qset)
restored = serde.loads_typed(encoded)
finally:
logger.removeHandler(handler)
assert restored == qset
assert "unregistered type" not in buf.getvalue()
def test_questionset_round_trips_through_sqlite_checkpointer_without_warning(
tmp_path,
) -> None:
# End-to-end against the REAL production saver: drive the graph to the
# clarifier suspend (which checkpoints a QuestionSet), force a checkpoint
# load, and resume — asserting no "unregistered type" warning prints and the
# task still completes. This is the runtime regression guard for the warning.
pytest.importorskip("langgraph.checkpoint.sqlite")
_reset_serde_warning_dedup()
logger, handler, buf = _capture_serde_warnings()
try:
cm = build_sqlite_checkpointer(tmp_path / "state.db")
with cm as saver:
graph = build_graph(saver)
thread_id, _ = start_task(graph, transport="slack")
pending = pending_question(graph, thread_id=thread_id)
assert isinstance(pending["question_set"], QuestionSet)
# Force a fresh checkpoint deserialize (the warning's trigger point).
get_pipeline_state(graph, thread_id=thread_id)
resumed = resume_task(graph, thread_id=thread_id, answer="scope it")
finally:
logger.removeHandler(handler)
assert resumed["status"] == TaskStatus.DONE.value
assert "unregistered type" not in buf.getvalue()
# --- P2 review-loop wiring. -------------------------------------------------
def _p2_plan_stub(state: PipelineState) -> PipelineState:
"""Stand-in for the real planner: emit a plan and advance to REVIEW.
Mirrors planner.plan_node's contract (sets ``plan`` + phase REVIEW) without a
model call, so the P2 graph topology + the review loop can be driven in a
unit test. The revision index tracks prior review rounds.
"""
revisions = len(state.get("review_verdicts") or [])
return PipelineState(
plan={"phases": ["P1"], "revision": revisions},
current_phase=Phase.REVIEW.value,
status=TaskStatus.ACTIVE.value,
)
def _p2_graph(review_text: str):
"""Compile a P2 graph whose review invoker returns ``review_text``."""
from agent_team.nodes import review_loop
review_loop.set_review_invoker(lambda prompt, **kw: review_text)
return build_graph(
checkpointer=_Saver(),
live_plan_node=_p2_plan_stub,
review_node=review_loop.bind_review_node(),
route_review=review_loop.route_after_review,
)
@pytest.fixture
def restore_review_invoker():
"""Save/restore the review-loop module-global invoker around a test."""
from agent_team.nodes import review_loop
saved = review_loop._review_invoker
yield
review_loop._review_invoker = saved
def test_build_graph_review_node_requires_route() -> None:
from agent_team.nodes import review_loop
with pytest.raises(ValueError, match="route_review"):
build_graph(review_node=review_loop.review_node)
def test_p2_graph_approve_terminates(restore_review_invoker) -> None:
# clarify(stub) -> plan(stub->REVIEW) -> review(APPROVE) -> END.
graph = _p2_graph("VERDICT: APPROVE\nlooks solid")
thread_id, _ = start_task(graph, transport="slack")
final = resume_task(graph, thread_id=thread_id, answer="scope is X")
# The review node advanced an APPROVED plan toward BUILD and the graph ended.
assert final["current_phase"] == Phase.BUILD.value
assert len(final["review_verdicts"]) == 1
def test_p2_graph_loops_then_escalates_on_persistent_changes(
restore_review_invoker,
) -> None:
# A reviewer that never approves loops plan<->review until the round cap,
# then escalates (parks) rather than spinning. Default cap is 3 rounds.
graph = _p2_graph("VERDICT: REQUEST CHANGES\nstill not ready")
thread_id, _ = start_task(graph, transport="slack")
final = resume_task(graph, thread_id=thread_id, answer="scope is X")
assert final["current_phase"] == Phase.PARKED.value
assert final["status"] == TaskStatus.PARKED.value
assert len(final["review_verdicts"]) == 3 # looped to the cap, then escalated
# --- P3 build -> verify subgraph wiring (opt-in). ---------------------------
def _p3_plan_stub(state: PipelineState) -> PipelineState:
"""P2/P3 planner stub: emit an APPROVED, scoped plan and advance to REVIEW.
Like ``_p2_plan_stub`` but carries a ``scope`` so the P3 BUILD node's
trust-control-surface scan accepts the candidate diff, letting the
build -> verify topology be driven end to end.
"""
revisions = len(state.get("review_verdicts") or [])
return PipelineState(
plan={
"title": "do it",
"scope": ["src"],
"phases": ["P1"],
"revision": revisions,
},
current_phase=Phase.REVIEW.value,
status=TaskStatus.ACTIVE.value,
)
def _p3_diff() -> str:
"""A minimal in-scope unified diff the fake builder returns."""
return "diff --git a/src/foo.py b/src/foo.py\n@@ -1 +1 @@\n-old\n+new\n"
def _p3_graph(review_text: str, *, ci_result_fetcher):
"""Compile a P3 graph: review -> build -> verify with injected seams.
The diff builder is a fixed in-scope diff; the CI-result fetcher is injected
so the test drives the verifier verdict (pass / fail / none) deterministically
with no live CI.
"""
from agent_team.nodes import review_loop
from agent_team.nodes.build_verify_subgraph import (
make_build_node,
make_verify_node,
route_after_verify,
)
from agent_team.nodes.verifier import VerifierConfig
review_loop.set_review_invoker(lambda prompt, **kw: review_text)
def fake_builder(*, plan, config):
return _p3_diff()
build_node = make_build_node(diff_builder=fake_builder)
verify_node = make_verify_node(
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
ci_result_fetcher=ci_result_fetcher,
)
return build_graph(
checkpointer=_Saver(),
live_plan_node=_p3_plan_stub,
review_node=review_loop.bind_review_node(),
route_review=review_loop.route_after_review,
build_verify=(build_node, verify_node, route_after_verify),
)
def test_build_graph_build_verify_requires_review_node() -> None:
"""build_verify without review_node is a wiring error (no 'build' route)."""
from agent_team.nodes.build_verify_subgraph import (
make_build_node,
make_verify_node,
route_after_verify,
)
from agent_team.nodes.verifier import VerifierConfig
tuple_ = (
make_build_node(diff_builder=None),
make_verify_node(VerifierConfig(expected_run_id="")),
route_after_verify,
)
with pytest.raises(ValueError, match="build_verify"):
build_graph(build_verify=tuple_)
def test_p3_graph_authenticated_pass_routes_to_done(restore_review_invoker) -> None:
"""review(APPROVE) -> build -> verify(PASS via fake CI) -> DONE (PR terminus)."""
def pass_fetcher(state):
from agent_team.state_store import compute_content_hash
diff_hash = compute_content_hash(_p3_diff().encode("utf-8"))
return {"run_id": "r1", "conclusion": "success", "diff_hash": diff_hash}
graph = _p3_graph("VERDICT: APPROVE\nlooks solid", ci_result_fetcher=pass_fetcher)
thread_id, _ = start_task(graph, transport="slack")
final = resume_task(graph, thread_id=thread_id, answer="scope is X")
# The authenticated CI pass cleared the gate -> DONE terminus.
assert final["current_phase"] == Phase.DONE.value
assert final["status"] == TaskStatus.DONE.value
def test_p3_graph_inert_default_parks_at_verify(restore_review_invoker) -> None:
"""review(APPROVE) -> build -> verify(no CI result) -> BLOCK -> PARKED.
With the INERT default (no authenticated CI result) the gate can never
fabricate a pass, so an approved plan still parks at VERIFY. This is the
production-safe behavior the opt-in subgraph ships with.
"""
graph = _p3_graph(
"VERDICT: APPROVE\nlooks solid", ci_result_fetcher=lambda state: None
)
thread_id, _ = start_task(graph, transport="slack")
final = resume_task(graph, thread_id=thread_id, answer="scope is X")
assert final["current_phase"] == Phase.PARKED.value
assert final["status"] == TaskStatus.PARKED.value
def test_p3_graph_route_constants_mirror_subgraph_by_value() -> None:
"""graph.py's P3 route ids match the subgraph module by value (no cycle)."""
from agent_team.nodes import build_verify_subgraph as bvs
assert graph_mod.APPROVED_ROUTE == bvs.APPROVED_ROUTE
assert graph_mod.BUILD_ROUTE == bvs.BUILD_ROUTE
assert graph_mod.PARKED_ROUTE == bvs.PARKED_ROUTE
# --- Module import hygiene. -------------------------------------------------
def test_module_imports_without_optional_sqlite_dep() -> None:
# The module-level import of graph must not pull in the optional SQLite
# checkpointer (that import is deferred into build_sqlite_checkpointer).
assert hasattr(graph_mod, "build_graph")
assert hasattr(graph_mod, "build_sqlite_checkpointer")