deer-flow/backend/tests/test_thread_goal_contract.py
Totoro 059758f321
fix(runtime): stop Goal continuations after loop hard stops (#7167)
* fix(runtime): stop Goal continuations after loop hard stops

* chore: remove redundant SVG sources from Goal fix

* test(goal): remove PR-only screenshot generation

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-20 16:55:13 +08:00

97 lines
4.9 KiB
Python

"""Contract tests for the chat goal state the web UI reads.
The chat goal bar translates stand-down codes, recognizes host-written reasons
and reads ``goal_outcome`` from the history head, so the backend must keep
producing exactly what ``contracts/thread_goal_contract.json`` lists.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from types import SimpleNamespace
from typing import get_args, get_type_hints
from unittest.mock import AsyncMock
import pytest
from _router_auth_helpers import call_unwrapped
from fastapi import BackgroundTasks
from langchain_core.messages import HumanMessage
from app.gateway.routers import threads
from app.gateway.services import SERVER_OWNED_STATE_CHANNELS
from deerflow.agents.goal_state import GoalOutcomeState
from deerflow.agents.thread_state import ThreadState
from deerflow.persistence.scheduled_task_runs import finalization
from deerflow.runtime import goal
from deerflow.runtime.runs import worker
_REPO_ROOT = Path(__file__).resolve().parents[2]
_CONTRACT = json.loads((_REPO_ROOT / "contracts" / "thread_goal_contract.json").read_text(encoding="utf-8"))
_MET = {"satisfied": True, "blocker": "none", "reason": "The report is complete."}
def test_stand_down_reason_codes_match_the_worker():
unmet = {"satisfied": False, "blocker": "goal_not_met_yet", "reason": ""}
# goal_not_met_yet never stands down by itself; the caps below decide it.
blocked = {worker._stand_down_reason({}, {**unmet, "blocker": blocker}, 0) for blocker in goal.GOAL_BLOCKERS - {"none"}} - {None}
caps = {
worker._stand_down_reason({"continuation_count": goal.DEFAULT_MAX_GOAL_CONTINUATIONS}, unmet, 0),
worker._stand_down_reason({"continuation_count": 0}, unmet, goal.DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS),
}
literals = set(re.findall(r'stand_down_reason\s*=\s*"([a-z_:]+)"', Path(worker.__file__).read_text(encoding="utf-8")))
assert literals, "pattern no longer matches worker.py; update this test"
assert set(_CONTRACT["stand_down_reason_codes"]) == blocked | caps | literals | worker._GOAL_RUN_HARD_STOP_REASONS
def test_check_failure_codes_match_the_scheduled_tasks_page():
assert tuple(_CONTRACT["check_failure_codes"]) == finalization.CHECK_FAILURE_CODES
assert set(_CONTRACT["check_failure_codes"]) <= set(_CONTRACT["stand_down_reason_codes"])
def test_goal_outcome_shape_matches_the_state_type_and_its_builder():
contract = _CONTRACT["goal_outcome"]
hints = get_type_hints(GoalOutcomeState)
record = goal.build_goal_outcome(goal.build_goal_state("Ship it"), _MET, reply_message_id="ai-1")
assert contract["channel"] == goal.GOAL_OUTCOME_CHANNEL
assert contract["channel"] in get_type_hints(ThreadState)
assert contract["channel"] in SERVER_OWNED_STATE_CHANNELS
assert list(hints) == list(record) == contract["keys"]
assert list(get_args(hints["status"])) == contract["statuses"]
assert record["status"] in contract["statuses"]
def test_host_reason_texts_are_the_reasons_the_host_writes():
pattern = r'GoalEvaluation\(\s*satisfied=False,\s*blocker="[a-z_]+",\s*reason="([^"]+)"'
texts = {text for module in (goal, worker) for text in re.findall(pattern, Path(module.__file__).read_text(encoding="utf-8"))}
assert texts, "pattern no longer matches goal.py/worker.py; update this test"
assert set(_CONTRACT["host_reason_texts"]) == texts
@pytest.mark.asyncio
async def test_history_head_carries_exactly_the_contract_keys(monkeypatch):
active = goal.build_goal_state("Ship it")
values = {
"title": "Report",
"thread_data": {"workspace_path": "/synthetic/workspace"},
"messages": [HumanMessage(id="h1", content="Ship it")],
# Server writers never leave both set; the projection must still carry each.
"goal": active,
"goal_outcome": goal.build_goal_outcome(active, _MET, reply_message_id=None),
"todos": [{"content": "write tests", "status": "pending"}],
"artifacts": ["/mnt/user-data/outputs/report.md"],
}
snapshots = [SimpleNamespace(values=values, config={"configurable": {"checkpoint_id": checkpoint_id}}, parent_config=None, metadata={}, next=(), created_at=None) for checkpoint_id in ("head", "older")]
accessor = SimpleNamespace(ahistory=AsyncMock(return_value=snapshots))
monkeypatch.setattr(threads, "get_checkpointer", lambda _request: None)
monkeypatch.setattr(threads, "build_thread_checkpoint_state_accessor", AsyncMock(return_value=(accessor, {})))
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
entries = await call_unwrapped(threads.get_thread_history, thread_id="contract-thread", body=threads.ThreadHistoryRequest(limit=2), request=request, background_tasks=BackgroundTasks())
head, older = (entry.model_dump(mode="json")["values"] for entry in entries)
assert set(head) == set(_CONTRACT["history_head_keys"])
assert set(older) == {"title", "thread_data"}