"""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" )