EPIC R04 (Team Duy) - the turn lifecycle leaves the widget. R04-T01 domain/agents/conversation_execution_request.py Frozen snapshot of one turn, captured on the UI thread at submit time. The job closure used to read widget/workspace state from inside the worker thread, so a turn could run on a mix of submit-time and later state depending on thread timing. R04-T02 domain/agents/agent_event.py 13 frozen event types replacing untyped emit() dicts, with a two-way bridge so existing widgets keep consuming the legacy shape until EPIC R08. Adds TurnCompletedEvent - the end-of-turn signal the engine never had, which is why a cancelled turn and a failed turn look identical to the UI today. R04-T03 application/conversations/conversation_application_service.py Runs a turn from a request and reports typed events. Never raises across the worker boundary; TurnResult.raise_if_failed() preserves the existing exception-based failure path. begin_turn()/execute_turn() expose the live message list for callers that autosave history mid-run. R04-T04 ui/cowork_tab.py::build_job -> snapshot + service. R04-T05 core/task_executors.py::_run_agent -> same service (was a second, slightly different assembly of the same call). Caught while wiring the bridge: the first event vocabulary had no "notice" event, so Agent Security warnings and auto-compaction notices would have been silently swallowed. Added NoticeEvent plus a test that scans the engine sources for emit() tags and fails when one has no typed counterpart. New: tests/integration/ - real offscreen CoworkTab running a scripted turn end to end (7 tests), including a characterisation of the extra provider call Agent Security spends reviewing each request. Suite: 225 passed, 2.74s. check_imports: PASS. All new files < 400 LOC. 2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
8.2 KiB
Python
202 lines
8.2 KiB
Python
"""End-to-end check that the Cowork screen really runs turns through the
|
|
application layer (R04-T04).
|
|
|
|
The unit tests prove ``ConversationApplicationService`` behaves correctly; this
|
|
one proves ``ui/cowork_tab.py::build_job`` actually goes through it, on a real
|
|
(offscreen) widget, with a scripted provider instead of a network call.
|
|
|
|
It also pins the property that motivated R04-T01: the turn runs on the state
|
|
captured at SUBMIT time, so a user editing the conversation while a turn is in
|
|
flight cannot change what that turn sends.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
from cowork_local.config import AppConfig # noqa: E402
|
|
from cowork_local.core import chat_agent # noqa: E402
|
|
from cowork_local.state import AppContext # noqa: E402
|
|
from tests.fakes import FakeProvider, ScriptedTurn # noqa: E402
|
|
|
|
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def qt_app():
|
|
"""One QApplication for the module - Qt allows only a single instance."""
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
return QApplication.instance() or QApplication([])
|
|
|
|
|
|
@pytest.fixture
|
|
def cowork_tab(qt_app, tmp_path: Path, monkeypatch):
|
|
"""A real CoworkTab on a throwaway config, with ambient inputs neutralised."""
|
|
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
|
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
|
from cowork_local.core import audit_log
|
|
|
|
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
|
|
|
from cowork_local.ui.cowork_tab import CoworkTab
|
|
|
|
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
|
# Agent Security's prompt validation is ON by default and spends an EXTRA
|
|
# provider call reviewing the request before the agent loop starts (see
|
|
# core/agent_security.py::enforce_prompt). That is real behaviour - pinned
|
|
# by its own test below - but it would make every other test here script a
|
|
# turn that has nothing to do with what it is checking.
|
|
ctx.config.agent_security["enabled"] = False
|
|
return CoworkTab(ctx)
|
|
|
|
|
|
class _StubWorker:
|
|
"""The slice of ``core.worker.AgentWorker`` a job actually touches."""
|
|
|
|
def __init__(self) -> None:
|
|
self.events: List[Dict[str, Any]] = []
|
|
self.gates_requested = 0
|
|
self._cancelled = False
|
|
|
|
def emit_event(self, payload: Dict[str, Any]) -> None:
|
|
self.events.append(payload)
|
|
|
|
def is_cancelled(self) -> bool:
|
|
return self._cancelled
|
|
|
|
def new_gate(self, _mode: str, **_kwargs) -> Any:
|
|
self.gates_requested += 1
|
|
return None
|
|
|
|
def cancel(self) -> None:
|
|
self._cancelled = True
|
|
|
|
|
|
def _run_job(tab, worker, provider, text="hello", messages=None, out_dir=None):
|
|
"""Build the tab's job with ``provider`` pinned, then run it like the worker
|
|
thread would."""
|
|
tab.build_provider = lambda: provider # what routing/agent selection resolves to
|
|
job = tab.build_job(text, messages if messages is not None
|
|
else [{"role": "user", "content": text}], out_dir)
|
|
return job(worker)
|
|
|
|
|
|
def test_a_turn_runs_through_the_service_and_returns_history(cowork_tab, tmp_path):
|
|
provider = FakeProvider([ScriptedTurn(text="Hello from the fake.")])
|
|
worker = _StubWorker()
|
|
|
|
result = _run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
|
|
|
assert provider.call_count == 1
|
|
# Same return contract as before the refactor - _cleanup_turn reads both keys.
|
|
assert set(result) == {"messages", "turn_dir"}
|
|
assert [m["role"] for m in result["messages"]] == ["system", "user", "assistant"]
|
|
assert result["messages"][-1]["content"] == "Hello from the fake."
|
|
|
|
|
|
def test_the_widget_still_receives_the_legacy_event_dicts(cowork_tab, tmp_path):
|
|
"""The chat widgets consume dicts and are not migrated until EPIC R08, so
|
|
the typed events must render back into exactly what they already handle -
|
|
plus the new end-of-turn signal, which the if/elif dispatch ignores."""
|
|
provider = FakeProvider([ScriptedTurn(text="Hi")])
|
|
worker = _StubWorker()
|
|
|
|
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
|
|
|
assert [e["type"] for e in worker.events] == ["text", "assistant_done", "turn_completed"]
|
|
assert worker.events[0] == {"type": "text", "delta": "Hi"}
|
|
|
|
|
|
def test_the_turn_ignores_messages_added_after_it_was_submitted(cowork_tab, tmp_path):
|
|
"""The bug ConversationExecutionRequest exists to prevent: the panel keeps
|
|
appending to its own list while a turn is in flight."""
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
worker = _StubWorker()
|
|
live_messages = [{"role": "user", "content": "first question"}]
|
|
|
|
job_result = _run_job(cowork_tab, worker, provider,
|
|
messages=live_messages, out_dir=tmp_path / "turn")
|
|
|
|
# Simulate the user typing a second message DURING the turn by mutating the
|
|
# list the panel handed over. The already-sent conversation must not include it.
|
|
live_messages.append({"role": "user", "content": "typed while running"})
|
|
|
|
sent = provider.calls[0].messages
|
|
assert [m["content"] for m in sent if m["role"] == "user"] == ["first question"]
|
|
assert "typed while running" not in str(job_result["messages"])
|
|
|
|
|
|
def test_a_failing_turn_still_raises_so_the_worker_reports_it(cowork_tab, tmp_path):
|
|
"""core/worker.py turns an exception into the `failed` signal the chat panel
|
|
already handles; swallowing it here would show a successful turn with no
|
|
answer instead of an error."""
|
|
provider = FakeProvider([ScriptedTurn(error="gateway down"),
|
|
ScriptedTurn(error="gateway down")])
|
|
worker = _StubWorker()
|
|
|
|
with pytest.raises(Exception) as excinfo:
|
|
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
|
|
|
assert "gateway down" in str(excinfo.value)
|
|
# The error was still reported as an event before being re-raised.
|
|
assert any(e["type"] == "error" for e in worker.events)
|
|
|
|
|
|
def test_a_permission_gate_is_only_requested_when_the_workspace_asks_for_it(
|
|
cowork_tab, tmp_path, monkeypatch):
|
|
provider = FakeProvider([ScriptedTurn(text="ok"), ScriptedTurn(text="ok")])
|
|
worker = _StubWorker()
|
|
|
|
monkeypatch.setattr(cowork_tab.ctx, "project_confirm_commands", lambda: False)
|
|
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "a")
|
|
assert worker.gates_requested == 0
|
|
|
|
monkeypatch.setattr(cowork_tab.ctx, "project_confirm_commands", lambda: True)
|
|
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "b")
|
|
assert worker.gates_requested == 1
|
|
|
|
|
|
def test_a_tool_turn_writes_into_this_turns_own_output_folder(cowork_tab, tmp_path):
|
|
"""Turn isolation: each turn writes into its own directory so parallel turns
|
|
cannot clobber each other's files."""
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("save_file", {"filename": "n.md", "content": "x"})]),
|
|
ScriptedTurn(text="Saved."),
|
|
])
|
|
worker = _StubWorker()
|
|
turn_dir = tmp_path / "turn-1"
|
|
|
|
result = _run_job(cowork_tab, worker, provider, out_dir=turn_dir)
|
|
|
|
assert result["turn_dir"] == str(turn_dir)
|
|
assert [p.name for p in turn_dir.iterdir()] and turn_dir.exists()
|
|
assert any(e["type"] == "tool_result" and e["ok"] for e in worker.events)
|
|
|
|
|
|
def test_agent_security_still_reviews_the_request_before_the_turn_runs(
|
|
cowork_tab, tmp_path):
|
|
"""Characterisation, not a new behaviour: with Agent Security enabled (the
|
|
shipped default) a turn costs an EXTRA provider call, because the request is
|
|
reviewed against the rulebase before the agent loop starts.
|
|
|
|
Pinned here because it is invisible from the call site and easy to break -
|
|
routing a turn through the application layer must not skip the review.
|
|
"""
|
|
cowork_tab.ctx.config.agent_security["enabled"] = True
|
|
provider = FakeProvider([
|
|
ScriptedTurn(text="ALLOW"), # the security pre-flight review
|
|
ScriptedTurn(text="the answer"), # the turn itself
|
|
])
|
|
worker = _StubWorker()
|
|
|
|
result = _run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
|
|
|
assert provider.call_count == 2
|
|
assert result["messages"][-1]["content"] == "the answer"
|