feat(R04): run every Cowork turn through ConversationApplicationService

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>
This commit is contained in:
2026-08-23 13:14:15 +09:00
co-authored by Claude Opus 5
parent 19e6b4deb2
commit 3665135c38
16 changed files with 2452 additions and 48 deletions
@@ -0,0 +1,207 @@
"""R04-T03 (c) — the service must behave exactly like ``run_cowork``.
The unit tests prove the loop follows the rules I wrote down. They cannot prove
those rules are the ones the shipped runtime actually follows. This file does:
each test scripts one provider, runs the SAME turn twice — once through
``core/chat_agent.py::run_cowork``, once through
``ConversationApplicationService`` wired by ``core_runtime_adapter`` — and
compares the emitted event stream, the resulting conversation and the tool list
the model was shown.
Anything the port got wrong (a missing event, a reordered guard, a different
tool set, a changed message) fails here rather than in front of a user. The only
allowed difference is the extra ``turn_completed`` event R04 introduces, which
has no legacy consumer.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from cowork_local.application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from cowork_local.core import chat_agent
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
from cowork_local.tests.fakes.fake_provider import FakeProvider
_USER_TURN = [{"role": "user", "content": "make me a report"}]
class _FakeGate:
"""Stands in for ``core/permissions.py::PermissionGate``."""
def __init__(self, approve: bool) -> None:
self.approve = approve
self.requests: List[Dict[str, Any]] = []
def request(self, action: Dict[str, Any]) -> bool:
self.requests.append(action)
return self.approve
def _normalise(events: List[Dict[str, Any]], out_dir: Path) -> List[Dict[str, Any]]:
"""Replace the run's own output path with a placeholder.
The two runs write into different temp folders, so absolute paths in
``tool_result``/``outputs_*`` events differ by construction. Everything else
must match verbatim.
"""
marker, raw = "<OUT>", str(out_dir)
def scrub(value: Any) -> Any:
if isinstance(value, str):
return value.replace(raw, marker).replace(raw.replace("\\", "/"), marker)
if isinstance(value, list):
return [scrub(v) for v in value]
if isinstance(value, dict):
return {k: scrub(v) for k, v in value.items()}
return value
return [scrub(e) for e in events]
def _run_legacy(tmp_path: Path, provider: FakeProvider, *, allowed_tools=None,
gate: Optional[_FakeGate] = None, max_steps: int = 30
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
"""Run the turn through the existing ``run_cowork``."""
out_dir = tmp_path / "legacy"
out_dir.mkdir(parents=True, exist_ok=True)
events: List[Dict[str, Any]] = []
messages = [dict(m) for m in _USER_TURN]
chat_agent.run_cowork(
provider, messages, out_dir, events.append, title="Report",
security_config=None, allowed_tools=allowed_tools, gate=gate, max_steps=max_steps,
)
tool_names = [t.name for t in (provider.last_tools or [])]
return _normalise(events, out_dir), messages, tool_names
def _run_service(tmp_path: Path, provider: FakeProvider, *, allowed_tools=None,
gate: Optional[_FakeGate] = None, max_steps: int = 30
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
"""Run the same turn through the application service."""
out_dir = tmp_path / "service"
out_dir.mkdir(parents=True, exist_ok=True)
events: List[Dict[str, Any]] = []
service = build_cowork_conversation_service(
provider, out_dir, events.append, title="Report", security_config=None, gate=gate)
request = ConversationExecutionRequest(
turn_id="t1", session_id="s1",
# run_cowork receives the user message already appended; the request
# carries the history and this turn's prompt separately.
messages=_USER_TURN[:-1], prompt=_USER_TURN[-1]["content"],
output_dir=out_dir, allowed_tools=allowed_tools, max_steps=max_steps,
gate_mode="confirm" if gate is not None else "auto",
)
result = service.execute(request, legacy_event_sink(events.append))
# The end-of-turn event is new in R04 and has no legacy counterpart.
kept = [e for e in events if e.get("type") != "turn_completed"]
tool_names = [t.name for t in (provider.last_tools or [])]
return _normalise(kept, out_dir), list(result.messages), tool_names
def _assert_parity(tmp_path: Path, script, *, approve: Optional[bool] = None, **kwargs) -> None:
"""Script two identical providers, run both paths, compare everything."""
legacy_provider, service_provider = FakeProvider(), FakeProvider()
script(legacy_provider)
script(service_provider)
legacy_gate = _FakeGate(approve) if approve is not None else None
service_gate = _FakeGate(approve) if approve is not None else None
legacy_events, legacy_messages, legacy_tools = _run_legacy(
tmp_path, legacy_provider, gate=legacy_gate, **kwargs)
service_events, service_messages, service_tools = _run_service(
tmp_path, service_provider, gate=service_gate, **kwargs)
assert service_events == legacy_events
assert service_messages == legacy_messages
assert service_tools == legacy_tools
if legacy_gate is not None and service_gate is not None:
assert [r["name"] for r in service_gate.requests] == \
[r["name"] for r in legacy_gate.requests]
# --------------------------------------------------------------------------- #
# Scenarios.
# --------------------------------------------------------------------------- #
def test_a_plain_answer_turn_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(content="Here you go.", chunks=["Here ", "you go."])
_assert_parity(tmp_path, script)
def test_a_save_file_turn_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(
content="Writing it.",
tool_calls=[{"id": "c1", "name": "save_file",
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
)
provider.queue_response(content="Saved.")
_assert_parity(tmp_path, script)
def test_an_update_plan_turn_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(
content="Planning.",
tool_calls=[{"id": "c1", "name": "update_plan",
"arguments": {"steps": [{"title": "Draft", "status": "running"},
{"title": "Ship", "status": "pending"}]}}],
)
provider.queue_response(content="Done.")
_assert_parity(tmp_path, script)
def test_a_reasoning_only_reply_behaves_identically(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(content="", reasoning="thinking hard")
_assert_parity(tmp_path, script)
def test_restricting_the_tool_scope_advertises_the_same_tools(tmp_path: Path) -> None:
def script(provider: FakeProvider) -> None:
provider.queue_response(content="ok")
_assert_parity(tmp_path, script, allowed_tools=["save_file"])
def test_a_rejected_command_behaves_identically(tmp_path: Path) -> None:
# The security-critical path: the gate says no, so the command must never
# run and the model must read back the same refusal in both designs.
def script(provider: FakeProvider) -> None:
provider.queue_response(
content="Running it.",
tool_calls=[{"id": "c1", "name": "run_command",
"arguments": {"command": "echo hi"}}],
)
provider.queue_response(content="Understood.")
_assert_parity(tmp_path, script, approve=False)
def test_hitting_the_step_ceiling_behaves_identically(tmp_path: Path) -> None:
# The model never stops calling tools, so both paths must stop at the same
# place and say so the same way.
def script(provider: FakeProvider) -> None:
for i in range(4):
provider.queue_response(
content=f"step {i}",
tool_calls=[{"id": f"c{i}", "name": "save_file",
"arguments": {"filename": f"f{i}.md", "content": "x"}}],
)
_assert_parity(tmp_path, script, max_steps=2)
+220
View File
@@ -0,0 +1,220 @@
"""R04-T04 — the migrated Cowork call site, exercised end to end without Qt.
``CoworkTab.build_job`` only ever *reads attributes* off its widget, so the real
production method can be invoked against a stand-in that supplies those
attributes. That is what happens here: the actual ``build_job`` body runs, builds
a request, wires the service through ``core_runtime_adapter``, and drives a real
turn (real tool execution, real output-folder cleanup) against ``FakeProvider``.
Why it matters: this is the only automated check that the widget's contract with
the service still holds — that the worker's list is appended to in place (the
transcript re-render and history merge both read it), that events still arrive as
legacy dicts, and that a produced file really lands in the turn's folder. None of
it needs a display server, so it runs in CI like every other test.
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
from cowork_local.config import DEFAULT_CONFIG, AppConfig
from cowork_local.tests.fakes.fake_provider import FakeProvider
class _FakeWorker:
"""The parts of ``core/worker.py::AgentWorker`` a job actually touches."""
def __init__(self, approve_commands: bool = True) -> None:
self.events: List[Dict[str, Any]] = []
self.gate: Optional[Any] = None
self._approve = approve_commands
self.cancelled = False
def emit_event(self, event: Dict[str, Any]) -> None:
self.events.append(event)
def is_cancelled(self) -> bool:
return self.cancelled
def new_gate(self, mode: str, agent_role: str = "") -> Any:
# Mirrors AgentWorker.new_gate: the gate is stored on the worker so the
# UI thread can resolve it, and answers request() from the worker thread.
worker = self
class _Gate:
requests: List[Dict[str, Any]] = []
def request(self, action: Dict[str, Any]) -> bool:
self.requests.append(action)
return worker._approve
self.gate = _Gate()
return self.gate
class _FakeCtx:
"""The ``AppContext`` surface ``build_job`` uses."""
def __init__(self, config: AppConfig, confirm_commands: bool = False) -> None:
self.config = config
self._confirm = confirm_commands
def project_confirm_commands(self) -> bool:
return self._confirm
def build_mcp_tools(self):
return [], None
class _WidgetStub:
"""Stands in for the CoworkTab instance ``build_job`` reads its state from."""
kind = "cowork"
def __init__(self, out_root: Path, ctx: _FakeCtx, provider: FakeProvider) -> None:
self._out_root = out_root
self.ctx = ctx
self._provider = provider
self.title = "Report"
self.session_id = "s1"
self.project_id = "" # the auto-seeded default workspace
self._model = ""
self._routed_provider = None
self._routed_model = None
def _session_output_dir(self) -> Path:
return self._out_root
def workspace_dir(self) -> Path:
return self._out_root
def admin_agent_prompt(self) -> str:
return ""
def build_provider(self) -> FakeProvider:
return self._provider
def _config() -> AppConfig:
"""A real AppConfig that never touches ``~/.cowork_local``.
The AI security guardrails are switched off: they would call the model to
review the prompt, which is a separate feature with its own tests and would
make this one depend on what the fake answers.
"""
data = copy.deepcopy(DEFAULT_CONFIG)
data["agent_security"]["enabled"] = False
return AppConfig(data)
def _run_turn(tmp_path: Path, provider: FakeProvider, messages: List[Dict[str, Any]],
*, confirm_commands: bool = False, approve: bool = True):
"""Invoke the real ``CoworkTab.build_job`` against the stub and run its job."""
from cowork_local.ui.cowork_tab import CoworkTab
out_dir = tmp_path / ".turns" / "t1"
out_dir.mkdir(parents=True, exist_ok=True)
widget = _WidgetStub(tmp_path, _FakeCtx(_config(), confirm_commands), provider)
worker = _FakeWorker(approve_commands=approve)
job = CoworkTab.build_job(widget, "make me a report", messages, out_dir)
result = job(worker)
return result, worker
def test_the_turn_runs_and_reports_its_folder(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="Here you go.", chunks=["Here ", "you go."])
messages = [{"role": "user", "content": "make me a report"}]
result, worker = _run_turn(tmp_path, provider, messages)
assert result["turn_dir"] == str(tmp_path / ".turns" / "t1")
assert [e["type"] for e in worker.events] == [
"text", "text", "assistant_done", "turn_completed"]
def test_the_worker_list_is_appended_to_in_place(tmp_path: Path) -> None:
# _reattach_running_turn replays from this very list while the turn runs, and
# _finalize_turn slices it by the pre-turn length afterwards.
provider = FakeProvider()
provider.queue_response(content="Done.")
user = {"role": "user", "content": "make me a report"}
messages = [user]
result, _ = _run_turn(tmp_path, provider, messages)
assert result["messages"] is messages
# Identity, not just equality: _reattach_running_turn locates the turn's user
# message with ``m is ctx["user_msg"]`` to replay the steps after it.
assert any(m is user for m in messages)
# Several system blocks are expected — the tool prompt plus the tagged
# skills/security-rules blocks the runtime refreshes on every turn.
assert [m["role"] for m in messages if m["role"] != "system"] == ["user", "assistant"]
assert messages[-1]["content"] == "Done."
def test_a_saved_file_lands_in_the_turn_folder(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Writing it.",
tool_calls=[{"id": "c1", "name": "save_file",
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
)
provider.queue_response(content="Saved.")
messages = [{"role": "user", "content": "make me a report"}]
_, worker = _run_turn(tmp_path, provider, messages)
produced = list((tmp_path / ".turns" / "t1").glob("*.md"))
assert len(produced) == 1
assert produced[0].read_text(encoding="utf-8") == "# Report\n"
results = [e for e in worker.events if e["type"] == "tool_result"]
assert results and results[0]["ok"] is True
def test_auto_run_mode_never_creates_a_permission_gate(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(content="ok")
_, worker = _run_turn(tmp_path, provider, [{"role": "user", "content": "hi"}],
confirm_commands=False)
assert worker.gate is None
def test_confirm_mode_creates_the_gate_and_a_refusal_stops_the_command(tmp_path: Path) -> None:
provider = FakeProvider()
provider.queue_response(
content="Running it.",
tool_calls=[{"id": "c1", "name": "run_command",
"arguments": {"command": "echo hi"}}],
)
provider.queue_response(content="Understood.")
_, worker = _run_turn(tmp_path, provider, [{"role": "user", "content": "run it"}],
confirm_commands=True, approve=False)
assert worker.gate is not None
refusals = [e for e in worker.events
if e["type"] == "tool_result" and e["output"] == "Rejected by user."]
assert len(refusals) == 1
def test_cancelling_before_the_turn_starts_calls_no_model(tmp_path: Path) -> None:
from cowork_local.ui.cowork_tab import CoworkTab
provider = FakeProvider()
provider.queue_response(content="never")
out_dir = tmp_path / ".turns" / "t1"
out_dir.mkdir(parents=True)
widget = _WidgetStub(tmp_path, _FakeCtx(_config()), provider)
worker = _FakeWorker()
worker.cancelled = True
CoworkTab.build_job(widget, "x", [{"role": "user", "content": "x"}], out_dir)(worker)
assert provider.call_count == 0
@@ -0,0 +1,191 @@
"""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"]