Files
cowork-local/tests/integration/test_task_executor_flow.py
T
anhtnm1andClaude Opus 5 15e1d3eb65 test(R03/R04): cover the three code paths that were changed but never executed
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>
2026-08-21 10:45:05 +09:00

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