R04-T03 — the turn lifecycle, extracted from `core/chat_agent.py::run_cowork` into `application/conversations/`. The 260-line body mixed the lifecycle (step budget, cancel checks, guard -> preview -> gate -> execute ordering, sandbox tidy-up) with the machinery doing each step, and reaching any of it meant standing up a Qt widget and a worker thread. It is now a plain object driven through two Protocols and six callables (`turn_runtime.py`), with the concrete `core/*` wiring confined to `core_runtime_adapter.py` — the same shape R03 used for routing. Faithful port, not an improvement pass: where the original had a quirk (the step-ceiling note only merges into the answer when the last message is the assistant's) the quirk is preserved and commented. R04-T04 — `ui/cowork_tab.py::build_job` no longer calls run_cowork. It captures the widget's state at submit time, builds the request via the new `cowork_turn_request.py` and executes it. `execute(..., messages=...)` hands the widget's own list over because `_reattach_running_turn` replays from it WHILE the worker appends and `_finalize_turn` slices it afterwards — a private list would break both silently. R04-T05 — `core/task_executors.py`'s cowork branch shares the same engine. All five unattended-run behaviours stay put (plan reminder, history_ready, History autosave per assistant message, timeout notice, plan_incomplete_reason), and `_unattended_prompt` now expresses the load-bearing prefix order in one readable call instead of three successive rebindings. Verification: 74 new tests (364 passed, 1 skipped overall; check_imports PASS). The two that matter most: - `test_conversation_service_parity.py` runs the same scripted turn through run_cowork AND the service and compares the event stream, the resulting conversation and the advertised tool list across 7 scenarios; - `test_task_executor_turn.py` was written BEFORE the migration and passed 8/8 against the old code, then unchanged against the new. Known: `ui/cowork_tab.py` (416 -> 455) and `core/task_executors.py` (476 -> 524) stay above the 400-LOC limit. Both were already over it before this change; bringing them under needs the R08 / R07 decompositions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
7.2 KiB
Python
192 lines
7.2 KiB
Python
"""R04-T05 — the Schedule Task runner's cowork branch, pinned before and after.
|
|
|
|
Written against the CURRENT ``_run_agent`` first, as the safety net for moving it
|
|
onto ``ConversationApplicationService``: an unattended run has five behaviours the
|
|
interactive path does not have (the plan reminder prefixed to the prompt, the
|
|
session registered in History before the model starts, a re-save after every
|
|
assistant message, the timeout notice, and the "did the agent's own checklist
|
|
finish?" report), and none of them was covered by a test.
|
|
|
|
Everything is isolated from the user's real config: history goes to ``tmp_path``
|
|
via ``history.custom_dir`` and the AI guardrails are off, so no run touches
|
|
``~/.cowork_local`` or calls a model to review a prompt.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from cowork_local.config import DEFAULT_CONFIG, AppConfig
|
|
from cowork_local.core import task_executors
|
|
from cowork_local.tests.fakes.fake_provider import FakeProvider
|
|
|
|
|
|
class _FakeCtx:
|
|
"""The ``AppContext`` surface ``_run_agent`` touches."""
|
|
|
|
def __init__(self, config: AppConfig, provider: FakeProvider) -> None:
|
|
self.config = config
|
|
self._provider = provider
|
|
|
|
def build_active_provider(self) -> FakeProvider:
|
|
return self._provider
|
|
|
|
def build_provider_for(self, name=None, model=None) -> FakeProvider:
|
|
return self._provider
|
|
|
|
|
|
def _config(tmp_path: Path) -> AppConfig:
|
|
data = copy.deepcopy(DEFAULT_CONFIG)
|
|
# Keep the run entirely offline and off the real config dir.
|
|
data["agent_security"]["enabled"] = False
|
|
data["history"]["custom_dir"] = str(tmp_path / "history")
|
|
return AppConfig(data)
|
|
|
|
|
|
def _run(tmp_path: Path, provider: FakeProvider, *, prompt: str = "write the report",
|
|
timeout_sec: Optional[int] = None, admin_agent: Any = None):
|
|
"""Run one cowork task and return ``(result_tuple, events, config)``."""
|
|
out_dir = tmp_path / "run"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
config = _config(tmp_path)
|
|
events: List[Dict[str, Any]] = []
|
|
|
|
result = task_executors._run_agent(
|
|
_FakeCtx(config, provider), "cowork", prompt, out_dir,
|
|
events.append, lambda: False, title="Weekly report",
|
|
timeout_sec=timeout_sec, admin_agent=admin_agent,
|
|
)
|
|
return result, events, config
|
|
|
|
|
|
def _saved_conversation(config: AppConfig) -> Dict[str, Any]:
|
|
"""The single conversation the run wrote into the isolated history folder."""
|
|
files = list(Path(config.history_dir()).rglob("*.json"))
|
|
assert len(files) == 1, f"expected one saved conversation, found {files}"
|
|
return json.loads(files[0].read_text(encoding="utf-8"))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
def test_a_cowork_task_returns_the_final_answer(tmp_path: Path) -> None:
|
|
provider = FakeProvider()
|
|
provider.queue_response(content="Report is ready.")
|
|
|
|
(answer, timed_out, incomplete), _events, _config = _run(tmp_path, provider)
|
|
|
|
assert answer == "Report is ready."
|
|
assert timed_out is False
|
|
assert incomplete == ""
|
|
|
|
|
|
def test_the_plan_reminder_is_prefixed_to_the_prompt(tmp_path: Path) -> None:
|
|
# An unattended run has nobody watching, so the agent is pushed to keep its
|
|
# own checklist honest. The reminder must lead the message.
|
|
provider = FakeProvider()
|
|
provider.queue_response(content="ok")
|
|
|
|
_run(tmp_path, provider, prompt="write the report")
|
|
|
|
sent = provider.call_history[0][-1]["content"]
|
|
assert sent.startswith("This runs unattended (Schedule Task)")
|
|
assert sent.endswith("write the report")
|
|
|
|
|
|
def test_an_admin_agent_persona_sits_between_the_reminder_and_the_prompt(
|
|
tmp_path: Path) -> None:
|
|
class _Agent:
|
|
# An admin agent may pin its own provider/model; blank means "use the
|
|
# machine's Settings default", which is what build_agent_provider reads.
|
|
provider = ""
|
|
model = ""
|
|
|
|
def effective_prompt(self) -> str:
|
|
return "You are the reporting agent."
|
|
|
|
provider = FakeProvider()
|
|
provider.queue_response(content="ok")
|
|
|
|
_run(tmp_path, provider, prompt="write the report", admin_agent=_Agent())
|
|
|
|
sent = provider.call_history[0][-1]["content"]
|
|
assert sent.index("This runs unattended") < sent.index("You are the reporting agent.")
|
|
assert sent.index("You are the reporting agent.") < sent.index("write the report")
|
|
|
|
|
|
def test_the_session_is_announced_once_it_exists_on_disk(tmp_path: Path) -> None:
|
|
# The scheduler refreshes History on this event, so it must not fire before
|
|
# the conversation is really there.
|
|
provider = FakeProvider()
|
|
provider.queue_response(content="ok")
|
|
|
|
_result, events, config = _run(tmp_path, provider)
|
|
|
|
ready = [e for e in events if e["type"] == "history_ready"]
|
|
assert len(ready) == 1
|
|
assert ready[0]["session_id"]
|
|
assert _saved_conversation(config)["session_id"] == ready[0]["session_id"]
|
|
|
|
|
|
def test_the_saved_conversation_carries_the_answer_and_the_task_title(
|
|
tmp_path: Path) -> None:
|
|
provider = FakeProvider()
|
|
provider.queue_response(content="Report is ready.")
|
|
|
|
_result, _events, config = _run(tmp_path, provider)
|
|
|
|
saved = _saved_conversation(config)
|
|
assert saved["title"] == "[Task] Weekly report"
|
|
assert saved["messages"][-1] == {"role": "assistant", "content": "Report is ready."}
|
|
|
|
|
|
def test_an_unfinished_checklist_is_reported_back_to_the_scheduler(
|
|
tmp_path: Path) -> None:
|
|
# The agent ticked no step to done, so the task must not be called finished
|
|
# just because no exception was raised.
|
|
provider = FakeProvider()
|
|
provider.queue_response(
|
|
content="Working on it.",
|
|
tool_calls=[{"id": "c1", "name": "update_plan",
|
|
"arguments": {"steps": [{"title": "Draft", "status": "running"}]}}],
|
|
)
|
|
provider.queue_response(content="Stopping here.")
|
|
|
|
(_answer, _timed_out, incomplete), _events, _config = _run(tmp_path, provider)
|
|
|
|
assert incomplete
|
|
assert "Draft" in incomplete
|
|
|
|
|
|
def test_a_finished_checklist_reports_nothing_outstanding(tmp_path: Path) -> None:
|
|
provider = FakeProvider()
|
|
provider.queue_response(
|
|
content="Done.",
|
|
tool_calls=[{"id": "c1", "name": "update_plan",
|
|
"arguments": {"steps": [{"title": "Draft", "status": "done"}]}}],
|
|
)
|
|
provider.queue_response(content="All done.")
|
|
|
|
(_answer, _timed_out, incomplete), _events, _config = _run(tmp_path, provider)
|
|
|
|
assert incomplete == ""
|
|
|
|
|
|
def test_running_out_of_time_appends_the_timeout_notice_to_the_conversation(
|
|
tmp_path: Path) -> None:
|
|
# A negative timeout puts the deadline in the past, which is the only
|
|
# deterministic way to exercise a wall-clock branch in a unit test.
|
|
provider = FakeProvider()
|
|
provider.queue_response(content="never gets there")
|
|
|
|
(answer, timed_out, incomplete), events, config = _run(
|
|
tmp_path, provider, timeout_sec=-1)
|
|
|
|
assert timed_out is True
|
|
assert incomplete == "" # a timeout is not an unfinished checklist
|
|
assert "quá thời gian chờ" in answer
|
|
assert any(e["type"] == "assistant_done" and "quá thời gian chờ" in e["content"]
|
|
for e in events)
|
|
assert "quá thời gian chờ" in _saved_conversation(config)["messages"][-1]["content"]
|