EPIC R01 (Team Duy) - safety net before the parallel refactor starts.
R01-T01 docs/architecture/ADR-001-layered-architecture.md
4-tier boundaries, allowed dependency directions, invariants I1-I6 and
the strangler-fig migration strategy.
R01-T02 tests/fakes/{fake_provider,fake_tool_executor}.py
Scripted, offline Provider and extra-tool executor doubles.
R01-T03 scripts/check_imports.py
AST-based Clean Architecture Guard (CASAN Check 3). Also covers relative
imports and function-local imports; ASCII-only output for cp932 consoles.
R01-T04 tests/characterization/test_run_cowork.py
13 snapshot tests pinning run_cowork's current observable contract before
EPIC R04 moves its orchestration into application/.
R01-T05 docs/architecture/dormant-code.md
Import-graph scan: 43 unimported modules verified down to 6 genuinely
dormant items (~1887 LOC); the rest run via subprocess/CLI entry points.
tests/conftest.py binds `cowork_local` to THIS checkout by absolute path -
previously sys.path discovery could import a sibling checkout and the suite
would silently test the wrong code.
Suite: 104 passed, 1.08s (2 pre-existing failures in test_config_security.py
remain - config.py still ships a hardcoded default password, EPIC R02/Team Nam).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""Characterization snapshot of ``core.chat_agent.run_cowork`` (R01-T04).
|
|
|
|
``run_cowork`` is the turn engine every Cowork surface funnels through (chat tab,
|
|
Co4E flow steps, Schedule Task runs). EPIC R04 moves its orchestration into
|
|
``application/conversations/conversation_application_service.py``; these tests
|
|
lock down the observable contract BEFORE that move so the new service can be
|
|
proven equivalent:
|
|
|
|
* which system prompt ends up in ``messages``
|
|
* which tools are advertised to the provider
|
|
* the exact ``emit`` event sequence for a plain turn and for a tool turn
|
|
* that ``save_file`` produces a real file in the turn's output folder
|
|
* that ``cancel`` stops the loop without calling the provider
|
|
|
|
Everything runs offline: :class:`FakeProvider` replaces the network and the two
|
|
disk-backed prompt sources (skills, security rules) are stubbed to empty so the
|
|
snapshot does not depend on the developer's own ``~/.cowork_local`` contents.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
import pytest
|
|
|
|
from cowork_local.core import chat_agent
|
|
from tests.fakes import FakeProvider, FakeToolExecutor, ScriptedTurn
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_agent(monkeypatch, tmp_path: Path):
|
|
"""Neutralise every ambient input ``run_cowork`` reads from the machine.
|
|
|
|
Without this the snapshot would silently depend on whichever skills and
|
|
security rules the developer happens to have enabled locally, and on the
|
|
real audit log under ``~/.cowork_local`` - the test would then pass on one
|
|
laptop and fail on another for reasons unrelated to the code under test.
|
|
"""
|
|
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
|
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
|
# audit_log is imported lazily inside run_cowork, so patch the module's own
|
|
# target directory rather than the name chat_agent sees.
|
|
from cowork_local.core import audit_log
|
|
|
|
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
|
return tmp_path
|
|
|
|
|
|
def _run(provider, messages, out_dir: Path, **kwargs):
|
|
"""Run one turn and return ``(returned_messages, emitted_events)``."""
|
|
events: List[Dict[str, Any]] = []
|
|
result = chat_agent.run_cowork(provider, messages, out_dir, events.append, **kwargs)
|
|
return result, events
|
|
|
|
|
|
def _types(events: List[Dict[str, Any]]) -> List[str]:
|
|
"""Event ``type`` values in order - the shape assertions read on."""
|
|
return [e.get("type") for e in events]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# A plain answer with no tool calls
|
|
# --------------------------------------------------------------------------- #
|
|
def test_plain_turn_streams_text_and_appends_assistant_message(isolated_agent):
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([ScriptedTurn(text="Hello there.")])
|
|
messages: List[Dict[str, Any]] = [{"role": "user", "content": "hi"}]
|
|
|
|
result, events = _run(provider, messages, out_dir)
|
|
|
|
# The loop ends as soon as the model stops calling tools: exactly one call.
|
|
assert provider.call_count == 1
|
|
# run_cowork mutates and returns the SAME list the caller passed in - callers
|
|
# (ui/cowork_tab.py::build_job) rely on this to persist conversation history.
|
|
assert result is messages
|
|
assert result[-1]["role"] == "assistant"
|
|
assert result[-1]["content"] == "Hello there."
|
|
assert _types(events) == ["text", "assistant_done"]
|
|
assert events[0]["delta"] == "Hello there."
|
|
assert events[-1]["content"] == "Hello there."
|
|
|
|
|
|
def test_system_prompt_is_inserted_once_at_the_front(isolated_agent):
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
messages: List[Dict[str, Any]] = [{"role": "user", "content": "hi"}]
|
|
|
|
result, _ = _run(provider, messages, out_dir)
|
|
|
|
assert result[0]["role"] == "system"
|
|
assert result[0]["content"].startswith("You are Cowork Local")
|
|
# Exactly one system message: a second turn on the same conversation must not
|
|
# stack another copy of the prompt (that would grow the context every turn).
|
|
assert sum(1 for m in result if m.get("role") == "system") == 1
|
|
|
|
|
|
def test_caller_supplied_system_prompt_is_preserved(isolated_agent):
|
|
"""A caller that already put a system message first keeps its own prompt.
|
|
|
|
Co4E flow steps depend on this to give a step its own persona instead of the
|
|
generic Cowork prompt.
|
|
"""
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
messages: List[Dict[str, Any]] = [
|
|
{"role": "system", "content": "CUSTOM PERSONA"},
|
|
{"role": "user", "content": "hi"},
|
|
]
|
|
|
|
result, _ = _run(provider, messages, out_dir)
|
|
|
|
assert result[0]["content"] == "CUSTOM PERSONA"
|
|
|
|
|
|
def test_reasoning_is_emitted_separately_and_never_joins_the_answer(isolated_agent):
|
|
"""Reasoning drives the "Thinking" indicator only - it must not become part
|
|
of the assistant's content, otherwise a reasoning model's private chain of
|
|
thought would be persisted into conversation history."""
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([ScriptedTurn(text="42", reasoning="let me think...")])
|
|
|
|
result, events = _run(provider, [{"role": "user", "content": "q"}], out_dir)
|
|
|
|
assert _types(events) == ["reasoning", "text", "assistant_done"]
|
|
assert result[-1]["content"] == "42"
|
|
assert "let me think" not in result[-1]["content"]
|
|
|
|
|
|
def test_reasoning_only_reply_gets_a_placeholder_answer(isolated_agent):
|
|
"""A model that returns only reasoning must not end the turn on a blank
|
|
bubble - headless callers (Schedule Task) read this content back as the
|
|
run's final answer and would otherwise write "(no output)"."""
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([ScriptedTurn(text="", reasoning="thinking")])
|
|
|
|
result, events = _run(provider, [{"role": "user", "content": "q"}], out_dir)
|
|
|
|
assert result[-1]["content"].startswith("*(model returned only its reasoning")
|
|
assert "text" in _types(events)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Tool advertising
|
|
# --------------------------------------------------------------------------- #
|
|
def test_save_file_and_update_plan_are_always_advertised(isolated_agent):
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
|
|
_run(provider, [{"role": "user", "content": "hi"}], out_dir)
|
|
|
|
advertised = provider.calls[0].tool_names
|
|
assert "save_file" in advertised
|
|
assert "update_plan" in advertised
|
|
|
|
|
|
def test_allowed_tools_scopes_the_catalogue_but_keeps_update_plan(isolated_agent):
|
|
"""``allowed_tools`` is the permission scope Co4E steps use: a read-only step
|
|
must literally not be offered a writing tool. ``update_plan`` survives the
|
|
filter because it has no side effects."""
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
|
|
_run(provider, [{"role": "user", "content": "hi"}], out_dir,
|
|
allowed_tools=["read_file"])
|
|
|
|
advertised = set(provider.calls[0].tool_names)
|
|
assert "save_file" not in advertised
|
|
assert "update_plan" in advertised
|
|
|
|
|
|
def test_extra_tools_are_advertised_alongside_built_ins(isolated_agent):
|
|
out_dir = isolated_agent / "out"
|
|
executor = FakeToolExecutor(results={"ms365_send_mail": {"output": "sent"}})
|
|
provider = FakeProvider([ScriptedTurn(text="ok")])
|
|
|
|
_run(provider, [{"role": "user", "content": "hi"}], out_dir,
|
|
extra_tools=executor.specs(), extra_executor=executor)
|
|
|
|
assert "ms365_send_mail" in provider.calls[0].tool_names
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Tool execution
|
|
# --------------------------------------------------------------------------- #
|
|
def test_save_file_writes_a_real_file_and_reports_it(isolated_agent):
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("save_file", {"filename": "note.md",
|
|
"content": "# Result\n"})]),
|
|
ScriptedTurn(text="Done."),
|
|
])
|
|
|
|
result, events = _run(provider, [{"role": "user", "content": "make a note"}], out_dir)
|
|
|
|
written = [p for p in out_dir.iterdir() if p.is_file()]
|
|
assert len(written) == 1
|
|
assert written[0].read_text(encoding="utf-8") == "# Result\n"
|
|
|
|
assert _types(events) == [
|
|
"assistant_done", # first turn: tool call only, no visible text
|
|
"tool_proposed", # the diff preview shown in the chat
|
|
"tool_result",
|
|
"text", # second turn's answer
|
|
"assistant_done",
|
|
]
|
|
assert events[2]["ok"] is True
|
|
|
|
# The tool result is fed back as a `tool` message so the model can react to it.
|
|
roles = [m["role"] for m in result]
|
|
assert roles == ["system", "user", "assistant", "tool", "assistant"]
|
|
assert result[3]["name"] == "save_file"
|
|
|
|
|
|
def test_extra_tool_calls_are_routed_to_the_extra_executor(isolated_agent):
|
|
"""MCP / Microsoft 365 tools bypass the built-in file+command handlers and go
|
|
to the caller-supplied executor instead."""
|
|
out_dir = isolated_agent / "out"
|
|
executor = FakeToolExecutor(results={"ms365_send_mail": {"ok": True, "output": "sent"}})
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("ms365_send_mail", {"to": "a@b.c"})]),
|
|
ScriptedTurn(text="Mail sent."),
|
|
])
|
|
|
|
result, events = _run(provider, [{"role": "user", "content": "mail them"}], out_dir,
|
|
extra_tools=executor.specs(), extra_executor=executor)
|
|
|
|
assert executor.call_names == ["ms365_send_mail"]
|
|
assert executor.args_for("ms365_send_mail") == [{"to": "a@b.c"}]
|
|
assert [e for e in events if e["type"] == "tool_result"][0]["output"] == "sent"
|
|
assert result[3] == {"role": "tool", "tool_call_id": result[3]["tool_call_id"],
|
|
"name": "ms365_send_mail", "content": "sent"}
|
|
|
|
|
|
def test_update_plan_drives_the_plan_panel_without_producing_a_file(isolated_agent):
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("update_plan", {"steps": [{"title": "step one"}]})]),
|
|
ScriptedTurn(text="Planned."),
|
|
])
|
|
|
|
result, events = _run(provider, [{"role": "user", "content": "plan it"}], out_dir)
|
|
|
|
plan_events = [e for e in events if e["type"] == "plan_set"]
|
|
assert len(plan_events) == 1
|
|
assert plan_events[0]["steps"]
|
|
# No tool_proposed/tool_result bubbles for a plan update, and no file on disk.
|
|
assert "tool_proposed" not in _types(events)
|
|
assert list(out_dir.iterdir()) == []
|
|
assert result[3]["content"] == "Plan updated."
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Cancellation
|
|
# --------------------------------------------------------------------------- #
|
|
def test_cancel_before_the_first_step_never_calls_the_provider(isolated_agent):
|
|
"""Stop pressed before the loop starts must cost zero tokens."""
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([], strict=True)
|
|
|
|
result, events = _run(provider, [{"role": "user", "content": "hi"}], out_dir,
|
|
cancel=lambda: True)
|
|
|
|
assert provider.call_count == 0
|
|
assert _types(events) == []
|
|
# The system prompt is still installed, so the conversation stays well-formed
|
|
# for a later retry on the same message list.
|
|
assert result[0]["role"] == "system"
|
|
|
|
|
|
def test_cancel_between_steps_stops_before_the_next_provider_call(isolated_agent):
|
|
"""After a tool call runs, a Stop must end the turn instead of paying for
|
|
another round trip."""
|
|
out_dir = isolated_agent / "out"
|
|
provider = FakeProvider([
|
|
ScriptedTurn(tool_calls=[("save_file", {"filename": "a.md", "content": "x"})]),
|
|
])
|
|
calls = {"n": 0}
|
|
|
|
def cancel() -> bool:
|
|
# False on the first check (loop entry), True afterwards - i.e. the user
|
|
# pressed Stop while the first step was running.
|
|
calls["n"] += 1
|
|
return calls["n"] > 1
|
|
|
|
result, _ = _run(provider, [{"role": "user", "content": "hi"}], out_dir, cancel=cancel)
|
|
|
|
assert provider.call_count == 1
|
|
assert result[-1]["role"] in {"assistant", "tool"}
|