feat(R04): immutable turn snapshot, typed agent events, conversation service

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>
This commit is contained in:
2026-08-21 10:32:14 +09:00
co-authored by Claude Opus 5
parent 96bec976e7
commit a53163ebaf
11 changed files with 1663 additions and 53 deletions
+38 -6
View File
@@ -248,9 +248,34 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
f"{prompt}"
)
messages = [{"role": "user", "content": prompt}]
# One immutable snapshot of this run, then the shared turn service (R04-T05).
# The Schedule Task path used to assemble the run_cowork call itself, in
# parallel with ui/cowork_tab.py doing the same thing slightly differently -
# so a fix to one path silently missed the other. Both now go through
# ConversationApplicationService.
from ..application.conversations import ConversationApplicationService
from ..domain.agents import ConversationExecutionRequest
session_id = new_session_id()
project_id = project.project_id if project is not None else ""
project_context = projects.project_context_text(project)
conversation_service = ConversationApplicationService(
# The provider was already resolved above (admin agent / per-task
# override / machine default), so the factory just hands it back.
lambda _provider_id, _model: provider,
security_config=ctx.config,
)
turn = conversation_service.begin_turn(ConversationExecutionRequest.create(
prompt, [{"role": "user", "content": prompt}],
output_dir=str(out_dir), session_id=session_id, surface="task",
title=title, project_id=project_id, project_context=project_context,
# Tags every tool call in the audit log as a scheduled task rather than
# as the interactive Cowork tab.
agent_role=agent_roles.TASK,
))
# The LIVE list the engine appends to - History is re-saved from it after
# every assistant message so a long run shows progress when reopened.
messages = turn.messages
_save_history_session(ctx, task_type, title, messages, session_id, project_id)
# Tell the scheduler the session now genuinely EXISTS on disk — it
# refreshes History on this, not on the earlier "task_started" signal
@@ -269,14 +294,21 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
elif ev.get("type") == "plan_set":
last_plan_steps[:] = ev.get("steps") or []
project_context = projects.project_context_text(project)
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
try:
if task_type == "cowork":
from .chat_agent import run_cowork
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
security_config=ctx.config, agent_role=agent_roles.TASK,
project_context=project_context)
# Typed events are rendered back into the legacy dict shape this
# module's autosave/plan tracking already consumes; it moves to
# AgentEvent directly once the scheduler UI migrates (EPIC R07/R08).
result = conversation_service.execute_turn(
turn,
on_event=lambda event: emit_and_autosave(event.to_dict()),
cancel=watched_cancel,
)
# This module's callers handle a failed run through an exception
# (execute_task writes error.txt from it), so re-raise the ORIGINAL
# error rather than reporting a silently empty answer.
result.raise_if_failed()
else:
from .code_agent import run_code
limits, block_network = agent_security.sandbox_settings(ctx.config)