Verification gap closed. The suite proved the new services correct in isolation, but three paths I had modified had no test actually running them: tests/integration/test_task_executor_flow.py (7 tests) The Schedule Task path after R04-T05. Pins that History is still re-saved from the LIVE message list mid-run (the reason begin_turn() exists - the pre-turn copy would have frozen progress at the first user message), that update_plan tracking still reports an unfinished checklist, and that a failed run still raises so execute_task writes error.txt. tests/integration/test_routing_surfaces.py (11 tests) Real offscreen CoworkTab/Co4ETab/FolderTab calling the shared routing service: correct surface key per screen, Auto switches, Off does not consult the engine, Manual switches only on approval, a pinned Admin agent still wins, and AI-Edit still pins TaskType.CODING. Also pins the field contract ui/routing_toggle.py reads off RoutingDecision (from_model/to_model as provider/model keys) - a rename there would only fail inside a modal dialog. Also updates docs/refactor/Refactoring_Checklist.md: the 16 completed R01/R03/R04 tasks, the Team Duy daily rows, and a status block recording the measured numbers, the scope correction (team owns R01/R02/R04/R10), and what is still outstanding. Suite: 243 passed, 2 pre-existing failures (EPIC R02). Fast suite (unit + contracts + characterization + routing): 218 passed in 1.16s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
7.2 KiB
Python
179 lines
7.2 KiB
Python
"""End-to-end check of the Schedule Task path after R04-T05.
|
|
|
|
``core/task_executors.py::_run_agent`` used to assemble its own ``run_cowork``
|
|
call, in parallel with ``ui/cowork_tab.py`` doing the same thing slightly
|
|
differently. It now goes through ``ConversationApplicationService``, and the
|
|
things most at risk from that change are exactly what this file pins:
|
|
|
|
* the unattended run still returns the answer text the scheduler writes to output.md
|
|
* History is still re-saved from the LIVE message list after every assistant
|
|
message, so a long run shows progress when reopened mid-flight
|
|
* ``update_plan`` tracking still works, so a task whose checklist is unfinished
|
|
is not reported as done
|
|
* a failed run still raises, because ``execute_task`` writes error.txt from it
|
|
|
|
No Qt and no network: the provider is scripted and History is redirected into a
|
|
tmp folder.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
import pytest
|
|
|
|
from cowork_local.config import AppConfig
|
|
from cowork_local.core import audit_log, chat_agent, task_executors
|
|
from cowork_local.state import AppContext
|
|
from tests.fakes import FakeProvider, ScriptedTurn
|
|
|
|
|
|
@pytest.fixture
|
|
def task_ctx(tmp_path: Path, monkeypatch):
|
|
"""An AppContext whose History and audit log live in a tmp folder."""
|
|
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
|
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
|
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
|
|
|
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
|
# Same reason as the Cowork integration suite: the security pre-flight costs
|
|
# an extra provider call that has nothing to do with what is being tested.
|
|
ctx.config.agent_security["enabled"] = False
|
|
monkeypatch.setattr(ctx.config, "history_dir", lambda: tmp_path / "history")
|
|
return ctx
|
|
|
|
|
|
@pytest.fixture
|
|
def history_saves(monkeypatch) -> List[List[Dict[str, Any]]]:
|
|
"""Capture a SNAPSHOT of the messages at each History save.
|
|
|
|
Snapshotting matters: the engine keeps appending to the same list, so
|
|
storing the list itself would make every recorded save look identical to the
|
|
final state and the "live progress" assertion would prove nothing.
|
|
"""
|
|
saves: List[List[Dict[str, Any]]] = []
|
|
|
|
def fake_save(_dir, _kind, _session_id, messages, **_kwargs):
|
|
saves.append([dict(m) for m in messages])
|
|
|
|
from cowork_local.core import history
|
|
|
|
monkeypatch.setattr(history, "save_conversation", fake_save)
|
|
return saves
|
|
|
|
|
|
def _run(ctx, provider, prompt="do the thing", out_dir: Path = None, **kwargs):
|
|
"""Run one unattended cowork task with ``provider`` pinned."""
|
|
ctx.build_active_provider = lambda: provider
|
|
events: List[Dict[str, Any]] = []
|
|
result = task_executors._run_agent(
|
|
ctx, "cowork", prompt, out_dir, events.append, lambda: False,
|
|
title=kwargs.pop("title", "T1"), **kwargs)
|
|
return result, events
|
|
|
|
|
|
def test_an_unattended_cowork_run_returns_the_answer(task_ctx, tmp_path, history_saves):
|
|
provider = FakeProvider([ScriptedTurn(text="task answer")])
|
|
|
|
(answer, timed_out, incomplete), events = _run(task_ctx, provider,
|
|
out_dir=tmp_path / "out")
|
|
|
|
assert answer == "task answer"
|
|
assert timed_out is False
|
|
assert incomplete == ""
|
|
assert provider.call_count == 1
|
|
|
|
|
|
def test_the_scheduler_still_gets_history_ready_before_the_turn_events(
|
|
task_ctx, tmp_path, history_saves):
|
|
"""The scheduler refreshes the History panel on this event, so a running
|
|
task's conversation shows up while it runs."""
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
|
|
_, events = _run(task_ctx, provider, out_dir=tmp_path / "out")
|
|
|
|
assert [e["type"] for e in events] == [
|
|
"history_ready", "text", "assistant_done", "turn_completed"]
|
|
|
|
|
|
def test_history_is_resaved_from_the_live_conversation_during_the_run(
|
|
task_ctx, tmp_path, history_saves):
|
|
"""The reason ``begin_turn()`` exists: the service builds its own message
|
|
list, and the scheduler needs THAT list - not the pre-turn copy - or the
|
|
mid-run saves would only ever contain the original user message.
|
|
"""
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("save_file", {"filename": "a.md", "content": "x"})]),
|
|
ScriptedTurn(text="Saved."),
|
|
])
|
|
|
|
_run(task_ctx, provider, out_dir=tmp_path / "out")
|
|
|
|
# At least one save DURING the run already carried an assistant message,
|
|
# and the final save carries the whole conversation.
|
|
assert len(history_saves) >= 3 # initial + per assistant_done + final
|
|
assert any(any(m["role"] == "assistant" for m in save)
|
|
for save in history_saves[1:-1])
|
|
assert [m["role"] for m in history_saves[-1]] == [
|
|
"system", "user", "assistant", "tool", "assistant"]
|
|
|
|
|
|
def test_an_unfinished_plan_is_reported_so_the_task_is_not_marked_done(
|
|
task_ctx, tmp_path, history_saves):
|
|
"""plan_set tracking runs through the same emit path; losing it would let a
|
|
task whose own checklist says "not finished" be reported as successful."""
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("update_plan", {"steps": [
|
|
{"title": "step one", "status": "running"}]})]),
|
|
ScriptedTurn(text="stopping here"),
|
|
])
|
|
|
|
(_answer, _timed_out, incomplete), _events = _run(task_ctx, provider,
|
|
out_dir=tmp_path / "out")
|
|
|
|
assert incomplete != ""
|
|
|
|
|
|
def test_a_completed_plan_reports_no_incompleteness(task_ctx, tmp_path, history_saves):
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("update_plan", {"steps": [
|
|
{"title": "step one", "status": "done"}]})]),
|
|
ScriptedTurn(text="all done"),
|
|
])
|
|
|
|
(_answer, _timed_out, incomplete), _events = _run(task_ctx, provider,
|
|
out_dir=tmp_path / "out")
|
|
|
|
assert incomplete == ""
|
|
|
|
|
|
def test_a_failed_run_still_raises_so_execute_task_writes_error_txt(
|
|
task_ctx, tmp_path, history_saves):
|
|
provider = FakeProvider([ScriptedTurn(error="provider down"),
|
|
ScriptedTurn(error="provider down")])
|
|
|
|
with pytest.raises(Exception) as excinfo:
|
|
_run(task_ctx, provider, out_dir=tmp_path / "out")
|
|
|
|
assert "provider down" in str(excinfo.value)
|
|
# The partial conversation is still saved - it is exactly what the user
|
|
# needs to see after a failure.
|
|
assert history_saves
|
|
|
|
|
|
def test_a_per_task_provider_override_is_honoured(task_ctx, tmp_path, history_saves):
|
|
"""A task can pin its own provider/model; the service must use that one, not
|
|
the machine's Settings default."""
|
|
default_provider = FakeProvider([], strict=True)
|
|
task_provider = FakeProvider([ScriptedTurn(text="from the pinned model")])
|
|
task_ctx.build_active_provider = lambda: default_provider
|
|
task_ctx.build_provider_for = lambda _name, _model: task_provider
|
|
|
|
(answer, _timed_out, _incomplete) = task_executors._run_agent(
|
|
task_ctx, "cowork", "go", tmp_path / "out", lambda _e: None, lambda: False,
|
|
title="T", provider_name="anthropic", model="claude")[0:3]
|
|
|
|
assert answer == "from the pinned model"
|
|
assert default_provider.call_count == 0
|
|
assert task_provider.call_count == 1
|