Files
cowork-local/tests/characterization/test_run_cowork.py
T
anhtnm1andClaude Opus 5 bbc09f628a feat(R01): architecture foundation, offline fakes and characterization net
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>
2026-08-21 10:05:50 +09:00

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"}