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:
@@ -0,0 +1,210 @@
|
||||
"""R04-T03 (b) — the turn loop: guards, permission gate, compaction, cleanup.
|
||||
|
||||
Split out of ``test_conversation_application_service.py`` to keep each file
|
||||
inside the 400-LOC limit. Same fakes, same service; this half pins the ORDER of
|
||||
the safety steps (guard before model, guard before execute, gate before execute)
|
||||
and the promise that the output sandbox is tidied on the way out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
from cowork_local.application.conversations.conversation_application_service import (
|
||||
ConversationApplicationService,
|
||||
)
|
||||
from cowork_local.domain.agents.agent_event import (
|
||||
ErrorEvent,
|
||||
OutputsAddedEvent,
|
||||
ReasoningChunkEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
)
|
||||
from cowork_local.tests.fakes.turn_runtime_fakes import (
|
||||
FakeModelCall,
|
||||
FakeReply,
|
||||
FakeToolRuntime,
|
||||
events_of_type,
|
||||
make_request,
|
||||
run_turn,
|
||||
tool_turn,
|
||||
)
|
||||
|
||||
|
||||
def _service(model, tools, **overrides) -> ConversationApplicationService:
|
||||
return ConversationApplicationService(model, tools, **overrides)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Guards and the permission gate.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_the_prompt_guard_runs_before_the_model_is_ever_called() -> None:
|
||||
order: List[str] = []
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
model_call = model.call
|
||||
|
||||
def call(*a, **kw):
|
||||
order.append("model")
|
||||
return model_call(*a, **kw)
|
||||
|
||||
model.call = call
|
||||
|
||||
run_turn(_service(model, FakeToolRuntime(), prompt_guard=lambda messages: order.append("guard")))
|
||||
|
||||
assert order == ["guard", "model"]
|
||||
|
||||
|
||||
def test_a_blocked_prompt_propagates_before_the_output_folder_is_touched() -> None:
|
||||
model = FakeModelCall([FakeReply(content="never")])
|
||||
tools = FakeToolRuntime()
|
||||
events: List[Any] = []
|
||||
|
||||
def guard(messages) -> None:
|
||||
raise RuntimeError("SecurityBlocked: nope")
|
||||
|
||||
service = _service(model, tools, prompt_guard=guard)
|
||||
|
||||
with pytest.raises(RuntimeError, match="SecurityBlocked"):
|
||||
service.execute(make_request(), events.append)
|
||||
|
||||
assert model.calls == []
|
||||
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="SecurityBlocked: nope")]
|
||||
# Cleanup is NOT a read-only operation (it deletes a stale .scratch and every
|
||||
# empty sub-folder), so a turn rejected before it started must not run it.
|
||||
assert tools.finalize_calls == []
|
||||
|
||||
|
||||
def test_output_cleanup_still_runs_when_the_turn_fails_mid_loop() -> None:
|
||||
# Once the turn has started producing files, the sandbox must be tidied on
|
||||
# the way out no matter how the turn ends.
|
||||
model = FakeModelCall([RuntimeError("gateway exploded")])
|
||||
tools = FakeToolRuntime()
|
||||
events: List[Any] = []
|
||||
|
||||
with pytest.raises(RuntimeError, match="gateway exploded"):
|
||||
_service(model, tools).execute(make_request(), events.append)
|
||||
|
||||
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]
|
||||
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="gateway exploded")]
|
||||
|
||||
|
||||
def test_the_command_guard_runs_before_the_tool_executes() -> None:
|
||||
order: List[str] = []
|
||||
model, tools = tool_turn("run_command", {"command": "ls"})
|
||||
original = tools.execute
|
||||
|
||||
def execute(name, args, on_output=None, cancel=None):
|
||||
order.append("execute")
|
||||
return original(name, args, on_output=on_output, cancel=cancel)
|
||||
|
||||
tools.execute = execute
|
||||
|
||||
run_turn(_service(model, tools,
|
||||
command_guard=lambda name, args: order.append(f"guard:{name}")))
|
||||
|
||||
assert order == ["guard:run_command", "execute"]
|
||||
|
||||
|
||||
def test_disabling_rule_enforcement_skips_both_guards() -> None:
|
||||
# Co4E flow steps run inside the workspace sandbox and opt out on purpose.
|
||||
calls: List[str] = []
|
||||
model, tools = tool_turn("run_command", {"command": "ls"})
|
||||
|
||||
run_turn(_service(model, tools,
|
||||
prompt_guard=lambda messages: calls.append("prompt"),
|
||||
command_guard=lambda name, args: calls.append("command")),
|
||||
make_request(enforce_rules=False))
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_the_permission_gate_is_asked_only_for_command_tools() -> None:
|
||||
asked: List[str] = []
|
||||
model, tools = tool_turn("save_file", {"filename": "a.md"})
|
||||
|
||||
run_turn(_service(model, tools,
|
||||
permission_request=lambda action: asked.append(action["name"]) or True),
|
||||
make_request(gate_mode="confirm"))
|
||||
|
||||
assert asked == [] # save_file writes into the sandbox: never gated
|
||||
|
||||
|
||||
def test_a_command_tool_in_confirm_mode_asks_before_running() -> None:
|
||||
asked: List[Dict[str, Any]] = []
|
||||
model, tools = tool_turn("run_command", {"command": "ls"})
|
||||
|
||||
def approve(action: Dict[str, Any]) -> bool:
|
||||
asked.append(action)
|
||||
return True
|
||||
|
||||
run_turn(_service(model, tools, permission_request=approve), make_request(gate_mode="confirm"))
|
||||
|
||||
assert [a["name"] for a in asked] == ["run_command"]
|
||||
assert tools.executed == [("run_command", {"command": "ls"})]
|
||||
|
||||
|
||||
def test_a_rejected_command_is_reported_as_a_failed_tool_and_never_runs() -> None:
|
||||
model, tools = tool_turn("run_command", {"command": "rm -rf /"})
|
||||
|
||||
result, events = run_turn(_service(model, tools, permission_request=lambda action: False),
|
||||
make_request(gate_mode="confirm"))
|
||||
|
||||
assert tools.executed == []
|
||||
assert events_of_type(events, ToolCallFinishedEvent) == [ToolCallFinishedEvent(
|
||||
call_id="c1", name="run_command", ok=False, output="Rejected by user.")]
|
||||
assert result.messages[-2]["content"] == "Rejected by user."
|
||||
|
||||
|
||||
def test_auto_mode_never_asks_even_for_a_command() -> None:
|
||||
model, tools = tool_turn("run_command", {"command": "ls"})
|
||||
|
||||
def refuse(action): # would block the turn if it were consulted
|
||||
raise AssertionError("the gate must not be consulted in auto mode")
|
||||
|
||||
run_turn(_service(model, tools, permission_request=refuse), make_request(gate_mode="auto"))
|
||||
|
||||
assert tools.executed == [("run_command", {"command": "ls"})]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Context compaction, reasoning, output cleanup.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_the_conversation_is_offered_for_compaction_before_every_call() -> None:
|
||||
compactions: List[int] = []
|
||||
model, tools = tool_turn()
|
||||
|
||||
run_turn(_service(model, tools,
|
||||
compact=lambda messages, cancel: compactions.append(len(messages))))
|
||||
|
||||
assert len(compactions) == 2 # once per provider call
|
||||
|
||||
|
||||
def test_reasoning_is_streamed_as_its_own_event() -> None:
|
||||
model = FakeModelCall([FakeReply(content="42", reasoning="thinking...")])
|
||||
|
||||
_, events = run_turn(_service(model, FakeToolRuntime()))
|
||||
|
||||
assert events_of_type(events, ReasoningChunkEvent) == [ReasoningChunkEvent(delta="thinking...")]
|
||||
|
||||
|
||||
def test_a_reasoning_only_reply_gets_a_visible_note_in_the_transcript() -> None:
|
||||
# Otherwise a Schedule Task run reads back an empty answer and writes
|
||||
# "(no output)" into its report.
|
||||
model = FakeModelCall([FakeReply(content="", reasoning="thought hard")])
|
||||
|
||||
result, events = run_turn(_service(model, FakeToolRuntime()))
|
||||
|
||||
assert "only its reasoning" in events_of_type(events, TextChunkEvent)[-1].delta
|
||||
assert "only its reasoning" in result.final_text
|
||||
|
||||
|
||||
def test_promoted_and_discarded_output_files_are_reported_at_the_end() -> None:
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
tools = FakeToolRuntime(added=("out/report.pptx",))
|
||||
|
||||
_, events = run_turn(_service(model, tools))
|
||||
|
||||
assert events_of_type(events, OutputsAddedEvent) == [
|
||||
OutputsAddedEvent(paths=("out/report.pptx",))]
|
||||
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]
|
||||
Reference in New Issue
Block a user