feat(R04): add the immutable turn snapshot and typed agent event stream

R04-T01 — `domain/agents/conversation_execution_request.py`: a frozen
snapshot of everything one chat turn needs. Turn inputs previously lived in a
closure plus a 15-key ctx dict inside `ui/chat_panel.py::_start_turn`, and the
worker thread kept reading the widget back while it ran, so every later click
was visible to work already in flight. The request also owns the prompt
composition rules (instruction prefix separator, session notes, model-switch
review note) that were inline in that closure.

R04-T02 — `domain/agents/agent_event.py`: 13 frozen event types replacing the
untyped `{"type": ...}` dicts, whose only specification was the 130-line
if/elif chain in `_on_event`. Each event serialises back to the exact legacy
dict, so the presentation layer is untouched; `agent_event_codec.py` parses the
other way and is a temporary shim, isolated so R08 can delete it in one move.
`assistant_done` is deliberately NOT the end of a turn (it fires once per
provider call), so it maps to AssistantMessageCompletedEvent while the new
TurnCompletedEvent reports the turn itself.

R04-T03 (part) — `domain/agents/agent_result.py`: one named outcome for a
finished turn, replacing the message list / 3-tuple / reconstructed-from-side-
effects trio the three callers each read differently.

Verification: 66 tests. Beyond the unit tests,
`tests/integration/test_agent_event_bridge.py` runs the REAL `run_cowork` loop
offline and asserts every dict it emits is recognised and round-trips
byte-for-byte — a guard against an event type nobody modelled or a key whose
meaning silently drifted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:13:56 +09:00
co-authored by Claude Opus 5
parent 176e6aef79
commit 19e6b4deb2
8 changed files with 1344 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
"""R04-T03 (a) — unit tests for the value a finished turn returns.
Two callers need different things out of one turn today:
``ui/chat_panel.py::_finalize_turn`` wants the message list, while
``core/task_executors.py::_run_agent`` returns a
``(answer_text, timed_out, incomplete_reason)`` tuple assembled by hand. This
type is what both read instead, so "what happened in that turn?" has one answer
with names on it.
"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from cowork_local.domain.agents.agent_event import PlanStep, TurnCompletedEvent
from cowork_local.domain.agents.agent_result import AgentResult
def test_result_rejects_mutation() -> None:
result = AgentResult(steps_used=1)
with pytest.raises(FrozenInstanceError):
result.steps_used = 2
def test_messages_are_frozen_into_a_tuple() -> None:
live = [{"role": "user", "content": "hi"}]
result = AgentResult(messages=live)
live.append({"role": "assistant", "content": "later"})
assert result.messages == ({"role": "user", "content": "hi"},)
def test_final_text_is_the_last_non_empty_assistant_message() -> None:
# A turn ends on a tool message often enough (cancelled mid-loop) that the
# answer cannot simply be messages[-1].
result = AgentResult(messages=[
{"role": "assistant", "content": "first pass"},
{"role": "assistant", "content": "the answer"},
{"role": "tool", "tool_call_id": "c", "name": "read_file", "content": "..."},
])
assert result.final_text == "the answer"
def test_final_text_skips_a_blank_assistant_message() -> None:
result = AgentResult(messages=[
{"role": "assistant", "content": "the answer"},
{"role": "assistant", "content": " "},
])
assert result.final_text == "the answer"
def test_final_text_is_empty_when_the_model_never_answered() -> None:
assert AgentResult(messages=[{"role": "user", "content": "hi"}]).final_text == ""
def test_a_plain_finished_turn_is_ok() -> None:
assert AgentResult(messages=[{"role": "assistant", "content": "done"}]).ok is True
def test_a_cancelled_turn_is_not_ok() -> None:
assert AgentResult(cancelled=True).ok is False
def test_a_failed_turn_is_not_ok_and_keeps_its_message() -> None:
result = AgentResult(error="SecurityBlocked: nope")
assert result.ok is False
assert result.error == "SecurityBlocked: nope"
def test_hitting_the_step_ceiling_is_reported_separately_from_cancelling() -> None:
# "Stopped because the safety limit was reached" and "the user pressed Stop"
# need different wording in the transcript, so they stay separate flags.
result = AgentResult(budget_exhausted=True, steps_used=30)
assert result.budget_exhausted is True
assert result.cancelled is False
def test_result_converts_to_the_turn_completed_event() -> None:
result = AgentResult(
messages=[{"role": "assistant", "content": "done"}],
steps_used=3, cancelled=False, budget_exhausted=True,
)
assert result.to_turn_completed_event() == TurnCompletedEvent(
final_text="done", steps_used=3, cancelled=False, budget_exhausted=True)
def test_plan_steps_are_frozen_into_a_tuple() -> None:
steps = [PlanStep(title="Draft", status="done")]
result = AgentResult(plan_steps=steps)
steps.append(PlanStep(title="Review"))
assert result.plan_steps == (PlanStep(title="Draft", status="done"),)