"""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