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>
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user