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>
394 lines
16 KiB
Python
394 lines
16 KiB
Python
"""Unit tests for EPIC R04: the turn snapshot, the typed events and the service.
|
|
|
|
The service tests run against the REAL engine (``core.chat_agent.run_cowork``)
|
|
driven by :class:`FakeProvider`, not against a stubbed runner. That is
|
|
deliberate: the whole point of R04 is that the service produces the same turn
|
|
the widget used to produce, and only an end-to-end path through the real engine
|
|
can show that. It still costs milliseconds - no Qt, no network, no disk beyond
|
|
a tmp folder.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
import pytest
|
|
|
|
from cowork_local.application.conversations import ConversationApplicationService
|
|
from cowork_local.core import chat_agent
|
|
from cowork_local.domain.agents import (
|
|
AssistantDoneEvent,
|
|
ConversationExecutionRequest,
|
|
ErrorEvent,
|
|
ReasoningChunkEvent,
|
|
TextChunkEvent,
|
|
ToolCallFinishedEvent,
|
|
ToolCallStartedEvent,
|
|
TurnCompletedEvent,
|
|
collect_text,
|
|
event_from_dict,
|
|
)
|
|
from tests.fakes import FakeProvider, FakeToolExecutor, ScriptedTurn
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# R04-T01 - the immutable request snapshot
|
|
# --------------------------------------------------------------------------- #
|
|
def test_the_snapshot_cannot_be_changed_by_the_caller_afterwards():
|
|
"""The motivating bug: the chat panel keeps appending to its own message
|
|
list while a turn runs, and the turn must not see those later messages."""
|
|
live_messages = [{"role": "user", "content": "first"}]
|
|
request = ConversationExecutionRequest.create("first", live_messages)
|
|
|
|
live_messages.append({"role": "user", "content": "typed while running"})
|
|
live_messages[0]["content"] = "edited"
|
|
|
|
assert len(request.messages) == 1
|
|
assert request.messages[0]["content"] == "first"
|
|
|
|
|
|
def test_message_list_hands_out_a_fresh_mutable_copy():
|
|
"""The engine appends assistant/tool messages to the list it is given, so a
|
|
copy is what keeps the snapshot immutable in practice, not just by
|
|
declaration."""
|
|
request = ConversationExecutionRequest.create("hi", [{"role": "user", "content": "hi"}])
|
|
|
|
first = request.message_list()
|
|
first.append({"role": "assistant", "content": "reply"})
|
|
|
|
assert len(request.message_list()) == 1
|
|
assert first is not request.message_list()
|
|
|
|
|
|
def test_with_model_produces_a_new_pinned_snapshot():
|
|
"""A routing switch must not mutate a request a turn may already be running."""
|
|
original = ConversationExecutionRequest.create("hi", provider="openai_compat", model="a")
|
|
|
|
routed = original.with_model("anthropic", "claude")
|
|
|
|
assert (original.provider, original.model) == ("openai_compat", "a")
|
|
assert (routed.provider, routed.model) == ("anthropic", "claude")
|
|
assert routed.turn_id == original.turn_id # same turn, different target
|
|
|
|
|
|
def test_every_turn_gets_its_own_id():
|
|
a = ConversationExecutionRequest.create("x")
|
|
b = ConversationExecutionRequest.create("x")
|
|
|
|
assert a.turn_id and b.turn_id and a.turn_id != b.turn_id
|
|
|
|
|
|
def test_run_to_completion_raises_the_step_ceiling():
|
|
interactive = ConversationExecutionRequest.create("x")
|
|
flow_step = ConversationExecutionRequest.create("x", run_to_completion=True)
|
|
|
|
assert interactive.effective_max_steps == 30
|
|
assert flow_step.effective_max_steps == 200
|
|
|
|
|
|
def test_permission_scope_always_keeps_update_plan():
|
|
"""update_plan has no side effects and drives the Plan panel; scoping it out
|
|
would break the UI rather than restrict a capability."""
|
|
request = ConversationExecutionRequest.create("x", allowed_tools=["read_file"])
|
|
|
|
assert request.allows_tool("read_file") is True
|
|
assert request.allows_tool("update_plan") is True
|
|
assert request.allows_tool("save_file") is False
|
|
# No scope at all means every enabled tool is allowed.
|
|
assert ConversationExecutionRequest.create("x").allows_tool("save_file") is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# R04-T02 - typed events and the legacy bridge
|
|
# --------------------------------------------------------------------------- #
|
|
@pytest.mark.parametrize("payload,expected", [
|
|
({"type": "text", "delta": "hi"}, TextChunkEvent),
|
|
({"type": "reasoning", "delta": "hmm"}, ReasoningChunkEvent),
|
|
({"type": "assistant_done", "content": "done"}, AssistantDoneEvent),
|
|
({"type": "tool_proposed", "id": "1", "name": "save_file"}, ToolCallStartedEvent),
|
|
({"type": "tool_result", "id": "1", "name": "save_file", "ok": True}, ToolCallFinishedEvent),
|
|
])
|
|
def test_legacy_emit_dicts_map_onto_typed_events(payload, expected):
|
|
assert isinstance(event_from_dict(payload), expected)
|
|
|
|
|
|
def test_an_unknown_event_tag_is_dropped_rather_than_raising():
|
|
"""The engine is still being refactored and may grow an event first. Losing
|
|
one bubble is survivable; aborting a turn that had succeeded is not."""
|
|
assert event_from_dict({"type": "something_new_in_r08"}) is None
|
|
|
|
|
|
@pytest.mark.parametrize("payload", [
|
|
{"type": "text", "delta": "hi"},
|
|
{"type": "tool_result", "id": "1", "name": "save_file", "ok": False, "output": "boom"},
|
|
{"type": "plan_set", "steps": [{"title": "a"}]},
|
|
{"type": "outputs_added", "paths": ["a.md"]},
|
|
])
|
|
def test_events_round_trip_back_into_the_legacy_shape(payload):
|
|
"""Existing widgets still consume dicts; an event must render back into
|
|
exactly what they already handle (EPIC R08 migrates them)."""
|
|
event = event_from_dict(payload)
|
|
|
|
rendered = event.to_dict()
|
|
|
|
assert rendered["type"] == payload["type"]
|
|
for key, value in payload.items():
|
|
assert rendered[key] == value
|
|
|
|
|
|
def test_events_are_immutable():
|
|
"""They cross a thread boundary; a consumer must not be able to edit one
|
|
out from under another consumer."""
|
|
event = TextChunkEvent("hi")
|
|
|
|
with pytest.raises(Exception):
|
|
event.delta = "changed" # type: ignore[misc]
|
|
|
|
|
|
def test_collect_text_returns_the_answer_without_the_reasoning():
|
|
events = [TextChunkEvent("Hel"), ReasoningChunkEvent("secret"), TextChunkEvent("lo")]
|
|
|
|
assert collect_text(events) == "Hello"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# R04-T03 - the service, running the real engine
|
|
# --------------------------------------------------------------------------- #
|
|
@pytest.fixture
|
|
def isolated(monkeypatch, tmp_path: Path):
|
|
"""Same ambient isolation the characterization suite uses."""
|
|
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")
|
|
return tmp_path
|
|
|
|
|
|
def _service(provider, **kwargs) -> ConversationApplicationService:
|
|
return ConversationApplicationService(lambda _p, _m: provider, **kwargs)
|
|
|
|
|
|
def _request(tmp_path: Path, prompt: str = "hi", **kwargs) -> ConversationExecutionRequest:
|
|
return ConversationExecutionRequest.create(
|
|
prompt, [{"role": "user", "content": prompt}],
|
|
output_dir=str(tmp_path / "out"), **kwargs)
|
|
|
|
|
|
def test_a_plain_turn_reports_text_and_a_final_answer(isolated):
|
|
provider = FakeProvider([ScriptedTurn(text="Hello there.")])
|
|
seen: List[Any] = []
|
|
|
|
result = _service(provider).run_turn(_request(isolated), on_event=seen.append)
|
|
|
|
assert result.ok is True
|
|
assert result.final_text == "Hello there."
|
|
assert [e.type for e in seen] == ["text", "assistant_done", "turn_completed"]
|
|
# The conversation coming back is what the caller persists as new history.
|
|
assert [m["role"] for m in result.messages] == ["system", "user", "assistant"]
|
|
|
|
|
|
def test_a_turn_always_ends_with_exactly_one_completion_event(isolated):
|
|
"""The end-of-turn signal the legacy engine never had: without it a
|
|
cancelled turn and a failed turn look identical to a consumer."""
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
seen: List[Any] = []
|
|
|
|
_service(provider).run_turn(_request(isolated), on_event=seen.append)
|
|
|
|
completions = [e for e in seen if isinstance(e, TurnCompletedEvent)]
|
|
assert len(completions) == 1
|
|
assert seen[-1] is completions[0]
|
|
|
|
|
|
def test_a_provider_failure_becomes_an_error_event_not_an_exception(isolated):
|
|
"""Callers run this on a worker thread; an escaped exception kills the
|
|
worker and the UI simply stops updating with nothing shown.
|
|
|
|
Two turns are scripted because the engine makes ONE silent recovery attempt
|
|
before giving up (core/code_agent.py::_call_provider_with_recovery) - the
|
|
service must report the failure only after that retry is also exhausted.
|
|
"""
|
|
provider = FakeProvider([ScriptedTurn(error="gateway exploded"),
|
|
ScriptedTurn(error="gateway exploded")])
|
|
seen: List[Any] = []
|
|
|
|
result = _service(provider).run_turn(_request(isolated), on_event=seen.append)
|
|
|
|
assert provider.call_count == 2 # original + one silent retry
|
|
assert result.ok is False
|
|
assert "gateway exploded" in result.error
|
|
assert any(isinstance(e, ErrorEvent) for e in seen)
|
|
assert isinstance(seen[-1], TurnCompletedEvent) # still a clean end
|
|
|
|
|
|
def test_a_transient_provider_failure_is_recovered_without_surfacing(isolated):
|
|
"""The engine's single retry must stay invisible: a turn that succeeds on
|
|
the second attempt reports no error at all."""
|
|
provider = FakeProvider([ScriptedTurn(error="connection reset"),
|
|
ScriptedTurn(text="recovered answer")])
|
|
|
|
result = _service(provider).run_turn(_request(isolated))
|
|
|
|
assert result.ok is True
|
|
assert result.final_text == "recovered answer"
|
|
assert not [e for e in result.events if isinstance(e, ErrorEvent)]
|
|
|
|
|
|
def test_a_cancelled_turn_is_reported_as_cancelled_not_failed(isolated):
|
|
provider = FakeProvider([], strict=True)
|
|
|
|
result = _service(provider).run_turn(_request(isolated), cancel=lambda: True)
|
|
|
|
assert result.cancelled is True
|
|
assert result.error == ""
|
|
assert provider.call_count == 0
|
|
assert result.events[-1].cancelled is True
|
|
|
|
|
|
def test_a_tool_turn_reports_the_full_lifecycle_and_writes_the_file(isolated):
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("save_file", {"filename": "note.md", "content": "# hi"})]),
|
|
ScriptedTurn(text="Saved."),
|
|
])
|
|
|
|
result = _service(provider).run_turn(_request(isolated, "make a note"))
|
|
|
|
assert [e.type for e in result.events] == [
|
|
"assistant_done", "tool_proposed", "tool_result",
|
|
"text", "assistant_done", "turn_completed",
|
|
]
|
|
finished = [e for e in result.events if isinstance(e, ToolCallFinishedEvent)]
|
|
assert finished[0].ok is True and finished[0].name == "save_file"
|
|
written = list((isolated / "out").iterdir())
|
|
assert len(written) == 1 and written[0].read_text(encoding="utf-8") == "# hi"
|
|
|
|
|
|
def test_external_tools_are_supplied_through_the_injected_tool_source(isolated):
|
|
executor = FakeToolExecutor(results={"ms365_send_mail": {"output": "sent"}})
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("ms365_send_mail", {"to": "a@b.c"})]),
|
|
ScriptedTurn(text="Mail sent."),
|
|
])
|
|
service = _service(provider, tool_source=lambda: (executor.specs(), executor))
|
|
|
|
result = service.run_turn(_request(isolated, "mail them"))
|
|
|
|
assert executor.call_names == ["ms365_send_mail"]
|
|
assert result.ok is True
|
|
|
|
|
|
def test_a_broken_tool_source_degrades_to_no_external_tools(isolated):
|
|
"""An MCP server that will not start must not stop the user from chatting -
|
|
the behaviour the chat panel already relies on today."""
|
|
def exploding_tool_source():
|
|
raise RuntimeError("mcp server did not start")
|
|
|
|
provider = FakeProvider([ScriptedTurn(text="still works")])
|
|
service = _service(provider, tool_source=exploding_tool_source)
|
|
|
|
result = service.run_turn(_request(isolated))
|
|
|
|
assert result.ok is True
|
|
assert result.final_text == "still works"
|
|
|
|
|
|
def test_a_consumer_that_raises_does_not_abort_the_turn(isolated):
|
|
"""A widget being torn down mid-turn must not take the turn with it."""
|
|
provider = FakeProvider([ScriptedTurn(text="answer")])
|
|
|
|
def bad_consumer(_event):
|
|
raise RuntimeError("widget already deleted")
|
|
|
|
result = _service(provider).run_turn(_request(isolated), on_event=bad_consumer)
|
|
|
|
assert result.ok is True
|
|
assert result.final_text == "answer"
|
|
|
|
|
|
def test_events_are_recorded_even_without_a_callback(isolated):
|
|
"""Headless callers (the scheduler) read the event list afterwards instead
|
|
of supplying a callback purely to collect it."""
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
|
|
result = _service(provider).run_turn(_request(isolated))
|
|
|
|
assert [e.type for e in result.events] == ["text", "assistant_done", "turn_completed"]
|
|
|
|
|
|
def test_the_request_permission_scope_reaches_the_engine(isolated):
|
|
"""A read-only step must literally not be offered a writing tool - the scope
|
|
has to survive the trip through the service or the restriction is silently
|
|
dropped."""
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
|
|
_service(provider).run_turn(_request(isolated, allowed_tools=["read_file"]))
|
|
|
|
advertised = set(provider.calls[0].tool_names)
|
|
assert "save_file" not in advertised
|
|
assert "update_plan" in advertised
|
|
|
|
|
|
def test_the_permission_gate_is_only_built_when_the_request_asks_for_it(isolated):
|
|
built: List[Any] = []
|
|
provider = FakeProvider([ScriptedTurn(text="ok"), ScriptedTurn(text="ok")])
|
|
service = _service(provider, gate_factory=lambda req: built.append(req) or object())
|
|
|
|
service.run_turn(_request(isolated))
|
|
assert built == []
|
|
|
|
service.run_turn(_request(isolated, confirm_commands=True))
|
|
assert len(built) == 1
|
|
|
|
|
|
def test_a_non_streamed_answer_still_produces_a_final_text(isolated):
|
|
"""A turn whose answer arrived without text events must still report an
|
|
answer - the scheduler writes it into output.md, and an empty string there
|
|
reads to the user as "(no output)"."""
|
|
provider = FakeProvider([ScriptedTurn(text="")])
|
|
service = _service(provider)
|
|
request = _request(isolated)
|
|
|
|
result = service.run_turn(request)
|
|
|
|
# run_cowork substitutes a placeholder for a reasoning-only reply; the
|
|
# service must surface that rather than an empty answer.
|
|
assert result.final_text != ""
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Bridge completeness - the failure mode that motivated this test
|
|
# --------------------------------------------------------------------------- #
|
|
def test_every_event_the_engine_emits_has_a_typed_counterpart():
|
|
"""Scan the engine sources for ``emit({"type": "..."})`` tags and assert the
|
|
bridge knows all of them.
|
|
|
|
Written after a real miss: the first version of the bridge had no
|
|
``notice`` event, so routing turns through the service would have silently
|
|
swallowed Agent Security warnings and auto-compaction notices - the user
|
|
would simply never see that a request had been blocked. An unknown tag is
|
|
dropped by design (see event_from_dict), which is safe for a NEW event but
|
|
hides a forgotten one; this test is what turns that silence into a failure.
|
|
"""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from cowork_local.domain.agents.agent_event import EVENT_TYPES
|
|
|
|
repo = Path(__file__).resolve().parents[2]
|
|
sources = ["core/chat_agent.py", "core/code_agent.py", "core/agent_security.py",
|
|
"core/context_budget.py", "core/task_executors.py"]
|
|
emitted = set()
|
|
for rel in sources:
|
|
text = (repo / rel).read_text(encoding="utf-8")
|
|
# Only tags inside an emit(...) call; a bare {"type": "object"} in a
|
|
# JSON-Schema tool definition is not an event.
|
|
for match in re.finditer(r'emit(?:_and_autosave)?\(\s*\{\s*"type":\s*"([a-z_]+)"', text):
|
|
emitted.add(match.group(1))
|
|
|
|
missing = sorted(emitted - set(EVENT_TYPES))
|
|
assert not missing, (
|
|
f"engine emits {missing} but domain/agents/agent_event.py has no typed "
|
|
"counterpart - those events would be silently dropped by event_from_dict"
|
|
)
|