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>
211 lines
7.7 KiB
Python
211 lines
7.7 KiB
Python
"""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}]
|