feat(R04): run every Cowork turn through ConversationApplicationService
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>
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
"""R04-T03 (b) — the turn loop: composition, tool dispatch, budget, cancel.
|
||||
|
||||
Behaviour that used to be reachable only by running the real widget. Every
|
||||
dependency is a fake from ``tests/fakes/turn_runtime_fakes.py``, so the file
|
||||
runs in milliseconds and each test states one rule of the loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from cowork_local.application.conversations.conversation_application_service import (
|
||||
ConversationApplicationService,
|
||||
)
|
||||
from cowork_local.domain.agents.agent_event import (
|
||||
AssistantMessageCompletedEvent,
|
||||
PlanStep,
|
||||
PlanUpdatedEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
ToolCallStartedEvent,
|
||||
ToolOutputChunkEvent,
|
||||
ToolPreview,
|
||||
TurnCompletedEvent,
|
||||
)
|
||||
from cowork_local.tests.fakes.turn_runtime_fakes import (
|
||||
FakeModelCall,
|
||||
FakeReply,
|
||||
FakeToolRuntime,
|
||||
events_of_type,
|
||||
make_request,
|
||||
run_turn,
|
||||
tool_turn,
|
||||
)
|
||||
|
||||
|
||||
def _service(model, tools, **overrides) -> ConversationApplicationService:
|
||||
return ConversationApplicationService(model, tools, **overrides)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The happy path.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_a_plain_answer_streams_text_then_reports_the_message_and_the_turn() -> None:
|
||||
model = FakeModelCall([FakeReply(content="Hello there", chunks=["Hello ", "there"])])
|
||||
|
||||
result, events = run_turn(_service(model, FakeToolRuntime()))
|
||||
|
||||
assert [e.delta for e in events_of_type(events, TextChunkEvent)] == ["Hello ", "there"]
|
||||
assert events_of_type(events, AssistantMessageCompletedEvent) == [
|
||||
AssistantMessageCompletedEvent(content="Hello there")]
|
||||
assert events_of_type(events, TurnCompletedEvent) == [
|
||||
TurnCompletedEvent(final_text="Hello there", steps_used=1)]
|
||||
assert result.final_text == "Hello there"
|
||||
assert result.ok is True
|
||||
|
||||
|
||||
def test_the_composed_user_message_is_appended_before_the_first_call() -> None:
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
request = make_request(prompt="ship it", instruction_prefix="RULES",
|
||||
session_notes="earlier: a.md",
|
||||
messages=[{"role": "user", "content": "previous"}])
|
||||
|
||||
run_turn(_service(model, FakeToolRuntime()), request)
|
||||
|
||||
sent = model.calls[0]["messages"]
|
||||
assert sent[-1] == {"role": "user",
|
||||
"content": "RULES\n\n---\n\nship it\n\nearlier: a.md"}
|
||||
assert sent[-2] == {"role": "user", "content": "previous"}
|
||||
|
||||
|
||||
def test_attachments_are_read_when_the_turn_runs_not_when_it_was_built() -> None:
|
||||
# Extraction can pip-install a parser or shell out to LibreOffice, so it must
|
||||
# happen here (worker thread), not while the UI was assembling the request.
|
||||
seen: List[Tuple[str, Tuple[str, ...]]] = []
|
||||
|
||||
def reader(prompt: str, attachments: Tuple[str, ...]) -> str:
|
||||
seen.append((prompt, attachments))
|
||||
return f"{prompt}\n\n<contents of {len(attachments)} file(s)>"
|
||||
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
request = make_request(prompt="summarise", attachments=["a.docx", "b.pdf"])
|
||||
|
||||
run_turn(_service(model, FakeToolRuntime(), attachment_reader=reader), request)
|
||||
|
||||
assert seen == [("summarise", ("a.docx", "b.pdf"))]
|
||||
assert "contents of 2 file(s)" in model.calls[0]["messages"][-1]["content"]
|
||||
|
||||
|
||||
def test_the_prompt_preparer_is_told_which_tools_the_turn_advertises() -> None:
|
||||
# The system prompt gains an MS365 paragraph only when ms365__* tools are
|
||||
# present, so the preparer has to see the real list.
|
||||
seen: List[Tuple[str, ...]] = []
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
tools = FakeToolRuntime(specs=("save_file", "ms365__send_mail"))
|
||||
|
||||
run_turn(_service(model, tools,
|
||||
prepare_prompt=lambda messages, names: seen.append(names)))
|
||||
|
||||
assert seen == [("save_file", "ms365__send_mail")]
|
||||
|
||||
|
||||
def test_only_the_allowed_tools_are_advertised() -> None:
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
tools = FakeToolRuntime(specs=("save_file", "run_command", "update_plan"))
|
||||
|
||||
run_turn(_service(model, tools), make_request(allowed_tools=("save_file", "update_plan")))
|
||||
|
||||
assert model.calls[0]["tool_names"] == ["save_file", "update_plan"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tool dispatch.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def tool_turn(tool_name: str = "save_file", args=None, **tool_kwargs):
|
||||
"""A turn that calls one tool, then answers."""
|
||||
calls = [{"id": "c1", "name": tool_name, "arguments": args or {"filename": "a.md"}}]
|
||||
model = FakeModelCall([FakeReply(content="working", tool_calls=calls),
|
||||
FakeReply(content="done")])
|
||||
return model, FakeToolRuntime(**tool_kwargs)
|
||||
|
||||
|
||||
def test_a_tool_call_is_announced_executed_and_answered_in_the_message_list() -> None:
|
||||
model, tools = tool_turn(results={"save_file": {"ok": True, "output": "saved",
|
||||
"path": "out/a.md"}})
|
||||
|
||||
result, events = run_turn(_service(model, tools))
|
||||
|
||||
assert events_of_type(events, ToolCallStartedEvent) == [ToolCallStartedEvent(
|
||||
call_id="c1", name="save_file", arguments={"filename": "a.md"},
|
||||
preview=ToolPreview(kind="info", title="save_file", text="{'filename': 'a.md'}"))]
|
||||
assert events_of_type(events, ToolCallFinishedEvent) == [ToolCallFinishedEvent(
|
||||
call_id="c1", name="save_file", ok=True, output="saved", path="out/a.md")]
|
||||
assert tools.executed == [("save_file", {"filename": "a.md"})]
|
||||
assert result.messages[-2] == {"role": "tool", "tool_call_id": "c1",
|
||||
"name": "save_file", "content": "saved"}
|
||||
|
||||
|
||||
def test_live_tool_output_is_streamed_while_the_tool_runs() -> None:
|
||||
model, tools = tool_turn("run_command", {"command": "ls"})
|
||||
tools.emit_output = "file-a\n"
|
||||
|
||||
_, events = run_turn(_service(model, tools))
|
||||
|
||||
assert events_of_type(events, ToolOutputChunkEvent) == [ToolOutputChunkEvent(
|
||||
call_id="c1", name="run_command", delta="file-a\n")]
|
||||
|
||||
|
||||
def test_the_loop_ends_as_soon_as_the_model_stops_calling_tools() -> None:
|
||||
model, tools = tool_turn()
|
||||
|
||||
result, _ = run_turn(_service(model, tools))
|
||||
|
||||
assert result.steps_used == 2
|
||||
assert result.budget_exhausted is False
|
||||
|
||||
|
||||
def test_the_plan_tool_reports_a_plan_update_and_no_tool_bubble() -> None:
|
||||
calls = [{"id": "c1", "name": "update_plan",
|
||||
"arguments": {"steps": [{"title": "Draft", "status": "running"}]}}]
|
||||
model = FakeModelCall([FakeReply(content="planning", tool_calls=calls), FakeReply(content="done")])
|
||||
tools = FakeToolRuntime(results={"update_plan": {
|
||||
"ok": True, "output": "Plan updated.",
|
||||
"plan_steps": [PlanStep(title="Draft", status="running")]}})
|
||||
|
||||
result, events = run_turn(_service(model, tools))
|
||||
|
||||
assert events_of_type(events, PlanUpdatedEvent) == [
|
||||
PlanUpdatedEvent(steps=(PlanStep(title="Draft", status="running"),))]
|
||||
assert events_of_type(events, ToolCallStartedEvent) == []
|
||||
assert events_of_type(events, ToolCallFinishedEvent) == []
|
||||
assert result.plan_steps == (PlanStep(title="Draft", status="running"),)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Budget, cancellation.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_running_out_of_steps_is_flagged_and_announced() -> None:
|
||||
# The model keeps calling tools forever; the ceiling must stop it visibly.
|
||||
forever = [FakeReply(content=f"step {i}",
|
||||
tool_calls=[{"id": f"c{i}", "name": "save_file", "arguments": {}}])
|
||||
for i in range(5)]
|
||||
model = FakeModelCall(forever)
|
||||
|
||||
result, events = run_turn(_service(model, FakeToolRuntime()), make_request(max_steps=2))
|
||||
|
||||
assert result.steps_used == 2
|
||||
assert result.budget_exhausted is True
|
||||
assert "2-step safety limit" in events_of_type(events, TextChunkEvent)[-1].delta
|
||||
# The note reaches the transcript but NOT the stored answer: a turn that hits
|
||||
# the ceiling always ends on a tool message, and the existing runtime only
|
||||
# merges the note when the last message is the assistant's. Pinned here so a
|
||||
# future change to that rule is a deliberate decision, not a silent drift.
|
||||
assert result.final_text == "step 1"
|
||||
|
||||
|
||||
def test_run_to_completion_uses_the_higher_ceiling() -> None:
|
||||
forever = [FakeReply(content="x", tool_calls=[{"id": "c", "name": "save_file", "arguments": {}}])
|
||||
for _ in range(6)]
|
||||
model = FakeModelCall(forever)
|
||||
|
||||
result, _ = run_turn(_service(model, FakeToolRuntime()),
|
||||
make_request(max_steps=2, completion_max_steps=5, run_to_completion=True))
|
||||
|
||||
assert result.steps_used == 5
|
||||
|
||||
|
||||
def test_a_turn_cancelled_before_it_starts_never_calls_the_model() -> None:
|
||||
model = FakeModelCall([FakeReply(content="never")])
|
||||
|
||||
result, events = run_turn(_service(model, FakeToolRuntime()), cancel=lambda: True)
|
||||
|
||||
assert model.calls == []
|
||||
assert result.cancelled is True
|
||||
assert result.budget_exhausted is False
|
||||
assert events_of_type(events, TurnCompletedEvent) == [TurnCompletedEvent(cancelled=True)]
|
||||
|
||||
|
||||
def test_cancelling_during_a_turn_stops_dispatching_the_remaining_tool_calls() -> None:
|
||||
calls = [{"id": "c1", "name": "save_file", "arguments": {}},
|
||||
{"id": "c2", "name": "save_file", "arguments": {}}]
|
||||
model = FakeModelCall([FakeReply(content="two tools", tool_calls=calls)])
|
||||
tools = FakeToolRuntime()
|
||||
stop = {"now": False}
|
||||
|
||||
def cancel() -> bool:
|
||||
return stop["now"]
|
||||
|
||||
original_execute = tools.execute
|
||||
|
||||
def execute(name, args, on_output=None, cancel=None):
|
||||
stop["now"] = True # cancel raised while the first tool runs
|
||||
return original_execute(name, args, on_output=on_output, cancel=cancel)
|
||||
|
||||
tools.execute = execute
|
||||
|
||||
result, _ = run_turn(_service(model, tools), cancel=cancel)
|
||||
|
||||
assert len(tools.executed) == 1
|
||||
assert result.cancelled is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bring-your-own working list.
|
||||
#
|
||||
# ``ui/chat_panel.py`` holds the turn's message list in its own turn context and
|
||||
# reads it WHILE the worker appends (``_reattach_running_turn`` replays the steps
|
||||
# done so far when the user reopens a running conversation; ``_finalize_turn``
|
||||
# slices it by ``snapshot_len``). A service that built its own private list would
|
||||
# silently break both, so a caller can hand its list over instead.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_a_caller_supplied_list_is_appended_to_in_place() -> None:
|
||||
model, tools = tool_turn()
|
||||
live: List[Dict[str, Any]] = [{"role": "user", "content": "already composed"}]
|
||||
|
||||
result = ConversationApplicationService(model, tools).execute(
|
||||
make_request(), lambda event: None, messages=live)
|
||||
|
||||
roles = [m["role"] for m in live]
|
||||
assert roles == ["user", "assistant", "tool", "assistant"]
|
||||
assert result.messages == tuple(live)
|
||||
|
||||
|
||||
def test_a_caller_supplied_list_is_used_as_is_without_recomposing_the_prompt() -> None:
|
||||
# The widget already applied the skill prefix and the session notes when it
|
||||
# built its message; composing again would duplicate them.
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
user = {"role": "user", "content": "already composed"}
|
||||
live = [user]
|
||||
|
||||
ConversationApplicationService(model, FakeToolRuntime()).execute(
|
||||
make_request(prompt="typed text", instruction_prefix="RULES",
|
||||
session_notes="notes"),
|
||||
lambda event: None, messages=live)
|
||||
|
||||
assert live[0] is user
|
||||
assert live[0]["content"] == "already composed"
|
||||
assert [m["role"] for m in live].count("user") == 1
|
||||
|
||||
|
||||
def test_a_caller_supplied_list_skips_the_attachment_reader() -> None:
|
||||
# Reading the attachments is what produced the caller's message in the first
|
||||
# place; doing it again would re-parse every file.
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
calls: List[Any] = []
|
||||
|
||||
ConversationApplicationService(
|
||||
model, FakeToolRuntime(),
|
||||
attachment_reader=lambda prompt, attachments: calls.append(prompt) or prompt,
|
||||
).execute(make_request(attachments=["a.docx"]), lambda event: None,
|
||||
messages=[{"role": "user", "content": "composed"}])
|
||||
|
||||
assert calls == []
|
||||
@@ -0,0 +1,210 @@
|
||||
"""R04-T03 (b) — the turn loop: guards, permission gate, compaction, cleanup.
|
||||
|
||||
Split out of ``test_conversation_application_service.py`` to keep each file
|
||||
inside the 400-LOC limit. Same fakes, same service; this half pins the ORDER of
|
||||
the safety steps (guard before model, guard before execute, gate before execute)
|
||||
and the promise that the output sandbox is tidied on the way out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
from cowork_local.application.conversations.conversation_application_service import (
|
||||
ConversationApplicationService,
|
||||
)
|
||||
from cowork_local.domain.agents.agent_event import (
|
||||
ErrorEvent,
|
||||
OutputsAddedEvent,
|
||||
ReasoningChunkEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
)
|
||||
from cowork_local.tests.fakes.turn_runtime_fakes import (
|
||||
FakeModelCall,
|
||||
FakeReply,
|
||||
FakeToolRuntime,
|
||||
events_of_type,
|
||||
make_request,
|
||||
run_turn,
|
||||
tool_turn,
|
||||
)
|
||||
|
||||
|
||||
def _service(model, tools, **overrides) -> ConversationApplicationService:
|
||||
return ConversationApplicationService(model, tools, **overrides)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Guards and the permission gate.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_the_prompt_guard_runs_before_the_model_is_ever_called() -> None:
|
||||
order: List[str] = []
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
model_call = model.call
|
||||
|
||||
def call(*a, **kw):
|
||||
order.append("model")
|
||||
return model_call(*a, **kw)
|
||||
|
||||
model.call = call
|
||||
|
||||
run_turn(_service(model, FakeToolRuntime(), prompt_guard=lambda messages: order.append("guard")))
|
||||
|
||||
assert order == ["guard", "model"]
|
||||
|
||||
|
||||
def test_a_blocked_prompt_propagates_before_the_output_folder_is_touched() -> None:
|
||||
model = FakeModelCall([FakeReply(content="never")])
|
||||
tools = FakeToolRuntime()
|
||||
events: List[Any] = []
|
||||
|
||||
def guard(messages) -> None:
|
||||
raise RuntimeError("SecurityBlocked: nope")
|
||||
|
||||
service = _service(model, tools, prompt_guard=guard)
|
||||
|
||||
with pytest.raises(RuntimeError, match="SecurityBlocked"):
|
||||
service.execute(make_request(), events.append)
|
||||
|
||||
assert model.calls == []
|
||||
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="SecurityBlocked: nope")]
|
||||
# Cleanup is NOT a read-only operation (it deletes a stale .scratch and every
|
||||
# empty sub-folder), so a turn rejected before it started must not run it.
|
||||
assert tools.finalize_calls == []
|
||||
|
||||
|
||||
def test_output_cleanup_still_runs_when_the_turn_fails_mid_loop() -> None:
|
||||
# Once the turn has started producing files, the sandbox must be tidied on
|
||||
# the way out no matter how the turn ends.
|
||||
model = FakeModelCall([RuntimeError("gateway exploded")])
|
||||
tools = FakeToolRuntime()
|
||||
events: List[Any] = []
|
||||
|
||||
with pytest.raises(RuntimeError, match="gateway exploded"):
|
||||
_service(model, tools).execute(make_request(), events.append)
|
||||
|
||||
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]
|
||||
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="gateway exploded")]
|
||||
|
||||
|
||||
def test_the_command_guard_runs_before_the_tool_executes() -> None:
|
||||
order: List[str] = []
|
||||
model, tools = tool_turn("run_command", {"command": "ls"})
|
||||
original = tools.execute
|
||||
|
||||
def execute(name, args, on_output=None, cancel=None):
|
||||
order.append("execute")
|
||||
return original(name, args, on_output=on_output, cancel=cancel)
|
||||
|
||||
tools.execute = execute
|
||||
|
||||
run_turn(_service(model, tools,
|
||||
command_guard=lambda name, args: order.append(f"guard:{name}")))
|
||||
|
||||
assert order == ["guard:run_command", "execute"]
|
||||
|
||||
|
||||
def test_disabling_rule_enforcement_skips_both_guards() -> None:
|
||||
# Co4E flow steps run inside the workspace sandbox and opt out on purpose.
|
||||
calls: List[str] = []
|
||||
model, tools = tool_turn("run_command", {"command": "ls"})
|
||||
|
||||
run_turn(_service(model, tools,
|
||||
prompt_guard=lambda messages: calls.append("prompt"),
|
||||
command_guard=lambda name, args: calls.append("command")),
|
||||
make_request(enforce_rules=False))
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_the_permission_gate_is_asked_only_for_command_tools() -> None:
|
||||
asked: List[str] = []
|
||||
model, tools = tool_turn("save_file", {"filename": "a.md"})
|
||||
|
||||
run_turn(_service(model, tools,
|
||||
permission_request=lambda action: asked.append(action["name"]) or True),
|
||||
make_request(gate_mode="confirm"))
|
||||
|
||||
assert asked == [] # save_file writes into the sandbox: never gated
|
||||
|
||||
|
||||
def test_a_command_tool_in_confirm_mode_asks_before_running() -> None:
|
||||
asked: List[Dict[str, Any]] = []
|
||||
model, tools = tool_turn("run_command", {"command": "ls"})
|
||||
|
||||
def approve(action: Dict[str, Any]) -> bool:
|
||||
asked.append(action)
|
||||
return True
|
||||
|
||||
run_turn(_service(model, tools, permission_request=approve), make_request(gate_mode="confirm"))
|
||||
|
||||
assert [a["name"] for a in asked] == ["run_command"]
|
||||
assert tools.executed == [("run_command", {"command": "ls"})]
|
||||
|
||||
|
||||
def test_a_rejected_command_is_reported_as_a_failed_tool_and_never_runs() -> None:
|
||||
model, tools = tool_turn("run_command", {"command": "rm -rf /"})
|
||||
|
||||
result, events = run_turn(_service(model, tools, permission_request=lambda action: False),
|
||||
make_request(gate_mode="confirm"))
|
||||
|
||||
assert tools.executed == []
|
||||
assert events_of_type(events, ToolCallFinishedEvent) == [ToolCallFinishedEvent(
|
||||
call_id="c1", name="run_command", ok=False, output="Rejected by user.")]
|
||||
assert result.messages[-2]["content"] == "Rejected by user."
|
||||
|
||||
|
||||
def test_auto_mode_never_asks_even_for_a_command() -> None:
|
||||
model, tools = tool_turn("run_command", {"command": "ls"})
|
||||
|
||||
def refuse(action): # would block the turn if it were consulted
|
||||
raise AssertionError("the gate must not be consulted in auto mode")
|
||||
|
||||
run_turn(_service(model, tools, permission_request=refuse), make_request(gate_mode="auto"))
|
||||
|
||||
assert tools.executed == [("run_command", {"command": "ls"})]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Context compaction, reasoning, output cleanup.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_the_conversation_is_offered_for_compaction_before_every_call() -> None:
|
||||
compactions: List[int] = []
|
||||
model, tools = tool_turn()
|
||||
|
||||
run_turn(_service(model, tools,
|
||||
compact=lambda messages, cancel: compactions.append(len(messages))))
|
||||
|
||||
assert len(compactions) == 2 # once per provider call
|
||||
|
||||
|
||||
def test_reasoning_is_streamed_as_its_own_event() -> None:
|
||||
model = FakeModelCall([FakeReply(content="42", reasoning="thinking...")])
|
||||
|
||||
_, events = run_turn(_service(model, FakeToolRuntime()))
|
||||
|
||||
assert events_of_type(events, ReasoningChunkEvent) == [ReasoningChunkEvent(delta="thinking...")]
|
||||
|
||||
|
||||
def test_a_reasoning_only_reply_gets_a_visible_note_in_the_transcript() -> None:
|
||||
# Otherwise a Schedule Task run reads back an empty answer and writes
|
||||
# "(no output)" into its report.
|
||||
model = FakeModelCall([FakeReply(content="", reasoning="thought hard")])
|
||||
|
||||
result, events = run_turn(_service(model, FakeToolRuntime()))
|
||||
|
||||
assert "only its reasoning" in events_of_type(events, TextChunkEvent)[-1].delta
|
||||
assert "only its reasoning" in result.final_text
|
||||
|
||||
|
||||
def test_promoted_and_discarded_output_files_are_reported_at_the_end() -> None:
|
||||
model = FakeModelCall([FakeReply(content="ok")])
|
||||
tools = FakeToolRuntime(added=("out/report.pptx",))
|
||||
|
||||
_, events = run_turn(_service(model, tools))
|
||||
|
||||
assert events_of_type(events, OutputsAddedEvent) == [
|
||||
OutputsAddedEvent(paths=("out/report.pptx",))]
|
||||
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""R04-T04 — unit tests for the UI-state -> request mapping.
|
||||
|
||||
Three small rules used to sit inline in ``ui/cowork_tab.py::build_job``, where no
|
||||
test could reach them: the turn's prompt is the last message in the working list,
|
||||
the history is everything before it, and the confirm-commands flag becomes a gate
|
||||
mode. Getting any of them wrong is silent (a duplicated user message, a command
|
||||
that stops asking for approval), so they are pinned here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from cowork_local.application.conversations.cowork_turn_request import (
|
||||
build_cowork_turn_request,
|
||||
)
|
||||
|
||||
|
||||
def _build(**overrides):
|
||||
base = {
|
||||
"turn_id": "t3",
|
||||
"session_id": "s1",
|
||||
"messages": [{"role": "user", "content": "make me a report"}],
|
||||
}
|
||||
base.update(overrides)
|
||||
return build_cowork_turn_request(**base)
|
||||
|
||||
|
||||
def test_the_last_message_becomes_the_prompt_and_the_rest_the_history() -> None:
|
||||
request = _build(messages=[
|
||||
{"role": "user", "content": "earlier"},
|
||||
{"role": "assistant", "content": "sure"},
|
||||
{"role": "user", "content": "now this"},
|
||||
])
|
||||
|
||||
assert request.prompt == "now this"
|
||||
assert request.messages == ({"role": "user", "content": "earlier"},
|
||||
{"role": "assistant", "content": "sure"})
|
||||
|
||||
|
||||
def test_an_empty_working_list_yields_an_empty_prompt() -> None:
|
||||
# Defensive: a turn with no message at all must not raise on messages[-1].
|
||||
request = _build(messages=[])
|
||||
|
||||
assert request.prompt == ""
|
||||
assert request.messages == ()
|
||||
|
||||
|
||||
def test_confirming_commands_puts_the_turn_in_confirm_gate_mode() -> None:
|
||||
assert _build(confirm_commands=True).gate_mode == "confirm"
|
||||
assert _build(confirm_commands=False).gate_mode == "auto"
|
||||
assert _build().gate_mode == "auto" # auto-run is the default
|
||||
|
||||
|
||||
def test_the_captured_widget_state_is_carried_into_the_request() -> None:
|
||||
request = _build(
|
||||
surface="cowork", project_id="p7", title="Weekly report",
|
||||
provider_id="anthropic", model="claude-sonnet-4-6",
|
||||
instructions="PROJECT RULES", output_dir="out/.turns/t3",
|
||||
home_output_root="out", agent_role="cowork",
|
||||
)
|
||||
|
||||
assert (request.turn_id, request.session_id) == ("t3", "s1")
|
||||
assert (request.surface, request.project_id, request.title) == \
|
||||
("cowork", "p7", "Weekly report")
|
||||
assert (request.provider_id, request.model) == ("anthropic", "claude-sonnet-4-6")
|
||||
assert request.project_context == "PROJECT RULES"
|
||||
assert request.output_dir == Path("out/.turns/t3")
|
||||
assert request.home_output_root == Path("out")
|
||||
assert request.agent_role == "cowork"
|
||||
|
||||
|
||||
def test_the_prompt_survives_a_message_whose_content_is_missing() -> None:
|
||||
request = _build(messages=[{"role": "user"}])
|
||||
|
||||
assert request.prompt == ""
|
||||
@@ -0,0 +1,34 @@
|
||||
"""R04-T05 — unit tests for the unattended-run prompt assembly.
|
||||
|
||||
``_run_agent`` used to build this by rebinding ``prompt`` three times, each with
|
||||
its own ``f"{block}\n\n{prompt}"``. The ORDER that produced is load-bearing (the
|
||||
plan reminder has to lead, the task's own words have to trail) and it was
|
||||
readable only by replaying the rebindings in your head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.core.task_executors import _unattended_prompt
|
||||
|
||||
|
||||
def test_the_plan_reminder_leads_and_the_task_prompt_trails() -> None:
|
||||
built = _unattended_prompt("write the report")
|
||||
|
||||
assert built.startswith("This runs unattended (Schedule Task)")
|
||||
assert built.endswith("write the report")
|
||||
|
||||
|
||||
def test_a_skill_block_sits_between_the_reminder_and_the_agent_persona() -> None:
|
||||
built = _unattended_prompt("write the report", skill_text="SKILL",
|
||||
agent_instructions="PERSONA")
|
||||
|
||||
assert built.index("This runs unattended") < built.index("SKILL")
|
||||
assert built.index("SKILL") < built.index("PERSONA")
|
||||
assert built.index("PERSONA") < built.index("write the report")
|
||||
|
||||
|
||||
def test_absent_blocks_leave_no_extra_blank_lines() -> None:
|
||||
built = _unattended_prompt("do it", skill_text="", agent_instructions=None)
|
||||
|
||||
assert "\n\n\n" not in built
|
||||
assert built.count("do it") == 1
|
||||
@@ -0,0 +1,36 @@
|
||||
"""R04-T04 — unit tests for the shared turn-runtime helpers.
|
||||
|
||||
``combine_instructions`` is the small rule the UI applied inline: a turn's
|
||||
standing instructions are several independent blocks (project context, an Admin
|
||||
agent's persona, a skill's rules, an unattended-run reminder) that must be joined
|
||||
with one blank line, skipping whatever is absent. Two call sites need it (T04's
|
||||
widget and T05's task runner), which is exactly when a rule stops being an inline
|
||||
expression.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.conversations.turn_runtime import combine_instructions
|
||||
|
||||
|
||||
def test_two_blocks_are_joined_by_a_blank_line() -> None:
|
||||
assert combine_instructions("PROJECT", "AGENT") == "PROJECT\n\nAGENT"
|
||||
|
||||
|
||||
def test_an_absent_block_leaves_no_blank_line_behind() -> None:
|
||||
assert combine_instructions("", "AGENT") == "AGENT"
|
||||
assert combine_instructions("PROJECT", "") == "PROJECT"
|
||||
assert combine_instructions("PROJECT", None) == "PROJECT"
|
||||
|
||||
|
||||
def test_whitespace_only_blocks_do_not_count_as_instructions() -> None:
|
||||
assert combine_instructions(" \n ", "AGENT") == "AGENT"
|
||||
|
||||
|
||||
def test_nothing_to_say_produces_an_empty_string() -> None:
|
||||
assert combine_instructions() == ""
|
||||
assert combine_instructions("", None, " ") == ""
|
||||
|
||||
|
||||
def test_more_than_two_blocks_keep_their_order() -> None:
|
||||
assert combine_instructions("A", "B", "C") == "A\n\nB\n\nC"
|
||||
Reference in New Issue
Block a user