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,213 @@
|
||||
"""FakeProvider - a scripted, offline stand-in for a real LLM provider (R01-T02).
|
||||
|
||||
The real providers (``providers/openai_compat.py``, ``providers/anthropic.py``)
|
||||
open HTTP connections, need API keys and stream at the mercy of the network, so
|
||||
nothing above them could be tested deterministically. This double implements the
|
||||
same :class:`providers.base.Provider` contract from a list of scripted turns:
|
||||
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "a.md", "content": "hi"})]),
|
||||
ScriptedTurn(text="Saved it."),
|
||||
])
|
||||
|
||||
Turn 1 asks the agent loop to call a tool, turn 2 ends the loop with plain text -
|
||||
exactly the two-step shape ``run_cowork`` exercises, with zero I/O.
|
||||
|
||||
It records every call it received (:attr:`FakeProvider.calls`) so a test can
|
||||
assert on what the layer above actually sent (message list, tool catalogue),
|
||||
which is how the characterization and contract suites pin current behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from cowork_local.providers.base import (
|
||||
CancelFn,
|
||||
Provider,
|
||||
ProviderError,
|
||||
TextCallback,
|
||||
ToolSpec,
|
||||
)
|
||||
|
||||
# One scripted tool call: (name, arguments). Ids are generated by the provider so
|
||||
# a test never has to invent them, mirroring what a real gateway does.
|
||||
ToolCallScript = Tuple[str, Dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScriptedTurn:
|
||||
"""What :class:`FakeProvider` should do for ONE ``chat()`` call.
|
||||
|
||||
``text`` is streamed through ``on_text`` and returned as the assistant
|
||||
message content. ``reasoning`` goes to ``on_reasoning`` only - it must never
|
||||
leak into the answer, and asserting that is one of this double's jobs.
|
||||
|
||||
``tool_calls`` makes the agent loop run tools and come back for another turn;
|
||||
an empty tuple ends the loop.
|
||||
|
||||
``error``, when set, raises :class:`ProviderError` instead of answering, so
|
||||
error/recovery paths are testable without simulating a network fault.
|
||||
|
||||
``chunk_size`` > 0 splits ``text`` into fixed-size pieces to exercise
|
||||
chunk-boundary handling in stream consumers (the ``<think>`` splitter and the
|
||||
UI's incremental markdown renderer both have boundary logic worth covering).
|
||||
"""
|
||||
|
||||
text: str = ""
|
||||
reasoning: str = ""
|
||||
tool_calls: Sequence[ToolCallScript] = ()
|
||||
error: Optional[str] = None
|
||||
chunk_size: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordedCall:
|
||||
"""A snapshot of one ``chat()`` invocation, for assertions after the fact."""
|
||||
|
||||
messages: List[Dict[str, Any]]
|
||||
tool_names: List[str]
|
||||
cancelled: bool = False
|
||||
|
||||
|
||||
class FakeProvider(Provider):
|
||||
"""A ``Provider`` that replays :class:`ScriptedTurn` objects.
|
||||
|
||||
Args:
|
||||
turns: the scripted turns, consumed in order.
|
||||
model: the model id reported through ``describe()`` / usage records.
|
||||
models: what :meth:`list_models` returns (Settings' "Load models").
|
||||
strict: when True (default) running past the end of the script raises
|
||||
``AssertionError``. That is intentional noise: a silent extra turn
|
||||
usually means the code under test looped more than the test author
|
||||
expected, and hiding it behind an empty answer would turn a real
|
||||
behaviour change into a passing test.
|
||||
"""
|
||||
|
||||
name = "fake"
|
||||
# The double can accept image content blocks, so vision code paths are
|
||||
# reachable in tests without a real vision-capable gateway.
|
||||
supports_vision = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
turns: Optional[Sequence[ScriptedTurn]] = None,
|
||||
*,
|
||||
model: str = "fake-model",
|
||||
models: Optional[Sequence[str]] = None,
|
||||
strict: bool = True,
|
||||
conf: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
super().__init__(dict(conf or {}, model=model))
|
||||
self._turns: List[ScriptedTurn] = list(turns or [])
|
||||
self._models = list(models or [model])
|
||||
self._strict = strict
|
||||
self._ids = itertools.count(1) # deterministic tool-call ids: call_1, call_2, ...
|
||||
self.calls: List[RecordedCall] = []
|
||||
|
||||
# -- introspection helpers used by tests ---------------------------- #
|
||||
@property
|
||||
def call_count(self) -> int:
|
||||
"""How many times the layer above asked this provider to run a turn."""
|
||||
return len(self.calls)
|
||||
|
||||
@property
|
||||
def remaining_turns(self) -> int:
|
||||
"""Scripted turns not consumed yet - assert 0 to prove the script was
|
||||
fully used (an unused turn means the code stopped earlier than intended)."""
|
||||
return len(self._turns)
|
||||
|
||||
def last_messages(self) -> List[Dict[str, Any]]:
|
||||
"""The message list sent on the most recent call (empty if never called)."""
|
||||
return self.calls[-1].messages if self.calls else []
|
||||
|
||||
# -- Provider contract ---------------------------------------------- #
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[ToolSpec]] = None,
|
||||
on_text: Optional[TextCallback] = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_reasoning: Optional[TextCallback] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Replay the next scripted turn, honouring cancel and both callbacks.
|
||||
|
||||
The message list is deep-ish copied into the recording because the agent
|
||||
loop keeps appending to the SAME list object; without the copy every
|
||||
recorded call would show the final state and assertions on "what was
|
||||
sent at step 1" would be meaningless.
|
||||
"""
|
||||
record = RecordedCall(
|
||||
messages=[dict(m) for m in messages],
|
||||
tool_names=[t.name for t in (tools or [])],
|
||||
)
|
||||
self.calls.append(record)
|
||||
|
||||
turn = self._next_turn()
|
||||
|
||||
# Checked before streaming anything: a provider that already knows the
|
||||
# caller gave up must not spend callbacks on text nobody will render.
|
||||
if self._is_cancelled(cancel):
|
||||
record.cancelled = True
|
||||
return {"role": "assistant", "content": "", "tool_calls": []}
|
||||
|
||||
if turn.error:
|
||||
raise ProviderError(turn.error)
|
||||
|
||||
if turn.reasoning and on_reasoning:
|
||||
on_reasoning(turn.reasoning)
|
||||
|
||||
for piece in self._stream_pieces(turn):
|
||||
# Re-checked between chunks so a mid-stream Stop truncates the answer
|
||||
# the same way a real streamed response does.
|
||||
if self._is_cancelled(cancel):
|
||||
record.cancelled = True
|
||||
break
|
||||
if on_text:
|
||||
on_text(piece)
|
||||
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": turn.text,
|
||||
"tool_calls": [
|
||||
{"id": f"call_{next(self._ids)}", "name": name, "arguments": dict(args)}
|
||||
for name, args in turn.tool_calls
|
||||
],
|
||||
}
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Configured model ids. Clears ``last_error`` so ``test_connection()``
|
||||
reports success, matching how a healthy real provider behaves."""
|
||||
self.last_error = ""
|
||||
return list(self._models)
|
||||
|
||||
# -- internals ------------------------------------------------------- #
|
||||
def _next_turn(self) -> ScriptedTurn:
|
||||
"""Pop the next scripted turn, or fail loudly when the script ran out."""
|
||||
if self._turns:
|
||||
return self._turns.pop(0)
|
||||
if self._strict:
|
||||
raise AssertionError(
|
||||
f"FakeProvider script exhausted: chat() was called {len(self.calls)} "
|
||||
"time(s) but fewer turns were scripted. Add a ScriptedTurn, or pass "
|
||||
"strict=False if the extra call is genuinely expected."
|
||||
)
|
||||
return ScriptedTurn()
|
||||
|
||||
@staticmethod
|
||||
def _stream_pieces(turn: ScriptedTurn) -> List[str]:
|
||||
"""Split a turn's answer into the fragments to stream.
|
||||
|
||||
``chunk_size == 0`` streams the whole answer in one piece (the common
|
||||
case); a positive size slices it so tests can drive chunk-boundary logic.
|
||||
"""
|
||||
if not turn.text:
|
||||
return []
|
||||
if turn.chunk_size <= 0:
|
||||
return [turn.text]
|
||||
size = turn.chunk_size
|
||||
return [turn.text[i:i + size] for i in range(0, len(turn.text), size)]
|
||||
|
||||
|
||||
__all__ = ["FakeProvider", "ScriptedTurn", "RecordedCall"]
|
||||
Reference in New Issue
Block a user