mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
* implement goal continuations * fix(goal): address review findings for goal continuations - goal: key the no-progress breaker on a signature of the latest visible assistant evidence instead of the evaluator's volatile free-text, so it actually fires on stalled turns; thread the signature through every worker persist / no-progress call site - goal: align _stand_down_reason default caps with should_continue_goal (8 / 2) so the two gate functions agree on goals missing the fields - runtime: offload the synchronous checkpointer fallback via asyncio.to_thread (goal.py + worker.py) to keep blocking IO off the loop - frontend: i18n the GoalStatus "Goal" label (goalLabel in en/zh/types) - frontend: extract pure composer helpers into input-box-helpers.ts with unit tests (parseGoalCommand, readGoalResponseError, skill suggestions) - tests: cover the evidence-based no-progress and default-cap behavior - docs: align backend/AGENTS.md goal paragraph with actual behavior - e2e: prettier-format chat.spec.ts (fixes the lint-frontend CI failure) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(frontend): hide goal continuation counter until the agent continues The goal status bar rendered a raw "0/8" before any auto-continuation, which read as a mysterious score. Now the counter is hidden until continuation_count > 0, then shows "Continuing N/M" with a tooltip explaining the auto-continuation cap. - Extract getGoalContinuationDisplay into a pure helper (hides at 0) + unit tests - Add goalContinuing / goalContinuationTooltip i18n keys (en/zh/types) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(goal): address review findings for goal continuations Frontend correctness - Fix the optimistic /goal result permanently shadowing server goal state: the streamed continuation counter never surfaced for a goal set in-session. Extract a shared useActiveGoal hook (used by both chat pages) that reconciles the optimistic copy with server state via a goalReconciliationKey, de-duping the copy-pasted goal block across the two pages. - Stop /goal status|clear failures from escaping handleSubmit as unhandled rejections (handleGoalCommand now returns success; the run only starts when a goal was actually saved). - Use a function replacer for the goal-status toast so an objective containing $&/$1 isn't treated as a replacement pattern. Backend cleanliness / correctness - De-duplicate four byte-identical helpers (_call_checkpointer_method, _message_type, _additional_kwargs, _is_visible_message) by importing them from runtime.goal instead of re-defining them in the run worker. - Remove the dead `checkpoint_tuple.tasks` durability guard (CheckpointTuple has no tasks field) and document that pending_writes is the durability signal. - Decompose the 176-line _prepare_goal_continuation_input: extract _reread_goal_and_checkpoint and a _persist closure so the thread-unchanged guard and stand-down persistence aren't open-coded three times. Document the last-writer-wins write-window limitation as a follow-up. - Add a shared parse_goal_command helper and use it from the TUI and IM-channel /goal handlers (one place for the status/clear/set semantics). Tests - Restore the 11 command-registry tests dropped by the previous goal change (filter_commands ranking/description, build_registry builtins/skills, resolve cases) alongside the new goal tests. - Add coverage for the IM-channel _handle_goal_command, the TUI _handle_goal handler, parse_goal_command, and goalReconciliationKey. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix goal review feedback * fix goal continuation checkpoint races * prioritize goal commands while streaming Route composer submits through a shared helper so /goal commands can be handled before the streaming stop shortcut, while ordinary streaming submits still stop the active run. Testing: cd frontend && pnpm exec rstest run tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; cd frontend && pnpm check * preserve goal status during clarification Keep omitted stream goal fields distinct from explicit null clears so clarification interrupts do not hide an active thread goal that is still present in the checkpoint. Testing: pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check * style: format active goal hook Run Prettier on use-active-goal.ts to satisfy the frontend lint workflow formatting gate. Testing: pnpm format; pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check * fix goal review followups --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
257 lines
8.7 KiB
Python
257 lines
8.7 KiB
Python
"""Integration tests for the Textual app via the pilot harness.
|
|
|
|
Uses a fake in-process session so no real model is invoked. Exercises the full
|
|
loop: keypress -> submit -> worker thread -> stream_actions -> reducer -> state.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from deerflow.client import StreamEvent
|
|
from deerflow.tui.app import DeerFlowTUI
|
|
from deerflow.tui.cli import LaunchPlan
|
|
|
|
|
|
class _FakeClient:
|
|
def list_models(self):
|
|
return {"models": [{"name": "fake-model", "display_name": "Fake Model"}]}
|
|
|
|
def list_skills(self, enabled_only=False):
|
|
return {"skills": [{"name": "tdd", "enabled": True}]}
|
|
|
|
def stream(self, message, *, thread_id=None, **kwargs):
|
|
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Hello ", "id": "m1"})
|
|
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "world", "id": "m1"})
|
|
yield StreamEvent(type="end", data={"usage": {"total_tokens": 3}})
|
|
|
|
|
|
class _FakeSession:
|
|
def __init__(self):
|
|
self.client = _FakeClient()
|
|
|
|
def resolve_thread(self, plan):
|
|
return None
|
|
|
|
|
|
async def _wait_until(predicate, pilot, *, timeout=3.0):
|
|
deadline = 0.0
|
|
while deadline < timeout:
|
|
await pilot.pause()
|
|
if predicate():
|
|
return True
|
|
await asyncio.sleep(0.02)
|
|
deadline += 0.02
|
|
return predicate()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_app_runs_a_turn_and_renders_streamed_assistant():
|
|
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
await pilot.press("h", "i")
|
|
await pilot.press("enter")
|
|
await _wait_until(
|
|
lambda: not app._streaming and any(r.kind == "assistant" for r in app.state.rows),
|
|
pilot,
|
|
)
|
|
|
|
kinds = [r.kind for r in app.state.rows]
|
|
assert "user" in kinds
|
|
assert "assistant" in kinds
|
|
assistant = [r for r in app.state.rows if r.kind == "assistant"][-1]
|
|
assert assistant.text == "Hello world"
|
|
assert app.state.usage == {"total_tokens": 3}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_app_assigns_thread_id_on_first_send():
|
|
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
assert app._conv_thread_id is None
|
|
await pilot.press("y", "o")
|
|
await pilot.press("enter")
|
|
await _wait_until(lambda: app._conv_thread_id is not None, pilot)
|
|
assert app._conv_thread_id is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_help_command_renders_system_row_without_calling_agent():
|
|
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
for ch in "/help":
|
|
await pilot.press(ch)
|
|
await pilot.press("enter")
|
|
await _wait_until(lambda: any(r.kind == "system" for r in app.state.rows), pilot)
|
|
|
|
assert any(r.kind == "system" for r in app.state.rows)
|
|
# /help must not produce a user turn or start streaming.
|
|
assert not any(r.kind == "user" for r in app.state.rows)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_up_arrow_recalls_previous_input_from_history():
|
|
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
for ch in "remember me":
|
|
await pilot.press("space" if ch == " " else ch)
|
|
await pilot.press("enter")
|
|
await _wait_until(lambda: any(r.kind == "user" for r in app.state.rows), pilot)
|
|
# Composer is empty after submit; Up should recall the last entry.
|
|
await pilot.press("up")
|
|
await pilot.pause()
|
|
assert app.query_one("#composer").value == "remember me"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escape_interrupts_an_active_run():
|
|
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
app._streaming = True
|
|
app._refresh_status()
|
|
await pilot.press("escape")
|
|
await pilot.pause()
|
|
assert app._streaming is False
|
|
assert any(r.kind == "system" and "Interrupt" in r.text for r in app.state.rows)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tab_keeps_focus_on_composer_when_palette_closed():
|
|
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
composer = app.query_one("#composer")
|
|
assert app.focused is composer
|
|
await pilot.press("tab")
|
|
await pilot.pause()
|
|
# Tab must not move focus off the composer to the scroll region.
|
|
assert app.focused is composer
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_command_shows_error_system_row():
|
|
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
for ch in "/nope":
|
|
await pilot.press(ch)
|
|
await pilot.press("enter")
|
|
await _wait_until(
|
|
lambda: any(r.kind == "system" and getattr(r, "tone", "") == "error" for r in app.state.rows),
|
|
pilot,
|
|
)
|
|
assert any(r.kind == "system" and r.tone == "error" for r in app.state.rows)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /goal handler
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class _GoalClient(_FakeClient):
|
|
"""Records goal API calls and keeps an in-memory active goal."""
|
|
|
|
def __init__(self):
|
|
self.calls: list[tuple] = []
|
|
self.goal: dict | None = None
|
|
|
|
def get_goal(self, thread_id):
|
|
self.calls.append(("get", thread_id))
|
|
return {"goal": self.goal}
|
|
|
|
def set_goal(self, thread_id, objective):
|
|
self.calls.append(("set", thread_id, objective))
|
|
self.goal = {"objective": objective, "status": "active"}
|
|
return {"goal": self.goal}
|
|
|
|
def clear_goal(self, thread_id):
|
|
self.calls.append(("clear", thread_id))
|
|
self.goal = None
|
|
return {"goal": None}
|
|
|
|
|
|
class _GoalSession(_FakeSession):
|
|
def __init__(self):
|
|
self.client = _GoalClient()
|
|
|
|
|
|
def _system_rows(app):
|
|
return [r for r in app.state.rows if r.kind == "system"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_goal_set_mints_thread_and_reports_objective():
|
|
session = _GoalSession()
|
|
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
assert app._conv_thread_id is None
|
|
app._handle_goal("finish the work")
|
|
await pilot.pause()
|
|
assert app._conv_thread_id is not None
|
|
assert ("set", app._conv_thread_id, "finish the work") in session.client.calls
|
|
assert any("Goal set: finish the work" in r.text for r in _system_rows(app))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_goal_status_without_thread_reports_no_active_goal():
|
|
session = _GoalSession()
|
|
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
app._handle_goal("")
|
|
await pilot.pause()
|
|
# No thread yet -> no gateway round-trip.
|
|
assert session.client.calls == []
|
|
assert any(r.text == "No active goal." for r in _system_rows(app))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_goal_status_reports_active_objective():
|
|
session = _GoalSession()
|
|
session.client.goal = {"objective": "ship it", "status": "active"}
|
|
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
app._conv_thread_id = "t-1"
|
|
app._handle_goal("")
|
|
await pilot.pause()
|
|
assert ("get", "t-1") in session.client.calls
|
|
assert any("Goal: ship it" in r.text for r in _system_rows(app))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_goal_clear_calls_gateway_and_confirms():
|
|
session = _GoalSession()
|
|
session.client.goal = {"objective": "ship it", "status": "active"}
|
|
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
app._conv_thread_id = "t-1"
|
|
app._handle_goal("clear")
|
|
await pilot.pause()
|
|
assert ("clear", "t-1") in session.client.calls
|
|
assert any(r.text == "Goal cleared." for r in _system_rows(app))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_goal_set_failure_shows_error_tone():
|
|
class _Boom(_GoalClient):
|
|
def set_goal(self, thread_id, objective):
|
|
raise RuntimeError("gateway down")
|
|
|
|
session = _GoalSession()
|
|
session.client = _Boom()
|
|
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
app._conv_thread_id = "t-1"
|
|
app._handle_goal("do it")
|
|
await pilot.pause()
|
|
errors = [r for r in _system_rows(app) if r.tone == "error"]
|
|
assert any("Could not set goal." in r.text for r in errors)
|