deer-flow/backend/tests/test_scheduled_goal_notes_contract.py
Totoro cdbcc0d9e9
feat(scheduled-tasks): show goal outcomes and end conditions in run history (#6326)
* docs(scheduler): document tool_enabled, goal notices and goal-evaluation change

Follow-up to #6229:
- CONFIGURATION.md: add scheduler.tool_enabled to the example and notes.
- CONFIGURATION.md, README (en/zh/pt): unmet goal runs send a goal-unmet
  notice instead of a completion notice, and three unmet runs add an
  auto-pause notice; the docs still said only success/failed notify.
- CHANGELOG (en/zh): the #6229 entry now states the default-on goal
  evaluation change for scheduled/webhook/autonomous goal runs and that
  evaluator usage counts toward run usage.

* feat(scheduled-tasks): show goal outcomes and end conditions in run history

Follow-up to #6229. The API already returns these fields; the tasks page
did not show them, so a met goal read as plain "Success", an unmet
reason code rendered as a red execution error, and the goal-unmet IM
notice pointed to a workspace showing neither objective nor verdict.

- Task detail shows the per-run goal, max automatic runs and end time
  when a conversation-created task has them.
- Goal runs show "Goal met" (or met relying on stated assumptions);
  unmet runs show a localized reason plus the host code in neutral
  styling. The run whose agent asked to stop the schedule is marked.
- Runs without a goal, and failed runs, render as before.

Read-only and frontend-only: no API change.

* docs(changelog): add #6326 scheduled-task goal outcome entry

* fix(scheduled-tasks): show readable unmet reasons instead of raw codes

Unmet runs now show only the localized reason; the host code moves to the
tooltip so it can still be matched with the IM notice. Codes without a
label are still shown verbatim.

* fix(scheduler): show readable goal reasons instead of internal codes

Task detail now localizes the host-written last_error values from goal and
stop finalization (agent stop, automatic pause, unmet reason codes) under a
matching label, and Chinese copy is tightened. Goal-unmet and auto-pause IM
notices state the reason in words instead of printing the raw code; unknown
codes are still not forwarded.

* docs(changelog): describe readable goal reasons in #6326 entry

* test(scheduler): pin host goal strings with a cross-language contract

Address review on #6326:
- Add contracts/scheduled_goal_notes_contract.json listing the task
  last_error strings and unmet reason codes the tasks page and IM notices
  translate. A backend test checks finalization constants, blocker and
  stand-down codes against it; a frontend test checks every listed value
  has a label.
- Move the two last_error strings in finalization.py into constants.
- Frontend reason lookup strips the blocked: prefix like the IM map and
  now covers goal_not_met_yet, with en/zh labels.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-05 21:56:04 +08:00

58 lines
2.4 KiB
Python

"""Contract tests for host-written scheduled goal strings.
The tasks page (``frontend/src/core/scheduled-tasks/goal-outcome.ts``) and the
IM notice renderer translate these values instead of showing them raw, so the
backend must keep producing exactly what ``contracts/scheduled_goal_notes_contract.json``
lists.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from app.scheduler.notification_delivery import render_notification_text
from deerflow.persistence.scheduled_task_runs import finalization
from deerflow.runtime.goal import DEFAULT_MAX_GOAL_CONTINUATIONS, DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS, GOAL_BLOCKERS
from deerflow.runtime.runs import worker
_REPO_ROOT = Path(__file__).resolve().parents[2]
_CONTRACT = json.loads((_REPO_ROOT / "contracts" / "scheduled_goal_notes_contract.json").read_text(encoding="utf-8"))
_CODES = set(_CONTRACT["unmet_reason_codes"])
def test_last_error_strings_match_contract():
assert finalization.AGENT_STOP_LAST_ERROR_PREFIX == _CONTRACT["agent_stop_last_error_prefix"]
assert finalization.AUTO_PAUSE_LAST_ERROR == _CONTRACT["auto_pause_last_error"]
def test_blocker_stand_down_codes_are_in_contract():
# An unsatisfied verdict never carries the "none" blocker.
assert {f"blocked:{blocker}" for blocker in GOAL_BLOCKERS - {"none"}} <= _CODES
def test_cap_stand_down_reasons_are_in_contract():
evaluation = {"satisfied": False, "blocker": "goal_not_met_yet", "reason": ""}
at_cap = {"continuation_count": DEFAULT_MAX_GOAL_CONTINUATIONS}
assert worker._stand_down_reason(at_cap, evaluation, 0) in _CODES
assert worker._stand_down_reason({"continuation_count": 0}, evaluation, DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS) in _CODES
def test_literal_stand_down_reasons_and_no_verdict_are_in_contract():
source = Path(worker.__file__).read_text(encoding="utf-8")
literals = set(re.findall(r'stand_down_reason\s*=\s*"([a-z_:]+)"', source))
assert literals, "pattern no longer matches worker.py; update this test"
assert literals <= _CODES
assert '"no_verdict"' in Path(finalization.__file__).read_text(encoding="utf-8")
assert "no_verdict" in _CODES
@pytest.mark.parametrize("code", sorted(_CODES))
def test_im_notice_translates_every_contract_code(code):
text = render_notification_text({"event": "run_unmet", "task_id": "task-a", "payload": {"reason_code": code}})
assert "Reason: unknown." not in text
assert code not in text