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>
214 lines
8.1 KiB
Python
214 lines
8.1 KiB
Python
"""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"]
|