R04-T03 — the turn lifecycle, extracted from `core/chat_agent.py::run_cowork` into `application/conversations/`. The 260-line body mixed the lifecycle (step budget, cancel checks, guard -> preview -> gate -> execute ordering, sandbox tidy-up) with the machinery doing each step, and reaching any of it meant standing up a Qt widget and a worker thread. It is now a plain object driven through two Protocols and six callables (`turn_runtime.py`), with the concrete `core/*` wiring confined to `core_runtime_adapter.py` — the same shape R03 used for routing. Faithful port, not an improvement pass: where the original had a quirk (the step-ceiling note only merges into the answer when the last message is the assistant's) the quirk is preserved and commented. R04-T04 — `ui/cowork_tab.py::build_job` no longer calls run_cowork. It captures the widget's state at submit time, builds the request via the new `cowork_turn_request.py` and executes it. `execute(..., messages=...)` hands the widget's own list over because `_reattach_running_turn` replays from it WHILE the worker appends and `_finalize_turn` slices it afterwards — a private list would break both silently. R04-T05 — `core/task_executors.py`'s cowork branch shares the same engine. All five unattended-run behaviours stay put (plan reminder, history_ready, History autosave per assistant message, timeout notice, plan_incomplete_reason), and `_unattended_prompt` now expresses the load-bearing prefix order in one readable call instead of three successive rebindings. Verification: 74 new tests (364 passed, 1 skipped overall; check_imports PASS). The two that matter most: - `test_conversation_service_parity.py` runs the same scripted turn through run_cowork AND the service and compares the event stream, the resulting conversation and the advertised tool list across 7 scenarios; - `test_task_executor_turn.py` was written BEFORE the migration and passed 8/8 against the old code, then unchanged against the new. Known: `ui/cowork_tab.py` (416 -> 455) and `core/task_executors.py` (476 -> 524) stay above the 400-LOC limit. Both were already over it before this change; bringing them under needs the R08 / R07 decompositions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 lines
3.0 KiB
Python
78 lines
3.0 KiB
Python
"""Turn the Cowork widget's captured state into a request (R04-T04).
|
|
|
|
``ui/cowork_tab.py::build_job`` reads a dozen values off the widget on the UI
|
|
thread and has to translate three of them before a turn can run: which message
|
|
is this turn's prompt, which messages are its history, and whether the workspace
|
|
wants commands confirmed. Those rules lived inline in the widget, where no test
|
|
could reach them — and each fails silently when wrong (a duplicated user message,
|
|
or a command that quietly stops asking for approval).
|
|
|
|
They live here instead, as the mapping step the migration map assigns to the
|
|
application layer. The widget keeps only what is genuinely widget-specific:
|
|
reading its own state and building the provider.
|
|
|
|
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): pure Python.
|
|
Everything arrives as a plain value, so this module never sees a widget.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, Optional, Sequence
|
|
|
|
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
|
|
|
|
|
|
def build_cowork_turn_request(
|
|
*,
|
|
turn_id: str,
|
|
session_id: str,
|
|
messages: Sequence[Dict[str, Any]],
|
|
surface: str = "cowork",
|
|
project_id: str = "",
|
|
title: str = "",
|
|
provider_id: str = "",
|
|
model: str = "",
|
|
instructions: str = "",
|
|
output_dir: Optional[Any] = None,
|
|
home_output_root: Optional[Any] = None,
|
|
confirm_commands: bool = False,
|
|
agent_role: str = "cowork",
|
|
) -> ConversationExecutionRequest:
|
|
"""Build one Cowork turn's immutable request.
|
|
|
|
``messages`` is the widget's working list, which ALREADY ends with this
|
|
turn's user message (the chat panel composes it — prefix, attachments,
|
|
session notes — before the job starts). So the prompt is that last message
|
|
and the history is everything before it. The request records both; the
|
|
service is handed the same working list and appends into it.
|
|
|
|
Keyword-only on purpose: a dozen positional strings in a call site is exactly
|
|
how a title ends up in the project-id slot.
|
|
"""
|
|
history = list(messages or ())
|
|
# ``pop`` rather than ``[-1]``/``[:-1]`` so the empty-list case needs no
|
|
# special branch: a turn with nothing in it yields an empty prompt instead of
|
|
# raising IndexError deep inside a worker thread.
|
|
last = history.pop() if history else {}
|
|
return ConversationExecutionRequest(
|
|
turn_id=turn_id,
|
|
session_id=session_id,
|
|
surface=surface,
|
|
project_id=project_id,
|
|
title=title,
|
|
prompt=str(last.get("content") or ""),
|
|
messages=history,
|
|
provider_id=provider_id,
|
|
model=model,
|
|
project_context=instructions,
|
|
output_dir=output_dir,
|
|
home_output_root=home_output_root,
|
|
# The workspace's Auto-run override (or the global setting) decides
|
|
# whether run_command/install_package must be approved first.
|
|
gate_mode="confirm" if confirm_commands else "auto",
|
|
agent_role=agent_role,
|
|
)
|
|
|
|
|
|
__all__ = ["build_cowork_turn_request"]
|