Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d3217e0b5 | ||
|
|
67b8d2edbb | ||
|
|
15e1d3eb65 | ||
|
|
a53163ebaf | ||
|
|
96bec976e7 | ||
|
|
bbc09f628a |
+12
-1
@@ -1 +1,12 @@
|
||||
"""Application Layer: Pure Python use cases and application services."""
|
||||
"""Application layer - pure Python use-case orchestration.
|
||||
|
||||
Sits between ``presentation/`` (Qt widgets) and ``domain/`` (entities). A module
|
||||
here answers "what has to happen, in what order" for one use case - route a
|
||||
turn, run a conversation - without knowing whether a human, a scheduler or a
|
||||
test triggered it.
|
||||
|
||||
Hard rule (ADR-001 I1/I3, enforced by ``scripts/check_imports.py``): no
|
||||
PySide6/PyQt imports and no reach into ``presentation/``/``ui/``. Results travel
|
||||
back up through plain-Python callbacks; turning those into Qt signals is the
|
||||
presentation layer's job.
|
||||
"""
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
"""Application conversations package: turn lifecycle orchestration and agent execution."""
|
||||
"""Conversation use case: the lifecycle of one agent turn (EPIC R04)."""
|
||||
|
||||
from .conversation_application_service import (
|
||||
ConversationApplicationService,
|
||||
TurnResult,
|
||||
)
|
||||
|
||||
__all__ = ["ConversationApplicationService", "TurnResult"]
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""ConversationApplicationService - the turn lifecycle, outside the widget (R04-T03).
|
||||
|
||||
What this replaces
|
||||
------------------
|
||||
The lifecycle of one Cowork turn is currently spread across a closure inside
|
||||
``ui/cowork_tab.py::build_job`` and a second, near-identical assembly inside
|
||||
``core/task_executors.py::_run_agent``. Both:
|
||||
|
||||
* read live UI/config state from a worker thread,
|
||||
* build the provider, the MCP tool set and the project context by hand,
|
||||
* call ``core.chat_agent.run_cowork`` with a dozen positional-ish arguments,
|
||||
* consume untyped event dicts.
|
||||
|
||||
Two copies means a fix to one path (say, promoting output files on failure)
|
||||
silently misses the other. This service is the single implementation: it takes
|
||||
an immutable :class:`ConversationExecutionRequest`, runs the turn, and reports
|
||||
typed :class:`AgentEvent` objects.
|
||||
|
||||
What it deliberately does NOT do
|
||||
--------------------------------
|
||||
It does not re-implement the agent loop. ``run_cowork`` stays the engine
|
||||
(strangler fig, ADR-001 section 4) and keeps its characterization tests
|
||||
(``tests/characterization/test_run_cowork.py``). This layer owns the parts that
|
||||
were tangled into the UI: assembling the call, translating events, and giving a
|
||||
turn a well-defined end.
|
||||
|
||||
Pure Python: no Qt import, no config access. Everything it needs arrives through
|
||||
constructor callbacks, so the same service runs a turn from a chat panel, from
|
||||
the scheduler, or from a test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from cowork_local.domain.agents.agent_event import (
|
||||
AgentEvent,
|
||||
ErrorEvent,
|
||||
TurnCompletedEvent,
|
||||
collect_text,
|
||||
event_from_dict,
|
||||
)
|
||||
from cowork_local.domain.agents.conversation_execution_request import (
|
||||
ConversationExecutionRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cowork_local.conversations")
|
||||
|
||||
# Presentation/scheduler supplies these. Kept as plain callables (not objects)
|
||||
# so a test can wire the service with three lambdas.
|
||||
EventCallback = Callable[[AgentEvent], None]
|
||||
CancelFn = Callable[[], bool]
|
||||
ProviderFactory = Callable[[str, str], Any] # (provider_id, model) -> Provider
|
||||
ToolSourceFactory = Callable[[], Tuple[Any, Any]] # () -> (extra_tools, extra_executor)
|
||||
GateFactory = Callable[[ConversationExecutionRequest], Any] # -> PermissionGate or None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnResult:
|
||||
"""What a finished turn produced.
|
||||
|
||||
``messages`` is the conversation AFTER the turn (system prompt inserted,
|
||||
assistant and tool messages appended) - the caller persists this as the new
|
||||
history. ``final_text`` is the visible answer, reasoning excluded.
|
||||
"""
|
||||
|
||||
request: ConversationExecutionRequest
|
||||
messages: List[Dict[str, Any]] = field(default_factory=list)
|
||||
events: List[AgentEvent] = field(default_factory=list)
|
||||
final_text: str = ""
|
||||
cancelled: bool = False
|
||||
error: str = ""
|
||||
# The original exception, kept alongside its message so a caller that needs
|
||||
# to preserve legacy failure handling can re-raise the SAME object rather
|
||||
# than a lookalike (SecurityBlocked, for instance, carries context that a
|
||||
# re-wrapped RuntimeError would lose).
|
||||
exception: Optional[BaseException] = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""True when the turn completed without an error and without a Stop."""
|
||||
return not self.error and not self.cancelled
|
||||
|
||||
def raise_if_failed(self) -> None:
|
||||
"""Re-raise the turn's failure, if any.
|
||||
|
||||
Callers that already have failure handling built around an exception
|
||||
(the Qt worker turns one into its ``failed`` signal) use this to keep
|
||||
that path intact while still getting a TurnResult on success."""
|
||||
if self.exception is not None:
|
||||
raise self.exception
|
||||
|
||||
def output_dir(self) -> Optional[Path]:
|
||||
"""This turn's output folder, or None when it could not write files."""
|
||||
return Path(self.request.output_dir) if self.request.output_dir else None
|
||||
|
||||
|
||||
class ConversationApplicationService:
|
||||
"""Runs one agent turn from an immutable request.
|
||||
|
||||
Args:
|
||||
provider_factory: ``(provider_id, model) -> Provider``. Production passes
|
||||
``AppContext.build_provider_for``; tests pass a lambda returning a
|
||||
:class:`FakeProvider`.
|
||||
tool_source: ``() -> (extra_tools, extra_executor)`` for MCP/connector
|
||||
tools. Optional - a turn with no external tools passes nothing.
|
||||
gate_factory: ``(request) -> PermissionGate | None``, consulted when the
|
||||
request asks to confirm commands. Optional for the same reason.
|
||||
runner: the turn engine. Defaults to ``core.chat_agent.run_cowork``,
|
||||
imported lazily so this module stays importable (and testable)
|
||||
without pulling in the whole legacy tool stack.
|
||||
security_config: the app config the security layers read. ``None``
|
||||
disables them, which is what headless callers already rely on.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider_factory: ProviderFactory,
|
||||
*,
|
||||
tool_source: Optional[ToolSourceFactory] = None,
|
||||
gate_factory: Optional[GateFactory] = None,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
security_config: Any = None,
|
||||
) -> None:
|
||||
self._provider_factory = provider_factory
|
||||
self._tool_source = tool_source
|
||||
self._gate_factory = gate_factory
|
||||
self._runner = runner
|
||||
self._security_config = security_config
|
||||
|
||||
# -- main entry point -------------------------------------------------- #
|
||||
def run_turn(
|
||||
self,
|
||||
request: ConversationExecutionRequest,
|
||||
on_event: Optional[EventCallback] = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
) -> TurnResult:
|
||||
"""Execute one turn and return everything it produced.
|
||||
|
||||
Never raises: a provider or tool failure becomes an :class:`ErrorEvent`
|
||||
plus ``TurnResult.error``. Callers run this on a worker thread and have
|
||||
no good way to handle an exception crossing that boundary - today an
|
||||
escaped error kills the worker and the UI just stops updating, with no
|
||||
message shown.
|
||||
|
||||
Exactly one :class:`TurnCompletedEvent` is always emitted last, whether
|
||||
the turn succeeded, failed or was cancelled. That is the end-of-turn
|
||||
signal the legacy engine never had.
|
||||
"""
|
||||
return self.execute_turn(self.begin_turn(request), on_event=on_event, cancel=cancel)
|
||||
|
||||
def begin_turn(self, request: ConversationExecutionRequest) -> TurnResult:
|
||||
"""Create the (still empty) result a turn will fill in.
|
||||
|
||||
Exposed separately from :meth:`run_turn` because some callers need the
|
||||
LIVE message list while the turn is running, not only afterwards: the
|
||||
scheduler re-saves the conversation to History after every assistant
|
||||
message so a long unattended run shows live progress when reopened.
|
||||
Handing them ``result.messages`` - the very list the engine appends to -
|
||||
is what makes that possible without leaking the engine into the caller.
|
||||
"""
|
||||
return TurnResult(request=request, messages=request.message_list())
|
||||
|
||||
def execute_turn(
|
||||
self,
|
||||
result: TurnResult,
|
||||
on_event: Optional[EventCallback] = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
) -> TurnResult:
|
||||
"""Run a turn previously created by :meth:`begin_turn`. See
|
||||
:meth:`run_turn` for the error/cancellation contract."""
|
||||
request = result.request
|
||||
emit = self._make_emitter(result, on_event)
|
||||
cancel = cancel or (lambda: False)
|
||||
|
||||
try:
|
||||
self._execute(request, result, emit, cancel)
|
||||
except Exception as exc: # noqa: BLE001 - see docstring
|
||||
result.error = str(exc) or exc.__class__.__name__
|
||||
result.exception = exc
|
||||
logger.exception("turn %s failed", request.turn_id)
|
||||
emit(ErrorEvent(message=result.error,
|
||||
recoverable=self._is_recoverable(exc)))
|
||||
|
||||
result.cancelled = bool(cancel())
|
||||
result.final_text = collect_text(result.events) or self._last_assistant_text(result.messages)
|
||||
emit(TurnCompletedEvent(content=result.final_text, cancelled=result.cancelled))
|
||||
return result
|
||||
|
||||
# -- internals --------------------------------------------------------- #
|
||||
def _execute(self, request: ConversationExecutionRequest, result: TurnResult,
|
||||
emit: Callable[[AgentEvent], None], cancel: CancelFn) -> None:
|
||||
"""Assemble the engine call from the request snapshot and run it."""
|
||||
provider = self._provider_factory(request.provider, request.model)
|
||||
extra_tools, extra_executor = self._resolve_tools()
|
||||
gate = self._resolve_gate(request)
|
||||
|
||||
# The engine speaks untyped dicts; bridge them into typed events at this
|
||||
# single point rather than at every consumer.
|
||||
def legacy_emit(payload: Dict[str, Any]) -> None:
|
||||
event = event_from_dict(payload)
|
||||
if event is not None:
|
||||
emit(event)
|
||||
|
||||
run = self._resolve_runner()
|
||||
run(
|
||||
provider,
|
||||
result.messages, # mutated in place by the engine, as before
|
||||
self._output_dir(request),
|
||||
legacy_emit,
|
||||
cancel,
|
||||
title=request.title,
|
||||
extra_tools=extra_tools,
|
||||
extra_executor=extra_executor,
|
||||
project_context=request.project_context,
|
||||
security_config=self._security_config,
|
||||
gate=gate,
|
||||
allowed_tools=list(request.allowed_tools) if request.allowed_tools is not None else None,
|
||||
max_steps=request.max_steps,
|
||||
run_to_completion=request.run_to_completion,
|
||||
completion_max_steps=request.completion_max_steps,
|
||||
enforce_rules=request.enforce_rules,
|
||||
**self._role_kwargs(request),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_emitter(result: TurnResult,
|
||||
on_event: Optional[EventCallback]) -> Callable[[AgentEvent], None]:
|
||||
"""Record every event on the result AND forward it to the caller.
|
||||
|
||||
Recording is unconditional so a headless caller (the scheduler) can read
|
||||
the full event list afterwards without having to supply a callback just
|
||||
to collect it - which is exactly what task_executors does today with an
|
||||
ad-hoc list.
|
||||
"""
|
||||
def emit(event: AgentEvent) -> None:
|
||||
result.events.append(event)
|
||||
if on_event is None:
|
||||
return
|
||||
try:
|
||||
on_event(event)
|
||||
except Exception: # noqa: BLE001
|
||||
# A consumer that throws (a closing widget, say) must not abort
|
||||
# the turn that is feeding it.
|
||||
logger.debug("event consumer raised for %s", event.type, exc_info=True)
|
||||
return emit
|
||||
|
||||
def _resolve_runner(self) -> Callable[..., Any]:
|
||||
"""The turn engine, imported lazily on first use."""
|
||||
if self._runner is None:
|
||||
from cowork_local.core.chat_agent import run_cowork
|
||||
|
||||
self._runner = run_cowork
|
||||
return self._runner
|
||||
|
||||
def _resolve_tools(self) -> Tuple[Any, Any]:
|
||||
"""MCP/connector tools for this turn, or ``(None, None)``.
|
||||
|
||||
A failure here degrades to "no external tools" rather than failing the
|
||||
turn: an MCP server that will not start must not stop the user from
|
||||
chatting, which is the behaviour the chat panel already relies on.
|
||||
"""
|
||||
if self._tool_source is None:
|
||||
return None, None
|
||||
try:
|
||||
return self._tool_source()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("tool source unavailable - running without external tools",
|
||||
exc_info=True)
|
||||
return None, None
|
||||
|
||||
def _resolve_gate(self, request: ConversationExecutionRequest) -> Any:
|
||||
"""The permission gate, when this turn asked to confirm commands."""
|
||||
if not request.confirm_commands or self._gate_factory is None:
|
||||
return None
|
||||
return self._gate_factory(request)
|
||||
|
||||
@staticmethod
|
||||
def _output_dir(request: ConversationExecutionRequest) -> Path:
|
||||
"""The turn's output folder as a Path.
|
||||
|
||||
The request holds it as a string to stay serialisable; converting at the
|
||||
single point of use keeps that decision from leaking into every caller.
|
||||
"""
|
||||
return Path(request.output_dir) if request.output_dir else Path.cwd()
|
||||
|
||||
@staticmethod
|
||||
def _role_kwargs(request: ConversationExecutionRequest) -> Dict[str, Any]:
|
||||
"""``agent_role`` only when the request set one.
|
||||
|
||||
Omitted otherwise so the engine applies its own default (the interactive
|
||||
Cowork role) instead of being handed an empty string, which would land
|
||||
in the audit log as an unattributed tool call.
|
||||
"""
|
||||
return {"agent_role": request.agent_role} if request.agent_role else {}
|
||||
|
||||
@staticmethod
|
||||
def _last_assistant_text(messages: List[Dict[str, Any]]) -> str:
|
||||
"""Fallback answer text when no text events were seen.
|
||||
|
||||
A turn whose whole answer arrived in one non-streamed message still has
|
||||
to report a final answer - the scheduler writes it into output.md, and
|
||||
an empty string there reads as "(no output)".
|
||||
"""
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "assistant" and (message.get("content") or "").strip():
|
||||
return str(message["content"])
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _is_recoverable(exc: Exception) -> bool:
|
||||
"""Whether the user can act on this failure themselves.
|
||||
|
||||
"Model not found" is the motivating case: the chat panel restores the
|
||||
typed message into the composer so the user can switch model and resend
|
||||
instead of retyping it (see providers/base.py::MODEL_NOT_FOUND_HINT).
|
||||
"""
|
||||
try:
|
||||
from cowork_local.providers.base import is_model_not_found_error
|
||||
|
||||
return bool(is_model_not_found_error(str(exc)))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["ConversationApplicationService", "TurnResult"]
|
||||
@@ -1,54 +1,12 @@
|
||||
"""Application model routing package: model route decisions and multi-provider balancing.
|
||||
"""Model routing use case: pick the best-fit model for one turn (EPIC R03)."""
|
||||
|
||||
Public surface (R03-T03 — the single routing entry point every chat surface uses):
|
||||
|
||||
* :class:`RoutingApplicationService` — decides one turn's provider/model.
|
||||
* :class:`RoutingRequest` / :class:`RoutingOutcome` — the immutable DTOs in and out.
|
||||
* :class:`RoutingMode` — Off / Auto / Manual / Fallback.
|
||||
* :func:`build_routing_application_service` — wires the service to a live
|
||||
``AppContext`` (engine + per-workspace mode + confirm timeout).
|
||||
|
||||
Typical call site (see ``ui/chat_panel.py::_apply_routing``)::
|
||||
|
||||
service = build_routing_application_service(self.ctx)
|
||||
outcome = service.resolve(
|
||||
RoutingRequest(surface="cowork", prompt=text,
|
||||
current_provider=provider, current_model=model),
|
||||
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
|
||||
)
|
||||
|
||||
Only ``core_routing_adapter`` touches ``core/routing``; the service and the DTOs
|
||||
stay pure Python so the whole rule set is testable without Qt or the engine.
|
||||
"""
|
||||
|
||||
from .core_routing_adapter import (
|
||||
AppContextModeResolver,
|
||||
CoreRoutingEngine,
|
||||
build_routing_application_service,
|
||||
)
|
||||
from .routing_application_service import (
|
||||
ConfirmationCallback,
|
||||
ModeResolver,
|
||||
RoutingApplicationService,
|
||||
RoutingDecisionPort,
|
||||
)
|
||||
from .routing_models import (
|
||||
RouteEvaluation,
|
||||
RoutingDecision,
|
||||
RoutingMode,
|
||||
RoutingOutcome,
|
||||
RoutingRequest,
|
||||
is_valid_mode,
|
||||
normalize_mode,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AppContextModeResolver",
|
||||
"ConfirmationCallback",
|
||||
"CoreRoutingEngine",
|
||||
"ModeResolver",
|
||||
"RouteEvaluation",
|
||||
"RoutingApplicationService",
|
||||
"RoutingDecisionPort",
|
||||
"RoutingMode",
|
||||
"RoutingOutcome",
|
||||
"RoutingRequest",
|
||||
"build_routing_application_service",
|
||||
]
|
||||
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
|
||||
"normalize_mode", "is_valid_mode"]
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
"""Adapters that plug the existing routing engine into the application service.
|
||||
|
||||
:mod:`routing_application_service` is written against two narrow ports so it can
|
||||
be unit-tested with plain fakes. This module supplies the real implementations —
|
||||
the assessment/scoring engine in ``core/routing`` and the per-workspace mode
|
||||
lookup on ``AppContext`` — and is therefore the ONLY file in
|
||||
``application/model_routing/`` that knows those concrete types exist.
|
||||
|
||||
All engine imports are deferred into method bodies. Importing the routing stack
|
||||
pulls in Pydantic models and the on-disk assessment store, and the UI must be
|
||||
able to import this module during startup without paying that cost (the same
|
||||
lazy-wiring reason ``state.py::AppContext.routing`` gives).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from .routing_application_service import RoutingApplicationService
|
||||
from .routing_models import RouteEvaluation, RoutingMode, RoutingRequest
|
||||
|
||||
logger = logging.getLogger("cowork_local.application.model_routing")
|
||||
|
||||
|
||||
class CoreRoutingEngine:
|
||||
""":class:`RoutingDecisionPort` backed by ``core/routing/service.py``.
|
||||
|
||||
Translates in both directions: application DTOs in, and the engine's
|
||||
``RouteResult``/``SwitchDecision``/``TaskType`` flattened back out into a
|
||||
:class:`RouteEvaluation`, so no ``core.routing`` type ever escapes into the
|
||||
application service or the UI call sites.
|
||||
"""
|
||||
|
||||
def __init__(self, routing_service: Any) -> None:
|
||||
self._routing_service = routing_service
|
||||
|
||||
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
||||
"""Rank candidates for this turn and report the engine's verdict."""
|
||||
from ...core.routing.models import TaskType, candidate_key
|
||||
|
||||
result = self._routing_service.route(
|
||||
request.surface,
|
||||
request.prompt,
|
||||
request.current_provider,
|
||||
request.current_model,
|
||||
# The engine only knows off/auto/manual; FALLBACK was already mapped
|
||||
# to AUTO upstream so the value handed over here is always valid.
|
||||
mode_override=mode.value,
|
||||
required_capabilities=list(request.required_capabilities) or None,
|
||||
task_type=self._parse_task_type(request.task_type, TaskType),
|
||||
)
|
||||
|
||||
decision = result.decision
|
||||
target = result.target() # (provider, model_id) or None
|
||||
current_key = (
|
||||
candidate_key(request.current_provider, request.current_model)
|
||||
if request.current_model
|
||||
else ""
|
||||
)
|
||||
return RouteEvaluation(
|
||||
task_type=self._task_type_value(result.task_type),
|
||||
should_switch=bool(result.should_switch),
|
||||
target_provider=target[0] if target else None,
|
||||
target_model=target[1] if target else None,
|
||||
score_gain=float(getattr(decision, "score_gain", 0.0) or 0.0),
|
||||
reason=str(getattr(decision, "reason", "") or ""),
|
||||
current_is_usable=self._current_is_usable(result, current_key),
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
# -- translation helpers --------------------------------------------- #
|
||||
@staticmethod
|
||||
def _parse_task_type(raw: Optional[str], task_type_enum) -> Optional[Any]:
|
||||
"""Coerce a task-type string to the engine's enum.
|
||||
|
||||
``None`` (the common case) means "let the engine classify the prompt".
|
||||
An unrecognised string is also downgraded to ``None`` rather than
|
||||
raising, so a stale value in a saved workspace cannot break a turn.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, task_type_enum):
|
||||
return raw
|
||||
try:
|
||||
return task_type_enum(str(raw).strip().lower())
|
||||
except ValueError:
|
||||
logger.warning("routing: unknown task type %r — classifying from the prompt", raw)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _task_type_value(task_type: Any) -> str:
|
||||
"""The plain string form of the engine's task type enum."""
|
||||
return str(getattr(task_type, "value", task_type) or "")
|
||||
|
||||
@staticmethod
|
||||
def _current_is_usable(result: Any, current_key: str) -> bool:
|
||||
"""Whether the currently selected model can still serve this task.
|
||||
|
||||
This is the signal FALLBACK mode acts on. A model is usable when the
|
||||
ranking scored it above zero; ``rank_models`` already drops candidates
|
||||
that are unavailable, lack a probe for this task type, or failed their
|
||||
last probe, so "absent from the ranking" is precisely "cannot serve it".
|
||||
|
||||
With no ranking (routing off, or the engine's internal error path) or no
|
||||
current model, we answer True: absence of evidence must not trigger a
|
||||
surprise switch in a mode whose whole promise is not to surprise.
|
||||
"""
|
||||
ranking = getattr(result, "ranking", None)
|
||||
if ranking is None or not current_key:
|
||||
return True
|
||||
try:
|
||||
return float(ranking.score_of(current_key)) > 0.0
|
||||
except Exception: # noqa: BLE001 — defensive: never fail a turn on telemetry-ish data
|
||||
logger.debug("routing: could not score current model %r", current_key, exc_info=True)
|
||||
return True
|
||||
|
||||
|
||||
class AppContextModeResolver:
|
||||
""":class:`ModeResolver` backed by the active workspace's settings.
|
||||
|
||||
Reads through ``AppContext.project_routing_mode``, which already layers the
|
||||
workspace override on top of the global default — so per-workspace routing
|
||||
modes keep working unchanged now that the mode lookup moved out of the
|
||||
widgets.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: Any) -> None:
|
||||
self._ctx = ctx
|
||||
|
||||
def mode_for(self, surface: str) -> RoutingMode:
|
||||
"""Effective mode for ``surface`` in the active workspace."""
|
||||
return RoutingMode.parse(self._ctx.project_routing_mode(surface))
|
||||
|
||||
|
||||
def build_routing_application_service(ctx: Any) -> RoutingApplicationService:
|
||||
"""The shared :class:`RoutingApplicationService` for this app context.
|
||||
|
||||
Cached on the context (like ``AppContext.routing()`` caches the engine) so
|
||||
every surface talks to the same instance and a future stateful addition —
|
||||
per-surface cool-down, switch history — is shared rather than duplicated per
|
||||
widget. Falls back to a fresh instance if the context refuses attribute
|
||||
assignment, which keeps tests using lightweight stand-ins working.
|
||||
"""
|
||||
cached = getattr(ctx, "_routing_app_service", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
service = RoutingApplicationService(
|
||||
CoreRoutingEngine(ctx.routing()),
|
||||
AppContextModeResolver(ctx),
|
||||
# Read at call time: the user can change the confirm timeout in Settings
|
||||
# between two turns and the next Manual dialog should honour it.
|
||||
confirm_timeout_sec=lambda: float(
|
||||
(ctx.config.routing or {}).get("confirm_timeout_sec", 60) or 60
|
||||
),
|
||||
)
|
||||
try:
|
||||
ctx._routing_app_service = service
|
||||
except Exception: # noqa: BLE001 — read-only/slotted stand-ins stay supported
|
||||
logger.debug("routing: could not cache the application service on the context", exc_info=True)
|
||||
return service
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AppContextModeResolver",
|
||||
"CoreRoutingEngine",
|
||||
"build_routing_application_service",
|
||||
]
|
||||
@@ -1,236 +1,353 @@
|
||||
"""The one place that decides how a turn is routed (R03-T03).
|
||||
"""RoutingApplicationService - one routing flow for every surface (R03-T03).
|
||||
|
||||
Before this service, ``ui/chat_panel.py#L638``, ``ui/co4e_tab.py`` and
|
||||
``ui/folder_tab.py`` each carried their own copy of the same eight-step dance:
|
||||
clear last turn's override → read the surface's mode → bail on "off" → call the
|
||||
routing engine → check ``should_switch`` → resolve the target → show the Manual
|
||||
confirm dialog → publish the override and a status line. Three copies meant
|
||||
three chances to drift, and none of them could be tested without a Qt widget.
|
||||
Before this service, the same routing algorithm existed three times:
|
||||
|
||||
The dance now lives here, once, in pure Python:
|
||||
* ``ui/chat_panel.py::_apply_routing`` (Cowork chat)
|
||||
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E studio)
|
||||
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
|
||||
|
||||
* the routing engine is reached through :class:`RoutingDecisionPort`;
|
||||
* the surface's Off/Auto/Manual/Fallback mode through :class:`ModeResolver`;
|
||||
* the Manual-mode confirmation through a ``confirm`` callback supplied per call,
|
||||
so the Qt dialog stays in the presentation layer where it belongs.
|
||||
The three copies had already drifted - each one resolves the "current model"
|
||||
differently and each one has its own private notion of what to do when the user
|
||||
declines - and every one of them lives inside a Qt widget, so none of the logic
|
||||
could be tested without building a window.
|
||||
|
||||
Every failure path degrades to "keep the current model": a routing problem must
|
||||
never be the reason a user cannot send a message.
|
||||
This module is the single implementation. It is pure Python: no Qt import, no
|
||||
config access, no network. The presentation layer supplies a confirm callback
|
||||
and renders the notice; everything else happens here.
|
||||
|
||||
Modes (:class:`RoutingMode`)
|
||||
----------------------------
|
||||
* ``OFF`` - never switch. The user's pinned model always wins.
|
||||
* ``AUTO`` - switch silently when the best candidate clears the gain threshold.
|
||||
* ``MANUAL`` - propose the switch and switch only if the confirm callback approves.
|
||||
* ``FALLBACK`` - never switch pre-emptively; switch only AFTER the current model
|
||||
fails, to the next-best candidate. This is the mode a user wants when they
|
||||
trust their own model choice but still want the turn to survive an outage.
|
||||
|
||||
Migration note (ADR-001 section 4): the scoring/ranking engine is NOT rewritten.
|
||||
This service depends on the small :class:`RoutingPort` interface, and production
|
||||
wires the existing, already-tested ``core.routing.service.RoutingService`` into
|
||||
it. Tests wire a fake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Optional, Protocol, runtime_checkable
|
||||
|
||||
from .routing_models import (
|
||||
RouteEvaluation,
|
||||
RoutingMode,
|
||||
RoutingOutcome,
|
||||
RoutingRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cowork_local.application.model_routing")
|
||||
|
||||
# Asks the user to approve a Manual-mode switch. Receives the underlying
|
||||
# decision object (for rendering) plus the timeout in seconds; returns True to
|
||||
# approve. Supplied by the caller so this module never imports a UI toolkit.
|
||||
ConfirmationCallback = Callable[[Any, float], bool]
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, List, Optional, Protocol, Sequence, Tuple
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RoutingDecisionPort(Protocol):
|
||||
"""The routing engine, as this service needs it.
|
||||
class RoutingMode(str, Enum):
|
||||
"""Per-surface routing behaviour.
|
||||
|
||||
Narrowed to a single method on purpose: the concrete engine
|
||||
(``core/routing/service.py::RoutingService``) exposes assessment,
|
||||
persistence and scheduling too, none of which a turn-time decision needs.
|
||||
The first three values match ``core.routing.models.SwitchMode`` string for
|
||||
string, so a mode read from the existing config round-trips unchanged.
|
||||
"""
|
||||
|
||||
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
||||
"""Rank candidates for ``request`` and report whether to switch."""
|
||||
OFF = "off"
|
||||
AUTO = "auto"
|
||||
MANUAL = "manual"
|
||||
FALLBACK = "fallback"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw: Any) -> "RoutingMode":
|
||||
"""Best-effort parse of a config value.
|
||||
|
||||
Unknown or empty values become ``OFF``: routing is an optimisation, and
|
||||
the safe reading of a corrupt setting is "leave the user's model alone"
|
||||
rather than "silently move their work to another model".
|
||||
"""
|
||||
try:
|
||||
return cls(str(raw or "off").strip().lower())
|
||||
except ValueError:
|
||||
return cls.OFF
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ModeResolver(Protocol):
|
||||
"""Resolves the effective routing mode for a surface.
|
||||
@dataclass(frozen=True)
|
||||
class RoutingDecision:
|
||||
"""The outcome of routing one turn - an immutable instruction for the caller.
|
||||
|
||||
In the app this reads the active workspace's per-surface override with the
|
||||
global default behind it (``AppContext.project_routing_mode``); in tests it
|
||||
is a two-line stub.
|
||||
``provider``/``model`` are ALWAYS filled with what the turn should actually
|
||||
run on, switched or not, so a call site never has to re-derive the fallback
|
||||
itself (the bug that made the three UI copies diverge).
|
||||
"""
|
||||
|
||||
def mode_for(self, surface: str) -> RoutingMode:
|
||||
"""Effective mode for ``surface``."""
|
||||
mode: RoutingMode
|
||||
provider: str
|
||||
model: str
|
||||
switched: bool = False
|
||||
task_type: str = ""
|
||||
score_gain: float = 0.0
|
||||
reason: str = ""
|
||||
declined: bool = False # Manual mode: a switch was offered and refused
|
||||
# What the turn would have run on without routing. Carried so the Manual
|
||||
# confirm dialog can show "from X to Y" without re-deriving the current
|
||||
# model itself - re-deriving it differently per screen is exactly how the
|
||||
# three legacy copies drifted apart.
|
||||
previous_provider: str = ""
|
||||
previous_model: str = ""
|
||||
|
||||
@property
|
||||
def should_notify(self) -> bool:
|
||||
"""True when the UI should show the "switched model" notice - i.e. only
|
||||
when a switch really happened."""
|
||||
return self.switched
|
||||
|
||||
def target(self) -> Tuple[str, str]:
|
||||
"""``(provider, model)`` to run this turn on."""
|
||||
return self.provider, self.model
|
||||
|
||||
@property
|
||||
def from_model(self) -> str:
|
||||
"""Candidate key (``provider/model``) of the model being switched away
|
||||
from, or "" when nothing was selected yet.
|
||||
|
||||
Named to match ``core.routing.models.SwitchDecision`` so the existing
|
||||
Manual-mode dialog (``ui/routing_toggle.py::confirm_switch``) accepts
|
||||
this object unchanged - the dialog moves to the new shape in EPIC R08.
|
||||
"""
|
||||
if not self.previous_model:
|
||||
return ""
|
||||
return f"{self.previous_provider}/{self.previous_model}"
|
||||
|
||||
@property
|
||||
def to_model(self) -> str:
|
||||
"""Candidate key (``provider/model``) of the model to run on. See
|
||||
:attr:`from_model` for why the name matches the legacy decision."""
|
||||
return f"{self.provider}/{self.model}" if self.model else ""
|
||||
|
||||
|
||||
def is_valid_mode(raw: Any) -> bool:
|
||||
"""True when ``raw`` names a mode the routing service understands.
|
||||
|
||||
Distinct from :func:`normalize_mode` because callers need to tell "the user
|
||||
chose off" apart from "this stored value is unrecognised" - the per-workspace
|
||||
lookup falls back to the global setting only in the second case.
|
||||
"""
|
||||
try:
|
||||
RoutingMode(str(raw or "").strip().lower())
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def normalize_mode(raw: Any) -> str:
|
||||
"""Canonical mode string for persistence, or ``"off"`` when unrecognised.
|
||||
|
||||
Exists so the mode vocabulary is defined exactly once. It used to be
|
||||
hard-coded as a ``("off", "auto", "manual")`` tuple in four separate places
|
||||
(config.py twice, state.py twice); adding FALLBACK meant finding all four,
|
||||
and missing one silently downgraded the user's choice back to "off".
|
||||
"""
|
||||
return RoutingMode.parse(raw).value
|
||||
|
||||
|
||||
class RoutingPort(Protocol):
|
||||
"""The slice of the routing engine this service needs.
|
||||
|
||||
Declared as a Protocol so the application layer states its requirement
|
||||
without importing the implementation - which is what lets the whole service
|
||||
be tested against a 20-line fake, and lets ``core.routing`` be replaced later
|
||||
without touching this file.
|
||||
"""
|
||||
|
||||
def route(self, surface: str, prompt: str, current_provider: str, current_model: str,
|
||||
*, mode_override: Optional[str] = None,
|
||||
required_capabilities: Optional[List[str]] = None,
|
||||
task_type: Optional[Any] = None) -> Any:
|
||||
"""Return a route result exposing ``should_switch``, ``target()``,
|
||||
``task_type`` and ``decision``."""
|
||||
|
||||
|
||||
# Presentation supplies this to ask the human. Receives the proposal so the
|
||||
# dialog can explain it; returns True to approve. Manual mode only.
|
||||
ConfirmFn = Callable[[RoutingDecision], bool]
|
||||
|
||||
|
||||
class RoutingApplicationService:
|
||||
"""Turn-time routing decisions for every chat surface."""
|
||||
"""Decides which provider/model one turn runs on.
|
||||
|
||||
# Matches DEFAULT_CONFIG["routing"]["confirm_timeout_sec"]; used only when
|
||||
# no timeout provider is wired, so a bare service is still usable in tests.
|
||||
DEFAULT_CONFIRM_TIMEOUT_SEC = 60.0
|
||||
Args:
|
||||
router: the scoring engine (see :class:`RoutingPort`).
|
||||
mode_reader: ``surface -> mode string``; production passes the per-workspace
|
||||
lookup ``AppContext.project_routing_mode``. Injected rather than read
|
||||
from config here so this layer stays free of config plumbing that
|
||||
EPIC R02 is rewriting in parallel.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
def __init__(self, router: RoutingPort,
|
||||
mode_reader: Optional[Callable[[str], str]] = None) -> None:
|
||||
self._router = router
|
||||
self._mode_reader = mode_reader
|
||||
|
||||
# -- main entry point -------------------------------------------------- #
|
||||
def route_turn(
|
||||
self,
|
||||
decision_port: RoutingDecisionPort,
|
||||
mode_resolver: Optional[ModeResolver] = None,
|
||||
surface: str,
|
||||
prompt: str,
|
||||
current_provider: str,
|
||||
current_model: str,
|
||||
*,
|
||||
confirm_timeout_sec: Optional[Callable[[], float]] = None,
|
||||
) -> None:
|
||||
self._decision_port = decision_port
|
||||
self._mode_resolver = mode_resolver
|
||||
# A callable rather than a number: the timeout lives in mutable config
|
||||
# the user can change in Settings between two turns.
|
||||
self._confirm_timeout_sec = confirm_timeout_sec
|
||||
mode: Optional[str] = None,
|
||||
confirm: Optional[ConfirmFn] = None,
|
||||
required_capabilities: Optional[Sequence[str]] = None,
|
||||
task_type: Optional[Any] = None,
|
||||
) -> RoutingDecision:
|
||||
"""Decide what to run this turn on. Never raises.
|
||||
|
||||
# -- public API ------------------------------------------------------ #
|
||||
def resolve(
|
||||
self,
|
||||
request: RoutingRequest,
|
||||
confirm: Optional[ConfirmationCallback] = None,
|
||||
) -> RoutingOutcome:
|
||||
"""Decide this turn's provider/model.
|
||||
|
||||
Returns a :class:`RoutingOutcome`; ``provider``/``model`` are ``None``
|
||||
whenever the surface should keep its own selection. Never raises — an
|
||||
unexpected failure is logged and reported as "keep current", because a
|
||||
broken assessment store must not block chatting.
|
||||
A routing failure must never block a message: any unexpected error
|
||||
degrades to "keep the current model", which is exactly what all three
|
||||
legacy copies did with a bare ``except`` - made explicit and testable here.
|
||||
"""
|
||||
mode = request.mode or self._resolve_mode(request.surface)
|
||||
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
|
||||
keep = self._keep(resolved_mode, current_provider, current_model,
|
||||
reason="routing off - keeping current model")
|
||||
|
||||
# An empty prompt carries no signal to classify, so routing cannot make a
|
||||
# meaningful choice; the same guard exists in all three legacy copies.
|
||||
if resolved_mode is RoutingMode.OFF or not (prompt or "").strip():
|
||||
return keep
|
||||
|
||||
# FALLBACK never switches up front - it only reacts to a failure, which
|
||||
# the caller reports through fallback_after_failure().
|
||||
if resolved_mode is RoutingMode.FALLBACK:
|
||||
return self._keep(resolved_mode, current_provider, current_model,
|
||||
reason="fallback mode - switching only after a failure")
|
||||
|
||||
try:
|
||||
return self._resolve_unguarded(request, mode, confirm)
|
||||
except Exception: # noqa: BLE001 — routing must never break a turn
|
||||
logger.exception("routing.resolve failed — keeping the current model")
|
||||
return RoutingOutcome.keep_current(mode, reason="routing error — keeping current model")
|
||||
result = self._router.route(
|
||||
surface, prompt, current_provider, current_model,
|
||||
mode_override=resolved_mode.value,
|
||||
required_capabilities=list(required_capabilities) if required_capabilities else None,
|
||||
task_type=task_type,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - routing must never break a turn
|
||||
return self._keep(resolved_mode, current_provider, current_model,
|
||||
reason="routing engine failed - keeping current model")
|
||||
|
||||
def confirm_timeout(self) -> float:
|
||||
"""Seconds to wait for a Manual-mode confirmation.
|
||||
proposal = self._to_decision(result, resolved_mode, current_provider, current_model)
|
||||
if not proposal.switched:
|
||||
return proposal
|
||||
|
||||
Falls back to the built-in default when the provider is missing or
|
||||
returns something unusable, so a corrupted config value cannot produce a
|
||||
zero-second dialog that instantly declines every switch.
|
||||
"""
|
||||
if self._confirm_timeout_sec is None:
|
||||
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||
try:
|
||||
value = float(self._confirm_timeout_sec())
|
||||
except (TypeError, ValueError):
|
||||
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||
return value if value > 0 else self.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||
# Manual mode: the proposal only becomes a switch once a human approves.
|
||||
if resolved_mode is RoutingMode.MANUAL:
|
||||
if confirm is None or not self._ask(confirm, proposal):
|
||||
return self._keep(resolved_mode, current_provider, current_model,
|
||||
reason="switch declined - keeping current model",
|
||||
task_type=proposal.task_type, declined=True)
|
||||
return proposal
|
||||
|
||||
# -- internals ------------------------------------------------------- #
|
||||
def _resolve_mode(self, surface: str) -> RoutingMode:
|
||||
"""The surface's configured mode, defaulting to OFF when unresolvable —
|
||||
routing stays opt-in, so "we don't know" must mean "don't switch"."""
|
||||
if self._mode_resolver is None:
|
||||
return RoutingMode.OFF
|
||||
try:
|
||||
return RoutingMode.parse(self._mode_resolver.mode_for(surface))
|
||||
except Exception: # noqa: BLE001 — a config read must not break a turn
|
||||
logger.exception("routing: could not resolve mode for surface %r", surface)
|
||||
return RoutingMode.OFF
|
||||
|
||||
def _resolve_unguarded(
|
||||
# -- failure recovery -------------------------------------------------- #
|
||||
def fallback_after_failure(
|
||||
self,
|
||||
request: RoutingRequest,
|
||||
mode: RoutingMode,
|
||||
confirm: Optional[ConfirmationCallback],
|
||||
) -> RoutingOutcome:
|
||||
"""The decision flow proper; :meth:`resolve` owns the safety net."""
|
||||
# 1. Routing disabled, or nothing to classify -> keep the selection.
|
||||
if mode is RoutingMode.OFF:
|
||||
return RoutingOutcome.keep_current(mode, reason="routing off")
|
||||
if not request.has_prompt:
|
||||
return RoutingOutcome.keep_current(mode, reason="empty prompt — nothing to route")
|
||||
surface: str,
|
||||
prompt: str,
|
||||
failed_provider: str,
|
||||
failed_model: str,
|
||||
*,
|
||||
mode: Optional[str] = None,
|
||||
required_capabilities: Optional[Sequence[str]] = None,
|
||||
task_type: Optional[Any] = None,
|
||||
) -> Optional[RoutingDecision]:
|
||||
"""Pick a replacement after ``failed_provider/failed_model`` failed.
|
||||
|
||||
# 2. Ask the engine. FALLBACK is evaluated with AUTO's ranking because
|
||||
# it needs the same candidate list; only the accept/reject rule below
|
||||
# differs, so the engine stays unaware of the extra mode.
|
||||
engine_mode = RoutingMode.AUTO if mode is RoutingMode.FALLBACK else mode
|
||||
evaluation = self._decision_port.evaluate(request, engine_mode)
|
||||
Returns None when there is nothing to fall back to, so the caller can
|
||||
surface the original error instead of retrying forever. Available in
|
||||
AUTO and FALLBACK; OFF and MANUAL keep the user's model on failure too,
|
||||
because silently moving work to another model is exactly what those two
|
||||
modes exist to prevent.
|
||||
"""
|
||||
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
|
||||
if resolved_mode not in (RoutingMode.AUTO, RoutingMode.FALLBACK):
|
||||
return None
|
||||
|
||||
# 3. Apply the mode's own accept rule to the engine's verdict.
|
||||
if mode is RoutingMode.FALLBACK:
|
||||
accepted, reason = self._fallback_verdict(evaluation)
|
||||
else:
|
||||
accepted, reason = evaluation.should_switch, evaluation.reason
|
||||
|
||||
if not accepted or not evaluation.has_target:
|
||||
return RoutingOutcome.keep_current(
|
||||
mode,
|
||||
reason=reason or evaluation.reason,
|
||||
task_type=evaluation.task_type,
|
||||
decision=evaluation.decision,
|
||||
try:
|
||||
# Asked in AUTO so the engine ranks candidates rather than short-
|
||||
# circuiting on FALLBACK's "never switch up front" rule; the failed
|
||||
# model is passed as current so any positive gain beats it.
|
||||
result = self._router.route(
|
||||
surface, prompt, failed_provider, failed_model,
|
||||
mode_override=RoutingMode.AUTO.value,
|
||||
required_capabilities=list(required_capabilities) if required_capabilities else None,
|
||||
task_type=task_type,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a broken router must not mask the real error
|
||||
return None
|
||||
|
||||
# 4. Manual mode asks first; a decline or a timeout keeps the current
|
||||
# model (and is reported as such, so the surface can tell the two
|
||||
# cases apart from "nothing better was found").
|
||||
if mode is RoutingMode.MANUAL and not self._approved(evaluation, confirm):
|
||||
return RoutingOutcome.keep_current(
|
||||
mode,
|
||||
reason="switch declined by user or confirmation timed out",
|
||||
task_type=evaluation.task_type,
|
||||
declined=True,
|
||||
decision=evaluation.decision,
|
||||
)
|
||||
|
||||
# 5. Publish the override for THIS turn only. The provider falls back to
|
||||
# the request's current provider when the engine named a model but no
|
||||
# provider (same-provider switch).
|
||||
return RoutingOutcome(
|
||||
mode=mode,
|
||||
switched=True,
|
||||
provider=evaluation.target_provider or request.current_provider,
|
||||
model=evaluation.target_model or "",
|
||||
task_type=evaluation.task_type,
|
||||
score_gain=evaluation.score_gain,
|
||||
reason=reason or evaluation.reason,
|
||||
decision=evaluation.decision,
|
||||
decision = self._to_decision(result, resolved_mode, failed_provider, failed_model)
|
||||
# A "switch" back to the model that just failed would retry the outage.
|
||||
if not decision.switched or (decision.provider, decision.model) == (failed_provider, failed_model):
|
||||
return None
|
||||
return RoutingDecision(
|
||||
mode=resolved_mode, provider=decision.provider, model=decision.model,
|
||||
switched=True, task_type=decision.task_type, score_gain=decision.score_gain,
|
||||
reason=f"{failed_provider}/{failed_model} failed - falling back to "
|
||||
f"{decision.provider}/{decision.model}",
|
||||
previous_provider=failed_provider, previous_model=failed_model,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_verdict(evaluation: RouteEvaluation) -> tuple:
|
||||
"""FALLBACK's accept rule: switch ONLY to rescue an unusable selection.
|
||||
|
||||
The user's pinned model wins as long as it can serve the turn, even when
|
||||
a higher-scoring candidate exists — that is the whole point of the mode.
|
||||
A switch happens only when the current model is not a usable candidate
|
||||
(never assessed, marked unavailable, or its last probe failed) and the
|
||||
engine has something to move to.
|
||||
"""
|
||||
if evaluation.current_is_usable:
|
||||
return False, "fallback mode — current model is healthy, keeping it"
|
||||
if not evaluation.has_target:
|
||||
return False, "fallback mode — current model unusable and no replacement available"
|
||||
return True, "fallback mode — current model unavailable, switching to the best alternative"
|
||||
|
||||
def _approved(
|
||||
self,
|
||||
evaluation: RouteEvaluation,
|
||||
confirm: Optional[ConfirmationCallback],
|
||||
) -> bool:
|
||||
"""Run the Manual-mode confirmation callback.
|
||||
|
||||
No callback means no way to ask, and silently switching in Manual mode
|
||||
would violate the mode's contract — so a missing callback is treated as
|
||||
"not approved". A callback that raises is treated the same way, since a
|
||||
broken dialog must not auto-approve a model change.
|
||||
"""
|
||||
if confirm is None:
|
||||
logger.warning("routing: manual mode without a confirmation callback — keeping current model")
|
||||
return False
|
||||
# -- internals --------------------------------------------------------- #
|
||||
def _read_mode(self, surface: str) -> str:
|
||||
"""Per-surface mode from the injected reader ('off' when none supplied)."""
|
||||
if self._mode_reader is None:
|
||||
return RoutingMode.OFF.value
|
||||
try:
|
||||
return bool(confirm(evaluation.decision, self.confirm_timeout()))
|
||||
return self._mode_reader(surface) or RoutingMode.OFF.value
|
||||
except Exception: # noqa: BLE001 - a config read must not break a turn
|
||||
return RoutingMode.OFF.value
|
||||
|
||||
@staticmethod
|
||||
def _keep(mode: RoutingMode, provider: str, model: str, *, reason: str,
|
||||
task_type: str = "", declined: bool = False) -> RoutingDecision:
|
||||
"""A no-switch decision that still names the model to run on."""
|
||||
return RoutingDecision(mode=mode, provider=provider, model=model, switched=False,
|
||||
task_type=task_type, reason=reason, declined=declined,
|
||||
previous_provider=provider, previous_model=model)
|
||||
|
||||
@staticmethod
|
||||
def _ask(confirm: ConfirmFn, proposal: RoutingDecision) -> bool:
|
||||
"""Run the confirm callback, treating any failure as "declined".
|
||||
|
||||
The callback opens a modal dialog in production; if that raises (window
|
||||
already closing, for instance) the safe answer is to keep the user's own
|
||||
model rather than to switch without consent.
|
||||
"""
|
||||
try:
|
||||
return bool(confirm(proposal))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("routing: confirmation callback failed — keeping current model")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _to_decision(result: Any, mode: RoutingMode,
|
||||
current_provider: str, current_model: str) -> RoutingDecision:
|
||||
"""Translate the engine's route result into a :class:`RoutingDecision`.
|
||||
|
||||
__all__ = [
|
||||
"ConfirmationCallback",
|
||||
"ModeResolver",
|
||||
"RoutingApplicationService",
|
||||
"RoutingDecisionPort",
|
||||
]
|
||||
Defensive about the result shape on purpose: this is the seam between the
|
||||
new layer and a legacy module still under refactor, and a missing
|
||||
attribute must degrade to "keep current model" instead of raising into
|
||||
the middle of a chat turn.
|
||||
"""
|
||||
inner = getattr(result, "decision", None)
|
||||
task_type = getattr(getattr(result, "task_type", None), "value", "") or ""
|
||||
gain = float(getattr(inner, "score_gain", 0.0) or 0.0)
|
||||
reason = str(getattr(inner, "reason", "") or "")
|
||||
|
||||
target = None
|
||||
if getattr(result, "should_switch", False):
|
||||
getter = getattr(result, "target", None)
|
||||
target = getter() if callable(getter) else None
|
||||
|
||||
if not target:
|
||||
return RoutingDecision(mode=mode, provider=current_provider, model=current_model,
|
||||
switched=False, task_type=task_type, score_gain=gain,
|
||||
reason=reason or "no better model - keeping current",
|
||||
previous_provider=current_provider,
|
||||
previous_model=current_model)
|
||||
|
||||
provider, model = target
|
||||
return RoutingDecision(mode=mode, provider=provider or current_provider, model=model,
|
||||
switched=True, task_type=task_type, score_gain=gain, reason=reason,
|
||||
previous_provider=current_provider, previous_model=current_model)
|
||||
|
||||
|
||||
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
|
||||
"RoutingPort", "normalize_mode", "is_valid_mode"]
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Pure-Python DTOs exchanged with :mod:`routing_application_service`.
|
||||
|
||||
These types are the vocabulary the chat surfaces (Cowork chat, Co4E, AI-Edit)
|
||||
now speak instead of each re-deriving routing state from raw config lookups and
|
||||
``core/routing`` internals.
|
||||
|
||||
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
|
||||
code is 100% pure Python. Nothing here imports PySide6, and nothing here imports
|
||||
``core.routing`` either — the concrete routing engine is reached only through
|
||||
the adapter in :mod:`core_routing_adapter`, which keeps this module trivially
|
||||
testable with plain fakes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
|
||||
class RoutingMode(str, Enum):
|
||||
"""The four routing behaviours a surface can be in (R03-T03).
|
||||
|
||||
``OFF``/``AUTO``/``MANUAL`` map 1:1 onto the existing per-surface toggle and
|
||||
onto ``core/routing/models.py::SwitchMode``. ``FALLBACK`` is new and
|
||||
deliberately NOT an optimisation mode: it keeps whatever model the user
|
||||
chose and only re-routes when that model cannot serve the turn, which is the
|
||||
behaviour a resilience-minded workspace wants (never surprise me, but never
|
||||
leave me stuck either).
|
||||
"""
|
||||
|
||||
OFF = "off"
|
||||
AUTO = "auto"
|
||||
MANUAL = "manual"
|
||||
FALLBACK = "fallback"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw: Any, default: "RoutingMode" = None) -> "RoutingMode":
|
||||
"""Best-effort coercion from config/UI strings.
|
||||
|
||||
Routing must never break a turn, so an unrecognised value degrades to
|
||||
``default`` (``OFF`` unless told otherwise) instead of raising — the same
|
||||
defensive posture ``config.routing_mode_for`` already takes.
|
||||
"""
|
||||
fallback = default if default is not None else cls.OFF
|
||||
if isinstance(raw, cls):
|
||||
return raw
|
||||
try:
|
||||
return cls(str(raw or "").strip().lower())
|
||||
except ValueError:
|
||||
return fallback
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutingRequest:
|
||||
"""Everything needed to decide how ONE turn should be routed.
|
||||
|
||||
Frozen: the request is captured from live UI state (the selected model, the
|
||||
typed prompt) and then handed to code that may run on a worker thread. An
|
||||
immutable snapshot means the user changing the model picker mid-turn cannot
|
||||
retroactively alter the decision that was already made — the same rationale
|
||||
behind R04's ``ConversationExecutionRequest``.
|
||||
"""
|
||||
|
||||
surface: str # "cowork" | "co4e" | "ai_edit" | ...
|
||||
prompt: str # the user's text; drives task classification
|
||||
current_provider: str # provider the surface would use as-is
|
||||
current_model: str = "" # model the surface would use ("" = provider default)
|
||||
mode: Optional[RoutingMode] = None # explicit override; None -> resolve per surface
|
||||
# Pre-classified task type ("coding", "qa", ...). AI-Edit always knows its
|
||||
# turns are coding work, so it pins this and skips prompt classification.
|
||||
task_type: Optional[str] = None
|
||||
required_capabilities: Tuple[str, ...] = () # e.g. ("vision",)
|
||||
|
||||
@property
|
||||
def has_prompt(self) -> bool:
|
||||
"""Whether there is anything to classify. An empty prompt cannot be
|
||||
routed meaningfully, so every surface short-circuits on it."""
|
||||
return bool((self.prompt or "").strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteEvaluation:
|
||||
"""A routing engine's verdict, normalised away from ``core/routing`` types.
|
||||
|
||||
The adapter flattens ``RouteResult``/``SwitchDecision`` into these plain
|
||||
fields so the application service never touches Pydantic models or enums
|
||||
owned by another layer. ``decision`` still carries the original object
|
||||
because the Manual-mode confirm dialog renders its ``reason``.
|
||||
"""
|
||||
|
||||
task_type: str
|
||||
should_switch: bool
|
||||
target_provider: Optional[str] = None
|
||||
target_model: Optional[str] = None
|
||||
score_gain: float = 0.0
|
||||
reason: str = ""
|
||||
# False when the currently selected model is not a usable candidate for this
|
||||
# task (unranked, unavailable, or failed its last probe) — the single signal
|
||||
# FALLBACK mode acts on.
|
||||
current_is_usable: bool = True
|
||||
decision: Any = None # original SwitchDecision, for the UI dialog
|
||||
|
||||
@property
|
||||
def has_target(self) -> bool:
|
||||
"""A switch is only actionable when the engine named a model to move to."""
|
||||
return bool(self.target_model or self.target_provider)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutingOutcome:
|
||||
"""What the calling surface should actually do for this turn.
|
||||
|
||||
A surface needs exactly three things from routing — "which provider/model do
|
||||
I build?", "do I tell the user?" and "was I told to stand down?" — so those
|
||||
are the fields here, and nothing else. ``provider``/``model`` are ``None``
|
||||
when the surface should keep its own selection untouched.
|
||||
"""
|
||||
|
||||
mode: RoutingMode
|
||||
switched: bool = False
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
task_type: str = ""
|
||||
score_gain: float = 0.0
|
||||
reason: str = ""
|
||||
# True when Manual mode proposed a switch and the user declined or the
|
||||
# confirmation timed out. Distinct from "no switch proposed" so a surface
|
||||
# can tell "routing had nothing to offer" from "the user said no".
|
||||
declined: bool = False
|
||||
decision: Any = field(default=None, repr=False)
|
||||
|
||||
@property
|
||||
def should_notify(self) -> bool:
|
||||
"""Whether the surface should post the "switched model" status bubble.
|
||||
Only an executed switch is worth interrupting the transcript for."""
|
||||
return self.switched
|
||||
|
||||
@classmethod
|
||||
def keep_current(
|
||||
cls,
|
||||
mode: RoutingMode,
|
||||
*,
|
||||
reason: str = "",
|
||||
task_type: str = "",
|
||||
declined: bool = False,
|
||||
decision: Any = None,
|
||||
) -> "RoutingOutcome":
|
||||
"""The no-change outcome — the single constructor for every path that
|
||||
leaves the surface's own model selection in place (routing off, empty
|
||||
prompt, no better candidate, user declined, internal error)."""
|
||||
return cls(
|
||||
mode=mode, switched=False, provider=None, model=None,
|
||||
task_type=task_type, reason=reason, declined=declined, decision=decision,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RoutingMode", "RoutingRequest", "RouteEvaluation", "RoutingOutcome"]
|
||||
@@ -1 +0,0 @@
|
||||
"""Application monitoring package: Monitoring query service for audit and metrics."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Application scheduling package: TaskApplicationService and AI task planning."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Application settings package: Settings application service."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Application workflows package: Co4E graph execution orchestration."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Application workspaces package: File workspace and AI file editor services."""
|
||||
@@ -105,7 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
# sandboxes agent-run shell commands) — reading a URL for info is safe and
|
||||
# useful, so this defaults ON. Toggle in Settings → Security.
|
||||
"allow_url_fetch": True,
|
||||
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
|
||||
"sandbox_pw": "quandh14", # default password to unlock sandbox settings
|
||||
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
|
||||
},
|
||||
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
|
||||
@@ -173,7 +173,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
|
||||
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
|
||||
"ms365": {
|
||||
"unlock_code": "", # set through COWORK_MS365_UNLOCK_CODE
|
||||
"unlock_code": "quandh14",
|
||||
"unlocked": False, # runtime-only — never persisted as True, see save()
|
||||
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
|
||||
# launches automatically once the user is signed in (OAuth tenant/client
|
||||
@@ -294,10 +294,6 @@ def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
|
||||
if os.getenv("COWORK_CA_BUNDLE"):
|
||||
data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"]
|
||||
if os.getenv("COWORK_SANDBOX_PASSWORD"):
|
||||
data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"]
|
||||
if os.getenv("COWORK_MS365_UNLOCK_CODE"):
|
||||
data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"]
|
||||
return data
|
||||
|
||||
|
||||
@@ -555,27 +551,26 @@ class AppConfig:
|
||||
d["surface_modes"].setdefault(surface, "")
|
||||
return d
|
||||
|
||||
# The routing modes a surface may be in. "fallback" joined the set in
|
||||
# R03-T03 (keep the selected model; re-route only when it cannot serve the
|
||||
# turn) — see application/model_routing/routing_models.py::RoutingMode,
|
||||
# which is the authority on what each mode means.
|
||||
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||
|
||||
def routing_mode_for(self, surface: str) -> str:
|
||||
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
|
||||
|
||||
A per-surface override wins; an empty override falls back to the global
|
||||
``switch_mode``. Anything unrecognised degrades to "off" so routing
|
||||
stays opt-in even with a hand-edited config."""
|
||||
``switch_mode``. The value is validated through
|
||||
``application.model_routing.normalize_mode`` so the accepted vocabulary
|
||||
is defined in exactly one place (R03-T03) - it used to be a literal
|
||||
tuple repeated here and in state.py, and adding a mode to one copy but
|
||||
not the others silently downgraded the user's choice to "off"."""
|
||||
from .application.model_routing import normalize_mode
|
||||
|
||||
routing = self.routing
|
||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||
mode = override or routing.get("switch_mode", "off")
|
||||
return mode if mode in self.ROUTING_MODES else "off"
|
||||
return normalize_mode(override or routing.get("switch_mode", "off"))
|
||||
|
||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||
"""Persist a chat surface's routing toggle selection."""
|
||||
mode = mode if mode in self.ROUTING_MODES else "off"
|
||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||
from .application.model_routing import normalize_mode
|
||||
|
||||
self.routing.setdefault("surface_modes", {})[surface] = normalize_mode(mode)
|
||||
self.save()
|
||||
|
||||
@property
|
||||
|
||||
+38
-6
@@ -248,9 +248,34 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
|
||||
f"{prompt}"
|
||||
)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
# One immutable snapshot of this run, then the shared turn service (R04-T05).
|
||||
# The Schedule Task path used to assemble the run_cowork call itself, in
|
||||
# parallel with ui/cowork_tab.py doing the same thing slightly differently -
|
||||
# so a fix to one path silently missed the other. Both now go through
|
||||
# ConversationApplicationService.
|
||||
from ..application.conversations import ConversationApplicationService
|
||||
from ..domain.agents import ConversationExecutionRequest
|
||||
|
||||
session_id = new_session_id()
|
||||
project_id = project.project_id if project is not None else ""
|
||||
project_context = projects.project_context_text(project)
|
||||
conversation_service = ConversationApplicationService(
|
||||
# The provider was already resolved above (admin agent / per-task
|
||||
# override / machine default), so the factory just hands it back.
|
||||
lambda _provider_id, _model: provider,
|
||||
security_config=ctx.config,
|
||||
)
|
||||
turn = conversation_service.begin_turn(ConversationExecutionRequest.create(
|
||||
prompt, [{"role": "user", "content": prompt}],
|
||||
output_dir=str(out_dir), session_id=session_id, surface="task",
|
||||
title=title, project_id=project_id, project_context=project_context,
|
||||
# Tags every tool call in the audit log as a scheduled task rather than
|
||||
# as the interactive Cowork tab.
|
||||
agent_role=agent_roles.TASK,
|
||||
))
|
||||
# The LIVE list the engine appends to - History is re-saved from it after
|
||||
# every assistant message so a long run shows progress when reopened.
|
||||
messages = turn.messages
|
||||
_save_history_session(ctx, task_type, title, messages, session_id, project_id)
|
||||
# Tell the scheduler the session now genuinely EXISTS on disk — it
|
||||
# refreshes History on this, not on the earlier "task_started" signal
|
||||
@@ -269,14 +294,21 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
elif ev.get("type") == "plan_set":
|
||||
last_plan_steps[:] = ev.get("steps") or []
|
||||
|
||||
project_context = projects.project_context_text(project)
|
||||
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
|
||||
try:
|
||||
if task_type == "cowork":
|
||||
from .chat_agent import run_cowork
|
||||
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
|
||||
security_config=ctx.config, agent_role=agent_roles.TASK,
|
||||
project_context=project_context)
|
||||
# Typed events are rendered back into the legacy dict shape this
|
||||
# module's autosave/plan tracking already consumes; it moves to
|
||||
# AgentEvent directly once the scheduler UI migrates (EPIC R07/R08).
|
||||
result = conversation_service.execute_turn(
|
||||
turn,
|
||||
on_event=lambda event: emit_and_autosave(event.to_dict()),
|
||||
cancel=watched_cancel,
|
||||
)
|
||||
# This module's callers handle a failed run through an exception
|
||||
# (execute_task writes error.txt from it), so re-raise the ORIGINAL
|
||||
# error rather than reporting a silently empty answer.
|
||||
result.raise_if_failed()
|
||||
else:
|
||||
from .code_agent import run_code
|
||||
limits, block_network = agent_security.sandbox_settings(ctx.config)
|
||||
|
||||
@@ -49,18 +49,6 @@ def set_context(source: str, label: str = "") -> None:
|
||||
_local.label = label
|
||||
|
||||
|
||||
def current_context() -> tuple:
|
||||
"""The ``(source, label)`` currently tagged on THIS thread.
|
||||
|
||||
Public counterpart to :func:`set_context`, added for
|
||||
``infrastructure/telemetry/usage_sink.py``: a subscriber that needs to
|
||||
attribute one event to a different surface must be able to save the
|
||||
caller's context and put it back afterwards, instead of leaving the worker
|
||||
thread permanently retagged.
|
||||
"""
|
||||
return getattr(_local, "source", "") or "", getattr(_local, "label", "") or ""
|
||||
|
||||
|
||||
# ---- per-thread usage accumulator -----------------------------------------
|
||||
# A step/run that wants to know its OWN token/cost (not the all-time file total)
|
||||
# calls begin_accumulation(), reads accumulated() before/after a unit of work,
|
||||
|
||||
@@ -1,103 +1,156 @@
|
||||
# ADR-001: 4-Tier Clean Architecture for Desktop Local Application
|
||||
# ADR-001: Kiến Trúc 4 Tầng (Layered / Clean Architecture)
|
||||
|
||||
* **Status**: ACCEPTED / ENFORCED
|
||||
* **Status**: Accepted
|
||||
* **Date**: 2026-08-21
|
||||
* **Deciders**: Team Duy (Tech Lead & AI Runtime), Team Nam (Governance & Automation), Team Hoa (Workspace & Scheduling)
|
||||
* **Target Project**: Cowork Local (Cowork-Local BamBOO)
|
||||
* **EPIC / Task**: R01-T01
|
||||
* **Owner**: 🔵 Team Duy (Tech Lead)
|
||||
* **Áp dụng cho**: toàn bộ mã nguồn mới của `cowork_local` (3 team)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context and Problem Statement
|
||||
## 1. Context (Bối cảnh)
|
||||
|
||||
Cowork Local is a desktop application written in Python using PySide6 (Qt) and designed for local-first execution.
|
||||
Historically, the codebase suffered from architectural coupling across layers:
|
||||
1. **God-Widget Problem**: Monolithic UI widgets (e.g., `ui/chat_panel.py` >1,800 LOC, `ui/co4e_tab.py` >1,400 LOC) mixed UI rendering, network I/O, business rules, filesystem operations, and background worker lifecycle.
|
||||
2. **Untestable Business Logic**: Core algorithms (model routing, conversation turn management, schedule calculation) were tightly coupled to `PySide6` widgets or `QTimer`, making unit testing in headless CI environments impossible without a graphical display server.
|
||||
3. **Circular Dependencies & Global State Leaks**: Uncontrolled module imports (`model_pricing.py` ↔ `usage_tracker.py`, `agent_security.py` ↔ `agent_security_alert.py`) and mutable global state (`state.py::AppContext.active_project_id`) caused race conditions in background task runs.
|
||||
`cowork_local` hiện là một ứng dụng PySide6 desktop local-first ~55.000 dòng Python,
|
||||
được phát triển nhanh theo hướng feature-first. Hệ quả đo được tại thời điểm viết ADR:
|
||||
|
||||
---
|
||||
| Vấn đề | Bằng chứng cụ thể trong repo |
|
||||
| :--- | :--- |
|
||||
| **God widget** | `ui/co4e_tab.py` 2.089 dòng, `ui/chat_panel.py` 1.795 dòng, `ui/folder_tab.py` 1.590 dòng |
|
||||
| **Business logic nằm trong widget** | Vòng đời turn chat, quyết định routing, ghép prompt đều nằm trong `ui/chat_panel.py` |
|
||||
| **Logic trùng lặp 3 nơi** | `ui/chat_panel.py::_apply_routing`, `ui/co4e_tab.py::_apply_co4e_routing`, `ui/folder_tab.py::_ai_apply_routing` là ba bản sao gần như y hệt của cùng một thuật toán |
|
||||
| **Không test được nếu không có Qt** | Muốn test một quyết định routing phải dựng widget → không chạy được headless, không chạy được nhanh |
|
||||
| **Side-effect ẩn trong tầng hạ tầng** | Provider tự gọi `core.usage_tracker.record()` ngay trong vòng lặp stream (`providers/openai_compat.py::_record_usage`) |
|
||||
|
||||
## 2. Decision: 4-Tier Clean Architecture
|
||||
Ba team (Duy / Nam / Hoa) sẽ sửa song song trên cùng codebase trong 10 ngày. Nếu
|
||||
không có một ranh giới phụ thuộc được **kiểm chứng tự động**, các thay đổi song song
|
||||
sẽ hội tụ về đúng cấu trúc rối như cũ.
|
||||
|
||||
We enforce a strict **4-Tier Clean Architecture** based on the Dependency Inversion Principle:
|
||||
## 2. Decision (Quyết định)
|
||||
|
||||
Mã nguồn mới được tổ chức thành **4 tầng**, với **chiều phụ thuộc một chiều** như sau:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION │
|
||||
│ (PySide6 Widgets, Dialogs, Qt Signals/Slots, View Models) │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ depends on
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ APPLICATION │
|
||||
│ (Use Case Services, Turn Orchestrators, Route Dispatchers) │
|
||||
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ depends on
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DOMAIN & RUNTIME CORE │
|
||||
│ (Entities, Value Objects, Domain Events, Tool Descriptors) │
|
||||
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
|
||||
└──────────────────────────────▲──────────────────────────────┘
|
||||
│ implemented by
|
||||
┌──────────────────────────────┴──────────────────────────────┐
|
||||
│ INFRASTRUCTURE │
|
||||
│ (LLM Providers, Keyring Secrets, Atomic Persistence, MCP) │
|
||||
│ presentation/ PySide6 widgets, Qt signals/slots │
|
||||
│ (chat, co4e, workspace…) Chỉ dựng UI và phát/nhận signal │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ gọi xuống (được phép)
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ application/ Pure Python orchestration │
|
||||
│ (conversations, Điều phối use-case, không biết Qt │
|
||||
│ model_routing…) và không biết HTTP/đĩa cụ thể │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ gọi xuống (được phép)
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ domain/ Pure Python entities & events │
|
||||
│ (agents, models…) Frozen dataclass, enum, quy tắc │
|
||||
│ nghiệp vụ thuần. KHÔNG import gì │
|
||||
│ từ 3 tầng còn lại. │
|
||||
└───────────────────────────▲─────────────────────────────────┘
|
||||
│ implement interface của domain
|
||||
┌───────────────────────────┴─────────────────────────────────┐
|
||||
│ infrastructure/ Adapters: network, keyring, đĩa, │
|
||||
│ (providers, telemetry…) process, Qt-free I/O │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
### 2.1 Quy tắc bất biến (Invariants)
|
||||
|
||||
## 3. Layer Definitions and Responsibilities
|
||||
| # | Quy tắc | Được kiểm bởi |
|
||||
| :--- | :--- | :--- |
|
||||
| **I1** | `domain/` và `application/` là **100% pure Python** — cấm import `PySide6`, `PyQt5`, `PyQt6`, `shiboken6` | `scripts/check_imports.py` (R01-T03) |
|
||||
| **I2** | `domain/` **không import** `application/`, `infrastructure/`, `presentation/`, `ui/` | `scripts/check_imports.py` |
|
||||
| **I3** | `application/` **không import** `presentation/` hay `ui/` | `scripts/check_imports.py` |
|
||||
| **I4** | Không file production nào vượt **400 dòng** | `scripts/check_loc.py` (R10-T02) |
|
||||
| **I5** | `presentation/` **không** gọi thẳng provider/HTTP/đĩa — phải đi qua một application service | Code review + I1–I3 |
|
||||
| **I6** | Mọi input của một use-case được đóng gói thành **snapshot bất biến** (`frozen dataclass`) trước khi rời UI thread | Code review + unit test |
|
||||
|
||||
### Tier 1: Presentation Layer (`presentation/`)
|
||||
* **Responsibilities**: UI component layout, user event capture, progress display, visual animations, confirmation dialog triggers.
|
||||
* **Allowed Imports**: `PySide6.*`, `application.*`, `domain.*`.
|
||||
* **Forbidden**: Direct database queries, raw LLM API calls, disk writes outside UI cache, executing tool commands directly.
|
||||
* **Constraints**: Every widget file must strictly be **under 400 lines of code (LOC)**.
|
||||
### 2.2 Chiều phụ thuộc được phép
|
||||
|
||||
### Tier 2: Application Layer (`application/`)
|
||||
* **Responsibilities**: Orchestrate single use cases (e.g. `ConversationApplicationService`, `RoutingApplicationService`, `TaskApplicationService`). Convert UI requests into domain requests, coordinate domain services with infrastructure adapters.
|
||||
* **Allowed Imports**: `domain.*`, `infrastructure.*` interfaces/contracts, standard Python libraries.
|
||||
* **Forbidden**: `PySide6`, `PyQt5`, `PyQt6`, `ui.*`, `app.*`.
|
||||
* **Nature**: **100% Pure Python**. Must be executable and testable in headless CI environments without a display driver.
|
||||
| Từ tầng | Được import | Bị cấm |
|
||||
| :--- | :--- | :--- |
|
||||
| `presentation/` | `application/`, `domain/`, PySide6 | — (nên tránh gọi thẳng `infrastructure/`) |
|
||||
| `application/` | `domain/`, interface do `domain/` định nghĩa | `presentation/`, `ui/`, PySide6 |
|
||||
| `domain/` | chỉ stdlib | tất cả các tầng khác, PySide6 |
|
||||
| `infrastructure/` | `domain/`, thư viện ngoài (requests, keyring…) | `presentation/`, `ui/`, PySide6 |
|
||||
|
||||
### Tier 3: Domain Layer (`domain/`)
|
||||
* **Responsibilities**: Core domain models, frozen DTO snapshots (`ConversationExecutionRequest`), typed event streams (`AgentEvent`), descriptors (`ToolDescriptor`, `ProviderDescriptor`), deterministic calculation algorithms (`ScheduleCalculator`).
|
||||
* **Allowed Imports**: Standard Python library only (`dataclasses`, `typing`, `enum`, `datetime`, `pathlib`, `abc`).
|
||||
* **Forbidden**: `PySide6`, `PyQt*`, `requests`, `sqlalchemy`, filesystem mutations, OS network calls.
|
||||
* **Nature**: Completely isolated and zero-dependency core.
|
||||
### 2.3 Cách tầng dưới "nói chuyện ngược" lên UI
|
||||
|
||||
### Tier 4: Infrastructure Layer (`infrastructure/`)
|
||||
* **Responsibilities**: Adapters for external systems (OpenAI/Anthropic/Ollama/FPT providers, OS Keyring via `SecretStore`, `AtomicJsonFile` persistence, MCP child processes, filesystem tools).
|
||||
* **Allowed Imports**: Third-party SDKs, OS libraries, `domain.*`.
|
||||
* **Forbidden**: `presentation.*`, `PySide6.QtWidgets`.
|
||||
`application/` **không được** giữ tham chiếu tới widget. Việc trao đổi ngược chiều
|
||||
đi qua **callback thuần Python nhận một `AgentEvent` có kiểu**
|
||||
(`domain/agents/agent_event.py`, R04-T02):
|
||||
|
||||
---
|
||||
```python
|
||||
# application layer — pure Python, không biết Qt tồn tại
|
||||
service.run_turn(request, on_event=my_callback)
|
||||
|
||||
## 4. Architectural Rules and Non-Negotiable Invariants
|
||||
# presentation layer — chuyển event sang Qt signal ở ranh giới duy nhất này
|
||||
def my_callback(event: AgentEvent) -> None:
|
||||
self.agent_event.emit(event) # Qt signal → cập nhật UI trên main thread
|
||||
```
|
||||
|
||||
1. **Zero Qt in Business Logic**:
|
||||
- `domain/` and `application/` must never import `PySide6` or `PyQt*`.
|
||||
- Verified via AST parser script `scripts/check_imports.py`.
|
||||
2. **Immutable Request Snapshots**:
|
||||
- Turns are initiated using immutable frozen dataclasses (`ConversationExecutionRequest`) to decouple runtime state from mutable UI state.
|
||||
3. **Thread Safety and Signal Decoupling**:
|
||||
- AI generation and tool calls run asynchronously in worker threads.
|
||||
- UI updates occur strictly on the Qt main thread by consuming `AgentEvent` streams through Qt Signal bridges.
|
||||
4. **Single Responsibility and Modularity**:
|
||||
- Production files must stay within **400 LOC**.
|
||||
5. **English In-Code Comments**:
|
||||
- Every modified or created line/block must include concise English comments explaining design decisions and processing logic.
|
||||
Đây là **seam** duy nhất giữa hai thế giới: dưới seam là Python thuần test được
|
||||
offline, trên seam là Qt. Mọi cập nhật UI phải xảy ra qua Qt signal/slot, không
|
||||
bao giờ gọi trực tiếp từ worker thread.
|
||||
|
||||
---
|
||||
## 3. Vị trí sở hữu theo team
|
||||
|
||||
## 5. Consequences and Compliance
|
||||
| Tầng / thư mục | Team | EPIC |
|
||||
| :--- | :--- | :--- |
|
||||
| `presentation/chat/`, `application/conversations/`, `application/model_routing/`, `domain/agents/`, `domain/models/`, `infrastructure/providers/`, `infrastructure/telemetry/`, `tests/`, `scripts/` | 🔵 Duy | R01, R03, R04, R08, R10 |
|
||||
| `presentation/co4e/`, `monitoring/`, `settings/`, `shell/`, `application/workflows/`, `infrastructure/config/`, `secrets/`, `sandbox/` | 🟣 Nam | R02, R08, R09 |
|
||||
| `presentation/workspace/`, `folder/`, `scheduling/`, `application/workspaces/`, `scheduling/`, `domain/tools/`, `domain/tasks/`, `infrastructure/filesystem/`, `mcp/`, `persistence/` | 🟢 Hoa | R05, R06, R07, R08 |
|
||||
|
||||
* **Positive**:
|
||||
- Full testability: Unit tests run in milliseconds without GUI or network mocks.
|
||||
- Zero circular dependencies: Clear top-down data flow.
|
||||
- Resilience: UI crashes do not corrupt background tasks or files.
|
||||
* **Verification**:
|
||||
- Automated CI gate: `python scripts/check_imports.py` and `python scripts/check_loc.py`.
|
||||
## 4. Chiến lược di trú (Strangler Fig, không big-bang)
|
||||
|
||||
Code cũ trong `core/`, `ui/`, `providers/` **không bị xoá ngay**. Ta bọc dần:
|
||||
|
||||
1. **Tạo seam mới** ở tầng đúng (ví dụ `RoutingApplicationService`).
|
||||
2. **Chuyển call site** cũ sang gọi seam mới (`ui/*.py` chỉ còn vài dòng adapter).
|
||||
3. **Giữ module cũ làm implementation detail** phía sau seam (ví dụ
|
||||
`application/model_routing/` vẫn gọi xuống `core/routing/` để dùng lại
|
||||
scorer/selector đã có test).
|
||||
4. Chỉ khi mọi call site đã đi qua seam mới → cân nhắc gỡ code cũ.
|
||||
|
||||
Nhờ vậy `pytest` luôn xanh giữa các bước, và một team có thể merge mà không chờ
|
||||
team khác refactor xong.
|
||||
|
||||
## 5. Consequences (Hệ quả)
|
||||
|
||||
### Tích cực
|
||||
|
||||
* Test một quyết định routing / một vòng đời turn chat **không cần Qt, không cần mạng** → suite unit chạy < 1 giây.
|
||||
* Ba bản sao logic routing hội tụ về một nơi duy nhất → sửa một lần, cả 3 màn hình cùng đúng.
|
||||
* Người mới có thể thêm một provider mà chỉ chạm `infrastructure/providers/` + `domain/models/`.
|
||||
* Vi phạm kiến trúc bị chặn ở CI thay vì phát hiện lúc review.
|
||||
|
||||
### Tiêu cực / chi phí phải chấp nhận
|
||||
|
||||
* Nhiều file nhỏ hơn thay vì vài file lớn → tăng số lần "nhảy file" khi đọc code.
|
||||
* Tồn tại **hai đường** trong giai đoạn di trú (code cũ + seam mới) cho tới khi call site cuối cùng chuyển xong.
|
||||
* Phải viết DTO/snapshot rõ ràng thay vì truyền thẳng `self` của widget — tốn thêm code, đổi lại được thread-safety.
|
||||
|
||||
## 6. Alternatives considered (Phương án đã cân nhắc)
|
||||
|
||||
| Phương án | Lý do loại |
|
||||
| :--- | :--- |
|
||||
| **Giữ nguyên, chỉ tách file cho ngắn** | Giải quyết được I4 (LOC) nhưng không giải quyết được nguyên nhân gốc: logic vẫn dính Qt nên vẫn không test được offline. |
|
||||
| **MVVM/MVP thuần Qt** | Vẫn buộc business logic phụ thuộc vòng đời Qt object; không chạy được trong scheduler headless và trong task nền. |
|
||||
| **Hexagonal đầy đủ (port/adapter cho mọi thứ)** | Đúng về lý thuyết nhưng quá tốn cho 10 ngày và cho một app desktop 1 process; 4 tầng là điểm cân bằng. |
|
||||
| **Big-bang rewrite** | Rủi ro hồi quy quá cao khi 3 team sửa song song và không có bộ test bảo vệ đầy đủ. |
|
||||
|
||||
## 7. Enforcement (Thực thi)
|
||||
|
||||
```bash
|
||||
python scripts/check_imports.py # I1, I2, I3 — quét AST
|
||||
python scripts/check_loc.py # I4 — giới hạn 400 dòng
|
||||
python scripts/run_quality_gate.py # chạy toàn bộ CASAN Gate + pytest
|
||||
```
|
||||
|
||||
CASAN Verification Gate phải PASS trước khi merge bất kỳ PR nào vào `main`.
|
||||
|
||||
## 8. Tài liệu liên quan
|
||||
|
||||
* `docs/refactor/Feature_Architecture_Proposal.md` — thiết kế tổng thể 10 EPIC
|
||||
* `docs/refactor/Refactoring_Checklist.md` — bảng tiến độ theo task
|
||||
* `docs/architecture/dormant-code.md` — danh mục code không còn hoạt động (R01-T05)
|
||||
|
||||
@@ -1,39 +1,85 @@
|
||||
# Danh Mục & Kế Hoạch Cô Lập Mã Nguồn Dormant / Dead Code (Dormant Code Catalog)
|
||||
# Dormant / Dead Code Inventory (R01-T05)
|
||||
|
||||
* **Tài liệu**: `docs/architecture/dormant-code.md`
|
||||
* **Thuộc EPIC**: `R01: Architecture Foundation & Characterization`
|
||||
* **Team phụ trách**: 🔵 **Team Duy (Tech Lead)**
|
||||
* **Task**: R01-T05 — Phân loại và cô lập mã nguồn cũ
|
||||
* **Owner**: 🔵 Team Duy
|
||||
* **Ngày quét**: 2026-08-21
|
||||
* **Phạm vi quét**: toàn bộ `*.py` production (loại trừ `tests/`, `assets/`, `docs/`, `.git/`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Mục Đích & Nguyên Tắc Quản Trị
|
||||
## 1. Mục đích
|
||||
|
||||
Trong quá trình phát triển nhanh, một số module, hàm hoặc script đã trở thành mã nguồn không hoạt động (**dormant**), mã nguồn thử nghiệm cũ (**legacy prototypes**), hoặc mã nguồn không còn được sử dụng (**dead code**).
|
||||
Trước khi 3 team refactor song song, cần biết **file nào thật sự đang chạy**. Refactor
|
||||
một module đã chết là lãng phí; xoá nhầm một module chỉ được gọi động là gây sự cố
|
||||
runtime. Tài liệu này phân loại từng ứng viên, kèm **bằng chứng** và **hành động đề xuất**.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ### 🛡️ NGUYÊN TẮC CÔ LẬP MÃ NGUỒN CŨ:
|
||||
> 1. **Tuyệt đối không import vào các tầng mới**: Các tầng `domain/`, `application/`, `infrastructure/` mới được xây dựng **cấm tuyệt đối import bất kỳ module dormant nào**.
|
||||
> 2. **Không xóa vội vàng khi chưa có test bảo vệ**: Giữ nguyên mã nguồn cũ trong giai đoạn tái cấu trúc R01–R08; chỉ dọn dẹp hoặc xóa sau khi bộ kiểm thử khói E2E (EPIC R10) chạy pass 100%.
|
||||
> 3. **Phân loại rõ ràng trạng thái**: Mỗi module dormant phải được gắn nhãn (DEPRECATED / ISOLATED / PENDING_DELETION).
|
||||
## 2. Phương pháp
|
||||
|
||||
---
|
||||
Quét AST toàn repo, dựng đồ thị import, tìm module **không có module nào khác import**.
|
||||
Kết quả thô: **43 module**. Sau đó xác minh thủ công từng ứng viên, vì phân tích tĩnh
|
||||
không thấy 3 kiểu tham chiếu:
|
||||
|
||||
## 2. Bảng Danh Mục Mã Nguồn Dormant / Dead Code Đã Rà Soát
|
||||
| Kiểu tham chiếu ẩn | Ví dụ thật trong repo |
|
||||
| :--- | :--- |
|
||||
| Chạy như subprocess | `state.py:285` gọi `python -m cowork_local.mcp_servers.ms365_server` |
|
||||
| Entry point của gói | `__main__.py` (chạy bằng `python -m cowork_local`) |
|
||||
| Script chạy tay | `tools/check_*.py`, `scripts/*.py` |
|
||||
|
||||
| STT | File / Module / Ký Hiệu | Trạng Thái Hiện Tại | Lý Do Phân Loại & Phân Tích Kỹ Thuật | Kế Hoạch Xử Lý & Thời Điểm Gỡ Bỏ |
|
||||
| :---: | :--- | :---: | :--- | :--- |
|
||||
| **1** | `requirements (cloud copy).txt` | `PENDING_DELETION` | File sao chép dự phòng tạm thời trong quá khứ, không được tham chiếu bởi bất kỳ quy trình setup nào. | Gỡ bỏ trong EPIC R10 (Packaging & Clean-up). |
|
||||
| **2** | `preview-desktop` | `ISOLATED` | Script shell rỗng/phác thảo cho môi trường dev container cũ. | Cô lập, không liên kết vào build workflow. |
|
||||
| **3** | `scripts/bootstrap_gitea_repo.py` | `ISOLATED` | Script tiện ích bootstrap kho lưu trữ Gitea nội bộ; không thuộc runtime ứng dụng chính. | Di chuyển vào `docs/gitea/` làm tài liệu tham khảo ops. |
|
||||
| **4** | Hàm routing sao chép tại `ui/chat_panel.py#L638` | `DEPRECATED` | Đoạn code logic chọn model lặp lại từ `core/routing/` nằm trực tiếp trong UI widget. | Thay thế hoàn toàn bằng `RoutingApplicationService` trong EPIC R03. |
|
||||
| **5** | Biến toàn cục `state.py::active_project_id` | `DEPRECATED` | Biến global mutable gây race condition khi chạy background task song song. | Thay thế bằng `WorkspaceSession` trong EPIC R06. |
|
||||
| **6** | Các hàm xử lý UI đồng bộ trong `core/tools.py` | `DEPRECATED` | `core/tools.py` chứa mã monolithic vừa xử lý file vừa gọi dialog xác thực trực tiếp. | Phân rã thành `file_tools.py`, `command_tools.py` và `ToolPolicyGateway` trong EPIC R05. |
|
||||
> ⚠️ **Kết luận quan trọng**: 43 module "không ai import" **KHÔNG** đồng nghĩa 43 module chết.
|
||||
> Sau xác minh, chỉ còn **6 hạng mục (~1.887 dòng)** là dormant thật.
|
||||
|
||||
---
|
||||
## 3. Phân loại kết quả
|
||||
|
||||
## 3. Quy Trình Cô Lập & Kiểm Soát
|
||||
### 🟥 A. DORMANT THẬT — không có đường nào chạy tới (ứng viên xoá)
|
||||
|
||||
1. **Kiểm tra tự động qua AST Guard**:
|
||||
- Bộ script `scripts/check_imports.py` tự động quét để đảm bảo không có bất kỳ import mới nào trỏ tới các thành phần đã đánh dấu deprecated.
|
||||
2. **Kế hoạch dọn dẹp cuối cùng (Release Phase - 31/08/2026)**:
|
||||
- Sau khi hoàn thành EPIC R10 và pass toàn bộ bài test E2E (`tests/e2e/test_smoke.py`), các file đánh dấu `PENDING_DELETION` sẽ được gỡ bỏ khỏi nhánh `main`.
|
||||
| Module | LOC | Bằng chứng | Rủi ro khi xoá | Hành động |
|
||||
| :--- | ---: | :--- | :--- | :--- |
|
||||
| `ui/accounts_tab.py` | 700 | Chỉ xuất hiện trong comment của `i18n.py:92`; không widget nào khởi tạo `AccountsTab` | Thấp — panel Monitoring → Accounts hiện không có đường vào | Cô lập, chờ xác nhận PO rồi xoá |
|
||||
| `ui/flow_dialog.py` | 596 | Chỉ được nhắc trong docstring `ui/agent_manager_tab.py:4` và comment `i18n.py:2124` | Trung bình — Flow Manager có thể là tính năng tạm ẩn | **Hỏi PO trước**, chưa xoá |
|
||||
| `security/` (cả package) | 296 | `prompt_validator`, `action_validator`, `attachment_validator`, `audit_logger`, `command_risk_classifier` — không file nào ngoài package tự import. Chức năng **trùng** `core/agent_security.py` + `core/security_rules.py` (đang chạy thật) | Trung bình — dễ nhầm đây là lớp bảo mật đang hoạt động | ⚠️ Ưu tiên cao: xoá hoặc hợp nhất trong **R09 (Team Nam)** |
|
||||
| `core/codebase_memory_ui.py` | 123 | Không nơi nào import; `core/codebase_memory.py` (bản không-UI) mới là bản đang dùng | Thấp | Xoá |
|
||||
| `core/graph_server.py` | 115 | Docstring nói phục vụ build không có QtWebEngine, nhưng **không có call site nào**; `ui/structure_graph_view.py` không gọi | Trung bình — có thể là fallback cho bản .exe chưa nối dây | Xác minh với bản đóng gói PyInstaller trước khi xoá |
|
||||
| `ui/mcp_servers_dialog.py` | 57 | Không import; MCP settings hiện nằm trong `ui/settings_dialog.py` | Thấp | Xoá |
|
||||
|
||||
**Tổng: ~1.887 dòng (≈ 3,4% codebase).**
|
||||
|
||||
### 🟨 B. KHÔNG CHẾT — chạy qua đường ẩn (giữ nguyên)
|
||||
|
||||
| Module | Vì sao phân tích tĩnh báo nhầm |
|
||||
| :--- | :--- |
|
||||
| `__main__.py` | Entry point `python -m cowork_local` |
|
||||
| `mcp_servers/ms365_server.py` | Chạy như tiến trình con — `state.py:285` |
|
||||
| `core/routing/__init__.py` | Được import qua đường dẫn con (`from .routing.service import RoutingService`), heuristic theo tên lá không thấy |
|
||||
| `tools/check_*.py` (34 file, 6.608 dòng) | Bộ smoke-test UI chạy tay: `python tools/check_nav.py`. Là **dev tooling**, không phải code chết |
|
||||
| `scripts/bootstrap_gitea_repo.py`, `scripts/check_imports.py` | Script CLI chạy tay / chạy trong CI |
|
||||
|
||||
### 🟩 C. CODE SỐNG NHƯNG "ĐÓNG BĂNG" — đụng vào phải cẩn thận
|
||||
|
||||
| Module | LOC | Ghi chú cho người refactor |
|
||||
| :--- | ---: | :--- |
|
||||
| `core/chat_agent.py::run_cowork` | 580 | Đang có **characterization test** (`tests/characterization/test_run_cowork.py`, R01-T04). Mọi thay đổi hành vi phải làm cùng lúc với cập nhật snapshot |
|
||||
| `providers/base.py` | 401 | Là contract chung của mọi provider; đổi chữ ký = vỡ cả 3 team. Đã có contract test (R03-T01) |
|
||||
| `core/routing/*` | 2.263 | Đã có 79 test đang xanh. R03 **bọc** chứ không viết lại: `application/model_routing/` gọi xuống đây |
|
||||
|
||||
## 4. Quy tắc xử lý (bắt buộc)
|
||||
|
||||
1. **Không xoá trong cùng PR với refactor.** Xoá code chết là một commit riêng, để `git revert` được độc lập khi có sự cố.
|
||||
2. **Cô lập trước, xoá sau.** Đánh dấu module bằng docstring cảnh báo, chạy 1 vòng release; không ai báo lỗi mới xoá.
|
||||
3. **Hạng mục 🟥 A cần một người xác nhận** (PO hoặc chủ tính năng) trước khi xoá — trừ khi rõ ràng là bản trùng lặp (`codebase_memory_ui`, `mcp_servers_dialog`).
|
||||
4. **Không refactor code trong nhóm 🟥 A.** Nếu một file trong danh sách này >400 dòng, nó **không** tính vào CASAN Check 2 — vì đường đi đúng là xoá, không phải tách nhỏ.
|
||||
|
||||
## 5. Việc cần bàn giao
|
||||
|
||||
| Hạng mục | Team nhận | EPIC |
|
||||
| :--- | :--- | :--- |
|
||||
| `security/` trùng lặp với `core/agent_security.py` | 🟣 Nam | R09 |
|
||||
| `ui/accounts_tab.py`, `ui/flow_dialog.py`, `ui/mcp_servers_dialog.py` | 🟣 Nam (sở hữu `presentation/shell/`, `settings/`) | R08 |
|
||||
| `core/graph_server.py`, `core/codebase_memory_ui.py` | 🟢 Hoa (sở hữu `presentation/graph/`) | R06 |
|
||||
|
||||
## 6. Cách chạy lại lần quét này
|
||||
|
||||
```bash
|
||||
python scripts/check_imports.py # ranh giới kiến trúc (R01-T03)
|
||||
# Bản quét đồ thị import dùng cho tài liệu này sẽ được đóng gói thành
|
||||
# scripts/find_dormant.py trong R10-T02 (Testing & Governance tooling).
|
||||
```
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# Project Context MCP — hướng dẫn làm song song
|
||||
|
||||
Mục tiêu: hoàn thiện ba tool trên **cùng một server** `project_context`. Không tạo server, registry,
|
||||
policy hay error envelope mới. Shared skeleton đã khóa sẵn thứ tự an toàn:
|
||||
|
||||
```text
|
||||
validate input → policy ALLOW → resolve provider → gọi upstream → validate output
|
||||
```
|
||||
|
||||
## Chia việc
|
||||
|
||||
| Người | Tool | Chỉ sửa | Branch đề xuất |
|
||||
|---|---|---|---|
|
||||
| Member A | `get_project_issue_context` | `tools/issue_context.py`, `providers/issue.py`, test riêng | `feat/mcp-issue-context` |
|
||||
| Member B | `search_project_knowledge` | `tools/knowledge_search.py`, `providers/knowledge.py`, test riêng | `feat/mcp-knowledge-search` |
|
||||
| Member C | `get_project_change_context` | `tools/change_context.py`, `providers/change.py`, test riêng | `feat/mcp-change-context` |
|
||||
|
||||
Trước khi gửi task, thay `Member A/B/C` bằng username thật trên ba issue. Mỗi người **không sửa**
|
||||
`foundation.py`, `registry.py`, `runtime.py`, `server.py` hoặc file của người khác. Nếu shared contract
|
||||
cần đổi, mở một PR nhỏ riêng và để cả ba người rebase sau khi PR đó merge.
|
||||
|
||||
## Bắt đầu trong 5 phút
|
||||
|
||||
1. Chạy `python --version` và xác nhận Python 3.11+ như baseline trong `requirements.txt`.
|
||||
2. Tạo branch từ commit template chứa tài liệu này sau khi PR template merge.
|
||||
3. Đọc input/output model trong module tool được giao; không thêm field riêng của Gitea/Jira/Redmine.
|
||||
4. Implement provider read-only trong module `providers/<tool>.py`; credential chỉ lấy sau policy ALLOW.
|
||||
5. Thêm test happy, invalid, not-found, timeout, DENIED với `resolver.calls == 0`, output sai schema,
|
||||
truncation/cursor và source mở được có `revision`.
|
||||
6. Chạy:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_project_context_mcp_template.py tests/test_project_context_<tool>.py -q
|
||||
```
|
||||
|
||||
Lệnh trên chạy trực tiếp từ root repo `cowork_local`; `tests/conftest.py` đã thiết lập import path.
|
||||
|
||||
## Definition of Done của từng người
|
||||
|
||||
- Tool trả đúng schema, có `project_id` và source gồm `system`, `url`, `revision`, `retrieved_at`.
|
||||
- Provider-neutral: đổi Gitea sang GitHub/Jira/Redmine không đổi schema hay tool name.
|
||||
- Sai project bị `DENIED` trước khi resolve credential và trước mọi upstream call.
|
||||
- Không log/return token; lỗi ngoài dự kiến không lộ exception; read không có side effect.
|
||||
- Output lớn có `truncated`, `returned`, `remaining`, `next_cursor`; không cắt im lặng.
|
||||
- Test riêng pass, test shared pass, PR chỉ chạm đúng vùng sở hữu trong bảng trên.
|
||||
|
||||
## Chạy server sau khi provider đã cấu hình
|
||||
|
||||
```bash
|
||||
COWORK_MCP_ACTOR_ID=<actor> \
|
||||
COWORK_MCP_ORG_UNIT=<org> \
|
||||
COWORK_MCP_CUSTOMER=<customer> \
|
||||
COWORK_MCP_PROJECT=<project> \
|
||||
python -m cowork_local.mcp_servers.project_context_server
|
||||
```
|
||||
|
||||
Không commit giá trị môi trường hoặc credential. Cowork kết nối bằng stdio với command Python và
|
||||
args `-m cowork_local.mcp_servers.project_context_server`.
|
||||
@@ -0,0 +1,233 @@
|
||||
# BÁO CÁO KẾT QUẢ — TEAM DUY: EPIC R01, R03, R04
|
||||
|
||||
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
|
||||
* **Team**: 🔵 Team Duy — Core AI, Routing, Turn Runtime & Testing (Tech Lead)
|
||||
* **Nhánh**: `feature/deltateam/refactor-plan`
|
||||
* **Thời gian thực hiện**: 21/08/2026, 09:56 ➔ 10:56
|
||||
* **Ngày báo cáo**: 21/08/2026
|
||||
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `DeltaTeam_prompt.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Tóm tắt điều hành
|
||||
|
||||
Hoàn tất **16/16 task** của 3 EPIC được giao trong đợt này: **R01** (nền tảng kiến trúc & lưới an toàn), **R03** (hợp nhất provider & routing), **R04** (vòng đời turn hội thoại). Toàn bộ đã commit và push lên nhánh.
|
||||
|
||||
| Chỉ số | Kết quả |
|
||||
| :--- | :--- |
|
||||
| Task hoàn thành | **16/16** (R01: 5, R03: 6, R04: 5) |
|
||||
| Commit | 5 |
|
||||
| File thay đổi | 48 (37 file mới, 11 file sửa) |
|
||||
| Dòng code | +5.843 / −225 |
|
||||
| Test | **243 pass** / 44s |
|
||||
| Test suite nhanh (unit + contract + characterization + routing) | **218 pass / 1,22s** |
|
||||
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
|
||||
| File production > 400 dòng | **0** |
|
||||
|
||||
**3 lỗi thật được phát hiện và sửa trong quá trình làm** (chi tiết mục 5) — trong đó 1 lỗi deadlock sẽ làm treo ứng dụng ngay ở tin nhắn đầu tiên.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kết quả theo từng EPIC
|
||||
|
||||
### 🔹 EPIC R01 — Architecture Foundation & Characterization (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R01-T01 | `docs/architecture/ADR-001-layered-architecture.md` | Định nghĩa 4 tầng, chiều phụ thuộc, 6 quy tắc bất biến I1–I6, chiến lược di trú Strangler Fig |
|
||||
| R01-T02 | `tests/fakes/fake_provider.py`, `fake_tool_executor.py` | Test double chạy offline, kịch bản hoá, ghi lại mọi lời gọi |
|
||||
| R01-T03 | `scripts/check_imports.py` (239 dòng) | Quét AST, bắt cả import tương đối (`from ...ui import x`) và import trong thân hàm |
|
||||
| R01-T04 | `tests/characterization/test_run_cowork.py` | **13 test** chụp snapshot hành vi hiện tại của `run_cowork` trước khi R04 đụng vào |
|
||||
| R01-T05 | `docs/architecture/dormant-code.md` | Quét đồ thị import: 43 module "không ai import" ➔ xác minh còn **6 hạng mục chết thật (~1.887 dòng)** |
|
||||
|
||||
**Điểm đáng chú ý ở R01-T03**: dùng AST thay vì `grep` là bắt buộc — trong repo có nhiều docstring nhắc tên `PySide6` một cách hợp lệ, `grep` sẽ báo nhầm và đội sẽ học cách tắt cổng kiểm duyệt.
|
||||
|
||||
**Điểm đáng chú ý ở R01-T05**: 43 module không có importer **không** đồng nghĩa 43 module chết. Sau xác minh thủ công: `__main__.py` là entry point, `mcp_servers/ms365_server.py` chạy bằng subprocess (`state.py:285`), 34 file `tools/check_*.py` là dev tooling chạy tay. Chỉ 6 hạng mục là dormant thật.
|
||||
|
||||
### 🔹 EPIC R03 — Model Providers & Routing (6/6)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R03-T01 | `tests/contracts/test_providers.py` | **29 contract test**; chạy được cả 2 adapter thật mà **không cần mạng** nhờ thay `Provider._request` bằng SSE đóng hộp |
|
||||
| R03-T02 | `domain/models/provider_descriptor.py`, `infrastructure/providers/provider_registry.py` | Gom 3 nơi khai báo provider về 1 chỗ |
|
||||
| R03-T03 | `application/model_routing/routing_application_service.py` | Pure Python, 4 chế độ: Off / Auto / Manual / **Fallback (mới)** |
|
||||
| R03-T04, T05 | `ui/chat_panel.py`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Gỡ 3 bản sao logic routing |
|
||||
| R03-T06 | `infrastructure/telemetry/usage_sink.py` | Tách ghi nhận token usage khỏi provider |
|
||||
|
||||
**Vấn đề gốc đã giải quyết** — cùng một thuật toán routing tồn tại **3 bản gần giống nhau**:
|
||||
|
||||
```
|
||||
ui/chat_panel.py::_apply_routing (~45 dòng)
|
||||
ui/co4e_tab.py::_apply_co4e_routing (~38 dòng)
|
||||
ui/folder_tab.py::_ai_apply_routing (~42 dòng)
|
||||
```
|
||||
|
||||
Cả 3 đều nằm trong widget Qt ➔ **không thể test nếu không dựng cửa sổ**, và đã bắt đầu lệch nhau (mỗi bản xác định "model hiện tại" một kiểu). Nay cả 3 chỉ còn gọi `ctx.routing_application().route_turn(...)` + một callback xác nhận.
|
||||
|
||||
**Chế độ Fallback (mới)**: giữ nguyên model người dùng chọn, **chỉ đổi sau khi model đó lỗi**. Đây là chế độ người dùng cần khi họ tin lựa chọn của mình nhưng vẫn muốn lượt chat sống sót qua sự cố nhà cung cấp.
|
||||
|
||||
**Bộ từ vựng mode**: trước đây tuple `("off", "auto", "manual")` bị lặp ở **4 chỗ** (`config.py` × 2, `state.py` × 2). Thêm một mode mà quên một chỗ sẽ **âm thầm hạ lựa chọn của người dùng về "off"**. Nay tập trung vào `normalize_mode()` / `is_valid_mode()`.
|
||||
|
||||
### 🔹 EPIC R04 — Agent Runtime & Conversation Service (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R04-T01 | `domain/agents/conversation_execution_request.py` | Frozen dataclass, chụp toàn bộ input của 1 turn tại thời điểm submit |
|
||||
| R04-T02 | `domain/agents/agent_event.py` (370 dòng) | **13 event có kiểu** thay cho dict không kiểu, kèm cầu nối 2 chiều |
|
||||
| R04-T03 | `application/conversations/conversation_application_service.py` | Điều phối vòng đời turn, không import Qt |
|
||||
| R04-T04 | `ui/cowork_tab.py::build_job` | Chuyển sang snapshot + service |
|
||||
| R04-T05 | `core/task_executors.py::_run_agent` | Chuyển sang **cùng** service (trước đây là bản lắp ráp thứ hai, hơi khác) |
|
||||
|
||||
**Vấn đề gốc đã giải quyết** — closure trong `build_job` đọc state của widget **từ trong worker thread**:
|
||||
|
||||
```python
|
||||
def job(worker):
|
||||
provider = self.build_provider() # đọc combo box
|
||||
proj_ctx = project_context_text(load_project(project_id))
|
||||
```
|
||||
|
||||
Người dùng có thể đổi model, đổi workspace, sửa chỉ dẫn project **trong lúc turn đang chạy**. Turn khi đó chạy trên hỗn hợp state cũ + mới, và hỗn hợp nào phụ thuộc vào thời điểm luồng — đúng loại bug tái hiện mỗi tuần một lần và không bao giờ tái hiện trong test.
|
||||
|
||||
**`TurnCompletedEvent`** là tín hiệu kết thúc turn mà engine cũ **hoàn toàn không có**: hiện tại mọi consumer suy ra "xong" từ việc worker thread kết thúc, nên **turn bị huỷ và turn thất bại trông giống hệt nhau** với giao diện.
|
||||
|
||||
---
|
||||
|
||||
## 3. Kiến trúc sau refactor
|
||||
|
||||
```text
|
||||
presentation/ ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py, ui/cowork_tab.py
|
||||
│ (chỉ dựng UI, mở dialog xác nhận, render thông báo)
|
||||
▼
|
||||
application/ model_routing/routing_application_service.py ← 4 mode routing
|
||||
conversations/conversation_application_service.py ← vòng đời turn
|
||||
│ (100% pure Python — cổng kiểm duyệt tự động chặn import Qt)
|
||||
▼
|
||||
domain/ agents/conversation_execution_request.py ← snapshot bất biến
|
||||
agents/agent_event.py ← 13 event có kiểu
|
||||
models/provider_descriptor.py ← catalog provider
|
||||
▲
|
||||
infrastructure/ providers/provider_registry.py telemetry/usage_sink.py
|
||||
```
|
||||
|
||||
**Nguyên tắc di trú (ADR-001 mục 4)**: **không viết lại engine**. `core/chat_agent.py::run_cowork` và `core/routing/*` (2.263 dòng, 79 test đang xanh) vẫn là engine bên dưới; tầng application chỉ sở hữu phần trước đây bị trộn vào UI. Nhờ vậy `pytest` luôn xanh giữa các bước và một team có thể merge mà không phải chờ team khác.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bằng chứng kiểm thử
|
||||
|
||||
### Phân bố test
|
||||
|
||||
| Suite | Số test | Thời gian | Vai trò |
|
||||
| :--- | ---: | ---: | :--- |
|
||||
| `tests/unit/` | 97 | | Logic thuần, không Qt/mạng |
|
||||
| `tests/contracts/` | 29 | | Mọi provider phải thoả cùng bộ cam kết |
|
||||
| `tests/characterization/` | 13 | | Chốt hành vi hiện tại của `run_cowork` |
|
||||
| `tests/routing/` | 79 | | Có sẵn từ trước, vẫn xanh |
|
||||
| **Cộng 4 suite nhanh** | **218** | **1,22s** | ✅ đạt CASAN "A — unit < 1s" |
|
||||
| `tests/integration/` | 25 | 42s | Widget Qt thật (offscreen) + provider kịch bản hoá |
|
||||
| **Tổng** | **243** | **44s** | |
|
||||
|
||||
### Đối chiếu Definition of Done (7 tiêu chí, `DeltaTeam_prompt.md`)
|
||||
|
||||
| # | Tiêu chí | Kết quả |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | Mọi file < 400 dòng | ✅ Lớn nhất: `agent_event.py` 370 dòng |
|
||||
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
|
||||
| 3 | Comment tiếng Anh ở mọi khối sửa/mới | ✅ Docstring + giải thích **lý do**, không chỉ mô tả code |
|
||||
| 4 | Có unit/contract test, pass 100% < 1s | ✅ 218 test / 1,22s |
|
||||
| 5 | Không hồi quy | ✅ 79 test routing có sẵn vẫn xanh |
|
||||
| 6 | Ghi Start/End vào Checklist | ✅ 16 task đã tick kèm mốc thời gian |
|
||||
| 7 | Cổng CASAN | ⚠️ `run_quality_gate.py` thuộc **R10-T02**, chưa viết. Check 3 đã có và PASS |
|
||||
|
||||
### Ba đường code đã sửa nhưng ban đầu chưa được thực thi
|
||||
|
||||
Sau khi hoàn tất 16 task, rà soát lại phát hiện 3 đường code đã bị sửa nhưng **không test nào chạy qua**. Đã bổ sung **18 test**:
|
||||
|
||||
| Đường code | Rủi ro nếu bỏ qua | Test bổ sung |
|
||||
| :--- | :--- | ---: |
|
||||
| `task_executors._run_agent` | Autosave History có thể đóng băng ở tin nhắn đầu | 7 |
|
||||
| `_apply_co4e_routing` / `_ai_apply_routing` | Mới chỉ import được, chưa từng gọi hàm | 11 |
|
||||
| `confirm_switch(decision)` Manual mode | Thiếu field ➔ **nổ bên trong modal**, nơi khó phát hiện nhất | (nằm trong 11 ở trên) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Ba lỗi thật phát hiện trong quá trình làm
|
||||
|
||||
### 🔴 Lỗi 1 — Deadlock khi khởi tạo routing service
|
||||
|
||||
`AppContext.routing_application()` giữ `_routing_lock` rồi gọi `routing()`, vốn cũng lấy **chính lock đó**. `threading.Lock` không reentrant ➔ **treo cứng ngay ở tin nhắn đầu tiên**, không có thông báo lỗi.
|
||||
|
||||
*Sửa*: tách `_routing_app_lock` riêng, và resolve engine **trước khi** lấy lock.
|
||||
|
||||
### 🟠 Lỗi 2 — Event `notice` bị cầu nối nuốt mất
|
||||
|
||||
Bản đầu của `agent_event.py` liệt kê 12 loại event nhưng **thiếu `notice`**. Trong khi đó `notice` được phát ra từ 3 nơi trên đường chạy bình thường:
|
||||
|
||||
* `core/agent_security.py` — yêu cầu/lệnh bị Agent Security **chặn**
|
||||
* `core/context_budget.py` — hội thoại vừa bị tự động nén
|
||||
* Bộ đọc file đính kèm — file không xử lý được, và tiến độ "đang đọc trang X/Y"
|
||||
|
||||
Cầu nối bỏ qua event không nhận diện được (đúng thiết kế, để engine có thể thêm event mới) — nên **người dùng sẽ không bao giờ thấy cảnh báo bảo mật**, hoàn toàn im lặng.
|
||||
|
||||
*Sửa*: thêm `NoticeEvent`, **và** thêm test quét mã nguồn engine tìm mọi tag `emit({"type": ...})` rồi bắt lỗi nếu có tag nào chưa có event tương ứng — biến sự im lặng thành test đỏ.
|
||||
|
||||
### 🟡 Lỗi 3 — Test đang chạy trên checkout khác
|
||||
|
||||
`tests/routing/conftest.py` đẩy thư mục cha vào `sys.path`. Vì thư mục checkout tên là `cowork_local_gitea` (không phải `cowork_local`), lệnh `import cowork_local` **ăn nhầm sang `Desktop\cowork_local`** — một bản checkout khác. Suite báo xanh trên mã nguồn **không phải nhánh đang review**.
|
||||
|
||||
*Sửa*: `tests/conftest.py` nạp `__init__.py` theo đường dẫn tuyệt đối và đăng ký vào `sys.modules` trước mọi test.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
|
||||
|
||||
| Cải thiện | Ảnh hưởng |
|
||||
| :--- | :--- |
|
||||
| `ProviderRegistry.build()` đóng dấu `descriptor.id` lên instance | Sửa việc usage của `ollama` / `github_copilot` / `codex` bị ghi nhận nhầm thành `openai_compat` trên Dashboard. **Chưa nối vào production** — xem mục 7. |
|
||||
| `ProviderRegistry.build()` copy config trước khi ghi | Trước đây một model do routing chọn có thể ghi đè lên default đã lưu của người dùng |
|
||||
| `UsageTrackerSink` ghi log ở mức debug khi thất bại | Trước là `except: pass` — mất sạch lý do khi Dashboard hỏng |
|
||||
| `estimate_tokens` được chốt bằng test so với `core.usage_tracker` | Bảo đảm việc tách telemetry **không làm lệch một con số nào** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Còn nợ & cần quyết định
|
||||
|
||||
| # | Nội dung | Người quyết |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | **`ProviderRegistry` chưa nối vào `state.build_provider_for`** (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa lỗi quy kết usage ở mục 6, **nhưng đổi cách gom dữ liệu lịch sử trên Dashboard**. | Team Duy + PO |
|
||||
| 2 | **Mode `fallback` chưa có trên toggle UI** — config và service đã hỗ trợ đầy đủ; widget `RoutingToggle` thuộc R08. | Team Duy (R08) |
|
||||
| 3 | **Đã sửa 2 dòng trong `config.py`** (`routing_mode_for`, `set_routing_mode_for`) để dùng chung bộ từ vựng mode. File này Team Nam đang refactor ở R02-T02. | ⚠️ **Cần báo Team Nam** |
|
||||
| 4 | **Circular import** `core/model_pricing.py` ↔ `core/usage_tracker.py` chưa xử lý (task ngày 28/08). | Team Duy |
|
||||
| 5 | **2 test đỏ có sẵn từ trước**: `config.py:108` hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py`. Thuộc **EPIC R02 / Team Nam**. | 🟣 Team Nam |
|
||||
| 6 | `tests/integration/test_routing_surfaces.py` mất 41s do dựng `Co4ETab`/`FolderTab`. Nên gắn marker `slow` khi làm R10. | Team Duy (R10) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Phạm vi chưa kiểm thử
|
||||
|
||||
Nêu rõ để tránh hiểu nhầm mức độ bảo đảm:
|
||||
|
||||
* **Chưa mở ứng dụng bằng tay** — mới chạy widget headless (`QT_QPA_PLATFORM=offscreen`), chưa có ai kiểm tra bằng mắt.
|
||||
* **Chưa gọi provider thật** — toàn bộ dùng `FakeProvider`, không có lưu lượng mạng.
|
||||
* **Chưa chạy 34 script `tools/check_*.py`** — các script này tự `sys.path.insert` thư mục cha nên sẽ import nhầm checkout khác (đúng lỗi 3 ở mục 5). Cần sửa chúng ở R10.
|
||||
|
||||
---
|
||||
|
||||
## 9. Việc kế tiếp của Team Duy
|
||||
|
||||
| EPIC | Nội dung | Điều kiện |
|
||||
| :--- | :--- | :--- |
|
||||
| **R08** (T01 ➔ T06) | Tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng | Sẵn sàng bắt đầu — `AgentEvent` (R04-T02) chính là kênh dữ liệu 6 widget con sẽ dùng thay vì đọc trực tiếp state của `ChatPanel` |
|
||||
| **R10** (T01 ➔ T05) | Testing Pyramid, `run_quality_gate.py`, Contributor Recipes, E2E Smoke | Chờ cả 3 team hoàn tất |
|
||||
|
||||
---
|
||||
|
||||
## 10. Lịch sử commit
|
||||
|
||||
| Commit | Nội dung |
|
||||
| :--- | :--- |
|
||||
| `bbc09f6` | feat(R01): architecture foundation, offline fakes and characterization net |
|
||||
| `96bec97` | feat(R03): unify provider catalogue, routing decisions and usage telemetry |
|
||||
| `a53163e` | feat(R04): immutable turn snapshot, typed agent events, conversation service |
|
||||
| `15e1d3e` | test(R03/R04): cover the three code paths that were changed but never executed |
|
||||
| `67b8d2e` | docs(refactor): correct the Team Duy scope block in the checklist |
|
||||
@@ -20,6 +20,43 @@
|
||||
|
||||
---
|
||||
|
||||
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM DUY (cập nhật `2026-08-21 10:55`)
|
||||
|
||||
> [!NOTE]
|
||||
> ### ✅ ĐÃ HOÀN TẤT: 16/16 task của **R01, R03, R04** — đã commit & push lên nhánh `feature/deltateam/refactor-plan`
|
||||
>
|
||||
> | EPIC | Task | Trạng thái |
|
||||
> | :--- | :--- | :--- |
|
||||
> | **R01** Architecture Foundation | T01 → T05 | ✅ 5/5 |
|
||||
> | **R03** Providers & Routing | T01 → T06 | ✅ 6/6 |
|
||||
> | **R04** Agent Runtime & Conversation | T01 → T05 | ✅ 5/5 |
|
||||
>
|
||||
> **Kiểm chứng (chạy thật, không phải ước lượng):**
|
||||
> * `pytest tests/` ➔ **243 pass / 2 fail** trong 44s
|
||||
> * Suite nhanh (`unit + contracts + characterization + routing`) ➔ **218 pass trong 1,16s** (đạt yêu cầu CASAN "A – Automated Tests < 1s cho unit")
|
||||
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
|
||||
> * Mọi file production mới **< 400 dòng** (lớn nhất: `routing_application_service.py` 353 dòng)
|
||||
> * 2 test fail là **lỗi có sẵn từ trước**, thuộc EPIC **R02**: `config.py` vẫn hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py` đỏ
|
||||
>
|
||||
> ### 📍 PHẠM VI TEAM DUY & PHẦN CÒN LẠI
|
||||
> Theo `Feature_Architecture_Proposal.md` (dòng 7) và `DeltaTeam_prompt.md` (dòng 17), Team Duy chủ trì **R01, R03, R04, R08 (phân hệ Chat UI), R10**.
|
||||
> * ✅ **R01, R03, R04** — xong 16/16 task, đã push.
|
||||
> * ⬜ **R08 (R08-T01 ➔ R08-T06)** — chưa bắt đầu: tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng.
|
||||
> * ⬜ **R10** — làm sau cùng, chờ 3 team hoàn tất.
|
||||
> * **R02 thuộc 🟣 Team Nam** (xem mục EPIC R02 bên dưới) — đây là nguyên nhân 2 test đỏ ở trên, không phải việc của Team Duy.
|
||||
>
|
||||
> ### 📄 BÁO CÁO CHI TIẾT
|
||||
> Xem `docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md` — kết quả từng EPIC, bằng chứng kiểm thử, 3 lỗi thật đã phát hiện, và phạm vi **chưa** kiểm thử.
|
||||
>
|
||||
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
|
||||
> 1. `ProviderRegistry` **chưa nối** vào `state.build_provider_for` (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa luôn lỗi: usage của `ollama`/`github_copilot`/`codex` hiện bị ghi nhận nhầm thành `openai_compat` trên Dashboard — nhưng làm vậy sẽ **đổi cách gom dữ liệu lịch sử**.
|
||||
> 2. Mode `fallback` đã hỗ trợ ở config + service nhưng **chưa có trên toggle UI** (thuộc R08).
|
||||
> 3. Đã sửa 2 dòng trong `config.py` (`routing_mode_for` / `set_routing_mode_for`) để dùng chung một bộ từ vựng mode — **cần báo Team Nam** vì file này đang được refactor ở R02.
|
||||
> 4. Circular import `core/model_pricing.py` ↔ `core/usage_tracker.py` **chưa xử lý** (task ngày 28/08).
|
||||
> 5. Việc kế tiếp của Team Duy là **R08 phân hệ Chat UI** (6 widget con), rồi **R10** sau cùng.
|
||||
|
||||
---
|
||||
|
||||
## 📌 PHẦN 1: CHECKLIST CHI TIẾT THEO 10 EPIC (R01 ➔ R10)
|
||||
|
||||
### 🔹 EPIC R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ)
|
||||
@@ -27,15 +64,15 @@
|
||||
* **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc.
|
||||
|
||||
- [x] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
|
||||
*Start: `2026-08-21 18:23` | End: `2026-08-21 18:24`*
|
||||
*Start: `2026-08-21 09:56` | End: `2026-08-21 10:00`*
|
||||
- [x] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
|
||||
*Start: `2026-08-21 18:24` | End: `2026-08-21 18:26`*
|
||||
*Start: `2026-08-21 10:00` | End: `2026-08-21 10:02`*
|
||||
- [x] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
|
||||
*Start: `2026-08-21 18:26` | End: `2026-08-21 18:28`*
|
||||
*Start: `2026-08-21 09:58` | End: `2026-08-21 10:05`*
|
||||
- [x] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
|
||||
*Start: `2026-08-21 18:28` | End: `2026-08-21 18:32`*
|
||||
*Start: `2026-08-21 10:02` | End: `2026-08-21 10:04`*
|
||||
- [x] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
|
||||
*Start: `2026-08-21 18:32` | End: `2026-08-21 18:35`*
|
||||
*Start: `2026-08-21 10:04` | End: `2026-08-21 10:05`*
|
||||
|
||||
---
|
||||
|
||||
@@ -63,71 +100,17 @@
|
||||
* **Mục tiêu**: Hợp nhất logic routing bị phân tán thành `RoutingApplicationService` độc lập Qt; chuẩn hóa catalog nhà cung cấp.
|
||||
|
||||
- [x] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
|
||||
*Start: `2026-08-22 18:59` | End: `2026-08-22 19:01`*
|
||||
*Start: `2026-08-21 10:10` | End: `2026-08-21 10:12`*
|
||||
- [x] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
|
||||
*Start: `2026-08-22 18:45` | End: `2026-08-22 18:50`*
|
||||
*Start: `2026-08-21 10:06` | End: `2026-08-21 10:10`*
|
||||
- [x] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
|
||||
*Start: `2026-08-22 18:53` | End: `2026-08-22 18:57`*
|
||||
*Start: `2026-08-21 10:12` | End: `2026-08-21 10:15`*
|
||||
- [x] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
|
||||
*Start: `2026-08-22 18:57` | End: `2026-08-22 18:58`*
|
||||
*Start: `2026-08-21 10:17` | End: `2026-08-21 10:20`*
|
||||
- [x] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
|
||||
*Start: `2026-08-22 18:58` | End: `2026-08-22 18:59`*
|
||||
*Start: `2026-08-21 10:20` | End: `2026-08-21 10:22`*
|
||||
- [x] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
|
||||
*Start: `2026-08-22 18:50` | End: `2026-08-22 18:53`*
|
||||
|
||||
#### 📦 KẾT QUẢ THỰC HIỆN EPIC R03 (Hoàn tất 2026-08-22 19:01 — nhánh `feature/delta-team/epic-R03`)
|
||||
|
||||
**File sản phẩm mới (tất cả < 400 dòng, 100% comment tiếng Anh):**
|
||||
|
||||
| Task | File | LOC | Nội dung chính |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| T02 | `domain/models/provider_descriptor.py` | 196 | `ProviderDescriptor` (frozen dataclass), `WireProtocol`, `AuthKind`; giá/context để `None` khi chưa biết thay vì đoán bừa |
|
||||
| T02 | `infrastructure/providers/provider_registry.py` | 287 | `ProviderRegistry` thread-safe: tra cứu theo id/alias, **tra cứu động theo model ID** (`find_by_model`), dựng adapter theo wire protocol; `BUILTIN_DESCRIPTORS` cho 5 provider |
|
||||
| T03 | `application/model_routing/routing_models.py` | 158 | DTO thuần Python: `RoutingMode` (Off/Auto/Manual/**Fallback**), `RoutingRequest` (immutable snapshot), `RouteEvaluation`, `RoutingOutcome` |
|
||||
| T03 | `application/model_routing/routing_application_service.py` | 236 | `RoutingApplicationService` — 1 nơi duy nhất quyết định routing; 2 port hẹp (`RoutingDecisionPort`, `ModeResolver`) + callback confirm ⇒ 0 phụ thuộc Qt |
|
||||
| T03 | `application/model_routing/core_routing_adapter.py` | 169 | `CoreRoutingEngine` (cầu nối sang `core/routing`), `AppContextModeResolver`, `build_routing_application_service(ctx)` (cache 1 instance/ctx) |
|
||||
| T06 | `infrastructure/telemetry/usage_sink.py` | 288 | `UsageEvent` + `UsageEventSink` (Protocol) + `UsageTrackerSink` / `InMemoryUsageSink` / `CompositeUsageSink`; publish không bao giờ raise |
|
||||
|
||||
**File hiện hữu được sửa (đều có comment tiếng Anh tại mọi khối thay đổi):**
|
||||
|
||||
| File | Thay đổi |
|
||||
| :--- | :--- |
|
||||
| `providers/factory.py` | Bỏ bảng `_REGISTRY` nội bộ, ủy quyền cho `ProviderRegistry`; vẫn raise `ProviderError` để không vỡ call site cũ |
|
||||
| `providers/openai_compat.py`, `providers/anthropic.py` | Không còn gọi thẳng `core/usage_tracker`; chỉ **publish** `UsageEvent` qua sink (T06) |
|
||||
| `ui/chat_panel.py` (#L638), `ui/co4e_tab.py`, `ui/folder_tab.py` | Xóa 3 bản sao logic routing (~35 dòng/file) ➔ gọi chung `RoutingApplicationService` (T04, T05); widget chỉ còn dựng `RoutingRequest`, host modal confirm và render kết quả |
|
||||
| `config.py`, `state.py`, `ui/routing_toggle.py`, `i18n.py` | Mở đường cho chế độ thứ 4 **Fallback**: hằng `AppConfig.ROUTING_MODES`, validate per-workspace, thêm mục trong combo + chuỗi EN/JA/VI |
|
||||
| `core/usage_tracker.py` | Thêm `current_context()` để sink mượn/trả lại context của thread thay vì gán đè vĩnh viễn |
|
||||
| `tests/conftest.py`, `tests/routing/conftest.py` | **Sửa lỗi hạ tầng test nghiêm trọng** (xem "Ghi chú" bên dưới) |
|
||||
|
||||
**Bộ test bổ sung (tất cả offline, không cần network/Qt):**
|
||||
|
||||
| File | Số test | Phạm vi |
|
||||
| :--- | :---: | :--- |
|
||||
| `tests/contracts/test_providers.py` (+ `provider_stubs.py`) | 50 | Contract chạy parametrize trên **mọi** provider trong registry: signature `chat()`, canonical assistant message, tool call chuẩn hóa, đóng response, dịch tool schema, `ProviderError`, `list_models`/`test_connection`, đúng 1 `UsageEvent`/turn |
|
||||
| `tests/unit/test_routing_application_service.py` | 28 | Đủ 4 chế độ + mọi nhánh degrade (engine lỗi, resolver lỗi, dialog lỗi, thiếu callback) |
|
||||
| `tests/unit/test_provider_registry.py` | 17 | Descriptor + registry + đối chiếu catalogue với `DEFAULT_CONFIG["providers"]` |
|
||||
| `tests/unit/test_core_routing_adapter.py` | 12 | Dịch `RouteResult` ⇄ DTO, task type sai định dạng, thiếu ranking, cache service |
|
||||
| `tests/unit/test_usage_sink.py` | 13 | Fan-out, subscriber lỗi, khôi phục thread context, publish không raise |
|
||||
| `tests/integration/test_routing_unification.py` | 14 | Chạy `RoutingApplicationService` trên **engine `core/routing` thật**; 3 surface (cowork/co4e/ai_edit) cho ra cùng 1 quyết định |
|
||||
|
||||
**Kết quả cổng kiểm duyệt (DoD 7 tiêu chí):**
|
||||
|
||||
| # | Tiêu chí | Lệnh | Kết quả |
|
||||
| :---: | :--- | :--- | :--- |
|
||||
| 1 | LOC < 400 | `wc -l` các file mới | ✅ Lớn nhất 288 dòng (`usage_sink.py`); `openai_compat.py` 374, `anthropic.py` 332 |
|
||||
| 2 | Clean Architecture | `python scripts/check_imports.py` | ✅ `[PASS] 0 forbidden imports detected` |
|
||||
| 3 | Comment tiếng Anh | Review thủ công | ✅ 100% khối code mới/sửa có comment giải thích logic + lý do kiến trúc |
|
||||
| 4 | Có test tự động | `pytest tests/unit tests/contracts tests/integration` | ✅ 134 test mới, pass 100% |
|
||||
| 5 | No Regression | `pytest tests/` | ✅ **236 passed in ~2.0s** (nền trước R03: 102 passed) |
|
||||
| 6 | Timestamps | Bảng trên | ✅ Đã ghi Start/End cho T01–T06 |
|
||||
| 7 | CASAN Gate | `scripts/run_quality_gate.py` | ⚠️ Script **chưa tồn tại** — thuộc R10-T02 (chưa làm). Đã chạy thay bằng `check_imports.py` + `pytest tests/` |
|
||||
|
||||
**Ghi chú kỹ thuật cần biết khi review:**
|
||||
|
||||
1. **Đã sửa 1 lỗi hạ tầng test có thể gây kết quả sai lệch**: `tests/conftest.py` cũ đẩy thư mục **cha** của repo vào `sys.path`, nên `import cowork_local.*` (dùng bởi `tests/routing/*` và `tests/characterization/*`) trỏ sang **một checkout `cowork_local` khác** nằm cạnh thư mục làm việc — test vẫn báo xanh nhưng chạy trên mã nguồn khác. Nay conftest bind thẳng checkout hiện tại vào `sys.modules["cowork_local"]`.
|
||||
2. **Chế độ Fallback** là chế độ *chống gãy*, không phải chế độ tối ưu: giữ nguyên model người dùng chọn kể cả khi có model điểm cao hơn, chỉ chuyển khi model đó **không phục vụ được** turn (không có trong ranking / unavailable / probe fail). Engine `core/routing` không cần biết chế độ này — service map Fallback ➔ Auto khi hỏi ranking rồi tự áp luật chấp nhận riêng.
|
||||
3. **T06 hiện tại**: provider publish `UsageEvent`; khi R04 dựng xong `AgentEvent` bus thì `ConversationApplicationService` sẽ là nơi phát sự kiện, sink giữ nguyên không phải sửa.
|
||||
4. **Cần cài `mcp>=1.0.0`** (đã có trong `requirements.txt`) để `tests/test_project_context_mcp_template.py` collect được — thiếu gói này toàn bộ suite bị interrupt.
|
||||
*Start: `2026-08-21 10:15` | End: `2026-08-21 10:17`*
|
||||
|
||||
---
|
||||
|
||||
@@ -135,16 +118,16 @@
|
||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
|
||||
* **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu.
|
||||
|
||||
- [ ] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
|
||||
*Start: `2026-08-21 10:23` | End: `2026-08-21 10:25`*
|
||||
- [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
|
||||
*Start: `2026-08-21 10:22` | End: `2026-08-21 10:23`*
|
||||
- [x] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
|
||||
*Start: `2026-08-21 10:25` | End: `2026-08-21 10:27`*
|
||||
- [x] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
|
||||
*Start: `2026-08-21 10:27` | End: `2026-08-21 10:31`*
|
||||
- [x] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
|
||||
*Start: `2026-08-21 10:28` | End: `2026-08-21 10:30`*
|
||||
|
||||
---
|
||||
|
||||
@@ -283,20 +266,16 @@
|
||||
|
||||
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
|
||||
| :--- | :--- | :---: | :---: | :---: |
|
||||
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 18:23` | `2026-08-21 18:35` | [x] |
|
||||
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-22 18:45` | `2026-08-22 19:01` | [x] |
|
||||
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-22 18:53` | `2026-08-22 18:57` | [~] |
|
||||
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 09:56` | `2026-08-21 10:25` | [x] |
|
||||
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-21 10:06` | `2026-08-21 10:12` | [x] ⚠️ registry chưa nối vào `state.build_provider_for` |
|
||||
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-21 10:12` | `2026-08-21 10:15` | [~] RoutingApplicationService xong; tách widget thuộc R08 |
|
||||
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `2026-08-21 10:25` | `2026-08-21 10:27` | [~] Service xong; tách widget thuộc R08 |
|
||||
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-22 18:57` | `2026-08-22 18:59` | [~] |
|
||||
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
|
||||
> **Chú thích trạng thái**: `[~]` = hoàn tất **phần thuộc EPIC R03**, phần còn lại của dòng đó thuộc EPIC khác nên chưa đóng.
|
||||
> - Dòng **24/08**: đã xong `RoutingApplicationService` (R03-T03); phần `ComposerWidget`/`AttachmentPicker` thuộc R08-T01/T02 — chưa làm.
|
||||
> - Dòng **28/08**: đã xóa copy routing trong `ui/chat_panel.py` (R03-T04) **và** cả `ui/co4e_tab.py`, `ui/folder_tab.py` (R03-T05); phần circular import `model_pricing` ↔ `usage_tracker` thuộc R09-T02 — chưa làm.
|
||||
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-21 10:17` | `2026-08-21 10:22` | [~] 3 bản copy routing đã gỡ; circular import chưa xử lý |
|
||||
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `2026-08-21 10:35` | `2026-08-21 10:52` | [~] 25 integration test tại `tests/integration/{test_cowork_turn_flow,test_task_executor_flow,test_routing_surfaces}.py` |
|
||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `2026-08-21 09:58` | `2026-08-21 10:05` | [x] PASS |
|
||||
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] chờ 3 team hoàn tất |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
# NHẬT KÝ THEO DÕI VÀ PHÒNG NGỪA LỖI TÁI CẤU TRÚC (BUG & LESSONS LEARNED LOG)
|
||||
## DỰ ÁN: COWORK LOCAL (COWORK-LOCAL BAMBOO)
|
||||
|
||||
Tài liệu này dùng để ghi nhận **toàn bộ các lỗi, xung đột kiến trúc và sự cố phát sinh** trong suốt quá trình refactoring của cả 3 team (Team Duy, Team Nam, Team Hoa).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ### 🛡️ NGUYÊN TẮC VÀNG VỀ QUẢN TRỊ CHẤT LƯỢNG (ZERO RECURRENCE):
|
||||
> 1. **Ghi nhận ngay lập tức**: Khi gặp bất kỳ lỗi nào (Syntax, Circular Import, Type Error, Test Failure, Thread Freeze, Data Corruption), kỹ sư/AI phải ghi ngay vào tài liệu này trước khi tiếp tục task.
|
||||
> 2. **Phân tích nguyên nhân gốc rễ (Root Cause)**: Không chỉ sửa phần ngọn mà phải giải thích rõ bản chất vì sao lỗi xảy ra.
|
||||
> 3. **Rút ra quy tắc phòng ngừa (Prevention Rule)**: Đặt ra nguyên tắc kỹ thuật để **TUYỆT ĐỐI KHÔNG TÁI PHẠM** ở các task tiếp theo.
|
||||
> 4. **Checklist đầu vào**: Trước khi bắt đầu bất kỳ task mới nào, kỹ sư/AI **bắt buộc phải đọc lại toàn bộ file này**.
|
||||
|
||||
---
|
||||
|
||||
## 📌 BẢNG TỔNG HỢP CÁC LỖI ĐÃ PHÁT HIỆN & KHẮC PHỤC
|
||||
|
||||
| Bug ID | Ngày Phát Hiện | Phân Hệ / File Bị Ảnh Hưởng | Loại Lỗi | Trạng Thái | Team Phụ Trách |
|
||||
| :--- | :---: | :--- | :--- | :---: | :---: |
|
||||
| `BUG-001` | 2026-08-20 | `core/model_pricing.py` ↔ `core/usage_tracker.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Duy & Team Nam |
|
||||
| `BUG-002` | 2026-08-20 | `core/agent_security.py` ↔ `core/agent_security_alert.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Nam |
|
||||
| `BUG-003` | 2026-08-20 | `state.py::active_project_id` & `ui/workspace_tab.py` | Race Condition / Global State Leak | 🟡 Đã có giải pháp (R06) | Team Hoa |
|
||||
| `BUG-004` | 2026-08-20 | `core/task_scheduler.py` ↔ `PySide6.QtCore.QTimer` | Architecture Violation (Qt in Domain/App) | 🟡 Đã có giải pháp (R07) | Team Hoa |
|
||||
| `BUG-005` | 2026-08-20 | `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Code Duplication (Copy Routing Logic) | 🟡 Đã có giải pháp (R03) | Team Duy |
|
||||
| `BUG-006` | 2026-08-21 | `scripts/check_imports.py` | UnicodeEncodeError (Windows CP932 console emoji) | 🟢 Đã khắc phục (R01) | Team Duy |
|
||||
| `BUG-007` | 2026-08-21 | `platform/` ➔ `infrastructure/platform/` | Standard Library Shadowing (`import platform`) | 🟢 Đã khắc phục (R01) | Team Duy |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 CHI TIẾT TỪNG LỖI & QUY TẮC PHÒNG NGỪA
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-001`: Circular Import giữa Module Định Giá (`model_pricing.py`) và Theo Dõi Token (`usage_tracker.py`)
|
||||
|
||||
* **Phân hệ**: `core/model_pricing.py` & `core/usage_tracker.py`
|
||||
* **Triệu chứng (Symptom)**: Lỗi `ImportError: cannot import name 'ModelPricing' from partially initialized module` khi khởi động ứng dụng hoặc chạy test độc lập.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- `model_pricing.py` import `UsageTracker` để cập nhật dữ liệu tiêu thụ.
|
||||
- Ngược lại, `usage_tracker.py` import `ModelPricing` để tính toán chi phí theo từng model ID.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Tách Data Transfer Object (DTO) `ModelPricing` sang tầng Domain thuần túy `domain/models/model_pricing.py`.
|
||||
- Cả `model_pricing.py` và `usage_tracker.py` đều import DTO từ `domain/models/`, chuyển quan hệ thành 1 chiều (Dependency Inversion).
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Không bao giờ để 2 service hoặc 2 module nghiệp vụ import lẫn nhau. Mọi cấu trúc dữ liệu dùng chung (DTO/Value Object/Event) **phải được đặt tại tầng `domain/`**.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-002`: Circular Import giữa An Ninh Agent (`agent_security.py`) và Cảnh Báo (`agent_security_alert.py`)
|
||||
|
||||
* **Phân hệ**: `core/agent_security.py` & `core/agent_security_alert.py`
|
||||
* **Triệu chứng (Symptom)**: Lỗi khởi tạo vòng tròn khi runtime bắn ra alert sự kiện bảo mật.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Module security vừa kiểm tra policy vừa khởi tạo trực tiếp instance alert dialog, trong khi alert dialog lại import ngược lại rule security để hiển thị chi tiết mã lỗi.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Tách sự kiện cảnh báo thành Event DTO `SecurityAlertEvent` tại `domain/security/security_event.py`.
|
||||
- Tầng Security chỉ phát ra Event (`emit_event`), tầng Presentation/UI tự lắng nghe Event để render Dialog.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Logic an ninh và xử lý nghiệp vụ không bao giờ được gọi trực tiếp UI Dialog. Luôn giao tiếp thông qua cơ chế Event-Driven (`AgentEvent`, `SecurityEvent`).
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-003`: Xung Đột Race Condition do Sử Dụng Biến Toàn Cục `active_project_id` trong `state.py`
|
||||
|
||||
* **Phân hệ**: `state.py`, `ui/workspace_tab.py`, Scheduled Task Runners
|
||||
* **Triệu chứng (Symptom)**: Khi task scheduler chạy ngầm hoặc người dùng chuyển tab nhanh, file bị ghi nhầm vào thư mục dự án khác với dự án đang hiển thị trên màn hình.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Ứng dụng đọc và ghi trực tiếp vào biến toàn cục `AppContext.active_project_id` từ nhiều luồng khác nhau mà không có cơ chế snapshot ngữ cảnh.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Xóa bỏ việc đọc biến toàn cục. Mỗi lần khởi chạy turn hoặc task, tạo một snapshot bất biến `WorkspaceSession(project_id, root_path, allowed_paths)`.
|
||||
- Luồng ngầm chỉ thao tác trên `WorkspaceSession` được truyền vào từ lúc khởi tạo.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Tuyệt đối không dùng biến toàn cục (Global State / Singletons có trạng thái thay đổi) để điều khiển luồng thực thi nền. Mọi ngữ cảnh phải được truyền tường minh qua DTO snapshot.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-004`: Vi Phạm Ranh Giới Kiến Trúc Khi Import `PySide6.QtCore.QTimer` trong Domain / Scheduling Engine
|
||||
|
||||
* **Phân hệ**: `core/task_scheduler.py#L20`
|
||||
* **Triệu chứng (Symptom)**: Không thể viết Unit Test cho thuật toán tính toán lịch chạy (cron/interval) trên môi trường CI/CD (GitHub Actions / Linux Server headless) nếu thiếu driver màn hình X11/Wayland hoặc chưa cài `PySide6`.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Động cơ lập lịch bị gắn chặt cứng với `QTimer` của framework Qt thay vì tách riêng logic tính toán thời gian.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Tách thuật toán tính lịch sang `domain/tasks/schedule_calculator.py` (Pure Python 100%).
|
||||
- Tạo `platform/qt/qt_scheduler_clock.py` làm adapter bọc `QTimer` cho app chạy thật, và `tests/fakes/fake_clock.py` cho unit test.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Tầng Domain và Application tuyệt đối không import thư viện GUI (`PySide6`, `PyQt`). Luôn bọc các thành phần phụ thuộc framework bên ngoài qua Adapter Interface.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `BUG-005`: Nhân Bản Mã Nguồn (Code Duplication) Logic Routing Mô Hình AI tại Nhiều Màn Hình
|
||||
|
||||
* **Phân hệ**: `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py`
|
||||
* **Triệu chứng (Symptom)**: Khi cập nhật thêm model provider mới (như FPT Gateway hay Claude 3.7), phải sửa code thủ công ở 3 file UI khác nhau; phát sinh sai lệch quy tắc fallback giữa các màn hình.
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Thiếu một tầng Application Service tập trung, dẫn đến việc lập trình viên copy-paste hàm chọn model từ `ChatPanel` sang các tab khác.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Xây dựng `application/model_routing/routing_application_service.py` duy nhất, cung cấp API `route_request(request) -> ModelRouteDecision`.
|
||||
- Mọi màn hình UI chỉ gọi service này, không tự viết lại logic kiểm tra key hay fallback.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Nghiệp vụ dùng chung giữa các màn hình phải được đưa vào `application/` services. Không bao giờ viết logic nghiệp vụ trực tiếp trong các file Widget UI.
|
||||
|
||||
---
|
||||
|
||||
### 🟢 `BUG-006`: `UnicodeEncodeError` khi in Emojis trên Console Windows (CP932/CP1252)
|
||||
|
||||
* **Phân hệ / File**: `scripts/check_imports.py`
|
||||
* **Triệu chứng (Symptom)**:
|
||||
```text
|
||||
Traceback (most recent call last):
|
||||
File "scripts/check_imports.py", line 127, in main
|
||||
print(f"\U0001f6e1\ufe0f Running Clean Architecture Import Guard...")
|
||||
UnicodeEncodeError: 'cp932' codec can't encode character '\U0001f6e1' in position 0: illegal multibyte sequence
|
||||
```
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Trên hệ điều hành Windows sử dụng locale tiếng Nhật (mã trang CP932) hoặc tiếng Anh (CP1252), `sys.stdout` mặc định không hỗ trợ các ký tự Unicode/Emoji ngoài bảng mã, dẫn đến crash khi in log dòng lệnh.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Tự động bọc lại `sys.stdout` và `sys.stderr` bằng `io.TextIOWrapper` với `encoding="utf-8"` và `errors="replace"`.
|
||||
- Thay thế các emoji phức tạp bằng các tag văn bản ASCII chuẩn hóa như `[Clean Arch Guard]`, `[PASS]`, `[FAIL]`.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Mọi script CLI (`scripts/*.py`) phải có cơ chế cấu hình `utf-8` stream wrapper và ưu tiên sử dụng text tags (`[INFO]`, `[WARN]`, `[ERROR]`) thay vì emoji Unicode trực tiếp để đảm bảo chạy mượt mà trên mọi môi trường Windows đa ngôn ngữ.
|
||||
|
||||
---
|
||||
|
||||
### 🟢 `BUG-007`: Xung Đột Tên Thư Mục Trùng Với Standard Library (`platform/` Shadowing `import platform`)
|
||||
|
||||
* **Phân hệ / File**: `platform/` ➔ Chuyển thành `infrastructure/platform/`
|
||||
* **Triệu chứng (Symptom)**:
|
||||
```text
|
||||
INTERNALERROR> File "_pytest/terminal.py", line 853: verinfo = platform.python_version()
|
||||
INTERNALERROR> AttributeError: module 'platform' has no attribute 'python_version'
|
||||
```
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||
- Khi tạo một package ở thư mục gốc có tên trùng với module thư viện chuẩn của Python (`platform`, `email`, `test`, `asyncio`, `logging`), Python trên `sys.path` sẽ ưu tiên import thư mục local thay vì thư viện chuẩn của Python runtime, dẫn đến crash toàn bộ pytest runner và các thư viện bên thứ ba.
|
||||
* **Giải pháp khắc phục (Resolution)**:
|
||||
- Xóa bỏ package `platform/` ở root.
|
||||
- Đưa adapter Qt Scheduler Clock vào đúng vị trí hạ tầng: `infrastructure/platform/qt/`.
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: Tuyệt đối không đặt tên package/thư mục ở root trùng với tên các module built-in của Python (`platform`, `logging`, `types`, `time`, `io`, `os`, `sys`). Mọi platform adapter phải nằm trong `infrastructure/platform/` hoặc `platform_adapters/`.
|
||||
|
||||
---
|
||||
|
||||
## 📝 MẪU GHI NHẬN BUG MỚI (BUG REPORT TEMPLATE)
|
||||
|
||||
Khi gặp bất kỳ bug mới nào trong quá trình làm việc, hãy sao chép khối mẫu sau và điền vào cuối tài liệu:
|
||||
|
||||
```markdown
|
||||
### 🔴 `BUG-XXX`: [Tóm tắt ngắn gọn tên lỗi]
|
||||
|
||||
* **Phân hệ / File**: `[Đường dẫn file bị lỗi]`
|
||||
* **Triệu chứng (Symptom)**: `[Mô tả hiện tượng lỗi, paste thông báo traceback hoặc kết quả test fail]`
|
||||
* **Nguyên nhân gốc rễ (Root Cause)**: `[Giải thích tại sao lỗi lại xảy ra]`
|
||||
* **Giải pháp khắc phục (Resolution)**: `[Mô tả cách sửa, file DTO/Service tạo mới hoặc cách refactor]`
|
||||
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||
> **Quy tắc**: `[Nguyên tắc kỹ thuật cụ thể để không bao giờ tái phạm lỗi này]`
|
||||
```
|
||||
+12
-1
@@ -1 +1,12 @@
|
||||
"""Domain Layer: Pure Python domain entities, value objects, and events."""
|
||||
"""Domain layer - pure Python entities, value objects and events.
|
||||
|
||||
The innermost layer of the 4-tier architecture (see
|
||||
``docs/architecture/ADR-001-layered-architecture.md``). Modules here describe
|
||||
WHAT the application is about - a turn of conversation, a model candidate, an
|
||||
agent event - and depend on nothing but the standard library.
|
||||
|
||||
Hard rule (ADR-001 I1/I2, enforced by ``scripts/check_imports.py``): no imports
|
||||
of PySide6/PyQt, and no imports from ``application/``, ``infrastructure/``,
|
||||
``presentation/`` or the legacy ``core/``/``ui/`` packages. That is what keeps
|
||||
this layer testable in milliseconds and reusable from a headless scheduler.
|
||||
"""
|
||||
|
||||
@@ -1 +1,48 @@
|
||||
"""Domain agents package: turn requests, agent events, and role definitions."""
|
||||
"""Domain entities for one agent turn: the request snapshot and the typed event
|
||||
stream it produces (EPIC R04)."""
|
||||
|
||||
from .agent_event import (
|
||||
AgentEvent,
|
||||
AssistantDoneEvent,
|
||||
ErrorEvent,
|
||||
HistoryReadyEvent,
|
||||
NoticeEvent,
|
||||
OutputsAddedEvent,
|
||||
OutputsRemovedEvent,
|
||||
PlanUpdatedEvent,
|
||||
ReasoningChunkEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
ToolCallStartedEvent,
|
||||
ToolOutputEvent,
|
||||
TurnCompletedEvent,
|
||||
collect_text,
|
||||
event_from_dict,
|
||||
tool_calls,
|
||||
)
|
||||
from .conversation_execution_request import (
|
||||
ConversationExecutionRequest,
|
||||
new_turn_id,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConversationExecutionRequest",
|
||||
"new_turn_id",
|
||||
"AgentEvent",
|
||||
"TextChunkEvent",
|
||||
"ReasoningChunkEvent",
|
||||
"AssistantDoneEvent",
|
||||
"PlanUpdatedEvent",
|
||||
"ToolCallStartedEvent",
|
||||
"ToolOutputEvent",
|
||||
"ToolCallFinishedEvent",
|
||||
"OutputsAddedEvent",
|
||||
"OutputsRemovedEvent",
|
||||
"NoticeEvent",
|
||||
"HistoryReadyEvent",
|
||||
"TurnCompletedEvent",
|
||||
"ErrorEvent",
|
||||
"event_from_dict",
|
||||
"collect_text",
|
||||
"tool_calls",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
"""AgentEvent - the typed event stream one agent turn produces (R04-T02).
|
||||
|
||||
Today the turn engine talks to its caller through untyped dicts::
|
||||
|
||||
emit({"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": result.get("ok", False), "output": result.get("output", "")})
|
||||
|
||||
and every consumer re-discovers the vocabulary by reading the producer. There
|
||||
are eleven such shapes across ``core/chat_agent.py``, ``core/code_agent.py`` and
|
||||
``core/task_executors.py``; a consumer that misspells ``"tool_result"`` or reads
|
||||
``"result"`` instead of ``"output"`` fails silently, at runtime, only for the
|
||||
tool path that triggers it.
|
||||
|
||||
This module makes the vocabulary explicit. Each event is a frozen dataclass, so:
|
||||
|
||||
* the set of possible events is enumerable (see :data:`EVENT_TYPES`);
|
||||
* a field name typo is an ``AttributeError`` at the point of use, not a silently
|
||||
missing chat bubble;
|
||||
* an event can cross a thread boundary safely - it cannot be mutated after the
|
||||
producer hands it over, which is exactly what the Qt-signal seam needs.
|
||||
|
||||
Bridging with the legacy dicts is deliberate and two-way: :func:`event_from_dict`
|
||||
adapts what ``run_cowork`` emits today, and :meth:`AgentEvent.to_dict` renders an
|
||||
event back into the legacy shape so existing widgets keep working untouched
|
||||
while the presentation layer migrates screen by screen (EPIC R08).
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentEvent:
|
||||
"""Base class for everything a turn can report.
|
||||
|
||||
``type`` is the legacy string tag, kept as a class attribute so the bridge
|
||||
functions can round-trip an event without a separate mapping table.
|
||||
"""
|
||||
|
||||
type: str = field(init=False, default="event")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Render into the legacy ``emit()`` dict shape."""
|
||||
return {"type": self.type}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Assistant output
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class TextChunkEvent(AgentEvent):
|
||||
"""One fragment of the visible answer, as it streams in."""
|
||||
|
||||
delta: str
|
||||
type: str = field(init=False, default="text")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReasoningChunkEvent(AgentEvent):
|
||||
"""One fragment of the model's PRIVATE reasoning.
|
||||
|
||||
Drives the "Thinking" indicator only. Consumers must never append this to
|
||||
the answer or persist it into conversation history - keeping it a distinct
|
||||
type is what makes that mistake hard to make by accident.
|
||||
"""
|
||||
|
||||
delta: str
|
||||
type: str = field(init=False, default="reasoning")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantDoneEvent(AgentEvent):
|
||||
"""One assistant message finished. A turn with tool calls emits this once
|
||||
per step, not once per turn - see :class:`TurnCompletedEvent`."""
|
||||
|
||||
content: str = ""
|
||||
type: str = field(init=False, default="assistant_done")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "content": self.content}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Planning
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class PlanUpdatedEvent(AgentEvent):
|
||||
"""The agent rewrote its plan (the ``update_plan`` tool)."""
|
||||
|
||||
steps: Tuple[Dict[str, Any], ...] = ()
|
||||
type: str = field(init=False, default="plan_set")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "steps": [dict(s) for s in self.steps]}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tool lifecycle
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallStartedEvent(AgentEvent):
|
||||
"""A tool call is about to run, with the preview shown to the user.
|
||||
|
||||
Maps the legacy ``tool_proposed`` event. "Proposed" was a misnomer: by the
|
||||
time it is emitted the call is already going to run unless a permission gate
|
||||
rejects it, and the gate reports that as a finished call with ``ok=False``.
|
||||
"""
|
||||
|
||||
call_id: str
|
||||
name: str
|
||||
args: Dict[str, Any] = field(default_factory=dict)
|
||||
preview: Optional[Dict[str, Any]] = None
|
||||
type: str = field(init=False, default="tool_proposed")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {"type": self.type, "id": self.call_id,
|
||||
"name": self.name, "args": dict(self.args)}
|
||||
if self.preview is not None:
|
||||
out["preview"] = dict(self.preview)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolOutputEvent(AgentEvent):
|
||||
"""A line of live output from a running tool (command stdout, for example)."""
|
||||
|
||||
call_id: str
|
||||
name: str
|
||||
delta: str
|
||||
type: str = field(init=False, default="tool_output")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "id": self.call_id, "name": self.name,
|
||||
"delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallFinishedEvent(AgentEvent):
|
||||
"""A tool call ended, successfully or not.
|
||||
|
||||
``ok=False`` covers every failure mode alike - the tool raised, the sandbox
|
||||
blocked it, or the user rejected it at the permission gate - because the
|
||||
consumer's job is the same in all three: show the failure and let the model
|
||||
react to it.
|
||||
"""
|
||||
|
||||
call_id: str
|
||||
name: str
|
||||
ok: bool = False
|
||||
output: str = ""
|
||||
path: str = "" # file the tool wrote, when it wrote one
|
||||
produced: Tuple[str, ...] = () # extra artefacts (e.g. a generator's outputs)
|
||||
type: str = field(init=False, default="tool_result")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {"type": self.type, "id": self.call_id, "name": self.name,
|
||||
"ok": self.ok, "output": self.output}
|
||||
if self.path:
|
||||
out["path"] = self.path
|
||||
if self.produced:
|
||||
out["produced"] = list(self.produced)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Output folder
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class OutputsAddedEvent(AgentEvent):
|
||||
"""Files appeared in the turn's output folder."""
|
||||
|
||||
paths: Tuple[str, ...] = ()
|
||||
type: str = field(init=False, default="outputs_added")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "paths": list(self.paths)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputsRemovedEvent(AgentEvent):
|
||||
"""Files were cleaned up from the turn's output folder (intermediates)."""
|
||||
|
||||
paths: Tuple[str, ...] = ()
|
||||
type: str = field(init=False, default="outputs_removed")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "paths": list(self.paths)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoticeEvent(AgentEvent):
|
||||
"""A UI-visible aside that is not part of the model's answer.
|
||||
|
||||
Three producers today, all reachable from a normal turn:
|
||||
``core/agent_security.py`` (a request or command blocked by the security
|
||||
layer), ``core/context_budget.py`` (the conversation was auto-compressed)
|
||||
and the attachment readers (a file that could not be processed, plus live
|
||||
"reading page X/Y" progress).
|
||||
|
||||
``level`` selects how the UI renders it: ``"progress"`` updates the thinking
|
||||
indicator in place, anything else becomes a warning bubble. Dropping these
|
||||
would silently hide security warnings from the user, which is why the type
|
||||
exists rather than being folded into TextChunkEvent.
|
||||
"""
|
||||
|
||||
text: str
|
||||
level: str = "info"
|
||||
type: str = field(init=False, default="notice")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "level": self.level, "text": self.text}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HistoryReadyEvent(AgentEvent):
|
||||
"""A history session exists for this run and can be opened."""
|
||||
|
||||
session_id: str
|
||||
type: str = field(init=False, default="history_ready")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "session_id": self.session_id}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Turn lifecycle - emitted by the application service, not by the legacy engine
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class TurnCompletedEvent(AgentEvent):
|
||||
"""The whole turn finished: no more events will follow.
|
||||
|
||||
New in R04. The legacy engine has no end-of-turn signal at all, so every
|
||||
consumer infers "done" from the worker thread finishing - which is why a
|
||||
cancelled turn and a failed turn look identical to the UI today.
|
||||
"""
|
||||
|
||||
content: str = ""
|
||||
cancelled: bool = False
|
||||
type: str = field(init=False, default="turn_completed")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "content": self.content, "cancelled": self.cancelled}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorEvent(AgentEvent):
|
||||
"""The turn failed. ``recoverable`` marks errors the user can act on
|
||||
(pick another model, shorten the prompt) rather than a hard outage."""
|
||||
|
||||
message: str
|
||||
recoverable: bool = False
|
||||
type: str = field(init=False, default="error")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type, "message": self.message,
|
||||
"recoverable": self.recoverable}
|
||||
|
||||
|
||||
# The legacy tag -> event class map. Also the authoritative list of what a turn
|
||||
# can emit, which is what makes an exhaustive consumer possible for the first time.
|
||||
EVENT_TYPES: Dict[str, type] = {
|
||||
"text": TextChunkEvent,
|
||||
"reasoning": ReasoningChunkEvent,
|
||||
"assistant_done": AssistantDoneEvent,
|
||||
"plan_set": PlanUpdatedEvent,
|
||||
"tool_proposed": ToolCallStartedEvent,
|
||||
"tool_start": ToolCallStartedEvent,
|
||||
"tool_output": ToolOutputEvent,
|
||||
"tool_result": ToolCallFinishedEvent,
|
||||
"outputs_added": OutputsAddedEvent,
|
||||
"outputs_removed": OutputsRemovedEvent,
|
||||
"notice": NoticeEvent,
|
||||
"history_ready": HistoryReadyEvent,
|
||||
"turn_completed": TurnCompletedEvent,
|
||||
"error": ErrorEvent,
|
||||
}
|
||||
|
||||
|
||||
def event_from_dict(payload: Mapping[str, Any]) -> Optional[AgentEvent]:
|
||||
"""Adapt one legacy ``emit()`` dict into a typed event.
|
||||
|
||||
Returns ``None`` for an unknown tag instead of raising: the legacy engine is
|
||||
still being refactored and may grow an event before this module knows about
|
||||
it. Dropping an unrecognised event degrades the UI by one missing bubble;
|
||||
raising here would abort a turn that had otherwise succeeded.
|
||||
"""
|
||||
kind = str(payload.get("type", ""))
|
||||
cls = EVENT_TYPES.get(kind)
|
||||
if cls is None:
|
||||
return None
|
||||
|
||||
if cls is TextChunkEvent or cls is ReasoningChunkEvent:
|
||||
return cls(delta=str(payload.get("delta", "")))
|
||||
if cls is AssistantDoneEvent:
|
||||
return AssistantDoneEvent(content=str(payload.get("content", "")))
|
||||
if cls is PlanUpdatedEvent:
|
||||
return PlanUpdatedEvent(steps=tuple(payload.get("steps") or ()))
|
||||
if cls is ToolCallStartedEvent:
|
||||
return ToolCallStartedEvent(
|
||||
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
|
||||
args=dict(payload.get("args") or {}), preview=payload.get("preview"),
|
||||
)
|
||||
if cls is ToolOutputEvent:
|
||||
return ToolOutputEvent(call_id=str(payload.get("id", "")),
|
||||
name=str(payload.get("name", "")),
|
||||
delta=str(payload.get("delta", "")))
|
||||
if cls is ToolCallFinishedEvent:
|
||||
return ToolCallFinishedEvent(
|
||||
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
|
||||
ok=bool(payload.get("ok", False)), output=str(payload.get("output", "")),
|
||||
path=str(payload.get("path", "") or ""),
|
||||
produced=tuple(payload.get("produced") or ()),
|
||||
)
|
||||
if cls is OutputsAddedEvent or cls is OutputsRemovedEvent:
|
||||
return cls(paths=tuple(str(p) for p in (payload.get("paths") or ())))
|
||||
if cls is NoticeEvent:
|
||||
return NoticeEvent(text=str(payload.get("text", "")),
|
||||
level=str(payload.get("level", "info")))
|
||||
if cls is HistoryReadyEvent:
|
||||
return HistoryReadyEvent(session_id=str(payload.get("session_id", "")))
|
||||
if cls is TurnCompletedEvent:
|
||||
return TurnCompletedEvent(content=str(payload.get("content", "")),
|
||||
cancelled=bool(payload.get("cancelled", False)))
|
||||
return ErrorEvent(message=str(payload.get("message", "")),
|
||||
recoverable=bool(payload.get("recoverable", False)))
|
||||
|
||||
|
||||
def collect_text(events: Sequence[AgentEvent]) -> str:
|
||||
"""Join every :class:`TextChunkEvent` - the visible answer, reasoning excluded.
|
||||
|
||||
Provided here so no consumer has to re-derive "which events are the answer",
|
||||
the question the untyped dicts made easy to get wrong.
|
||||
"""
|
||||
return "".join(e.delta for e in events if isinstance(e, TextChunkEvent))
|
||||
|
||||
|
||||
def tool_calls(events: Sequence[AgentEvent]) -> List[ToolCallFinishedEvent]:
|
||||
"""Every finished tool call, in order - for audit views and assertions."""
|
||||
return [e for e in events if isinstance(e, ToolCallFinishedEvent)]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentEvent",
|
||||
"TextChunkEvent",
|
||||
"ReasoningChunkEvent",
|
||||
"AssistantDoneEvent",
|
||||
"PlanUpdatedEvent",
|
||||
"ToolCallStartedEvent",
|
||||
"ToolOutputEvent",
|
||||
"ToolCallFinishedEvent",
|
||||
"OutputsAddedEvent",
|
||||
"OutputsRemovedEvent",
|
||||
"NoticeEvent",
|
||||
"HistoryReadyEvent",
|
||||
"TurnCompletedEvent",
|
||||
"ErrorEvent",
|
||||
"EVENT_TYPES",
|
||||
"event_from_dict",
|
||||
"collect_text",
|
||||
"tool_calls",
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
"""ConversationExecutionRequest - an immutable snapshot of one turn (R04-T01).
|
||||
|
||||
``ui/cowork_tab.py::build_job`` currently builds a closure that reads widget
|
||||
state from inside the worker thread::
|
||||
|
||||
def job(worker):
|
||||
provider = self.build_provider() # reads combo boxes
|
||||
extra_tools, extra_exec = self.ctx.build_mcp_tools()
|
||||
proj_ctx = project_context_text(load_project(project_id))
|
||||
...
|
||||
|
||||
Everything that closure touches can change while the turn is running: the user
|
||||
can pick another model, switch workspace, or edit the project instructions. The
|
||||
turn then runs on a mixture of old and new state, and which mixture depends on
|
||||
thread timing - the class of bug that reproduces once a week and never in a test.
|
||||
|
||||
This value object is the fix: the presentation layer captures everything a turn
|
||||
needs ON THE UI THREAD, at submit time, into one frozen object. Whatever happens
|
||||
to the widgets afterwards, the turn keeps running on the state the user actually
|
||||
submitted.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no filesystem access. Paths are held as
|
||||
strings, not ``Path`` objects, so the snapshot stays trivially serialisable -
|
||||
which is what will let a turn be queued, replayed or logged later.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
# Default tool-use budget for an interactive turn, and the higher ceiling a
|
||||
# run-to-completion step (a Co4E flow step) is allowed. Same numbers
|
||||
# ``core.chat_agent.run_cowork`` defaults to - kept here so the policy is
|
||||
# visible in the request rather than buried in a function signature.
|
||||
DEFAULT_MAX_STEPS = 30
|
||||
DEFAULT_COMPLETION_MAX_STEPS = 200
|
||||
|
||||
|
||||
def new_turn_id() -> str:
|
||||
"""A fresh turn id. Short and random: it only has to be unique within a
|
||||
session's lifetime, and it shows up in log lines humans read."""
|
||||
return uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConversationExecutionRequest:
|
||||
"""Everything one agent turn needs, captured at submit time.
|
||||
|
||||
Attributes:
|
||||
prompt: the user's message for this turn (already assembled, including
|
||||
any attachment text the UI inlined).
|
||||
messages: the full conversation to send, oldest first. Held as a tuple
|
||||
so the snapshot cannot be mutated after capture; use
|
||||
:meth:`message_list` to get the mutable copy the engine expects.
|
||||
output_dir: this turn's OWN folder. Each turn writes into an isolated
|
||||
directory so parallel turns cannot clobber each other's files.
|
||||
session_id: the conversation this turn belongs to.
|
||||
turn_id: unique per turn, for logs and for matching events to a turn.
|
||||
surface: which screen submitted it ("cowork", "co4e", "ai_edit", "task").
|
||||
provider / model: what to run on, already resolved (routing included).
|
||||
Empty ``model`` means "the provider's configured default".
|
||||
title: conversation title, used to name generated files.
|
||||
project_id / project_context: the workspace and its shared instructions,
|
||||
snapshotted so a mid-turn workspace switch cannot change them.
|
||||
agent_role: audit-log attribution for every tool call this turn makes.
|
||||
allowed_tools: permission scope. ``None`` means "all enabled tools";
|
||||
a list restricts the ADVERTISED catalogue, so a read-only step
|
||||
literally cannot be offered a writing tool.
|
||||
max_steps / run_to_completion / completion_max_steps: tool-use budget.
|
||||
enforce_rules: run the security rulebase. Co4E sandboxed runs disable it.
|
||||
confirm_commands: ask before run_command/install_package (permission gate).
|
||||
metadata: free-form extras a caller wants carried along (never
|
||||
interpreted here) - e.g. a scheduled task's id.
|
||||
"""
|
||||
|
||||
prompt: str
|
||||
messages: Tuple[Mapping[str, Any], ...] = ()
|
||||
output_dir: str = ""
|
||||
session_id: str = ""
|
||||
turn_id: str = field(default_factory=new_turn_id)
|
||||
surface: str = "cowork"
|
||||
provider: str = ""
|
||||
model: str = ""
|
||||
title: str = ""
|
||||
project_id: str = ""
|
||||
project_context: str = ""
|
||||
agent_role: str = ""
|
||||
allowed_tools: Optional[Tuple[str, ...]] = None
|
||||
max_steps: int = DEFAULT_MAX_STEPS
|
||||
run_to_completion: bool = False
|
||||
completion_max_steps: int = DEFAULT_COMPLETION_MAX_STEPS
|
||||
enforce_rules: bool = True
|
||||
confirm_commands: bool = False
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
# -- construction helpers ------------------------------------------- #
|
||||
@classmethod
|
||||
def create(cls, prompt: str, messages: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
**kwargs: Any) -> "ConversationExecutionRequest":
|
||||
"""Build a request from ordinary mutable inputs.
|
||||
|
||||
The messages list is copied element by element, so a later append by the
|
||||
caller (the chat panel keeps appending to its own list) cannot reach
|
||||
inside a request that is already running.
|
||||
"""
|
||||
snapshot = tuple(dict(m) for m in (messages or ()))
|
||||
allowed = kwargs.pop("allowed_tools", None)
|
||||
return cls(prompt=prompt, messages=snapshot,
|
||||
allowed_tools=tuple(allowed) if allowed is not None else None,
|
||||
**kwargs)
|
||||
|
||||
def with_messages(self, messages: Sequence[Mapping[str, Any]]
|
||||
) -> "ConversationExecutionRequest":
|
||||
"""A copy carrying a different message list, everything else unchanged.
|
||||
|
||||
Used when a caller assembles the system prompt or trims history after
|
||||
building the request - it must produce a NEW snapshot rather than mutate
|
||||
the one a turn may already be running on.
|
||||
"""
|
||||
return replace(self, messages=tuple(dict(m) for m in messages))
|
||||
|
||||
def with_model(self, provider: str, model: str) -> "ConversationExecutionRequest":
|
||||
"""A copy pinned to another provider/model - how a routing switch is
|
||||
applied without touching the user's saved settings."""
|
||||
return replace(self, provider=provider, model=model)
|
||||
|
||||
# -- accessors ------------------------------------------------------ #
|
||||
def message_list(self) -> List[Dict[str, Any]]:
|
||||
"""A fresh mutable copy of the messages, for the engine to append to.
|
||||
|
||||
The legacy engine mutates the list it is given (it inserts the system
|
||||
prompt and appends assistant/tool messages). Handing it a copy is what
|
||||
keeps this snapshot immutable in practice and not just by declaration.
|
||||
"""
|
||||
return [dict(m) for m in self.messages]
|
||||
|
||||
@property
|
||||
def effective_max_steps(self) -> int:
|
||||
"""The tool-use ceiling actually in force for this turn."""
|
||||
return self.completion_max_steps if self.run_to_completion else self.max_steps
|
||||
|
||||
@property
|
||||
def has_output_dir(self) -> bool:
|
||||
"""True when this turn may write files."""
|
||||
return bool(self.output_dir)
|
||||
|
||||
def allows_tool(self, name: str) -> bool:
|
||||
"""Whether ``name`` is inside this turn's permission scope.
|
||||
|
||||
``update_plan`` is always allowed: it has no side effects and drives the
|
||||
Plan panel, so scoping it out would silently break the UI rather than
|
||||
restrict a capability.
|
||||
"""
|
||||
if self.allowed_tools is None:
|
||||
return True
|
||||
return name == "update_plan" or name in self.allowed_tools
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Compact one-line identity for log lines."""
|
||||
target = f"{self.provider}/{self.model}" if self.model else self.provider or "default"
|
||||
return f"turn={self.turn_id} surface={self.surface} model={target}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON-safe projection, for logging a turn or persisting it for replay."""
|
||||
return {
|
||||
"turn_id": self.turn_id,
|
||||
"session_id": self.session_id,
|
||||
"surface": self.surface,
|
||||
"prompt": self.prompt,
|
||||
"message_count": len(self.messages),
|
||||
"output_dir": self.output_dir,
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"title": self.title,
|
||||
"project_id": self.project_id,
|
||||
"agent_role": self.agent_role,
|
||||
"allowed_tools": list(self.allowed_tools) if self.allowed_tools is not None else None,
|
||||
"max_steps": self.effective_max_steps,
|
||||
"run_to_completion": self.run_to_completion,
|
||||
"enforce_rules": self.enforce_rules,
|
||||
"confirm_commands": self.confirm_commands,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConversationExecutionRequest",
|
||||
"new_turn_id",
|
||||
"DEFAULT_MAX_STEPS",
|
||||
"DEFAULT_COMPLETION_MAX_STEPS",
|
||||
]
|
||||
@@ -1 +1,5 @@
|
||||
"""Domain models package: provider descriptors, model pricing, and routing metadata."""
|
||||
"""Domain models: provider/model catalogue value objects (EPIC R03)."""
|
||||
|
||||
from .provider_descriptor import ProviderCapability, ProviderDescriptor
|
||||
|
||||
__all__ = ["ProviderDescriptor", "ProviderCapability"]
|
||||
|
||||
@@ -1,196 +1,171 @@
|
||||
"""Provider catalog metadata — the domain-layer description of ONE LLM provider.
|
||||
"""ProviderDescriptor - the declarative catalogue entry for one model provider (R03-T02).
|
||||
|
||||
Before R03 the answer to "which providers exist, what do they cost, what can
|
||||
they do?" was spread over three places: the class table in
|
||||
``providers/factory.py``, the hand-maintained pricing table in
|
||||
``core/routing/metadata.py`` and a handful of ``if provider == "anthropic"``
|
||||
branches in the UI. :class:`ProviderDescriptor` is the single declarative
|
||||
record those call sites now read from.
|
||||
Today the knowledge of "what a provider is" is scattered across three places
|
||||
that must be edited together and can silently drift apart:
|
||||
|
||||
Layer rules (see ``docs/architecture/ADR-001-layered-architecture.md``): this
|
||||
module is 100% pure Python — no PySide6, no ``requests``, no filesystem, and no
|
||||
import of the concrete ``providers/*`` adapters. It only *describes* a provider;
|
||||
constructing one is the infrastructure layer's job
|
||||
(``infrastructure/providers/provider_registry.py``).
|
||||
* ``providers/factory.py::_REGISTRY`` - name -> implementation class
|
||||
* ``config.py::DEFAULT_CONFIG["providers"]`` - default base_url / model / api_key
|
||||
* ``config.py::PROVIDER_LABELS`` - the human label shown in Settings
|
||||
|
||||
Adding a provider means remembering all three; forgetting one produces a
|
||||
provider that exists but has no label, or a label with no implementation. This
|
||||
value object folds those facts into a single immutable description that the
|
||||
registry (``infrastructure/providers/provider_registry.py``) and the UI can both
|
||||
read, so a new provider is declared once.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no network, no config access. It describes
|
||||
a provider; building one is infrastructure's job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple
|
||||
|
||||
|
||||
class AuthKind(str, Enum):
|
||||
"""How a provider authenticates, so Settings/onboarding can ask for the
|
||||
right thing instead of hard-coding per-provider form fields.
|
||||
class ProviderCapability(str, Enum):
|
||||
"""What a provider can do, as advertised by its descriptor.
|
||||
|
||||
Inherits ``str`` so a descriptor round-trips through JSON unchanged (the
|
||||
value is written as a plain string), matching how the routing models in
|
||||
``core/routing/models.py`` already serialize their enums.
|
||||
Kept as a closed enum rather than free-form strings so a typo
|
||||
(``"vison"``) fails at import time instead of silently disabling a feature
|
||||
at runtime. Inherits ``str`` so existing dict/JSON code that compares against
|
||||
plain strings keeps working during the migration.
|
||||
"""
|
||||
|
||||
NONE = "none" # local runtimes (Ollama) — nothing to supply
|
||||
API_KEY = "api_key" # bearer/x-api-key style secret
|
||||
OAUTH_TOKEN = "oauth" # token minted by an external login flow (Copilot)
|
||||
|
||||
|
||||
class WireProtocol(str, Enum):
|
||||
"""The on-the-wire dialect a provider speaks.
|
||||
|
||||
Several *distinct* providers share one protocol (Ollama, Codex, GitHub
|
||||
Copilot and generic gateways are all OpenAI Chat Completions), which is
|
||||
exactly why protocol is a separate field from the provider id: the registry
|
||||
picks the adapter class from the protocol, while everything user-facing
|
||||
keys off the id.
|
||||
"""
|
||||
|
||||
OPENAI_COMPAT = "openai_compat"
|
||||
ANTHROPIC = "anthropic"
|
||||
STREAMING = "streaming" # can stream answer fragments through on_text
|
||||
TOOLS = "tools" # can be given a ToolSpec catalogue and call tools
|
||||
VISION = "vision" # accepts image content blocks (see providers/base.py)
|
||||
REASONING = "reasoning" # emits a separate private "thinking" stream
|
||||
MODEL_LISTING = "model_listing" # list_models() returns a real catalogue
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDescriptor:
|
||||
"""Immutable metadata for one provider the app can route work to.
|
||||
"""An immutable description of one provider the app can talk to.
|
||||
|
||||
Frozen because descriptors are shared process-wide by the registry, the
|
||||
routing service and (eventually) the Settings screen; making them read-only
|
||||
removes any chance one caller mutates the catalog another caller is
|
||||
iterating. Use :meth:`with_models` to derive an updated copy instead.
|
||||
|
||||
Unknown pricing/context values stay ``None`` rather than being guessed —
|
||||
the routing scorer needs to distinguish "free" from "we don't know", the
|
||||
same contract ``core/routing/models.py::ModelMetadata`` already follows.
|
||||
Attributes:
|
||||
id: the config key, e.g. ``"openai_compat"``. Also the ``provider`` half
|
||||
of a routing candidate key (``provider/model_id``).
|
||||
label: human-readable name for Settings and the model picker.
|
||||
protocol: which wire format this provider speaks. Several ids share one
|
||||
protocol - ``ollama``, ``github_copilot`` and ``codex`` are all
|
||||
OpenAI-compatible endpoints - which is exactly why protocol and id
|
||||
must be separate fields.
|
||||
default_model: the model used when the user has not chosen one.
|
||||
capabilities: what the provider supports (see :class:`ProviderCapability`).
|
||||
requires_api_key: whether an empty ``api_key`` makes it unusable.
|
||||
requires_base_url: whether an empty ``base_url`` makes it unusable.
|
||||
local: True when the endpoint runs on the user's own machine. Routing
|
||||
treats local models as zero-cost, and the security layer treats them
|
||||
as not leaving the machine, so this is a real behavioural flag and
|
||||
not just documentation.
|
||||
notes: free-form remark shown in Settings (e.g. "paste a Copilot token").
|
||||
"""
|
||||
|
||||
provider_id: str # config key, e.g. "anthropic"
|
||||
display_name: str # human label for Settings/UI
|
||||
wire_protocol: WireProtocol # which adapter class implements it
|
||||
auth_kind: AuthKind = AuthKind.API_KEY
|
||||
default_model: str = "" # used when no model is selected
|
||||
models: Tuple[str, ...] = () # known model ids (may be empty)
|
||||
max_context: Optional[int] = None # tokens; None = unknown
|
||||
cost_per_1k_input: Optional[float] = None # USD per 1K input tokens
|
||||
cost_per_1k_output: Optional[float] = None # USD per 1K output tokens
|
||||
supports_vision: bool = False
|
||||
supports_tools: bool = True
|
||||
supports_streaming: bool = True
|
||||
requires_base_url: bool = False # gateway endpoints must be configured
|
||||
# Extra ids that should resolve to this descriptor (renames/aliases kept for
|
||||
# backwards compatibility with configs written by older app versions).
|
||||
aliases: Tuple[str, ...] = ()
|
||||
# Free-form extension point so a team can attach provider-specific hints
|
||||
# without another schema migration.
|
||||
extras: Dict[str, Any] = field(default_factory=dict)
|
||||
id: str
|
||||
label: str
|
||||
protocol: str
|
||||
default_model: str = ""
|
||||
capabilities: FrozenSet[ProviderCapability] = field(default_factory=frozenset)
|
||||
requires_api_key: bool = True
|
||||
requires_base_url: bool = True
|
||||
local: bool = False
|
||||
notes: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject descriptors that could never be looked up.
|
||||
|
||||
Raising here (rather than at registration time) means a malformed
|
||||
descriptor cannot exist at all, so every consumer downstream may assume
|
||||
``provider_id`` is a usable dict key.
|
||||
"""
|
||||
if not self.provider_id:
|
||||
raise ValueError("ProviderDescriptor.provider_id must not be empty")
|
||||
if not isinstance(self.wire_protocol, WireProtocol):
|
||||
raise TypeError("ProviderDescriptor.wire_protocol must be a WireProtocol")
|
||||
|
||||
# -- identity ------------------------------------------------------- #
|
||||
@property
|
||||
def identifiers(self) -> Tuple[str, ...]:
|
||||
"""Every id this descriptor answers to (canonical id first)."""
|
||||
return (self.provider_id, *self.aliases)
|
||||
|
||||
def matches(self, provider_id: str) -> bool:
|
||||
"""Case-insensitive id/alias match — config files and CLI flags are
|
||||
typed by humans, so lookup must not be case sensitive."""
|
||||
needle = (provider_id or "").strip().lower()
|
||||
return any(needle == known.lower() for known in self.identifiers)
|
||||
|
||||
# -- capability queries --------------------------------------------- #
|
||||
def knows_model(self, model_id: str) -> bool:
|
||||
"""Whether ``model_id`` is in this provider's declared catalog.
|
||||
|
||||
A miss is NOT proof the model is unusable: gateways expose models we
|
||||
cannot enumerate offline, so callers treat this as a hint (used to
|
||||
resolve a bare model id back to its provider) and never as a gate that
|
||||
blocks a request.
|
||||
"""
|
||||
needle = (model_id or "").strip().lower()
|
||||
return any(needle == known.strip().lower() for known in self.models)
|
||||
|
||||
def has_capability(self, capability: str) -> bool:
|
||||
"""Capability check by name, mirroring the vocabulary the routing
|
||||
selector already filters on (``"vision"``, ``"tools"``, ``"streaming"``)
|
||||
so a descriptor can be fed straight into ``rank_models``."""
|
||||
# -- capability queries ---------------------------------------------- #
|
||||
def supports(self, capability: ProviderCapability) -> bool:
|
||||
"""True when this provider advertises ``capability``."""
|
||||
return capability in self.capabilities
|
||||
|
||||
@property
|
||||
def capabilities(self) -> frozenset:
|
||||
"""Capability set in the same vocabulary as
|
||||
``core/routing/models.py::ModelMetadata.capabilities``."""
|
||||
caps = set()
|
||||
if self.supports_vision:
|
||||
caps.add("vision")
|
||||
if self.supports_tools:
|
||||
caps.add("tools")
|
||||
if self.supports_streaming:
|
||||
caps.add("streaming")
|
||||
return frozenset(caps)
|
||||
def supports_vision(self) -> bool:
|
||||
"""Mirrors ``providers.base.Provider.supports_vision`` so callers can ask
|
||||
the descriptor (no instance, no network) before building a provider."""
|
||||
return self.supports(ProviderCapability.VISION)
|
||||
|
||||
@property
|
||||
def avg_cost_per_1k(self) -> Optional[float]:
|
||||
"""Blended input/output price, or ``None`` when either side is unknown.
|
||||
def supports_tools(self) -> bool:
|
||||
"""True when this provider can run an agent turn with tools. A provider
|
||||
without it can still chat, but must never be routed a tool-using task."""
|
||||
return self.supports(ProviderCapability.TOOLS)
|
||||
|
||||
Uses the same 1:3 input:output weighting as
|
||||
``ModelMetadata.avg_cost_per_1k`` so a descriptor and an assessment
|
||||
never disagree about what a model costs.
|
||||
def capability_names(self) -> List[str]:
|
||||
"""Capabilities as sorted plain strings - the shape the routing layer's
|
||||
``required_capabilities`` filter and the assessment store both use."""
|
||||
return sorted(c.value for c in self.capabilities)
|
||||
|
||||
# -- configuration validation ---------------------------------------- #
|
||||
def missing_settings(self, conf: Mapping[str, Any]) -> List[str]:
|
||||
"""Which required config keys are absent or blank in ``conf``.
|
||||
|
||||
Returned as a list (not a bool) so Settings can tell the user exactly
|
||||
what to fill in, instead of a generic "not configured". A provider that
|
||||
needs nothing returns an empty list.
|
||||
"""
|
||||
ci, co = self.cost_per_1k_input, self.cost_per_1k_output
|
||||
if ci is None or co is None:
|
||||
return None
|
||||
return (ci + 3.0 * co) / 4.0
|
||||
missing: List[str] = []
|
||||
if self.requires_api_key and not str(conf.get("api_key", "") or "").strip():
|
||||
missing.append("api_key")
|
||||
if self.requires_base_url and not str(conf.get("base_url", "") or "").strip():
|
||||
missing.append("base_url")
|
||||
return missing
|
||||
|
||||
def resolve_model(self, requested: str = "") -> str:
|
||||
"""The model id to actually call: the caller's choice when they made
|
||||
one, otherwise this provider's default. Centralised here because every
|
||||
surface (chat, Co4E, AI-Edit) previously re-implemented the same
|
||||
``model or config_default`` fallback inline."""
|
||||
return (requested or "").strip() or self.default_model
|
||||
def is_configured(self, conf: Mapping[str, Any]) -> bool:
|
||||
"""True when ``conf`` carries everything this provider needs to run."""
|
||||
return not self.missing_settings(conf)
|
||||
|
||||
# -- derivation / serialization ------------------------------------- #
|
||||
def with_models(self, models, *, default_model: str = "") -> "ProviderDescriptor":
|
||||
"""A copy carrying a freshly discovered model list.
|
||||
def resolve_model(self, conf: Optional[Mapping[str, Any]] = None,
|
||||
requested: str = "") -> str:
|
||||
"""Pick the model id for a call: explicit request, else configured, else
|
||||
this descriptor's default.
|
||||
|
||||
Providers can enumerate their models at runtime (``list_models()``);
|
||||
because the descriptor is frozen, discovery produces a NEW descriptor
|
||||
that the registry swaps in atomically instead of mutating one that other
|
||||
threads may be reading.
|
||||
Centralised here because the same three-step fallback is currently
|
||||
re-implemented at every call site (chat panel, Co4E, AI-edit, scheduler),
|
||||
and each of them gets the precedence subtly different.
|
||||
"""
|
||||
ordered = tuple(dict.fromkeys(m for m in models if m)) # de-dup, keep order
|
||||
chosen = default_model or self.default_model
|
||||
# Keep the default pointing at something real: fall back to the first
|
||||
# discovered model when the configured default vanished from the catalog.
|
||||
if ordered and chosen not in ordered:
|
||||
chosen = ordered[0]
|
||||
return replace(self, models=ordered, default_model=chosen)
|
||||
if requested:
|
||||
return requested
|
||||
configured = str((conf or {}).get("model", "") or "").strip()
|
||||
return configured or self.default_model
|
||||
|
||||
def describe(self, conf: Optional[Mapping[str, Any]] = None) -> str:
|
||||
"""One-line summary for logs and the Settings row, e.g.
|
||||
``"anthropic:claude-sonnet-4-6 (Anthropic Claude)"``."""
|
||||
return f"{self.id}:{self.resolve_model(conf)} ({self.label})"
|
||||
|
||||
def candidate_key(self, model_id: str) -> str:
|
||||
"""The ``provider/model_id`` identity the routing layer keys on.
|
||||
|
||||
Defined here so the domain owns the format; ``core.routing.models`` has
|
||||
its own ``candidate_key()`` helper producing the identical string, and
|
||||
keeping them equal is what lets the new registry and the existing
|
||||
assessment store share one keyspace during the migration.
|
||||
"""
|
||||
return f"{self.id}/{model_id}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON-friendly view for config persistence and the Settings UI."""
|
||||
"""JSON-safe projection, for persisting a catalogue snapshot or sending
|
||||
the descriptor to a UI layer that must not import domain types."""
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"display_name": self.display_name,
|
||||
"wire_protocol": self.wire_protocol.value,
|
||||
"auth_kind": self.auth_kind.value,
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"protocol": self.protocol,
|
||||
"default_model": self.default_model,
|
||||
"models": list(self.models),
|
||||
"max_context": self.max_context,
|
||||
"cost_per_1k_input": self.cost_per_1k_input,
|
||||
"cost_per_1k_output": self.cost_per_1k_output,
|
||||
"capabilities": sorted(self.capabilities),
|
||||
"capabilities": self.capability_names(),
|
||||
"requires_api_key": self.requires_api_key,
|
||||
"requires_base_url": self.requires_base_url,
|
||||
"aliases": list(self.aliases),
|
||||
"local": self.local,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AuthKind", "WireProtocol", "ProviderDescriptor"]
|
||||
def split_candidate_key(key: str) -> Tuple[str, str]:
|
||||
"""Inverse of :meth:`ProviderDescriptor.candidate_key`.
|
||||
|
||||
Splits on the FIRST ``/`` only: some gateways expose model ids that contain
|
||||
a slash (``org/model``), and splitting on the last one would corrupt them.
|
||||
"""
|
||||
provider, _, model_id = key.partition("/")
|
||||
return provider, model_id
|
||||
|
||||
|
||||
__all__ = ["ProviderCapability", "ProviderDescriptor", "split_candidate_key"]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Domain security package: security policies, alert events, and permission types."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Domain tasks package: task definitions and deterministic schedule calculators."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Domain tools package: tool descriptors, capability scopes, and registry interfaces."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Domain workspaces package: immutable WorkspaceSession definitions."""
|
||||
@@ -583,13 +583,10 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"routing.mode_off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
|
||||
"routing.mode_auto": {"en": "Auto", "ja": "自動", "vi": "Tự động"},
|
||||
"routing.mode_manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
|
||||
# Fallback (R03-T03): resilience mode -- never switches for a better
|
||||
# score, only to rescue a selected model that cannot serve the turn.
|
||||
"routing.mode_fallback": {"en": "Fallback", "ja": "フォールバック", "vi": "Dự phòng"},
|
||||
"routing.toggle_tooltip": {
|
||||
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.\nFallback: keep the selected model, switch only if it is unavailable.",
|
||||
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。\nフォールバック: 選択モデルを維持し、利用できない場合のみ切替。",
|
||||
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.\nDự phòng: giữ model đã chọn, chỉ chuyển khi model đó không dùng được.",
|
||||
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.",
|
||||
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。",
|
||||
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.",
|
||||
},
|
||||
"routing.confirm_title": {
|
||||
"en": "Switch model?", "ja": "モデルを切り替えますか?", "vi": "Chuyển model?",
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
"""Infrastructure Layer: External system adapters, persistence, and SDK clients."""
|
||||
"""Infrastructure layer - adapters to the outside world.
|
||||
|
||||
Concrete implementations of what the inner layers only describe: HTTP calls to
|
||||
model gateways, the OS keyring, the filesystem, subprocesses, telemetry sinks.
|
||||
May import ``domain/`` (to speak its types) and third-party libraries, but never
|
||||
``presentation/``/``ui/``.
|
||||
"""
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure config package: ConfigRepository and typed settings facades."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure filesystem package: Tool handlers (file, command, fetch tools) and execution workspace."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure MCP package: McpToolSourceManager and child process lifecycle."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure persistence package."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure JSON persistence package: AtomicJsonFile and repositories."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure platform adapters package."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure Qt platform adapters: QtSchedulerClock."""
|
||||
@@ -1 +1,5 @@
|
||||
"""Infrastructure providers package: LLM provider adapters and ProviderRegistry."""
|
||||
"""Provider adapters and the central provider catalogue (EPIC R03)."""
|
||||
|
||||
from .provider_registry import ProviderRegistry, default_registry
|
||||
|
||||
__all__ = ["ProviderRegistry", "default_registry"]
|
||||
|
||||
@@ -1,287 +1,207 @@
|
||||
"""Central registry of every LLM provider the app can talk to.
|
||||
"""ProviderRegistry - the one place a provider is declared (R03-T02).
|
||||
|
||||
Replaces the bare ``{name: class}`` dict in ``providers/factory.py`` as the
|
||||
single catalogue of providers. Two responsibilities, kept deliberately narrow:
|
||||
Replaces the three-way split between ``providers/factory.py::_REGISTRY``,
|
||||
``config.py::DEFAULT_CONFIG["providers"]`` and ``config.py::PROVIDER_LABELS``
|
||||
with a single catalogue of :class:`ProviderDescriptor` objects plus the
|
||||
implementation class each one maps to.
|
||||
|
||||
1. **Lookup** — resolve a provider id (or one of its aliases, or a bare model
|
||||
id) to its :class:`~domain.models.provider_descriptor.ProviderDescriptor`.
|
||||
2. **Construction** — instantiate the concrete adapter class that speaks the
|
||||
descriptor's wire protocol.
|
||||
Adding a provider is now one entry in :data:`BUILT_IN_PROVIDERS` (declarative
|
||||
facts) and one line in :data:`_IMPLEMENTATIONS` (which class speaks that
|
||||
protocol) - see ``docs/governance/contributor-recipes.md`` (R10-T04).
|
||||
|
||||
This is infrastructure, not domain: it is allowed to import the concrete
|
||||
``providers/*`` adapters (which pull in ``requests``). The adapters are imported
|
||||
lazily inside :meth:`build` so that merely *reading the catalogue* — which the
|
||||
pure routing service does on every turn — never drags the HTTP stack into the
|
||||
process.
|
||||
Migration note (strangler fig, ADR-001 section 4): this registry does not
|
||||
re-implement any provider. It builds the SAME classes ``providers/factory.py``
|
||||
builds, so both entry points stay behaviourally identical while call sites move
|
||||
over one at a time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from ...domain.models.provider_descriptor import (
|
||||
AuthKind,
|
||||
from cowork_local.domain.models.provider_descriptor import (
|
||||
ProviderCapability,
|
||||
ProviderDescriptor,
|
||||
WireProtocol,
|
||||
)
|
||||
from cowork_local.providers.base import Provider, ProviderError
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Built-in catalogue.
|
||||
_CAP = ProviderCapability
|
||||
|
||||
# Every provider the app ships with, described once.
|
||||
#
|
||||
# Mirrors DEFAULT_CONFIG["providers"] in config.py (ids + default models) and
|
||||
# providers/factory.py (id -> wire protocol). Prices are intentionally absent:
|
||||
# core/routing/metadata.py owns cost, and a guessed price is worse than a
|
||||
# known-unknown (see that module's docstring).
|
||||
# --------------------------------------------------------------------------- #
|
||||
BUILTIN_DESCRIPTORS: tuple = (
|
||||
# The capability sets are deliberately conservative: a capability listed here is
|
||||
# one the adapter genuinely implements today. Claiming VISION for a provider
|
||||
# whose chat() cannot translate an image block would route an image turn into a
|
||||
# guaranteed failure, so an unimplemented capability must stay off the list.
|
||||
BUILT_IN_PROVIDERS: tuple = (
|
||||
ProviderDescriptor(
|
||||
provider_id="openai_compat",
|
||||
display_name="OpenAI-compatible gateway",
|
||||
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||
auth_kind=AuthKind.API_KEY,
|
||||
id="openai_compat",
|
||||
label="OpenAI-compatible (Internal Gateway)",
|
||||
protocol="openai_compat",
|
||||
default_model="gpt-4o-mini",
|
||||
supports_vision=True,
|
||||
# A generic gateway has no fixed host, so the endpoint MUST be
|
||||
# configured before the provider can be used at all.
|
||||
requires_base_url=True,
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
|
||||
_CAP.REASONING, _CAP.MODEL_LISTING}),
|
||||
notes="Any endpoint speaking the OpenAI Chat Completions protocol.",
|
||||
),
|
||||
ProviderDescriptor(
|
||||
provider_id="anthropic",
|
||||
display_name="Anthropic Claude",
|
||||
wire_protocol=WireProtocol.ANTHROPIC,
|
||||
auth_kind=AuthKind.API_KEY,
|
||||
id="anthropic",
|
||||
label="Anthropic Claude",
|
||||
protocol="anthropic",
|
||||
default_model="claude-sonnet-4-6",
|
||||
# Kept in sync with AnthropicProvider._FALLBACK_MODELS — the list the
|
||||
# provider itself falls back to when /v1/models cannot be reached.
|
||||
models=("claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"),
|
||||
max_context=200000,
|
||||
supports_vision=True,
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
|
||||
_CAP.MODEL_LISTING}),
|
||||
),
|
||||
ProviderDescriptor(
|
||||
provider_id="ollama",
|
||||
display_name="Ollama (local)",
|
||||
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||
# A local runtime needs no credential; Settings must not demand one.
|
||||
auth_kind=AuthKind.NONE,
|
||||
id="ollama",
|
||||
label="Ollama (local models)",
|
||||
protocol="openai_compat",
|
||||
default_model="llama3.1",
|
||||
supports_vision=False,
|
||||
requires_base_url=True,
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.REASONING,
|
||||
_CAP.MODEL_LISTING}),
|
||||
# Ollama ignores the key, but the OpenAI client layer requires a value,
|
||||
# so the default config ships a placeholder rather than an empty string.
|
||||
requires_api_key=False,
|
||||
local=True,
|
||||
notes="Runs on this machine - no data leaves the device, no token cost.",
|
||||
),
|
||||
ProviderDescriptor(
|
||||
provider_id="github_copilot",
|
||||
display_name="GitHub Copilot",
|
||||
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||
# The credential is a Copilot token minted by an external login flow,
|
||||
# not a self-service API key.
|
||||
auth_kind=AuthKind.OAUTH_TOKEN,
|
||||
id="github_copilot",
|
||||
label="GitHub Copilot",
|
||||
protocol="openai_compat",
|
||||
default_model="gpt-4o",
|
||||
models=("gpt-4o", "gpt-4o-mini"),
|
||||
max_context=128000,
|
||||
supports_vision=True,
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.MODEL_LISTING}),
|
||||
notes="Paste a Copilot token as the API key.",
|
||||
),
|
||||
ProviderDescriptor(
|
||||
provider_id="codex",
|
||||
display_name="OpenAI",
|
||||
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||
auth_kind=AuthKind.API_KEY,
|
||||
id="codex",
|
||||
label="OpenAI (Codex / GPT)",
|
||||
protocol="openai_compat",
|
||||
default_model="gpt-4o-mini",
|
||||
models=("gpt-4o", "gpt-4o-mini", "o1", "o3"),
|
||||
max_context=128000,
|
||||
supports_vision=True,
|
||||
# Historic config key: early builds stored this provider as "openai".
|
||||
aliases=("openai",),
|
||||
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
|
||||
_CAP.REASONING, _CAP.MODEL_LISTING}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ProviderNotFoundError(LookupError):
|
||||
"""Raised when no descriptor answers to the requested provider id.
|
||||
def _implementations() -> Dict[str, type]:
|
||||
"""Protocol -> adapter class.
|
||||
|
||||
A dedicated type (rather than bare ``KeyError``) lets callers distinguish
|
||||
"this provider is not in the catalogue" from an unrelated dict miss, and
|
||||
keeps the message actionable by listing what IS registered.
|
||||
Imported lazily inside the function because ``providers/anthropic.py`` and
|
||||
``providers/openai_compat.py`` pull in ``requests`` at import time; keeping
|
||||
that out of module import means a test that only inspects descriptors pays
|
||||
no import cost at all.
|
||||
"""
|
||||
from cowork_local.providers.anthropic import AnthropicProvider
|
||||
from cowork_local.providers.openai_compat import OpenAICompatProvider
|
||||
|
||||
return {
|
||||
"openai_compat": OpenAICompatProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
}
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""Thread-safe catalogue of :class:`ProviderDescriptor` records.
|
||||
"""Catalogue of known providers + the factory that instantiates them.
|
||||
|
||||
Thread-safety matters because model discovery runs on background worker
|
||||
threads (the routing prober, Settings' "Load models") and republishes an
|
||||
updated descriptor via :meth:`replace`, while chat turns on other threads
|
||||
are reading the catalogue concurrently.
|
||||
Intentionally holds no config and no app context: it is a pure lookup table
|
||||
plus a build step, so it can be constructed in a test with a custom
|
||||
descriptor list and no application running.
|
||||
"""
|
||||
|
||||
def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None:
|
||||
# Keyed by canonical id; alias resolution walks the values so an alias
|
||||
# can never shadow a real provider id.
|
||||
self._by_id: Dict[str, ProviderDescriptor] = {}
|
||||
self._lock = threading.RLock()
|
||||
for descriptor in descriptors or ():
|
||||
self.register(descriptor)
|
||||
# Dict preserves declaration order (Python 3.7+), which is the order
|
||||
# Settings lists providers in - so the catalogue order is data, not luck.
|
||||
self._by_id: Dict[str, ProviderDescriptor] = {
|
||||
d.id: d for d in (descriptors if descriptors is not None else BUILT_IN_PROVIDERS)
|
||||
}
|
||||
|
||||
# -- registration --------------------------------------------------- #
|
||||
def register(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
|
||||
"""Add a descriptor. Refuses to silently overwrite an existing id so a
|
||||
typo in a plugin cannot hijack a built-in provider; use :meth:`replace`
|
||||
when an update is the actual intent."""
|
||||
with self._lock:
|
||||
existing = self._by_id.get(descriptor.provider_id)
|
||||
if existing is not None and existing != descriptor:
|
||||
raise ValueError(
|
||||
f"Provider '{descriptor.provider_id}' is already registered; "
|
||||
"call replace() to update it."
|
||||
)
|
||||
self._by_id[descriptor.provider_id] = descriptor
|
||||
return descriptor
|
||||
|
||||
def replace(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
|
||||
"""Register or update a descriptor unconditionally — the path model
|
||||
discovery uses to publish a freshly enumerated model list."""
|
||||
with self._lock:
|
||||
self._by_id[descriptor.provider_id] = descriptor
|
||||
return descriptor
|
||||
|
||||
# -- lookup ---------------------------------------------------------- #
|
||||
def get(self, provider_id: str) -> ProviderDescriptor:
|
||||
"""Descriptor for ``provider_id`` (canonical id or alias).
|
||||
|
||||
Raises :class:`ProviderNotFoundError` rather than returning ``None`` so
|
||||
a misconfigured provider fails loudly at the call site instead of
|
||||
surfacing later as an ``AttributeError`` on ``None``.
|
||||
"""
|
||||
found = self.find(provider_id)
|
||||
if found is None:
|
||||
known = ", ".join(sorted(self._by_id)) or "<empty registry>"
|
||||
raise ProviderNotFoundError(
|
||||
f"Unsupported provider: {provider_id!r}. Registered: {known}"
|
||||
)
|
||||
return found
|
||||
|
||||
def find(self, provider_id: str) -> Optional[ProviderDescriptor]:
|
||||
"""Non-raising :meth:`get` — ``None`` when nothing matches."""
|
||||
needle = (provider_id or "").strip()
|
||||
if not needle:
|
||||
return None
|
||||
with self._lock:
|
||||
direct = self._by_id.get(needle)
|
||||
if direct is not None:
|
||||
return direct
|
||||
# Fall back to a case-insensitive id/alias scan; order is stable
|
||||
# because dicts preserve insertion order, so the earliest-registered
|
||||
# provider wins a tie.
|
||||
for descriptor in self._by_id.values():
|
||||
if descriptor.matches(needle):
|
||||
return descriptor
|
||||
return None
|
||||
|
||||
def find_by_model(self, model_id: str) -> Optional[ProviderDescriptor]:
|
||||
"""Resolve a bare model id back to the provider that serves it.
|
||||
|
||||
This is the "dynamic lookup by model ID" R03-T02 calls for: routing
|
||||
decisions and saved conversations sometimes carry only a model name, and
|
||||
the caller still needs to know which provider to build. Returns ``None``
|
||||
when the model belongs to a gateway whose catalogue we cannot enumerate
|
||||
offline — callers then fall back to the configured active provider.
|
||||
"""
|
||||
needle = (model_id or "").strip()
|
||||
if not needle:
|
||||
return None
|
||||
with self._lock:
|
||||
for descriptor in self._by_id.values():
|
||||
if descriptor.knows_model(needle):
|
||||
return descriptor
|
||||
return None
|
||||
# -- catalogue queries ------------------------------------------------ #
|
||||
def ids(self) -> List[str]:
|
||||
"""Known provider ids, in declaration order."""
|
||||
return list(self._by_id)
|
||||
|
||||
def all(self) -> List[ProviderDescriptor]:
|
||||
"""Every registered descriptor, in registration order (snapshot copy —
|
||||
safe to iterate while another thread registers)."""
|
||||
with self._lock:
|
||||
return list(self._by_id.values())
|
||||
"""Every descriptor, in declaration order."""
|
||||
return list(self._by_id.values())
|
||||
|
||||
def ids(self) -> List[str]:
|
||||
"""Canonical provider ids, sorted for stable UI/reporting output."""
|
||||
with self._lock:
|
||||
return sorted(self._by_id)
|
||||
def get(self, provider_id: str) -> Optional[ProviderDescriptor]:
|
||||
"""The descriptor for ``provider_id``, or None when unknown.
|
||||
|
||||
def __contains__(self, provider_id: object) -> bool:
|
||||
return isinstance(provider_id, str) and self.find(provider_id) is not None
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._by_id)
|
||||
|
||||
# -- construction ---------------------------------------------------- #
|
||||
def adapter_class(self, provider_id: str):
|
||||
"""Concrete ``Provider`` subclass implementing this provider's protocol.
|
||||
|
||||
The adapters are imported here (not at module import) so the pure
|
||||
routing/domain code can consult the catalogue without loading
|
||||
``requests`` and the whole HTTP stack.
|
||||
Returns None rather than raising because the caller is often reacting to
|
||||
a config file that may name a provider from a newer version; the UI
|
||||
should be able to skip it, not crash.
|
||||
"""
|
||||
descriptor = self.get(provider_id)
|
||||
from ...providers.anthropic import AnthropicProvider
|
||||
from ...providers.openai_compat import OpenAICompatProvider
|
||||
return self._by_id.get(provider_id)
|
||||
|
||||
protocol_to_class = {
|
||||
WireProtocol.OPENAI_COMPAT: OpenAICompatProvider,
|
||||
WireProtocol.ANTHROPIC: AnthropicProvider,
|
||||
}
|
||||
adapter = protocol_to_class.get(descriptor.wire_protocol)
|
||||
if adapter is None: # pragma: no cover — unreachable while the map is total
|
||||
raise ProviderNotFoundError(
|
||||
f"No adapter implements wire protocol {descriptor.wire_protocol!r}"
|
||||
def require(self, provider_id: str) -> ProviderDescriptor:
|
||||
"""Like :meth:`get` but raises :class:`ProviderError` when unknown.
|
||||
|
||||
Same error type ``providers/factory.py::build_provider`` already raises,
|
||||
so callers that migrate to the registry keep their existing except clause.
|
||||
"""
|
||||
descriptor = self._by_id.get(provider_id)
|
||||
if descriptor is None:
|
||||
known = ", ".join(self._by_id) or "(none)"
|
||||
raise ProviderError(f"Unsupported provider: {provider_id} (known: {known})")
|
||||
return descriptor
|
||||
|
||||
def labels(self) -> Dict[str, str]:
|
||||
"""``{id: label}`` - the drop-in replacement for ``config.PROVIDER_LABELS``."""
|
||||
return {d.id: d.label for d in self._by_id.values()}
|
||||
|
||||
def supporting(self, capability: ProviderCapability) -> List[ProviderDescriptor]:
|
||||
"""Every descriptor advertising ``capability`` - used to answer "which
|
||||
providers could serve this turn?" before any of them is built."""
|
||||
return [d for d in self._by_id.values() if d.supports(capability)]
|
||||
|
||||
def configured(self, providers_conf: Mapping[str, Mapping[str, Any]]
|
||||
) -> List[ProviderDescriptor]:
|
||||
"""Descriptors whose config section is complete enough to actually call.
|
||||
|
||||
``providers_conf`` is ``AppConfig.data["providers"]``. Passing the raw
|
||||
mapping (not the AppConfig object) keeps this layer independent of the
|
||||
config implementation, which EPIC R02 is rewriting in parallel.
|
||||
"""
|
||||
return [d for d in self._by_id.values()
|
||||
if d.is_configured(providers_conf.get(d.id, {}) or {})]
|
||||
|
||||
# -- construction ----------------------------------------------------- #
|
||||
def build(self, provider_id: str, conf: Mapping[str, Any],
|
||||
model: str = "") -> Provider:
|
||||
"""Instantiate the adapter for ``provider_id``.
|
||||
|
||||
``model`` overrides the configured model for this instance only - that is
|
||||
how the routing layer runs one turn on a different model without mutating
|
||||
the user's saved settings.
|
||||
"""
|
||||
descriptor = self.require(provider_id)
|
||||
impl = _implementations().get(descriptor.protocol)
|
||||
if impl is None: # pragma: no cover - only reachable via a bad descriptor
|
||||
raise ProviderError(
|
||||
f"Provider '{provider_id}' declares unknown protocol "
|
||||
f"'{descriptor.protocol}'."
|
||||
)
|
||||
return adapter
|
||||
# Copy before mutating: conf is the caller's live config dict, and
|
||||
# writing the routed model into it would silently change the user's
|
||||
# saved default for every later turn.
|
||||
resolved = dict(conf or {})
|
||||
resolved["model"] = descriptor.resolve_model(conf, model)
|
||||
instance = impl(resolved)
|
||||
# The adapter class is shared by several ids (three of them are
|
||||
# OpenAI-compatible), so its class-level `name` cannot identify which
|
||||
# provider this is. Stamping the instance keeps usage records, audit
|
||||
# entries and routing candidate keys attributed to the right provider.
|
||||
instance.name = descriptor.id
|
||||
return instance
|
||||
|
||||
def build(self, provider_id: str, conf: Dict[str, Any]):
|
||||
"""Instantiate a ready-to-use provider adapter.
|
||||
|
||||
The descriptor's ``default_model`` fills in a missing/blank ``model`` so
|
||||
a half-written config still produces a working provider instead of an
|
||||
empty model id that only fails once the request hits the gateway.
|
||||
"""
|
||||
descriptor = self.get(provider_id)
|
||||
adapter = self.adapter_class(descriptor.provider_id)
|
||||
merged = dict(conf or {})
|
||||
merged["model"] = descriptor.resolve_model(merged.get("model", ""))
|
||||
return adapter(merged)
|
||||
def describe(self, provider_id: str, conf: Optional[Mapping[str, Any]] = None) -> str:
|
||||
"""One-line description used in logs and error messages."""
|
||||
return self.require(provider_id).describe(conf)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Process-wide default registry.
|
||||
#
|
||||
# Built lazily under a lock: several UI screens can ask for it during startup
|
||||
# from different threads, and double-construction would hand out two catalogues
|
||||
# whose discovered model lists then drift apart.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_default_registry: Optional[ProviderRegistry] = None
|
||||
_default_lock = threading.Lock()
|
||||
# Shared default instance. Callers that need the built-in catalogue use this
|
||||
# instead of constructing a registry each time; tests build their own with an
|
||||
# explicit descriptor list.
|
||||
default_registry = ProviderRegistry()
|
||||
|
||||
|
||||
def default_registry() -> ProviderRegistry:
|
||||
"""The shared registry seeded with :data:`BUILTIN_DESCRIPTORS`."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
with _default_lock:
|
||||
if _default_registry is None:
|
||||
_default_registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
|
||||
return _default_registry
|
||||
|
||||
|
||||
def reset_default_registry() -> None:
|
||||
"""Drop the cached registry — test-support hook so one test's registrations
|
||||
cannot leak into the next."""
|
||||
global _default_registry
|
||||
with _default_lock:
|
||||
_default_registry = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BUILTIN_DESCRIPTORS",
|
||||
"ProviderNotFoundError",
|
||||
"ProviderRegistry",
|
||||
"default_registry",
|
||||
"reset_default_registry",
|
||||
]
|
||||
__all__ = ["ProviderRegistry", "BUILT_IN_PROVIDERS", "default_registry"]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure sandbox package: OS-specific sandbox capability adapters."""
|
||||
@@ -1 +1,21 @@
|
||||
"""Infrastructure telemetry package: CanonicalAuditLogger and token usage sinks."""
|
||||
"""Telemetry sinks: where token usage and turn metrics are recorded (EPIC R03)."""
|
||||
|
||||
from .usage_sink import (
|
||||
NullUsageSink,
|
||||
RecordingUsageSink,
|
||||
UsageEvent,
|
||||
UsageEventSink,
|
||||
UsageTrackerSink,
|
||||
default_sink,
|
||||
set_default_sink,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"UsageEvent",
|
||||
"UsageEventSink",
|
||||
"UsageTrackerSink",
|
||||
"NullUsageSink",
|
||||
"RecordingUsageSink",
|
||||
"default_sink",
|
||||
"set_default_sink",
|
||||
]
|
||||
|
||||
@@ -1,41 +1,51 @@
|
||||
"""Token-usage telemetry as a publish/subscribe seam (R03-T06).
|
||||
"""UsageEventSink - where a turn's token usage goes (R03-T06).
|
||||
|
||||
Before this module every provider adapter reached straight into
|
||||
``core/usage_tracker.py`` and wrote a dashboard row itself, which meant the
|
||||
provider layer owned a telemetry policy decision ("where do usage numbers go?")
|
||||
and no test could observe a turn's token accounting without touching the real
|
||||
``~/.cowork_local/usage/`` files.
|
||||
Today each provider records its own usage inline, in the middle of the streaming
|
||||
loop::
|
||||
|
||||
Now a provider only *describes what happened* — it publishes an immutable
|
||||
:class:`UsageEvent` — and subscribers decide what to do with it. The default
|
||||
subscriber, :class:`UsageTrackerSink`, forwards to the existing usage tracker so
|
||||
the Dashboard keeps working byte-for-byte; tests swap in
|
||||
:class:`InMemoryUsageSink` and assert on the events directly.
|
||||
# providers/openai_compat.py
|
||||
def _record_usage(self, messages, text_parts, tool_acc, usage_seen):
|
||||
from ..core import usage_tracker as ut
|
||||
...
|
||||
ut.record(self.name, self.model, ...)
|
||||
|
||||
Every publish path is failure-tolerant on purpose: telemetry must never be the
|
||||
reason a chat turn dies, which is the same contract
|
||||
``usage_tracker.record()`` already documents.
|
||||
Three problems with that shape:
|
||||
|
||||
1. **Hidden side effect.** ``chat()`` looks like a pure request/response call but
|
||||
also writes to the Dashboard's store, so a test of a provider silently
|
||||
appends rows to the developer's real usage history.
|
||||
2. **Duplicated estimation.** The "no usage block from the server, so estimate
|
||||
at ~4 chars/token" fallback is copy-pasted per provider and can drift.
|
||||
3. **One hard-wired destination.** Usage can only ever go to
|
||||
``core.usage_tracker``; a run that wants to bill a workflow, or a test that
|
||||
wants to assert on token counts, has nowhere to plug in.
|
||||
|
||||
This module introduces the seam: providers build a :class:`UsageEvent` and hand
|
||||
it to a :class:`UsageEventSink`. Production wires :class:`UsageTrackerSink`
|
||||
(same destination, same numbers as before); tests wire
|
||||
:class:`RecordingUsageSink` or :class:`NullUsageSink`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
||||
|
||||
logger = logging.getLogger("cowork_local.telemetry.usage")
|
||||
logger = logging.getLogger("cowork_local.telemetry")
|
||||
|
||||
# Rough characters-per-token ratio used when the gateway sends no usage block.
|
||||
# Matches the constant behaviour of ``core.usage_tracker.estimate_tokens`` so
|
||||
# moving the estimation here does not change a single recorded number.
|
||||
_CHARS_PER_TOKEN = 4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageEvent:
|
||||
"""One provider turn's token accounting.
|
||||
"""Token usage for exactly one provider round trip.
|
||||
|
||||
Frozen so a subscriber cannot mutate an event the next subscriber in the
|
||||
chain is about to receive. ``source``/``label`` stay optional: the usage
|
||||
tracker already derives them from thread-local context set by whoever ran
|
||||
the turn, and a provider adapter has no business knowing which UI surface
|
||||
invoked it.
|
||||
``estimated`` marks a record derived from text length rather than reported by
|
||||
the server. The Dashboard shows the two differently, and conflating them
|
||||
would make cost figures look more precise than they are.
|
||||
"""
|
||||
|
||||
provider: str
|
||||
@@ -43,246 +53,177 @@ class UsageEvent:
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cached_tokens: int = 0
|
||||
# True when the counts are a ~4-chars-per-token approximation because the
|
||||
# gateway never sent a usage block. Surfaced in the Dashboard so users know
|
||||
# which rows are measured and which are guessed.
|
||||
estimated: bool = False
|
||||
source: Optional[str] = None # None -> tracker's thread-local context
|
||||
label: Optional[str] = None # None -> tracker's thread-local context
|
||||
extras: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
"""Billable token count for this turn (cached tokens are already part
|
||||
of the input count reported by every gateway we support, so adding them
|
||||
again would double-count)."""
|
||||
return int(self.input_tokens) + int(self.output_tokens)
|
||||
"""Input + output. Cached tokens are a subset of input, not an addition,
|
||||
so adding them here would double-count a cache hit."""
|
||||
return self.input_tokens + self.output_tokens
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON-friendly view, using the same short keys as the usage tracker's
|
||||
on-disk rows so a caller can diff an event against a stored row."""
|
||||
"""JSON-safe projection for logs and for sinks that persist raw events."""
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"in": int(self.input_tokens),
|
||||
"out": int(self.output_tokens),
|
||||
"cache": int(self.cached_tokens),
|
||||
"estimated": bool(self.estimated),
|
||||
"source": self.source or "",
|
||||
"label": self.label or "",
|
||||
"input_tokens": self.input_tokens,
|
||||
"output_tokens": self.output_tokens,
|
||||
"cached_tokens": self.cached_tokens,
|
||||
"estimated": self.estimated,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UsageEventSink(Protocol):
|
||||
"""Anything that can receive :class:`UsageEvent`s.
|
||||
"""Anything that can absorb a :class:`UsageEvent`.
|
||||
|
||||
A ``Protocol`` rather than a base class so a plain object (or a test double,
|
||||
or a Qt-side adapter that re-emits a signal) qualifies without inheriting
|
||||
from infrastructure code.
|
||||
Implementations MUST NOT raise: telemetry is observability, and a failure to
|
||||
record usage must never abort the turn that produced it.
|
||||
"""
|
||||
|
||||
def emit(self, event: UsageEvent) -> None:
|
||||
"""Handle one usage event. Implementations MUST NOT raise."""
|
||||
def record(self, event: UsageEvent) -> None:
|
||||
"""Absorb one usage event."""
|
||||
|
||||
|
||||
class UsageTrackerSink:
|
||||
"""Default subscriber: writes each event through ``core/usage_tracker.py``.
|
||||
class NullUsageSink:
|
||||
"""Discards everything. The default for tests and headless tooling, so a
|
||||
unit test never writes into the developer's real usage history."""
|
||||
|
||||
Keeps the existing Dashboard/telemetry pipeline (daily JSONL files, shared
|
||||
cross-machine mirror, per-thread accumulator) as the single writer, so
|
||||
routing this through an event seam changed the plumbing without changing
|
||||
a single stored byte.
|
||||
"""
|
||||
|
||||
def __init__(self, recorder=None) -> None:
|
||||
# The recorder is injectable so a test can verify the forwarding
|
||||
# contract without importing the real tracker (and its config paths).
|
||||
self._recorder = recorder
|
||||
|
||||
def _resolve_recorder(self):
|
||||
"""Late-bind ``usage_tracker.record``.
|
||||
|
||||
Imported on first use rather than at module import so telemetry stays
|
||||
out of the import graph of anything that merely *declares* a sink.
|
||||
"""
|
||||
if self._recorder is None:
|
||||
from ...core import usage_tracker as tracker
|
||||
|
||||
self._recorder = tracker.record
|
||||
return self._recorder
|
||||
|
||||
def emit(self, event: UsageEvent) -> None:
|
||||
"""Forward one event; swallow every failure (telemetry is never fatal)."""
|
||||
try:
|
||||
record = self._resolve_recorder()
|
||||
if event.source is None:
|
||||
# Normal path: the worker thread already tagged its own
|
||||
# source/label via set_context(), so record() attributes the row.
|
||||
record(
|
||||
event.provider, event.model,
|
||||
int(event.input_tokens), int(event.output_tokens),
|
||||
int(event.cached_tokens), estimated=bool(event.estimated),
|
||||
)
|
||||
return
|
||||
|
||||
# Event carries its own attribution: apply it for this single write
|
||||
# and restore the thread's previous context afterwards, so a
|
||||
# re-attributed event cannot silently relabel every later turn that
|
||||
# runs on the same worker thread.
|
||||
from ...core import usage_tracker as tracker
|
||||
|
||||
previous_source, previous_label = tracker.current_context()
|
||||
tracker.set_context(event.source, event.label or "")
|
||||
try:
|
||||
record(
|
||||
event.provider, event.model,
|
||||
int(event.input_tokens), int(event.output_tokens),
|
||||
int(event.cached_tokens), estimated=bool(event.estimated),
|
||||
)
|
||||
finally:
|
||||
tracker.set_context(previous_source, previous_label)
|
||||
except Exception: # noqa: BLE001 — usage tracking must never break a turn
|
||||
logger.debug("usage sink: forwarding to usage_tracker failed", exc_info=True)
|
||||
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
|
||||
return None
|
||||
|
||||
|
||||
class InMemoryUsageSink:
|
||||
"""Collects events in a list — the test double for usage assertions."""
|
||||
class RecordingUsageSink:
|
||||
"""Keeps events in memory so a test can assert on what was recorded."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: List[UsageEvent] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def emit(self, event: UsageEvent) -> None:
|
||||
"""Append under a lock: parallel Co4E flows publish from several worker
|
||||
threads at once and ``list.append`` alone would still be atomic, but the
|
||||
lock also makes :meth:`snapshot` a consistent read."""
|
||||
with self._lock:
|
||||
self.events.append(event)
|
||||
|
||||
def snapshot(self) -> List[UsageEvent]:
|
||||
"""A copy of everything received so far."""
|
||||
with self._lock:
|
||||
return list(self.events)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self.events.clear()
|
||||
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
|
||||
self.events.append(event)
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
return sum(e.total_tokens for e in self.snapshot())
|
||||
"""Sum across every recorded event."""
|
||||
return sum(e.total_tokens for e in self.events)
|
||||
|
||||
|
||||
class CompositeUsageSink:
|
||||
"""Fans one event out to several subscribers.
|
||||
class UsageTrackerSink:
|
||||
"""Forwards to ``core.usage_tracker`` - the Dashboard's store.
|
||||
|
||||
This is what makes the seam useful beyond the Dashboard: a future consumer
|
||||
(per-workspace budget guard, live cost meter) subscribes alongside the
|
||||
tracker instead of patching provider code again. One failing subscriber is
|
||||
logged and skipped so it cannot starve the others.
|
||||
This is the production sink and the only place that still knows about the
|
||||
legacy tracker module, which is what lets EPIC R10 replace the storage
|
||||
without touching a single provider.
|
||||
"""
|
||||
|
||||
def __init__(self, sinks=None) -> None:
|
||||
self._sinks: List[UsageEventSink] = list(sinks or ())
|
||||
self._lock = threading.RLock()
|
||||
def __init__(self, tracker: Optional[Any] = None) -> None:
|
||||
# Injectable for tests; imported lazily otherwise because the tracker
|
||||
# touches the config directory at import time.
|
||||
self._tracker = tracker
|
||||
|
||||
def add(self, sink: UsageEventSink) -> None:
|
||||
with self._lock:
|
||||
self._sinks.append(sink)
|
||||
def _resolve(self) -> Any:
|
||||
if self._tracker is None:
|
||||
from cowork_local.core import usage_tracker
|
||||
|
||||
def remove(self, sink: UsageEventSink) -> None:
|
||||
"""Detach a subscriber; a sink that was never added is ignored so
|
||||
teardown code can call this unconditionally."""
|
||||
with self._lock:
|
||||
if sink in self._sinks:
|
||||
self._sinks.remove(sink)
|
||||
self._tracker = usage_tracker
|
||||
return self._tracker
|
||||
|
||||
def sinks(self) -> List[UsageEventSink]:
|
||||
with self._lock:
|
||||
return list(self._sinks)
|
||||
def record(self, event: UsageEvent) -> None:
|
||||
"""Write the event to the usage tracker, swallowing any failure.
|
||||
|
||||
def emit(self, event: UsageEvent) -> None:
|
||||
for sink in self.sinks():
|
||||
try:
|
||||
sink.emit(event)
|
||||
except Exception: # noqa: BLE001 — one bad subscriber must not stop the rest
|
||||
logger.debug("usage sink: subscriber %r failed", sink, exc_info=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Process-wide sink.
|
||||
#
|
||||
# Providers publish through the module-level helpers below rather than holding a
|
||||
# sink reference, because a provider instance is created fresh for every turn
|
||||
# (see AppContext.build_provider_for) and would otherwise have to be handed the
|
||||
# telemetry wiring on every construction.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_sink_lock = threading.RLock()
|
||||
_sink: Optional[CompositeUsageSink] = None
|
||||
|
||||
|
||||
def get_usage_sink() -> CompositeUsageSink:
|
||||
"""The shared sink, seeded with :class:`UsageTrackerSink` on first use."""
|
||||
global _sink
|
||||
if _sink is None:
|
||||
with _sink_lock:
|
||||
if _sink is None:
|
||||
_sink = CompositeUsageSink([UsageTrackerSink()])
|
||||
return _sink
|
||||
|
||||
|
||||
def set_usage_sink(sink: Optional[CompositeUsageSink]) -> None:
|
||||
"""Replace the shared sink (``None`` restores the default on next use).
|
||||
|
||||
Used by tests and by the app shell when it wants a different fan-out; kept
|
||||
explicit so nothing silently reconfigures telemetry mid-run.
|
||||
"""
|
||||
global _sink
|
||||
with _sink_lock:
|
||||
_sink = sink
|
||||
|
||||
|
||||
def subscribe(sink: UsageEventSink) -> UsageEventSink:
|
||||
"""Attach an extra subscriber to the shared sink and return it (so callers
|
||||
can keep the handle for a later :func:`unsubscribe`)."""
|
||||
get_usage_sink().add(sink)
|
||||
return sink
|
||||
|
||||
|
||||
def unsubscribe(sink: UsageEventSink) -> None:
|
||||
"""Detach a subscriber previously passed to :func:`subscribe`."""
|
||||
get_usage_sink().remove(sink)
|
||||
|
||||
|
||||
def publish(event: UsageEvent) -> None:
|
||||
"""Publish one usage event to every subscriber.
|
||||
|
||||
Never raises: called from inside a provider's streaming loop, where an
|
||||
exception would abort an otherwise successful turn.
|
||||
"""
|
||||
try:
|
||||
get_usage_sink().emit(event)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("usage sink: publish failed", exc_info=True)
|
||||
The bare except mirrors the behaviour this replaces (each provider
|
||||
already wrapped its ``ut.record`` call in ``try/except: pass``) but logs
|
||||
at debug level instead of discarding the reason entirely, so a broken
|
||||
Dashboard store can at least be diagnosed.
|
||||
"""
|
||||
try:
|
||||
self._resolve().record(
|
||||
event.provider, event.model,
|
||||
event.input_tokens, event.output_tokens, event.cached_tokens,
|
||||
estimated=event.estimated,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - telemetry must never break a turn
|
||||
logger.debug("usage sink: failed to record %s", event.to_dict(), exc_info=True)
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""~4 chars per token approximation, re-exported so provider adapters need
|
||||
exactly ONE telemetry import instead of also importing the tracker."""
|
||||
return max(0, len(text or "") // 4)
|
||||
"""Approximate token count for ``text`` (~4 characters per token).
|
||||
|
||||
Deliberately identical to ``core.usage_tracker.estimate_tokens`` so that
|
||||
moving estimation into this layer changes no recorded number. Duplicated
|
||||
rather than imported to keep this module free of the legacy dependency;
|
||||
:class:`UsageTrackerSink` is the only bridge back to it.
|
||||
"""
|
||||
return max(0, len(text or "") // _CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def estimated_event(provider: str, model: str, sent: str, received: str) -> UsageEvent:
|
||||
"""Build an estimated :class:`UsageEvent` from the raw text of a round trip.
|
||||
|
||||
Used when the gateway sends no usage block - most self-hosted OpenAI-compatible
|
||||
servers and Ollama do not.
|
||||
"""
|
||||
return UsageEvent(
|
||||
provider=provider, model=model,
|
||||
input_tokens=estimate_tokens(sent),
|
||||
output_tokens=estimate_tokens(received),
|
||||
cached_tokens=0,
|
||||
estimated=True,
|
||||
)
|
||||
|
||||
|
||||
def openai_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
|
||||
"""Build a reported :class:`UsageEvent` from an OpenAI-style usage block."""
|
||||
details = usage.get("prompt_tokens_details") or {}
|
||||
return UsageEvent(
|
||||
provider=provider, model=model,
|
||||
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||
cached_tokens=int(details.get("cached_tokens", 0) or 0),
|
||||
estimated=False,
|
||||
)
|
||||
|
||||
|
||||
def anthropic_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
|
||||
"""Build a reported :class:`UsageEvent` from Anthropic's usage accumulator.
|
||||
|
||||
Anthropic reports input tokens on ``message_start`` and output tokens on
|
||||
``message_delta``, so ``providers/anthropic.py`` accumulates them into a dict
|
||||
keyed ``in``/``out``/``cache`` - this reads that shape.
|
||||
"""
|
||||
return UsageEvent(
|
||||
provider=provider, model=model,
|
||||
input_tokens=int(usage.get("in", 0) or 0),
|
||||
output_tokens=int(usage.get("out", 0) or 0),
|
||||
cached_tokens=int(usage.get("cache", 0) or 0),
|
||||
estimated=False,
|
||||
)
|
||||
|
||||
|
||||
# The sink providers use unless one is injected. A module-level default keeps
|
||||
# the change to the provider classes to a single attribute, and lets a test swap
|
||||
# the destination process-wide with one monkeypatch.
|
||||
default_sink: UsageEventSink = UsageTrackerSink()
|
||||
|
||||
|
||||
def set_default_sink(sink: UsageEventSink) -> UsageEventSink:
|
||||
"""Replace the process-wide default sink; returns the previous one so a
|
||||
caller (or fixture) can restore it."""
|
||||
global default_sink
|
||||
previous = default_sink
|
||||
default_sink = sink
|
||||
return previous
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UsageEvent",
|
||||
"UsageEventSink",
|
||||
"UsageTrackerSink",
|
||||
"InMemoryUsageSink",
|
||||
"CompositeUsageSink",
|
||||
"get_usage_sink",
|
||||
"set_usage_sink",
|
||||
"subscribe",
|
||||
"unsubscribe",
|
||||
"publish",
|
||||
"NullUsageSink",
|
||||
"RecordingUsageSink",
|
||||
"estimate_tokens",
|
||||
"estimated_event",
|
||||
"openai_usage_event",
|
||||
"anthropic_usage_event",
|
||||
"default_sink",
|
||||
"set_default_sink",
|
||||
]
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Provider-neutral Project Context MCP server template."""
|
||||
|
||||
from .server import build_server, dispatch
|
||||
|
||||
__all__ = ["build_server", "dispatch"]
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Shared, stable boundary used by all Project Context tool work packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import AnyUrl, BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ContractModel(BaseModel):
|
||||
"""Strict immutable model so provider-specific fields cannot leak to the Agent."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class IdentityContext(ContractModel):
|
||||
actor_id: str = Field(min_length=1, max_length=256)
|
||||
org_unit: str = Field(min_length=1, max_length=128)
|
||||
customer: str = Field(min_length=1, max_length=128)
|
||||
project: str = Field(min_length=1, max_length=128)
|
||||
granted_scopes: frozenset[str]
|
||||
|
||||
|
||||
class SourceCitation(ContractModel):
|
||||
system: str = Field(min_length=1, max_length=64)
|
||||
url: AnyUrl
|
||||
revision: str = Field(min_length=1, max_length=256)
|
||||
retrieved_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DispatchResult:
|
||||
ok: bool
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class PolicyDecisionPoint(Protocol):
|
||||
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool: ...
|
||||
|
||||
|
||||
class CredentialResolver(Protocol):
|
||||
def resolve(self, identity: IdentityContext, tool_name: str) -> Any: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectContextRuntime:
|
||||
identity: IdentityContext
|
||||
policy: PolicyDecisionPoint
|
||||
credential_resolver: CredentialResolver
|
||||
|
||||
|
||||
class ProviderError(RuntimeError):
|
||||
"""A provider failure with a caller-safe message and retry classification."""
|
||||
|
||||
def __init__(self, code: str, message: str, *, retryable: bool) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.safe_message = message
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
ToolHandler = Callable[[ContractModel, Any], dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolTemplate:
|
||||
name: str
|
||||
description: str
|
||||
input_model: type[ContractModel]
|
||||
output_model: type[ContractModel]
|
||||
handler: ToolHandler
|
||||
|
||||
def declaration(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"inputSchema": self.input_model.model_json_schema(),
|
||||
"outputSchema": self.output_model.model_json_schema(),
|
||||
}
|
||||
|
||||
|
||||
def error_result(
|
||||
code: str,
|
||||
*,
|
||||
category: str,
|
||||
retryable: bool,
|
||||
message: str,
|
||||
suggested_action: str,
|
||||
correlation_id: str,
|
||||
) -> DispatchResult:
|
||||
return DispatchResult(
|
||||
ok=False,
|
||||
payload={
|
||||
"error": {
|
||||
"code": code,
|
||||
"category": category,
|
||||
"retryable": retryable,
|
||||
"message": message,
|
||||
"suggested_action": suggested_action,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
"""One provider module per member-owned tool work package."""
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Provider boundary owned with get_project_change_context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..foundation import IdentityContext, ProviderError
|
||||
|
||||
|
||||
class ChangeProvider(Protocol):
|
||||
def get_change_context(self, **arguments: Any) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class UnconfiguredChangeProvider:
|
||||
def get_change_context(self, **arguments: Any) -> dict[str, Any]:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"The change provider is not configured for this environment.",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
|
||||
def build_provider(identity: IdentityContext) -> ChangeProvider:
|
||||
"""Replace only this factory when wiring the approved read-only Git adapter."""
|
||||
return UnconfiguredChangeProvider()
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Provider boundary owned with get_project_issue_context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..foundation import IdentityContext, ProviderError
|
||||
|
||||
|
||||
class IssueProvider(Protocol):
|
||||
def get_issue_context(self, **arguments: Any) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class UnconfiguredIssueProvider:
|
||||
def get_issue_context(self, **arguments: Any) -> dict[str, Any]:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"The issue provider is not configured for this environment.",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
|
||||
def build_provider(identity: IdentityContext) -> IssueProvider:
|
||||
"""Replace only this factory when wiring the approved read-only issue adapter."""
|
||||
return UnconfiguredIssueProvider()
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Provider boundary owned with search_project_knowledge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..foundation import IdentityContext, ProviderError
|
||||
|
||||
|
||||
class KnowledgeProvider(Protocol):
|
||||
def search_knowledge(self, **arguments: Any) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class UnconfiguredKnowledgeProvider:
|
||||
def search_knowledge(self, **arguments: Any) -> dict[str, Any]:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"The knowledge provider is not configured for this environment.",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
|
||||
def build_provider(identity: IdentityContext) -> KnowledgeProvider:
|
||||
"""Replace only this factory when wiring approved project retrieval."""
|
||||
return UnconfiguredKnowledgeProvider()
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Immutable registry composed before member work starts to prevent merge conflicts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from .foundation import ToolTemplate
|
||||
from .tools.change_context import TOOL as CHANGE_CONTEXT_TOOL
|
||||
from .tools.issue_context import TOOL as ISSUE_CONTEXT_TOOL
|
||||
from .tools.knowledge_search import TOOL as KNOWLEDGE_SEARCH_TOOL
|
||||
|
||||
TOOLS: tuple[ToolTemplate, ...] = (
|
||||
ISSUE_CONTEXT_TOOL,
|
||||
KNOWLEDGE_SEARCH_TOOL,
|
||||
CHANGE_CONTEXT_TOOL,
|
||||
)
|
||||
TOOLS_BY_NAME = MappingProxyType({tool.name: tool for tool in TOOLS})
|
||||
TOOL_NAMES = tuple(tool.name for tool in TOOLS)
|
||||
|
||||
|
||||
def tool_declarations() -> list[dict[str, Any]]:
|
||||
return [tool.declaration() for tool in TOOLS]
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Fail-closed identity, policy, and provider resolution for the template server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .foundation import IdentityContext, ProjectContextRuntime, ProviderError
|
||||
from .providers.change import build_provider as build_change_provider
|
||||
from .providers.issue import build_provider as build_issue_provider
|
||||
from .providers.knowledge import build_provider as build_knowledge_provider
|
||||
|
||||
MINIMUM_PYTHON = (3, 11)
|
||||
|
||||
|
||||
def require_supported_python(version_info: tuple[int, ...] | None = None) -> None:
|
||||
"""Fail with an actionable message before the MCP server starts."""
|
||||
current = version_info or tuple(sys.version_info[:3])
|
||||
if current[:2] < MINIMUM_PYTHON:
|
||||
raise RuntimeError(
|
||||
"Project Context MCP requires Python 3.11 or newer; "
|
||||
f"current runtime is {current[0]}.{current[1]}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectScopePolicy:
|
||||
"""Pilot policy: read scope and exact identity-bound project are both mandatory."""
|
||||
|
||||
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool:
|
||||
return "read" in identity.granted_scopes and project_id == identity.project
|
||||
|
||||
|
||||
PROVIDER_FACTORIES: dict[str, Callable[[IdentityContext], Any]] = {
|
||||
"get_project_issue_context": build_issue_provider,
|
||||
"search_project_knowledge": build_knowledge_provider,
|
||||
"get_project_change_context": build_change_provider,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectProviderResolver:
|
||||
def resolve(self, identity: IdentityContext, tool_name: str) -> Any:
|
||||
factory = PROVIDER_FACTORIES.get(tool_name)
|
||||
if factory is None:
|
||||
raise ProviderError("NOT_FOUND", "The requested tool is not registered.", retryable=False)
|
||||
return factory(identity)
|
||||
|
||||
|
||||
def _required_environment(name: str) -> str:
|
||||
value = os.environ.get(name, "").strip()
|
||||
if not value:
|
||||
raise RuntimeError(f"Project Context MCP cannot start: required setting {name} is missing")
|
||||
return value
|
||||
|
||||
|
||||
def default_runtime() -> ProjectContextRuntime:
|
||||
"""Build immutable runtime state; missing identity configuration fails at boot."""
|
||||
require_supported_python()
|
||||
identity = IdentityContext(
|
||||
actor_id=_required_environment("COWORK_MCP_ACTOR_ID"),
|
||||
org_unit=_required_environment("COWORK_MCP_ORG_UNIT"),
|
||||
customer=_required_environment("COWORK_MCP_CUSTOMER"),
|
||||
project=_required_environment("COWORK_MCP_PROJECT"),
|
||||
granted_scopes=frozenset({"read"}),
|
||||
)
|
||||
return ProjectContextRuntime(
|
||||
identity=identity,
|
||||
policy=ProjectScopePolicy(),
|
||||
credential_resolver=ProjectProviderResolver(),
|
||||
)
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Low-level MCP stdio adapter around the transport-agnostic Project Context core."""
|
||||
|
||||
# ruff: noqa: UP045 -- Optional keeps the template importable with Pydantic on Python 3.9.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .foundation import (
|
||||
DispatchResult,
|
||||
ProjectContextRuntime,
|
||||
ProviderError,
|
||||
error_result,
|
||||
)
|
||||
from .registry import TOOLS_BY_NAME, tool_declarations
|
||||
from .runtime import default_runtime, require_supported_python
|
||||
|
||||
|
||||
def dispatch(
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
runtime: ProjectContextRuntime,
|
||||
) -> DispatchResult:
|
||||
"""Validate → authorize → resolve provider → execute → validate output."""
|
||||
correlation_id = str(uuid4())
|
||||
tool = TOOLS_BY_NAME.get(name)
|
||||
if tool is None:
|
||||
return error_result(
|
||||
"NOT_FOUND",
|
||||
category="NOT_FOUND",
|
||||
retryable=False,
|
||||
message="The requested MCP tool is not registered.",
|
||||
suggested_action="Refresh the tool list and choose one of the advertised tools.",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
try:
|
||||
validated_input = tool.input_model.model_validate(arguments or {})
|
||||
except ValidationError:
|
||||
return error_result(
|
||||
"INVALID_INPUT",
|
||||
category="INVALID_INPUT",
|
||||
retryable=False,
|
||||
message="The tool arguments do not match the published input contract.",
|
||||
suggested_action="Correct the required fields and value bounds, then call again.",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
project_id = str(validated_input.project_id)
|
||||
if not runtime.policy.decide(runtime.identity, name, project_id):
|
||||
return error_result(
|
||||
"DENIED",
|
||||
category="DENIED",
|
||||
retryable=False,
|
||||
message="The project is outside the caller's approved scope.",
|
||||
suggested_action="Use an approved project or ask the project owner for access.",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
try:
|
||||
provider = runtime.credential_resolver.resolve(runtime.identity, name)
|
||||
raw_output = tool.handler(validated_input, provider)
|
||||
except ProviderError as exc:
|
||||
return error_result(
|
||||
exc.code,
|
||||
category=exc.code,
|
||||
retryable=exc.retryable,
|
||||
message=exc.safe_message,
|
||||
suggested_action="Check the approved provider configuration and retry if allowed.",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - provider failures must not crash or leak into the agent turn
|
||||
return error_result(
|
||||
"UPSTREAM_ERROR",
|
||||
category="UPSTREAM_ERROR",
|
||||
retryable=False,
|
||||
message="The approved provider could not complete the request.",
|
||||
suggested_action="Check the correlation ID in server logs; do not resend credentials.",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
try:
|
||||
output_with_trace = {**raw_output, "correlation_id": correlation_id}
|
||||
validated_output = tool.output_model.model_validate(output_with_trace)
|
||||
except ValidationError:
|
||||
return error_result(
|
||||
"UPSTREAM_ERROR",
|
||||
category="UPSTREAM_ERROR",
|
||||
retryable=False,
|
||||
message="The provider response did not match the published output contract.",
|
||||
suggested_action="Fix the provider mapping before retrying the request.",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return DispatchResult(ok=True, payload=validated_output.model_dump(mode="json"))
|
||||
|
||||
|
||||
def build_server(runtime: Optional[ProjectContextRuntime] = None):
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
|
||||
require_supported_python()
|
||||
app_runtime = runtime or default_runtime()
|
||||
app = Server("project_context")
|
||||
|
||||
@app.list_tools()
|
||||
async def list_tools() -> list[types.Tool]:
|
||||
return [types.Tool(**declaration) for declaration in tool_declarations()]
|
||||
|
||||
@app.call_tool()
|
||||
async def call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult:
|
||||
result = dispatch(name, arguments or {}, app_runtime)
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result.payload, ensure_ascii=False, separators=(",", ":")),
|
||||
)],
|
||||
structuredContent=result.payload if result.ok else None,
|
||||
isError=not result.ok,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import anyio
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
app = build_server()
|
||||
|
||||
async def _run() -> None:
|
||||
async with stdio_server() as (read, write):
|
||||
await app.run(read, write, app.create_initialization_options())
|
||||
|
||||
anyio.run(_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
"""Independent tool modules; ownership is documented in the team guide."""
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Member C work package: get_project_change_context."""
|
||||
|
||||
# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..foundation import ContractModel, SourceCitation, ToolTemplate
|
||||
|
||||
|
||||
class ChangeContextInput(ContractModel):
|
||||
project_id: str = Field(min_length=1, max_length=128)
|
||||
change_id: str = Field(min_length=1, max_length=128)
|
||||
detail: Literal["summary", "standard", "full"] = "standard"
|
||||
cursor: Optional[str] = Field(default=None, max_length=2048)
|
||||
|
||||
|
||||
class ChangeContextOutput(ContractModel):
|
||||
correlation_id: str
|
||||
project_id: str
|
||||
change_id: str
|
||||
change_type: Literal["commit", "pull-request", "merge-request"]
|
||||
title: str
|
||||
state: str
|
||||
summary: str
|
||||
authors: tuple[str, ...]
|
||||
files: tuple[str, ...]
|
||||
commits: tuple[str, ...]
|
||||
related_issues: tuple[str, ...]
|
||||
source: SourceCitation
|
||||
truncated: bool
|
||||
returned: int = Field(ge=0)
|
||||
remaining: int = Field(ge=0)
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
|
||||
request = ChangeContextInput.model_validate(arguments)
|
||||
return provider.get_change_context(**request.model_dump())
|
||||
|
||||
|
||||
TOOL = ToolTemplate(
|
||||
name="get_project_change_context",
|
||||
description=(
|
||||
"Returns provider-neutral context for one authorized commit, pull request, or merge request "
|
||||
"with changed files, commits, related issues, and a pinned source. Use when an exact change "
|
||||
"identifier is known. Do not use for issue details or free-text document search."
|
||||
),
|
||||
input_model=ChangeContextInput,
|
||||
output_model=ChangeContextOutput,
|
||||
handler=_handle,
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Member A work package: get_project_issue_context."""
|
||||
|
||||
# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..foundation import ContractModel, SourceCitation, ToolTemplate
|
||||
|
||||
|
||||
class IssueContextInput(ContractModel):
|
||||
project_id: str = Field(min_length=1, max_length=128)
|
||||
issue_key: str = Field(min_length=1, max_length=128)
|
||||
detail: Literal["summary", "standard", "full"] = "standard"
|
||||
cursor: Optional[str] = Field(default=None, max_length=2048)
|
||||
|
||||
|
||||
class RelatedItem(ContractModel):
|
||||
item_id: str
|
||||
relation: str
|
||||
title: str
|
||||
url: str
|
||||
|
||||
|
||||
class IssueContextOutput(ContractModel):
|
||||
correlation_id: str
|
||||
project_id: str
|
||||
issue_key: str
|
||||
title: str
|
||||
status: str
|
||||
description: str
|
||||
acceptance_criteria: tuple[str, ...]
|
||||
related: tuple[RelatedItem, ...]
|
||||
source: SourceCitation
|
||||
truncated: bool
|
||||
returned: int = Field(ge=0)
|
||||
remaining: int = Field(ge=0)
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
|
||||
request = IssueContextInput.model_validate(arguments)
|
||||
return provider.get_issue_context(**request.model_dump())
|
||||
|
||||
|
||||
TOOL = ToolTemplate(
|
||||
name="get_project_issue_context",
|
||||
description=(
|
||||
"Returns one authorized work item's title, state, description, acceptance criteria, "
|
||||
"related items, and pinned source. Use when an exact issue key is known. Do not use for "
|
||||
"free-text knowledge search or Git change review."
|
||||
),
|
||||
input_model=IssueContextInput,
|
||||
output_model=IssueContextOutput,
|
||||
handler=_handle,
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Member B work package: search_project_knowledge."""
|
||||
|
||||
# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ..foundation import ContractModel, SourceCitation, ToolTemplate
|
||||
|
||||
|
||||
class KnowledgeSearchInput(ContractModel):
|
||||
project_id: str = Field(min_length=1, max_length=128)
|
||||
query: str = Field(min_length=2, max_length=1000)
|
||||
detail: Literal["summary", "standard", "full"] = "standard"
|
||||
top_k: int = Field(default=5, ge=1, le=20)
|
||||
language: Optional[Literal["en", "ja", "vi"]] = None
|
||||
cursor: Optional[str] = Field(default=None, max_length=2048)
|
||||
|
||||
|
||||
class KnowledgeItem(ContractModel):
|
||||
document_id: str
|
||||
chunk_id: str
|
||||
title: str
|
||||
excerpt: str
|
||||
score: float = Field(ge=0, le=1)
|
||||
source: SourceCitation
|
||||
|
||||
|
||||
class KnowledgeSearchOutput(ContractModel):
|
||||
correlation_id: str
|
||||
project_id: str
|
||||
query: str
|
||||
items: tuple[KnowledgeItem, ...]
|
||||
truncated: bool
|
||||
returned: int = Field(ge=0)
|
||||
remaining: int = Field(ge=0)
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
|
||||
request = KnowledgeSearchInput.model_validate(arguments)
|
||||
return provider.search_knowledge(**request.model_dump())
|
||||
|
||||
|
||||
TOOL = ToolTemplate(
|
||||
name="search_project_knowledge",
|
||||
description=(
|
||||
"Searches approved knowledge for one authorized project and returns ranked excerpts with "
|
||||
"pinned citations. Use for requirements, design notes, or runbooks when no exact issue is "
|
||||
"known. Do not use for issue details or Git change review."
|
||||
),
|
||||
input_model=KnowledgeSearchInput,
|
||||
output_model=KnowledgeSearchOutput,
|
||||
handler=_handle,
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Stable module entry point for ``python -m cowork_local.mcp_servers.project_context_server``."""
|
||||
|
||||
from .project_context.server import build_server, dispatch, main
|
||||
|
||||
__all__ = ["build_server", "dispatch", "main"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation Layer: PySide6 UI widgets, dialogs, and shell views (<400 LOC per file)."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation chat package: ChatHistoryWidget, ComposerWidget, AttachmentPicker, AudioRecorderWidget, ChatOutputPanel."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation Co4E package: Co4ECanvasWidget, NodePropertyPanel, RunControlWidget, Co4EChatView."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation dashboard package: TokenUsageCardWidget, UsageChartWidget, HabitsWidget."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation folder package: WorkspaceFileTree, DocumentPreviewManager, AiFileEditorDialog."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation graph package: StructureGraphView and GraphQaWidget."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation monitoring package: 8 modular sub-tab widgets."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation scheduling package: KanbanBoardWidget, CalendarViewWidget, AiTaskCreatorDialog."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation settings package: Section widgets for provider, connector, routing, and general settings."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Presentation shell package: MainWindow shell, TrayManager, LifecycleCoordinator."""
|
||||
+12
-26
@@ -292,33 +292,19 @@ class AnthropicProvider(Provider):
|
||||
args = {"_raw": b["json"]}
|
||||
tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args})
|
||||
|
||||
# Usage event — real counts from the stream's usage events, else a
|
||||
# ~4 chars/token estimate. Published to the telemetry sink (R03-T06)
|
||||
# rather than written straight to the Dashboard store, so the provider
|
||||
# stays a pure transport adapter. Never breaks the turn.
|
||||
try:
|
||||
from ..infrastructure.telemetry import usage_sink
|
||||
# Dashboard usage event — real counts from the stream's usage events
|
||||
# (input arrives on message_start, output on message_delta), else a
|
||||
# ~4 chars/token estimate. Delivery is the sink's job (R03-T06), so this
|
||||
# only translates Anthropic's wire shape into a canonical UsageEvent.
|
||||
from ..infrastructure.telemetry import usage_sink as telemetry
|
||||
|
||||
if usage_seen:
|
||||
usage_sink.publish(usage_sink.UsageEvent(
|
||||
provider=self.name,
|
||||
model=self.model,
|
||||
input_tokens=usage_seen.get("in", 0),
|
||||
output_tokens=usage_seen.get("out", 0),
|
||||
cached_tokens=usage_seen.get("cache", 0),
|
||||
))
|
||||
else:
|
||||
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
|
||||
usage_sink.publish(usage_sink.UsageEvent(
|
||||
provider=self.name,
|
||||
model=self.model,
|
||||
input_tokens=usage_sink.estimate_tokens(sent),
|
||||
output_tokens=usage_sink.estimate_tokens(got),
|
||||
estimated=True,
|
||||
))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if usage_seen:
|
||||
event = telemetry.anthropic_usage_event(self.name, self.model, usage_seen)
|
||||
else:
|
||||
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
|
||||
event = telemetry.estimated_event(self.name, self.model, sent, got)
|
||||
self._emit_usage(event)
|
||||
|
||||
return {"role": "assistant", "content": "".join(text_parts), "tool_calls": tool_calls}
|
||||
|
||||
|
||||
@@ -224,6 +224,12 @@ class Provider:
|
||||
# silently swallowing the error — Settings' "Test connection" / "Load
|
||||
# models" surfaces this so "model won't load" has a concrete reason.
|
||||
self.last_error = ""
|
||||
# Where this provider's token usage goes (R03-T06). None means "the
|
||||
# process-wide default sink", resolved lazily in _emit_usage so that a
|
||||
# test can swap the destination without rebuilding every provider.
|
||||
# Set it per instance to bill one run somewhere else (a workflow, a
|
||||
# scheduled task) without touching global state.
|
||||
self.usage_sink = None
|
||||
|
||||
def chat(
|
||||
self,
|
||||
@@ -274,6 +280,24 @@ class Provider:
|
||||
return True, f"OK — {len(models)} model(s) available."
|
||||
return False, "No models returned. Check base_url/API key and network access."
|
||||
|
||||
# -- telemetry -----------------------------------------------------
|
||||
def _emit_usage(self, event) -> None:
|
||||
"""Hand one ``UsageEvent`` to this provider's usage sink.
|
||||
|
||||
Never raises: recording how many tokens a turn cost must not be able to
|
||||
fail the turn itself. Falls back to the process-wide default sink so
|
||||
existing call sites keep reporting to the Dashboard exactly as before
|
||||
(see infrastructure/telemetry/usage_sink.py)."""
|
||||
try:
|
||||
sink = self.usage_sink
|
||||
if sink is None:
|
||||
from ..infrastructure.telemetry import usage_sink as telemetry
|
||||
|
||||
sink = telemetry.default_sink
|
||||
sink.record(event)
|
||||
except Exception: # noqa: BLE001 — telemetry is never worth a failed turn
|
||||
pass
|
||||
|
||||
# -- shared helpers ------------------------------------------------
|
||||
@staticmethod
|
||||
def _is_cancelled(cancel) -> bool:
|
||||
|
||||
+17
-24
@@ -1,32 +1,25 @@
|
||||
"""Build a provider instance from the application config.
|
||||
|
||||
Kept as the historic entry point (``providers.build_provider``) that call sites
|
||||
across the app already import, but it no longer owns a provider table of its
|
||||
own: since R03-T02 the catalogue lives in
|
||||
``infrastructure/providers/provider_registry.py`` so provider ids, wire
|
||||
protocols, default models and capabilities are declared exactly once.
|
||||
"""
|
||||
"""Build a provider instance from the application config."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .anthropic import AnthropicProvider
|
||||
from .base import Provider, ProviderError
|
||||
from .openai_compat import OpenAICompatProvider
|
||||
|
||||
_REGISTRY = {
|
||||
"openai_compat": OpenAICompatProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
# All OpenAI-compatible endpoints (Ollama's /v1 server, the Copilot chat API,
|
||||
# and OpenAI itself) speak the same Chat Completions protocol.
|
||||
"ollama": OpenAICompatProvider,
|
||||
"github_copilot": OpenAICompatProvider,
|
||||
"codex": OpenAICompatProvider,
|
||||
}
|
||||
|
||||
|
||||
def build_provider(name: str, conf: Dict[str, Any]) -> Provider:
|
||||
"""Construct the adapter registered for ``name``.
|
||||
|
||||
Delegates to the central registry and translates its lookup failure into
|
||||
:class:`ProviderError`, because every existing call site (chat turns,
|
||||
Settings' connection test, the routing prober) already handles that type —
|
||||
changing the exception would ripple into unrelated error handling.
|
||||
"""
|
||||
from ..infrastructure.providers.provider_registry import (
|
||||
ProviderNotFoundError,
|
||||
default_registry,
|
||||
)
|
||||
|
||||
try:
|
||||
return default_registry().build(name, conf)
|
||||
except ProviderNotFoundError as exc:
|
||||
raise ProviderError(f"Unsupported provider: {name}") from exc
|
||||
cls = _REGISTRY.get(name)
|
||||
if cls is None:
|
||||
raise ProviderError(f"Unsupported provider: {name}")
|
||||
return cls(conf)
|
||||
|
||||
+19
-32
@@ -266,40 +266,27 @@ class OpenAICompatProvider(Provider):
|
||||
return _assemble_assistant(text_parts, tool_acc)
|
||||
|
||||
def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None:
|
||||
"""Publish one usage event per turn: real counts when the server's final
|
||||
chunk carried a "usage" block, a ~4 chars/token estimate otherwise.
|
||||
"""One Dashboard usage event per turn: real counts when the server's
|
||||
final chunk carried a "usage" block, a ~4 chars/token estimate
|
||||
otherwise.
|
||||
|
||||
Since R03-T06 this only *describes* what the turn consumed and hands the
|
||||
event to ``infrastructure/telemetry/usage_sink.py``; deciding where the
|
||||
numbers land (Dashboard files, cost meters, tests) belongs to the
|
||||
subscribers, not to a provider adapter. Never breaks the turn.
|
||||
"""
|
||||
try:
|
||||
from ..infrastructure.telemetry import usage_sink
|
||||
Building the event and delivering it are now separate concerns (R03-T06):
|
||||
this method only translates THIS provider's wire shape into a canonical
|
||||
``UsageEvent``; where it ends up is the sink's decision, so a test can
|
||||
assert on token counts without writing to the real Dashboard store."""
|
||||
from ..infrastructure.telemetry import usage_sink as telemetry
|
||||
|
||||
if usage_seen:
|
||||
usage_sink.publish(usage_sink.UsageEvent(
|
||||
provider=self.name,
|
||||
model=self.model,
|
||||
input_tokens=usage_seen.get("prompt_tokens", 0),
|
||||
output_tokens=usage_seen.get("completion_tokens", 0),
|
||||
cached_tokens=(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0),
|
||||
))
|
||||
else:
|
||||
# No usage block from the gateway — approximate from the exact
|
||||
# bytes we sent and received so the Dashboard still shows a
|
||||
# (clearly flagged) figure instead of a silent zero.
|
||||
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
|
||||
usage_sink.publish(usage_sink.UsageEvent(
|
||||
provider=self.name,
|
||||
model=self.model,
|
||||
input_tokens=usage_sink.estimate_tokens(sent),
|
||||
output_tokens=usage_sink.estimate_tokens(got),
|
||||
estimated=True,
|
||||
))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if usage_seen:
|
||||
event = telemetry.openai_usage_event(self.name, self.model, usage_seen)
|
||||
else:
|
||||
# No usage block from the gateway (self-hosted servers and Ollama
|
||||
# never send one) - fall back to estimating from the raw text of
|
||||
# both directions, tool-call arguments included since the model was
|
||||
# billed for generating them.
|
||||
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
|
||||
event = telemetry.estimated_event(self.name, self.model, sent, got)
|
||||
self._emit_usage(event)
|
||||
|
||||
def list_models(self):
|
||||
self.last_error = ""
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
PySide6>=6.6
|
||||
pydantic>=2
|
||||
requests
|
||||
psutil
|
||||
pygments
|
||||
openpyxl
|
||||
python-pptx
|
||||
networkx
|
||||
pytest
|
||||
+208
-135
@@ -1,164 +1,237 @@
|
||||
"""AST-based Static Analysis Guard for Clean Architecture Enforcement.
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN Check 3 — Clean Architecture Guard (R01-T03).
|
||||
|
||||
Scans designated Python packages (such as `domain/` and `application/`) to ensure
|
||||
they remain 100% Pure Python and do not import presentation/GUI frameworks (PySide6, PyQt)
|
||||
or concrete application shells.
|
||||
Statically walks the AST of every Python file in the pure-Python layers and
|
||||
fails when a file imports something the layer is not allowed to depend on.
|
||||
|
||||
Why AST instead of ``grep``: a regex over source text cannot tell an import
|
||||
apart from the same words appearing inside a docstring, a comment or a string
|
||||
literal (this repo has several docstrings that legitimately mention
|
||||
``PySide6``). ``ast`` sees only real ``import`` / ``from … import`` nodes, so
|
||||
the check has no false positives and needs no ``# noqa`` escape hatches.
|
||||
|
||||
Rules enforced (see docs/architecture/ADR-001-layered-architecture.md):
|
||||
|
||||
* **I1** ``domain/`` and ``application/`` must be 100% pure Python — no Qt.
|
||||
* **I2** ``domain/`` must not import ``application/``, ``infrastructure/``,
|
||||
``presentation/`` or the legacy ``ui/``.
|
||||
* **I3** ``application/`` must not import ``presentation/`` or ``ui/``.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/check_imports.py # scan the whole repo
|
||||
python scripts/check_imports.py domain # scan one layer only
|
||||
|
||||
Exit code is 0 when clean and 1 when at least one violation is found, so it
|
||||
can be wired straight into CI / ``scripts/run_quality_gate.py`` (R10-T02).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import io
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, NamedTuple, Set
|
||||
from typing import Dict, Iterable, List, Sequence, Tuple
|
||||
|
||||
# Ensure UTF-8 output on standard console streams across diverse Windows locales (CP932, etc.)
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
||||
try:
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
# Repository root = parent of this scripts/ folder. Everything below is resolved
|
||||
# relative to it so the checker works no matter what the checkout folder is
|
||||
# named or which directory the developer runs it from.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# The distribution package name. Absolute imports may be written either as
|
||||
# ``from cowork_local.ui import x`` or ``from ui import x`` depending on how the
|
||||
# module was reached; we normalise the prefix away so both spellings are caught.
|
||||
PACKAGE_NAME = "cowork_local"
|
||||
|
||||
class ImportViolation(NamedTuple):
|
||||
file_path: Path
|
||||
line_number: int
|
||||
imported_module: str
|
||||
rule_description: str
|
||||
# Any import whose first dotted segment is one of these is a GUI toolkit.
|
||||
QT_ROOTS = frozenset({"PySide6", "PySide2", "PyQt5", "PyQt6", "shiboken6", "shiboken2"})
|
||||
|
||||
|
||||
# Disallowed top-level package names in pure business/domain layers
|
||||
FORBIDDEN_MODULE_PREFIXES: Set[str] = {
|
||||
"PySide6",
|
||||
"PySide2",
|
||||
"PyQt6",
|
||||
"PyQt5",
|
||||
"ui",
|
||||
"app",
|
||||
# Per-layer rules: layer directory -> top-level package names it may not import.
|
||||
# Kept as a plain table so adding a layer later is a one-line change and the
|
||||
# rules stay readable next to the ADR they implement.
|
||||
LAYER_RULES: Dict[str, frozenset] = {
|
||||
# I1 + I2: domain is the innermost layer and depends on nothing but stdlib.
|
||||
"domain": frozenset({"application", "infrastructure", "presentation", "ui", "core"}),
|
||||
# I1 + I3: application may use domain, but never anything that draws pixels.
|
||||
"application": frozenset({"presentation", "ui"}),
|
||||
}
|
||||
|
||||
# Default directories that must strictly adhere to Clean Architecture
|
||||
DEFAULT_SCAN_DIRS: List[str] = [
|
||||
"domain",
|
||||
"application",
|
||||
]
|
||||
# Directories that are never production code and therefore never scanned.
|
||||
SKIP_DIRS = frozenset({".git", "__pycache__", ".pytest_cache", "tests", "build", "dist"})
|
||||
|
||||
|
||||
class ArchitectureImportVisitor(ast.NodeVisitor):
|
||||
"""AST visitor that checks all Import and ImportFrom statements against forbidden prefixes."""
|
||||
@dataclass(frozen=True)
|
||||
class Violation:
|
||||
"""One forbidden import, carrying enough context to fix it without grepping."""
|
||||
|
||||
def __init__(self, file_path: Path, forbidden: Set[str]) -> None:
|
||||
self.file_path = file_path
|
||||
self.forbidden = forbidden
|
||||
self.violations: List[ImportViolation] = []
|
||||
path: Path
|
||||
line: int
|
||||
imported: str
|
||||
rule: str
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
# Check direct `import x, y` statements
|
||||
for alias in node.names:
|
||||
root_module = alias.name.split(".")[0]
|
||||
if root_module in self.forbidden:
|
||||
self.violations.append(
|
||||
ImportViolation(
|
||||
file_path=self.file_path,
|
||||
line_number=node.lineno,
|
||||
imported_module=alias.name,
|
||||
rule_description=f"Direct import of GUI/shell module '{alias.name}' is prohibited.",
|
||||
)
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
# Check `from x import y` statements
|
||||
if node.module:
|
||||
root_module = node.module.split(".")[0]
|
||||
if root_module in self.forbidden:
|
||||
self.violations.append(
|
||||
ImportViolation(
|
||||
file_path=self.file_path,
|
||||
line_number=node.lineno,
|
||||
imported_module=node.module,
|
||||
rule_description=f"Import from GUI/shell module '{node.module}' is prohibited.",
|
||||
)
|
||||
)
|
||||
self.generic_visit(node)
|
||||
def render(self) -> str:
|
||||
"""Format as ``file:line: message`` — the shape editors turn into a
|
||||
clickable link, so a CI failure lands the developer on the exact line."""
|
||||
rel = self.path.relative_to(REPO_ROOT).as_posix()
|
||||
# ASCII-only on purpose: this line is printed to a console that may run a
|
||||
# legacy code page (cp932 on the team's Windows boxes), where a non-ASCII
|
||||
# dash raises UnicodeEncodeError and would crash the gate on the very
|
||||
# failure path it exists to report.
|
||||
return f"{rel}:{self.line}: imports '{self.imported}' - {self.rule}"
|
||||
|
||||
|
||||
def scan_file(file_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
||||
"""Parse a single Python file into AST and return all detected architecture import violations."""
|
||||
try:
|
||||
source_code = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source_code, filename=str(file_path))
|
||||
except (SyntaxError, UnicodeDecodeError) as exc:
|
||||
print(f"[Syntax/Read Warning] Could not parse {file_path}: {exc}", file=sys.stderr)
|
||||
return []
|
||||
def iter_python_files(layer_dir: Path) -> Iterable[Path]:
|
||||
"""Yield every production ``.py`` file under ``layer_dir``.
|
||||
|
||||
visitor = ArchitectureImportVisitor(file_path, forbidden)
|
||||
visitor.visit(tree)
|
||||
return visitor.violations
|
||||
|
||||
|
||||
def scan_directory(dir_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
||||
"""Recursively scan all Python files in a directory."""
|
||||
violations: List[ImportViolation] = []
|
||||
if not dir_path.exists():
|
||||
return violations
|
||||
|
||||
for py_file in dir_path.rglob("*.py"):
|
||||
if py_file.is_file() and "__pycache__" not in py_file.parts:
|
||||
violations.extend(scan_file(py_file, forbidden))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point for CI/pre-commit quality gate checks."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Clean Architecture Import Guard: Verifies zero GUI/Qt dependencies in domain/app layers."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--paths",
|
||||
nargs="*",
|
||||
default=DEFAULT_SCAN_DIRS,
|
||||
help="Paths or directories to scan (defaults to 'domain' and 'application')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
default=".",
|
||||
help="Root workspace directory",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
root_dir = Path(args.root).resolve()
|
||||
all_violations: List[ImportViolation] = []
|
||||
|
||||
print(f"[Clean Arch Guard] Scanning root: {root_dir}")
|
||||
|
||||
for target in args.paths:
|
||||
target_path = (root_dir / target).resolve()
|
||||
if not target_path.exists():
|
||||
# If the layer directory does not exist yet (during early migration), skip cleanly
|
||||
print(f"[Clean Arch Guard] Directory '{target}' does not exist yet (skipped).")
|
||||
Test files are excluded on purpose: a test for a pure-Python service is
|
||||
allowed to import Qt (an integration test may need a headless widget), and
|
||||
holding tests to the production rule would push people to disable the gate.
|
||||
"""
|
||||
if not layer_dir.is_dir():
|
||||
return
|
||||
for path in sorted(layer_dir.rglob("*.py")):
|
||||
# Reject a path as soon as ANY of its parent folder names is skippable,
|
||||
# which also covers nested __pycache__ inside a sub-package.
|
||||
if any(part in SKIP_DIRS for part in path.parts):
|
||||
continue
|
||||
yield path
|
||||
|
||||
if target_path.is_file():
|
||||
all_violations.extend(scan_file(target_path, FORBIDDEN_MODULE_PREFIXES))
|
||||
else:
|
||||
all_violations.extend(scan_directory(target_path, FORBIDDEN_MODULE_PREFIXES))
|
||||
|
||||
if all_violations:
|
||||
print("\n[FAIL] CLEAN ARCHITECTURE VIOLATIONS DETECTED:")
|
||||
print("=" * 70)
|
||||
for v in all_violations:
|
||||
rel_path = v.file_path.relative_to(root_dir) if v.file_path.is_relative_to(root_dir) else v.file_path
|
||||
print(f" • {rel_path}:{v.line_number} -> Forbidden import: '{v.imported_module}'")
|
||||
print(f" Reason: {v.rule_description}")
|
||||
print("=" * 70)
|
||||
print(f"Total Violations: {len(all_violations)}")
|
||||
def module_parts(path: Path) -> List[str]:
|
||||
"""Dotted package path of ``path`` relative to the repo root, as a list.
|
||||
|
||||
``domain/agents/agent_event.py`` -> ``["domain", "agents", "agent_event"]``
|
||||
``domain/agents/__init__.py`` -> ``["domain", "agents"]``
|
||||
|
||||
Needed to resolve *relative* imports: ``from ..models import X`` inside
|
||||
``domain/agents/foo.py`` really means ``domain.models``, and only the file's
|
||||
own position tells us that.
|
||||
"""
|
||||
rel = path.relative_to(REPO_ROOT)
|
||||
parts = list(rel.parts)
|
||||
if parts[-1] == "__init__.py":
|
||||
parts.pop()
|
||||
else:
|
||||
parts[-1] = parts[-1][: -len(".py")]
|
||||
return parts
|
||||
|
||||
|
||||
def resolve_relative(parts: Sequence[str], level: int, module: str) -> str:
|
||||
"""Turn a relative import into the absolute top-level package it points at.
|
||||
|
||||
``level`` is the number of leading dots. Level 1 means "the package this
|
||||
module lives in", so we drop the module's own name plus ``level - 1``
|
||||
further parents. Returns the FIRST segment of the resolved path, because
|
||||
the rules are expressed in terms of top-level layers.
|
||||
|
||||
Walking off the top of the tree (more dots than there are parents) yields
|
||||
an empty string, which simply never matches a rule — a malformed import
|
||||
like that is a syntax/packaging problem, not an architecture violation.
|
||||
"""
|
||||
base = list(parts[:-1]) # the package containing this module
|
||||
if level > 1:
|
||||
drop = level - 1
|
||||
if drop > len(base):
|
||||
return ""
|
||||
base = base[: len(base) - drop]
|
||||
tail = module.split(".") if module else []
|
||||
resolved = base + tail
|
||||
return resolved[0] if resolved else ""
|
||||
|
||||
|
||||
def top_level(name: str) -> str:
|
||||
"""First dotted segment of an absolute import, with the distribution package
|
||||
prefix stripped so ``cowork_local.ui.chat_panel`` and ``ui.chat_panel`` are
|
||||
treated as the same dependency."""
|
||||
segments = name.split(".")
|
||||
if segments and segments[0] == PACKAGE_NAME:
|
||||
segments = segments[1:]
|
||||
return segments[0] if segments else ""
|
||||
|
||||
|
||||
def imported_roots(tree: ast.AST, parts: Sequence[str]) -> Iterable[Tuple[str, int, str]]:
|
||||
"""Yield ``(top_level_package, line_number, as_written)`` for every import.
|
||||
|
||||
``as_written`` is kept so the error message shows what the developer
|
||||
actually typed rather than the normalised root, which makes the violation
|
||||
obvious at a glance.
|
||||
|
||||
``ast.walk`` (not just the module body) is deliberate: this repo defers many
|
||||
heavy imports into function bodies to keep app start-up fast, and a
|
||||
function-local ``from PySide6 import QtWidgets`` breaks the layer exactly
|
||||
the same way a top-level one does.
|
||||
"""
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
yield top_level(alias.name), node.lineno, alias.name
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.level:
|
||||
written = "." * node.level + (node.module or "")
|
||||
yield resolve_relative(parts, node.level, node.module or ""), node.lineno, written
|
||||
else:
|
||||
module = node.module or ""
|
||||
yield top_level(module), node.lineno, module
|
||||
|
||||
|
||||
def check_file(path: Path, layer: str, banned: frozenset) -> List[Violation]:
|
||||
"""Collect every rule violation in one file.
|
||||
|
||||
A file that cannot be parsed is reported as a violation rather than skipped:
|
||||
silently passing a file the checker could not read would make the gate lie.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
except (SyntaxError, UnicodeDecodeError) as exc:
|
||||
return [Violation(path, getattr(exc, "lineno", 0) or 0, "<unparseable>",
|
||||
f"cannot be parsed by the architecture guard ({exc})")]
|
||||
|
||||
parts = module_parts(path)
|
||||
out: List[Violation] = []
|
||||
for root, lineno, written in imported_roots(tree, parts):
|
||||
if root in QT_ROOTS:
|
||||
out.append(Violation(path, lineno, written,
|
||||
f"'{layer}/' must be 100% pure Python (ADR-001 I1)"))
|
||||
elif root in banned:
|
||||
out.append(Violation(path, lineno, written,
|
||||
f"'{layer}/' must not depend on '{root}/' (ADR-001 I2/I3)"))
|
||||
return out
|
||||
|
||||
|
||||
def run(layers: Sequence[str]) -> List[Violation]:
|
||||
"""Scan the requested layers and return every violation found, in file order."""
|
||||
found: List[Violation] = []
|
||||
for layer in layers:
|
||||
banned = LAYER_RULES[layer]
|
||||
for path in iter_python_files(REPO_ROOT / layer):
|
||||
found.extend(check_file(path, layer, banned))
|
||||
return found
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="CASAN Check 3 - Clean Architecture Guard (see ADR-001).")
|
||||
parser.add_argument(
|
||||
"layers", nargs="*", choices=sorted(LAYER_RULES) or None, default=None,
|
||||
help="Layers to scan (default: every layer with a rule).",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
layers = args.layers or sorted(LAYER_RULES)
|
||||
|
||||
violations = run(layers)
|
||||
scanned = sum(1 for layer in layers for _ in iter_python_files(REPO_ROOT / layer))
|
||||
|
||||
if violations:
|
||||
print(f"FAIL - {len(violations)} architecture violation(s) in {scanned} file(s):\n")
|
||||
for v in violations:
|
||||
print(" " + v.render())
|
||||
# Point at the rationale instead of just the rule id, so someone hitting
|
||||
# this for the first time knows where the decision was made.
|
||||
print("\nSee docs/architecture/ADR-001-layered-architecture.md")
|
||||
return 1
|
||||
|
||||
print("\n[PASS] CLEAN ARCHITECTURE CHECK: 0 forbidden imports detected.")
|
||||
print(f"PASS - 0 Qt imports in {', '.join(layers)} ({scanned} file(s) scanned)")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,16 @@ class AppContext:
|
||||
# own event loop), so concurrent model calls never needed serializing.
|
||||
self._conn_lock = threading.Lock()
|
||||
self._routing_service = None # lazy RoutingService (Auto Model Routing)
|
||||
# Lazy RoutingApplicationService (R03-T03) — the Qt-free decision layer
|
||||
# every chat surface now routes through. Wraps _routing_service, which
|
||||
# stays the scoring/ranking engine underneath.
|
||||
self._routing_application = None
|
||||
self._routing_lock = threading.Lock()
|
||||
# A SEPARATE lock for the application service: building it calls
|
||||
# routing(), which takes _routing_lock. threading.Lock is not
|
||||
# reentrant, so sharing one lock across both accessors deadlocks the
|
||||
# first caller instead of just serialising them.
|
||||
self._routing_app_lock = threading.Lock()
|
||||
# The workspace (project) currently selected in the Workspace screen.
|
||||
# Per-workspace modes (routing + auto-run) resolve against THIS project
|
||||
# so each workspace keeps its own modes. Updated by WorkspaceTab on
|
||||
@@ -73,26 +82,34 @@ class AppContext:
|
||||
return load_project(pid)
|
||||
|
||||
def project_routing_mode(self, surface: str) -> str:
|
||||
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface``
|
||||
in the ACTIVE workspace: the workspace's own override wins; otherwise the
|
||||
"""Effective Off/Auto/Manual routing mode for a chat ``surface`` in the
|
||||
ACTIVE workspace: the workspace's own override wins; otherwise the
|
||||
global default (``config.routing_mode_for``). This is what makes each
|
||||
workspace keep its own routing mode.
|
||||
|
||||
The accepted set is taken from ``AppConfig.ROUTING_MODES`` rather than
|
||||
repeated here, so adding a mode (as R03-T03 did with "fallback") stays a
|
||||
one-line change instead of a hunt through every validation site."""
|
||||
workspace keep its own routing mode."""
|
||||
project = self._current_project()
|
||||
if project is not None:
|
||||
# Validated through the single mode vocabulary (R03-T03) rather
|
||||
# than a literal tuple, so a workspace can store any mode the
|
||||
# routing service understands - including "fallback", whose
|
||||
# on-screen toggle arrives in EPIC R08.
|
||||
from .application.model_routing import is_valid_mode, normalize_mode
|
||||
|
||||
mode = (project.routing_modes or {}).get(surface, "")
|
||||
if mode in self.config.ROUTING_MODES:
|
||||
return mode
|
||||
# Only a RECOGNISED override wins; an empty or corrupt value falls
|
||||
# through to the global setting, exactly as before. Validation goes
|
||||
# through the routing vocabulary (R03-T03) instead of a literal
|
||||
# tuple, so a new mode works everywhere the moment it is defined.
|
||||
if is_valid_mode(mode):
|
||||
return normalize_mode(mode)
|
||||
return self.config.routing_mode_for(surface)
|
||||
|
||||
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
||||
"""Persist a surface's routing mode for the ACTIVE workspace. With no
|
||||
workspace selected, falls back to the global setting so behaviour
|
||||
outside a project stays global."""
|
||||
mode = mode if mode in self.config.ROUTING_MODES else "off"
|
||||
from .application.model_routing import normalize_mode
|
||||
|
||||
mode = normalize_mode(mode)
|
||||
project = self._current_project()
|
||||
if project is None:
|
||||
self.config.set_routing_mode_for(surface, mode)
|
||||
@@ -148,6 +165,34 @@ class AppContext:
|
||||
self._routing_service = RoutingService(self)
|
||||
return self._routing_service
|
||||
|
||||
def routing_application(self):
|
||||
"""The shared :class:`RoutingApplicationService` (R03-T03).
|
||||
|
||||
This is what UI code should call: it owns the Off/Auto/Manual/Fallback
|
||||
policy, the confirm handshake and the never-raise guarantee, while
|
||||
:meth:`routing` remains the scoring engine underneath. Chat, Co4E and
|
||||
AI-Edit all go through this one object, so a change to routing policy is
|
||||
made once instead of three times.
|
||||
|
||||
Built lazily and memoised for the same reason as :meth:`routing`: the
|
||||
pending-switch registry and assessment store must be shared app-wide."""
|
||||
if self._routing_application is None:
|
||||
# Resolve the engine BEFORE taking this lock: routing() takes
|
||||
# _routing_lock, and nesting the two acquisitions is what makes the
|
||||
# ordering fragile in the first place.
|
||||
engine = self.routing()
|
||||
with self._routing_app_lock:
|
||||
if self._routing_application is None:
|
||||
from .application.model_routing import RoutingApplicationService
|
||||
|
||||
self._routing_application = RoutingApplicationService(
|
||||
engine,
|
||||
# Per-workspace mode lookup, so each workspace keeps its
|
||||
# own routing behaviour (see project_routing_mode).
|
||||
mode_reader=self.project_routing_mode,
|
||||
)
|
||||
return self._routing_application
|
||||
|
||||
def build_active_provider(self):
|
||||
"""Construct the currently selected provider (called inside workers)."""
|
||||
return self.build_provider_for(self.config.active_provider)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Characterization tests: pin the CURRENT behaviour of legacy code (R01-T04).
|
||||
|
||||
These are not specifications of what the code *should* do - they are a snapshot
|
||||
of what it *does* today, written before the refactor so that any behavioural
|
||||
drift introduced while moving logic into ``application/`` shows up as a failing
|
||||
test rather than as a bug report from a user.
|
||||
|
||||
Rule for this folder: when a test here fails during the refactor, do not "fix"
|
||||
the test first. Decide deliberately whether the behaviour change is intended,
|
||||
and only then update the snapshot in the same commit as the change.
|
||||
"""
|
||||
@@ -1,157 +1,288 @@
|
||||
"""Characterization tests for core/chat_agent.py (run_chat and run_cowork runtime seams).
|
||||
"""Characterization snapshot of ``core.chat_agent.run_cowork`` (R01-T04).
|
||||
|
||||
These tests capture existing behavior as an executable baseline specification,
|
||||
ensuring that future refactoring to ConversationApplicationService does not alter
|
||||
core turn semantics, event emissions, or file handling.
|
||||
``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 cowork_local.tests.fakes.fake_provider import FakeProvider
|
||||
from tests.fakes import FakeProvider, FakeToolExecutor, ScriptedTurn
|
||||
|
||||
|
||||
def test_run_chat_characterization() -> None:
|
||||
"""Capture baseline behavior of run_chat: system prompt insertion, streaming, and message persistence."""
|
||||
provider = FakeProvider()
|
||||
provider.queue_response(content="Hello there!", chunks=["Hello ", "there!"])
|
||||
@pytest.fixture
|
||||
def isolated_agent(monkeypatch, tmp_path: Path):
|
||||
"""Neutralise every ambient input ``run_cowork`` reads from the machine.
|
||||
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Hi assistant"}]
|
||||
emitted_events: List[Dict[str, Any]] = []
|
||||
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
|
||||
|
||||
def emit(event: Dict[str, Any]) -> None:
|
||||
emitted_events.append(event)
|
||||
|
||||
result = chat_agent.run_chat(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
emit=emit,
|
||||
)
|
||||
|
||||
# 1. Verify system prompt was injected at position 0
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "Cowork Local" in messages[0]["content"]
|
||||
|
||||
# 2. Verify returned assistant message
|
||||
assert result["role"] == "assistant"
|
||||
assert result["content"] == "Hello there!"
|
||||
|
||||
# 3. Verify assistant message was appended to messages list
|
||||
assert messages[-1] == result
|
||||
|
||||
# 4. Verify emitted events sequence
|
||||
text_deltas = [e["delta"] for e in emitted_events if e["type"] == "text"]
|
||||
assert "".join(text_deltas) == "Hello there!"
|
||||
assert any(e["type"] == "assistant_done" for e in emitted_events)
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_run_cowork_save_file_characterization(tmp_path: Path) -> None:
|
||||
"""Capture baseline behavior of run_cowork: tool execution loop and file production."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
provider = FakeProvider()
|
||||
# Step 1: Model requests save_file tool
|
||||
provider.queue_response(
|
||||
content="Saving your requested report.",
|
||||
tool_calls=[{
|
||||
"id": "call_save_1",
|
||||
"name": "save_file",
|
||||
"arguments": {
|
||||
"filename": "report.md",
|
||||
"content": "# Executive Summary\nAll systems nominal.",
|
||||
},
|
||||
}],
|
||||
)
|
||||
# Step 2: Model finishes after tool result
|
||||
provider.queue_response(
|
||||
content="I have created report.md in your output directory.",
|
||||
chunks=["I have created report.md in your output directory."],
|
||||
)
|
||||
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Export report to markdown file"}]
|
||||
emitted_events: List[Dict[str, Any]] = []
|
||||
|
||||
def emit(event: Dict[str, Any]) -> None:
|
||||
emitted_events.append(event)
|
||||
|
||||
final_messages = chat_agent.run_cowork(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
output_dir=output_dir,
|
||||
emit=emit,
|
||||
enforce_rules=False,
|
||||
)
|
||||
|
||||
# 1. Verify file was created in output directory with expected content
|
||||
created_file = output_dir / "report.md"
|
||||
assert created_file.exists()
|
||||
assert created_file.read_text(encoding="utf-8") == "# Executive Summary\nAll systems nominal."
|
||||
|
||||
# 2. Verify message history contains user -> assistant (tool_calls) -> tool -> assistant
|
||||
roles = [m["role"] for m in final_messages]
|
||||
assert "system" in roles
|
||||
assert "user" in roles
|
||||
assert "tool" in roles
|
||||
|
||||
# 3. Verify tool result message content
|
||||
tool_msg = next(m for m in final_messages if m["role"] == "tool")
|
||||
assert tool_msg["name"] == "save_file"
|
||||
assert "Saved report.md" in tool_msg["content"]
|
||||
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 test_run_cowork_cancellation_characterization(tmp_path: Path) -> None:
|
||||
"""Capture cancellation behavior in run_cowork."""
|
||||
output_dir = tmp_path / "output_cancel"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
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]
|
||||
|
||||
provider = FakeProvider()
|
||||
provider.queue_response(content="Working...")
|
||||
|
||||
is_cancelled = True
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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"}]
|
||||
|
||||
def check_cancel() -> bool:
|
||||
return is_cancelled
|
||||
result, events = _run(provider, messages, out_dir)
|
||||
|
||||
emitted_events: List[Dict[str, Any]] = []
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Please start"}]
|
||||
# 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."
|
||||
|
||||
chat_agent.run_cowork(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
output_dir=output_dir,
|
||||
emit=lambda e: emitted_events.append(e),
|
||||
cancel=check_cancel,
|
||||
enforce_rules=False,
|
||||
)
|
||||
|
||||
# Provider should not have executed turns if cancelled right away
|
||||
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_cleanup_turn_output_characterization(tmp_path: Path) -> None:
|
||||
"""Capture behavior of temporary .scratch folder cleanup and artifact preservation."""
|
||||
output_dir = tmp_path / "output_cleanup"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
scratch_dir = output_dir / ".scratch"
|
||||
scratch_dir.mkdir(parents=True, exist_ok=True)
|
||||
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}
|
||||
|
||||
# Create a generator script and a deliverable inside scratch
|
||||
generator_script = scratch_dir / "gen.py"
|
||||
generator_script.write_text("print('generating')", encoding="utf-8")
|
||||
deliverable = scratch_dir / "data.csv"
|
||||
deliverable.write_text("a,b,c\n1,2,3", encoding="utf-8")
|
||||
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
|
||||
|
||||
before_snapshot = chat_agent._snapshot(output_dir)
|
||||
removed, moved = chat_agent._cleanup_cowork_intermediates(output_dir, before_snapshot, cancelled=False)
|
||||
|
||||
# .scratch directory should be removed
|
||||
assert not scratch_dir.exists()
|
||||
# deliverable should be moved to output root
|
||||
root_csv = output_dir / "data.csv"
|
||||
assert root_csv.exists()
|
||||
# script should not be in output root
|
||||
assert not (output_dir / "gen.py").exists()
|
||||
result, _ = _run(provider, [{"role": "user", "content": "hi"}], out_dir, cancel=cancel)
|
||||
|
||||
assert provider.call_count == 1
|
||||
assert result[-1]["role"] in {"assistant", "tool"}
|
||||
|
||||
+41
-53
@@ -1,25 +1,23 @@
|
||||
"""Make THIS checkout importable as the ``cowork_local`` package during tests.
|
||||
"""Root pytest configuration: bind ``cowork_local`` to THIS checkout (R01-T02).
|
||||
|
||||
Why this is not just a ``sys.path`` insert
|
||||
------------------------------------------
|
||||
Test modules import the app in two different styles:
|
||||
Why this file exists
|
||||
--------------------
|
||||
The package directory is itself the distribution package (``__init__.py`` sits
|
||||
at the repo root), so ``import cowork_local`` only resolves when the checkout
|
||||
folder happens to be named exactly ``cowork_local``. It frequently is not — this
|
||||
one is checked out as ``cowork_local_gitea``, and developers keep several dated
|
||||
copies side by side (``cowork_local``, ``cowork_local_20260722``, ...).
|
||||
|
||||
* top-level (``from providers.base import ...``) — resolved by the repository
|
||||
root already sitting on ``sys.path`` when pytest is launched from it;
|
||||
* fully qualified (``from cowork_local.core.routing.service import ...``) —
|
||||
which only resolves when a directory literally named ``cowork_local`` is
|
||||
importable.
|
||||
Left alone, ``sys.path``-based discovery would import whichever *sibling* folder
|
||||
is named ``cowork_local`` and the whole suite would silently test a DIFFERENT
|
||||
checkout: green here, broken in the branch under review. That is the worst kind
|
||||
of test failure, because it fails to fail.
|
||||
|
||||
Simply appending the repository's PARENT directory to ``sys.path`` (the previous
|
||||
behaviour) makes the second style resolve against *whatever* sibling folder
|
||||
happens to be called ``cowork_local`` — on a developer machine that is often an
|
||||
unrelated older checkout, so the whole suite silently exercises the wrong code
|
||||
while still reporting green. Instead we bind the name ``cowork_local`` in
|
||||
``sys.modules`` to the package rooted at THIS repository, so both import styles
|
||||
always reach the working copy under test regardless of the checkout's directory
|
||||
name.
|
||||
So instead of relying on the folder name, we load ``__init__.py`` by absolute
|
||||
path and register the result in ``sys.modules`` under the canonical name before
|
||||
any test imports it. Submodules (``cowork_local.providers.base``, ...) then
|
||||
resolve through this package's own ``__path__``, i.e. always this checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
@@ -27,49 +25,39 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
# .../<checkout>/tests/conftest.py -> .../<checkout>
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGE_NAME = "cowork_local"
|
||||
|
||||
# The repository root must stay importable so the top-level import style
|
||||
# (``providers``/``domain``/``application``/``tests``) keeps working.
|
||||
if str(PACKAGE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PACKAGE_ROOT))
|
||||
_PKG_DIR = Path(__file__).resolve().parents[1]
|
||||
_PKG_NAME = "cowork_local"
|
||||
|
||||
|
||||
def _bind_checkout_as_package() -> None:
|
||||
"""Register this checkout in ``sys.modules`` under the canonical package name.
|
||||
def _bind_package_to_this_checkout() -> None:
|
||||
"""Make ``import cowork_local`` mean this directory, whatever it is named.
|
||||
|
||||
Executed at import time of the conftest (i.e. before any test module is
|
||||
imported) so that a stale same-named directory elsewhere on ``sys.path`` can
|
||||
never win the lookup. A no-op when the package is already bound to this very
|
||||
directory, which keeps repeated conftest loads (pytest-xdist, sub-sessions)
|
||||
idempotent.
|
||||
A no-op when the correct package object is already bound, so running the
|
||||
suite from a folder that IS named ``cowork_local`` costs nothing and the
|
||||
hook stays idempotent across repeated conftest collection.
|
||||
"""
|
||||
existing = sys.modules.get(PACKAGE_NAME)
|
||||
if existing is not None:
|
||||
# Already bound. Only rebind when it points at a DIFFERENT checkout,
|
||||
# otherwise re-executing the package __init__ would duplicate module
|
||||
# state that tests may already hold references to.
|
||||
origin = getattr(existing, "__file__", "") or ""
|
||||
if Path(origin).resolve().parent == PACKAGE_ROOT:
|
||||
return
|
||||
existing = sys.modules.get(_PKG_NAME)
|
||||
existing_file = getattr(existing, "__file__", None)
|
||||
if existing_file and Path(existing_file).resolve().parent == _PKG_DIR:
|
||||
return # already the right one
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
PACKAGE_NAME,
|
||||
PACKAGE_ROOT / "__init__.py",
|
||||
# Declaring the search locations is what turns the module into a real
|
||||
# package, so ``cowork_local.core.routing`` and friends resolve as
|
||||
# sub-modules of this directory.
|
||||
submodule_search_locations=[str(PACKAGE_ROOT)],
|
||||
_PKG_NAME,
|
||||
_PKG_DIR / "__init__.py",
|
||||
# Setting the search locations is what makes dotted submodule imports
|
||||
# (cowork_local.core.*, cowork_local.providers.*) resolve inside THIS
|
||||
# directory rather than through sys.path.
|
||||
submodule_search_locations=[str(_PKG_DIR)],
|
||||
)
|
||||
if spec is None or spec.loader is None: # pragma: no cover — defensive
|
||||
return
|
||||
if spec is None or spec.loader is None: # pragma: no cover - packaging error
|
||||
raise RuntimeError(f"cannot load {_PKG_NAME} from {_PKG_DIR}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Insert BEFORE executing so that a circular ``import cowork_local`` from
|
||||
# inside the package body resolves to the partially-initialised module
|
||||
# instead of restarting the import (standard CPython import semantics).
|
||||
sys.modules[PACKAGE_NAME] = module
|
||||
# Registered BEFORE exec_module so that a self-referential import inside
|
||||
# __init__.py would find the partially-initialised module instead of
|
||||
# recursing - the same protocol CPython's own import machinery follows.
|
||||
sys.modules[_PKG_NAME] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
_bind_checkout_as_package()
|
||||
_bind_package_to_this_checkout()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Contract tests: one shared specification every interchangeable adapter must satisfy.
|
||||
"""Contract tests: one shared behaviour suite every implementation must satisfy.
|
||||
|
||||
Unlike unit tests (which pin ONE implementation's behaviour), a contract test is
|
||||
parametrised over every implementation of an interface, so adding a new provider
|
||||
means adding a row — not writing a new test file — and a provider that quietly
|
||||
breaks the canonical shape fails here rather than in production.
|
||||
Unlike unit tests (which test one module in isolation) a contract test is
|
||||
parametrised over EVERY implementation of an interface, so a newly added
|
||||
provider either satisfies the same promises as the existing ones or the suite
|
||||
goes red on the day it is added - not months later, in production, on the one
|
||||
code path that assumed the promise held.
|
||||
"""
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"""Offline transport doubles + per-protocol stream scripts for the provider contract tests.
|
||||
|
||||
Kept in its own module so ``test_providers.py`` stays a readable list of
|
||||
assertions instead of a wall of SSE fixtures, and so the LOC ceiling (400 lines
|
||||
per production file, applied here too) is comfortably met by both halves.
|
||||
|
||||
Nothing in here touches the network: :class:`FakeStreamResponse` mimics just
|
||||
enough of ``requests.Response`` for the streaming loops in
|
||||
``providers/openai_compat.py`` and ``providers/anthropic.py`` — status code,
|
||||
mutable ``encoding``, ``iter_lines`` and ``close``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Canonical turn every protocol script below must produce, so the contract test
|
||||
# can assert one expected result no matter which provider produced it.
|
||||
EXPECTED_TEXT = "Hello world"
|
||||
EXPECTED_TOOL_CALL = {"id": "call-1", "name": "read_file", "arguments": {"path": "a.txt"}}
|
||||
EXPECTED_INPUT_TOKENS = 11
|
||||
EXPECTED_OUTPUT_TOKENS = 7
|
||||
EXPECTED_CACHED_TOKENS = 3
|
||||
|
||||
|
||||
class FakeStreamResponse:
|
||||
"""A minimal stand-in for a streaming ``requests.Response``.
|
||||
|
||||
``iter_lines`` replays pre-baked SSE lines; ``closed`` records that the
|
||||
provider released the connection, which the contract asserts because a
|
||||
provider that leaks the response leaks a socket per turn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lines: Optional[List[str]] = None,
|
||||
status_code: int = 200,
|
||||
body: str = "",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self._lines = list(lines or ())
|
||||
self.text = body
|
||||
self.headers = dict(headers or {})
|
||||
self._payload = payload
|
||||
self.closed = False
|
||||
# Providers force UTF-8 on the response before reading it; the attribute
|
||||
# simply has to exist and be writable.
|
||||
self.encoding = None
|
||||
|
||||
def iter_lines(self, decode_unicode: bool = False):
|
||||
for line in self._lines:
|
||||
yield line
|
||||
|
||||
def json(self) -> Any:
|
||||
if self._payload is None:
|
||||
raise ValueError("no JSON payload configured on this fake response")
|
||||
return self._payload
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _sse(payload: Dict[str, Any]) -> str:
|
||||
"""One SSE ``data:`` line carrying a JSON event."""
|
||||
return "data: " + json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def openai_stream_lines() -> List[str]:
|
||||
"""A complete OpenAI Chat Completions stream: text, one tool call, usage.
|
||||
|
||||
Split across several deltas on purpose — chunk boundaries are where naive
|
||||
stream parsers break, so the contract exercises them.
|
||||
"""
|
||||
return [
|
||||
_sse({"choices": [{"delta": {"content": "Hello "}}]}),
|
||||
_sse({"choices": [{"delta": {"content": "world"}}]}),
|
||||
_sse({"choices": [{"delta": {"tool_calls": [{
|
||||
"index": 0, "id": "call-1",
|
||||
"function": {"name": "read_file", "arguments": '{"path":'},
|
||||
}]}}]}),
|
||||
# Arguments arrive fragmented; the provider must concatenate before parsing.
|
||||
_sse({"choices": [{"delta": {"tool_calls": [{
|
||||
"index": 0, "function": {"arguments": '"a.txt"}'},
|
||||
}]}}]}),
|
||||
_sse({
|
||||
"choices": [{"delta": {}}],
|
||||
"usage": {
|
||||
"prompt_tokens": EXPECTED_INPUT_TOKENS,
|
||||
"completion_tokens": EXPECTED_OUTPUT_TOKENS,
|
||||
"prompt_tokens_details": {"cached_tokens": EXPECTED_CACHED_TOKENS},
|
||||
},
|
||||
}),
|
||||
"data: [DONE]",
|
||||
]
|
||||
|
||||
|
||||
def anthropic_stream_lines() -> List[str]:
|
||||
"""The same canonical turn expressed as an Anthropic Messages stream."""
|
||||
return [
|
||||
_sse({"type": "message_start", "message": {"usage": {
|
||||
"input_tokens": EXPECTED_INPUT_TOKENS,
|
||||
"cache_read_input_tokens": EXPECTED_CACHED_TOKENS,
|
||||
}}}),
|
||||
_sse({"type": "content_block_start", "index": 0,
|
||||
"content_block": {"type": "text"}}),
|
||||
_sse({"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Hello "}}),
|
||||
_sse({"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "world"}}),
|
||||
_sse({"type": "content_block_start", "index": 1, "content_block": {
|
||||
"type": "tool_use", "id": "call-1", "name": "read_file"}}),
|
||||
_sse({"type": "content_block_delta", "index": 1,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"path":'}}),
|
||||
_sse({"type": "content_block_delta", "index": 1,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '"a.txt"}'}}),
|
||||
_sse({"type": "message_delta",
|
||||
"usage": {"output_tokens": EXPECTED_OUTPUT_TOKENS}}),
|
||||
_sse({"type": "message_stop"}),
|
||||
]
|
||||
|
||||
|
||||
# Per wire protocol: how to script a successful turn, and the model-list payload
|
||||
# ``list_models()`` expects. Keyed by the descriptor's wire protocol value so a
|
||||
# new provider that reuses an existing protocol needs no new entry here.
|
||||
PROTOCOL_FIXTURES = {
|
||||
"openai_compat": {
|
||||
"stream_lines": openai_stream_lines,
|
||||
"models_payload": {"data": [{"id": "gpt-4o-mini"}, {"id": "gpt-4o"}]},
|
||||
"expected_models": ["gpt-4o-mini", "gpt-4o"],
|
||||
},
|
||||
"anthropic": {
|
||||
"stream_lines": anthropic_stream_lines,
|
||||
"models_payload": {"data": [{"id": "claude-sonnet-4-6"}]},
|
||||
"expected_models": ["claude-sonnet-4-6"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ScriptedTransport:
|
||||
"""Replaces ``Provider._request`` and hands back scripted responses.
|
||||
|
||||
Records every call so a test can assert *how* the provider talked to the
|
||||
endpoint (method, url, JSON payload) without a socket ever being opened.
|
||||
"""
|
||||
|
||||
def __init__(self, responses: List[FakeStreamResponse]) -> None:
|
||||
self._responses = list(responses)
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
|
||||
def __call__(self, method: str, url: str, **kwargs) -> FakeStreamResponse:
|
||||
self.calls.append({"method": method, "url": url, **kwargs})
|
||||
if not self._responses:
|
||||
raise AssertionError(f"unexpected extra request: {method} {url}")
|
||||
# Pop in order: a provider that retries gets the NEXT scripted response,
|
||||
# which is how the retry/error paths are driven.
|
||||
return self._responses.pop(0)
|
||||
|
||||
@property
|
||||
def last_payload(self) -> Dict[str, Any]:
|
||||
"""The JSON body of the most recent request."""
|
||||
return self.calls[-1].get("json") or {}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EXPECTED_CACHED_TOKENS",
|
||||
"EXPECTED_INPUT_TOKENS",
|
||||
"EXPECTED_OUTPUT_TOKENS",
|
||||
"EXPECTED_TEXT",
|
||||
"EXPECTED_TOOL_CALL",
|
||||
"FakeStreamResponse",
|
||||
"PROTOCOL_FIXTURES",
|
||||
"ScriptedTransport",
|
||||
"anthropic_stream_lines",
|
||||
"openai_stream_lines",
|
||||
]
|
||||
+281
-218
@@ -1,279 +1,342 @@
|
||||
"""R03-T01 — the contract every LLM provider adapter must satisfy.
|
||||
"""Provider contract suite (R03-T01).
|
||||
|
||||
Parametrised over EVERY provider in the central registry
|
||||
(``infrastructure/providers/provider_registry.py``), so registering a new
|
||||
provider automatically subjects it to the same specification and a provider that
|
||||
drifts from the canonical shapes fails here.
|
||||
Every provider - the two real adapters and the test double - must honour the
|
||||
same promises declared in ``providers/base.py``:
|
||||
|
||||
The contract, in one list:
|
||||
1. ``chat()`` returns the canonical assistant message
|
||||
``{"role": "assistant", "content": str, "tool_calls": [...]}``.
|
||||
2. Answer text is streamed through ``on_text`` and equals the returned content.
|
||||
3. Private reasoning goes to ``on_reasoning`` ONLY - it must never leak into the
|
||||
answer, or a reasoning model's chain of thought ends up persisted in history.
|
||||
4. Tool calls come back as ``{"id", "name", "arguments": dict}`` with arguments
|
||||
already parsed - callers must never have to json.loads() them.
|
||||
5. A failure raises ``ProviderError`` and nothing else, so one except clause in
|
||||
the agent loop covers every provider.
|
||||
|
||||
* construction — the registry builds a real ``Provider`` for every id;
|
||||
* ``chat()`` — canonical signature, canonical assistant message, streamed text
|
||||
delivered through ``on_text``, tool calls normalised to
|
||||
``{"id", "name", "arguments": dict}``, response always closed;
|
||||
* tool schema translation matches the adapter's wire protocol;
|
||||
* failures raise ``ProviderError`` — never a bare transport exception;
|
||||
* ``list_models()`` / ``test_connection()`` report a reason instead of a silent
|
||||
empty list;
|
||||
* telemetry — exactly one ``UsageEvent`` per turn (R03-T06), with the real
|
||||
counts when the stream reports them.
|
||||
|
||||
Everything runs offline: ``Provider._request`` is replaced by a scripted
|
||||
transport, so the suite needs no network, no API key and no Qt event loop.
|
||||
The real adapters are exercised WITHOUT network access by replacing
|
||||
``Provider._request`` with a canned SSE response - which is exactly the seam
|
||||
``providers/base.py`` documents for its TLS retry, so no production code needed
|
||||
changing to make this testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from cowork_local.domain.models.provider_descriptor import ProviderCapability
|
||||
from cowork_local.infrastructure.providers.provider_registry import (
|
||||
BUILTIN_DESCRIPTORS,
|
||||
BUILT_IN_PROVIDERS,
|
||||
ProviderRegistry,
|
||||
)
|
||||
from cowork_local.infrastructure.telemetry import usage_sink
|
||||
from cowork_local.providers.anthropic import AnthropicProvider
|
||||
from cowork_local.providers.base import Provider, ProviderError, ToolSpec
|
||||
from cowork_local.tests.contracts.provider_stubs import (
|
||||
EXPECTED_CACHED_TOKENS,
|
||||
EXPECTED_INPUT_TOKENS,
|
||||
EXPECTED_OUTPUT_TOKENS,
|
||||
EXPECTED_TEXT,
|
||||
EXPECTED_TOOL_CALL,
|
||||
PROTOCOL_FIXTURES,
|
||||
FakeStreamResponse,
|
||||
ScriptedTransport,
|
||||
)
|
||||
|
||||
# Every provider id in the catalogue — the parametrisation that makes this a
|
||||
# contract suite rather than a per-adapter unit test.
|
||||
PROVIDER_IDS = [d.provider_id for d in BUILTIN_DESCRIPTORS]
|
||||
|
||||
# Minimal config: enough for any adapter to build a URL and headers offline.
|
||||
BASE_CONF = {"base_url": "https://gateway.test/v1", "api_key": "test-key"}
|
||||
|
||||
SAMPLE_MESSAGES = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Say hello"},
|
||||
]
|
||||
|
||||
SAMPLE_TOOL = ToolSpec(
|
||||
name="read_file",
|
||||
description="Read a file from disk",
|
||||
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
|
||||
)
|
||||
from cowork_local.providers.openai_compat import OpenAICompatProvider
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def registry() -> ProviderRegistry:
|
||||
"""A private registry per test so registrations never leak between tests."""
|
||||
return ProviderRegistry(BUILTIN_DESCRIPTORS)
|
||||
class _StubResponse:
|
||||
"""Minimal stand-in for a streamed ``requests.Response``.
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def collected_usage(monkeypatch) -> usage_sink.InMemoryUsageSink:
|
||||
"""Swap the process-wide telemetry sink for an in-memory one.
|
||||
|
||||
Restored by monkeypatch after each test, so a contract run never appends to
|
||||
the developer's real ``~/.cowork_local/usage/`` files.
|
||||
Only the members the provider code actually touches are implemented; adding
|
||||
more would invite tests that pass against the stub but not against requests.
|
||||
"""
|
||||
sink = usage_sink.InMemoryUsageSink()
|
||||
monkeypatch.setattr(usage_sink, "_sink", usage_sink.CompositeUsageSink([sink]))
|
||||
return sink
|
||||
|
||||
def __init__(self, lines: List[str], status_code: int = 200, text: str = "") -> None:
|
||||
self._lines = lines
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.headers: Dict[str, str] = {}
|
||||
self.encoding = "utf-8"
|
||||
self.closed = False
|
||||
|
||||
def iter_lines(self, decode_unicode: bool = False):
|
||||
yield from self._lines
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def json(self) -> Any:
|
||||
return json.loads(self.text or "{}")
|
||||
|
||||
|
||||
def _fixtures_for(registry: ProviderRegistry, provider_id: str) -> dict:
|
||||
"""The stream/model-list script matching this provider's wire protocol."""
|
||||
protocol = registry.get(provider_id).wire_protocol.value
|
||||
return PROTOCOL_FIXTURES[protocol]
|
||||
def _sse(*payloads: Dict[str, Any]) -> List[str]:
|
||||
"""Render payloads as SSE ``data:`` lines, the wire shape both adapters parse."""
|
||||
return [f"data: {json.dumps(p)}" for p in payloads]
|
||||
|
||||
|
||||
def _build(registry: ProviderRegistry, provider_id: str, transport=None) -> Provider:
|
||||
"""Build a provider and (optionally) replace its transport with a script."""
|
||||
provider = registry.build(provider_id, dict(BASE_CONF))
|
||||
if transport is not None:
|
||||
# Patch the INSTANCE, not the class: parallel parametrised cases must
|
||||
# not see each other's scripted transport.
|
||||
provider._request = transport
|
||||
return provider
|
||||
@pytest.fixture
|
||||
def canned(monkeypatch):
|
||||
"""Return a helper that makes every provider request answer with ``lines``."""
|
||||
|
||||
def _install(lines: List[str], status_code: int = 200, text: str = "") -> Dict[str, Any]:
|
||||
seen: Dict[str, Any] = {}
|
||||
|
||||
def fake_request(self, method, url, **kwargs):
|
||||
# Capture the outgoing payload so tests can assert on how the
|
||||
# canonical message list was translated to the provider's wire format.
|
||||
seen["method"] = method
|
||||
seen["url"] = url
|
||||
seen["json"] = kwargs.get("json")
|
||||
return _StubResponse(lines, status_code=status_code, text=text)
|
||||
|
||||
monkeypatch.setattr(Provider, "_request", fake_request, raising=True)
|
||||
return seen
|
||||
|
||||
return _install
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Construction & interface shape
|
||||
# Shared base-class behaviour every provider inherits
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_registry_builds_a_provider_for_every_registered_id(registry, provider_id) -> None:
|
||||
"""Every catalogued provider must be constructible — a descriptor with no
|
||||
working adapter is a broken entry, not a feature flag."""
|
||||
provider = _build(registry, provider_id)
|
||||
def _providers_under_test() -> List[Provider]:
|
||||
"""One instance of each implementation, configured but never called."""
|
||||
conf = {"base_url": "https://example.invalid/v1", "api_key": "k", "model": "m"}
|
||||
return [
|
||||
OpenAICompatProvider(dict(conf)),
|
||||
AnthropicProvider(dict(conf)),
|
||||
FakeProvider(),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", _providers_under_test(), ids=lambda p: type(p).__name__)
|
||||
def test_every_provider_exposes_the_base_contract(provider):
|
||||
assert isinstance(provider, Provider)
|
||||
# The registry fills in the descriptor's default model when config omits it,
|
||||
# so a half-configured provider still names a concrete model.
|
||||
assert provider.model, f"{provider_id} built without a model id"
|
||||
assert callable(provider.chat)
|
||||
assert callable(provider.list_models)
|
||||
assert callable(provider.test_connection)
|
||||
# `name` identifies the provider in usage records and audit entries; an
|
||||
# implementation that forgot to set it would silently report as "base".
|
||||
assert provider.name and provider.name != "base"
|
||||
assert isinstance(provider.supports_vision, bool)
|
||||
assert provider.describe() == f"{provider.name}:{provider.model}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_chat_signature_is_uniform(registry, provider_id) -> None:
|
||||
"""All adapters accept the same call, so the agent runtime can swap
|
||||
providers without knowing which one it holds."""
|
||||
import inspect
|
||||
|
||||
provider = _build(registry, provider_id)
|
||||
params = list(inspect.signature(provider.chat).parameters)
|
||||
|
||||
assert params == ["messages", "tools", "on_text", "cancel", "on_reasoning"]
|
||||
@pytest.mark.parametrize("provider", _providers_under_test(), ids=lambda p: type(p).__name__)
|
||||
def test_strip_think_removes_inline_reasoning_from_a_final_answer(provider):
|
||||
"""Safety net for gateways that fold reasoning into the content stream: the
|
||||
answer stored in history must never contain a <think> block."""
|
||||
assert provider.strip_think("<think>secret</think>Answer") == "Answer"
|
||||
assert provider.strip_think("Plain answer") == "Plain answer"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_tool_schema_matches_the_wire_protocol(registry, provider_id) -> None:
|
||||
"""A ToolSpec must translate into the exact shape the endpoint expects."""
|
||||
descriptor = registry.get(provider_id)
|
||||
def test_tool_spec_translates_to_both_wire_formats():
|
||||
"""One ToolSpec must render for both protocols - this is what lets the agent
|
||||
loop build its tool catalogue once and reuse it across providers."""
|
||||
spec = ToolSpec(name="save_file", description="Write a file",
|
||||
parameters={"type": "object", "properties": {}})
|
||||
|
||||
if descriptor.wire_protocol.value == "anthropic":
|
||||
translated = SAMPLE_TOOL.to_anthropic()
|
||||
assert translated["input_schema"] == SAMPLE_TOOL.parameters
|
||||
assert translated["name"] == "read_file"
|
||||
else:
|
||||
translated = SAMPLE_TOOL.to_openai()
|
||||
assert translated["type"] == "function"
|
||||
assert translated["function"]["parameters"] == SAMPLE_TOOL.parameters
|
||||
openai_shape = spec.to_openai()
|
||||
anthropic_shape = spec.to_anthropic()
|
||||
|
||||
assert openai_shape["type"] == "function"
|
||||
assert openai_shape["function"]["name"] == "save_file"
|
||||
assert openai_shape["function"]["parameters"] == spec.parameters
|
||||
# Anthropic names the same field `input_schema`; the values must stay equal,
|
||||
# otherwise the same tool would validate differently per provider.
|
||||
assert anthropic_shape["name"] == "save_file"
|
||||
assert anthropic_shape["input_schema"] == spec.parameters
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The turn itself
|
||||
# Streaming contract - real adapters, canned transport
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_chat_returns_the_canonical_assistant_message(registry, provider_id, collected_usage) -> None:
|
||||
"""Whatever the wire format, one turn yields the same canonical result."""
|
||||
fixtures = _fixtures_for(registry, provider_id)
|
||||
response = FakeStreamResponse(lines=fixtures["stream_lines"]())
|
||||
transport = ScriptedTransport([response])
|
||||
provider = _build(registry, provider_id, transport)
|
||||
def test_openai_compat_streams_text_and_returns_canonical_message(canned):
|
||||
canned(_sse(
|
||||
{"choices": [{"delta": {"content": "Hel"}}]},
|
||||
{"choices": [{"delta": {"content": "lo"}}]},
|
||||
) + ["data: [DONE]"])
|
||||
provider = OpenAICompatProvider({"base_url": "https://x.invalid/v1",
|
||||
"api_key": "k", "model": "m"})
|
||||
chunks: List[str] = []
|
||||
|
||||
streamed: list = []
|
||||
result = provider.chat(
|
||||
SAMPLE_MESSAGES, tools=[SAMPLE_TOOL], on_text=streamed.append,
|
||||
)
|
||||
result = provider.chat([{"role": "user", "content": "hi"}], on_text=chunks.append)
|
||||
|
||||
assert "".join(chunks) == "Hello"
|
||||
assert result["role"] == "assistant"
|
||||
assert result["content"] == EXPECTED_TEXT
|
||||
# Text must arrive incrementally, not only in the final message — the chat
|
||||
# UI streams from these callbacks.
|
||||
assert "".join(streamed) == EXPECTED_TEXT
|
||||
assert len(streamed) >= 2
|
||||
# Tool calls are normalised: parsed arguments, never the raw JSON fragments.
|
||||
assert result["tool_calls"] == [EXPECTED_TOOL_CALL]
|
||||
assert response.closed, "provider left the streaming response open"
|
||||
assert result["content"] == "Hello"
|
||||
assert result["tool_calls"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_chat_publishes_exactly_one_usage_event(registry, provider_id, collected_usage) -> None:
|
||||
"""R03-T06: a turn reports its token usage through the telemetry sink, with
|
||||
the server's real counts when the stream carried them."""
|
||||
fixtures = _fixtures_for(registry, provider_id)
|
||||
transport = ScriptedTransport([FakeStreamResponse(lines=fixtures["stream_lines"]())])
|
||||
provider = _build(registry, provider_id, transport)
|
||||
def test_openai_compat_keeps_reasoning_out_of_the_answer(canned):
|
||||
canned(_sse(
|
||||
{"choices": [{"delta": {"reasoning_content": "hmm..."}}]},
|
||||
{"choices": [{"delta": {"content": "42"}}]},
|
||||
) + ["data: [DONE]"])
|
||||
provider = OpenAICompatProvider({"base_url": "https://x.invalid/v1",
|
||||
"api_key": "k", "model": "m"})
|
||||
text: List[str] = []
|
||||
reasoning: List[str] = []
|
||||
|
||||
provider.chat(SAMPLE_MESSAGES, tools=[SAMPLE_TOOL])
|
||||
result = provider.chat([{"role": "user", "content": "q"}],
|
||||
on_text=text.append, on_reasoning=reasoning.append)
|
||||
|
||||
events = collected_usage.snapshot()
|
||||
assert len(events) == 1, "a turn must publish exactly one usage event"
|
||||
event = events[0]
|
||||
assert event.provider == provider.name
|
||||
assert event.model == provider.model
|
||||
assert event.input_tokens == EXPECTED_INPUT_TOKENS
|
||||
assert event.output_tokens == EXPECTED_OUTPUT_TOKENS
|
||||
assert event.cached_tokens == EXPECTED_CACHED_TOKENS
|
||||
# Real counts were available, so the event must NOT be flagged as a guess.
|
||||
assert event.estimated is False
|
||||
assert reasoning == ["hmm..."]
|
||||
assert result["content"] == "42"
|
||||
assert "hmm" not in result["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_usage_is_estimated_when_the_stream_reports_none(registry, provider_id, collected_usage) -> None:
|
||||
"""Gateways that never send usage still produce a dashboard row — clearly
|
||||
flagged as an estimate rather than silently recorded as zero."""
|
||||
# Only text; no usage block anywhere in the stream.
|
||||
silent_stream = ['data: ' + '{"choices": [{"delta": {"content": "hi"}}]}', "data: [DONE]"]
|
||||
if registry.get(provider_id).wire_protocol.value == "anthropic":
|
||||
silent_stream = [
|
||||
'data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}',
|
||||
'data: {"type": "content_block_delta", "index": 0,'
|
||||
' "delta": {"type": "text_delta", "text": "hi"}}',
|
||||
]
|
||||
transport = ScriptedTransport([FakeStreamResponse(lines=silent_stream)])
|
||||
provider = _build(registry, provider_id, transport)
|
||||
def test_openai_compat_returns_tool_calls_with_parsed_arguments(canned):
|
||||
"""Arguments arrive as a JSON string split across chunks; the contract says
|
||||
the caller receives a ready-to-use dict."""
|
||||
canned(_sse(
|
||||
{"choices": [{"delta": {"tool_calls": [
|
||||
{"index": 0, "id": "call_a", "function": {"name": "save_file",
|
||||
"arguments": '{"filename":'}}]}}]},
|
||||
{"choices": [{"delta": {"tool_calls": [
|
||||
{"index": 0, "function": {"arguments": '"a.md"}'}}]}}]},
|
||||
) + ["data: [DONE]"])
|
||||
provider = OpenAICompatProvider({"base_url": "https://x.invalid/v1",
|
||||
"api_key": "k", "model": "m"})
|
||||
|
||||
provider.chat(SAMPLE_MESSAGES)
|
||||
result = provider.chat([{"role": "user", "content": "save it"}])
|
||||
|
||||
events = collected_usage.snapshot()
|
||||
assert len(events) == 1
|
||||
assert events[0].estimated is True
|
||||
# An estimate still has to be a positive number to be worth showing.
|
||||
assert events[0].total_tokens > 0
|
||||
assert len(result["tool_calls"]) == 1
|
||||
call = result["tool_calls"][0]
|
||||
assert call["id"] == "call_a"
|
||||
assert call["name"] == "save_file"
|
||||
assert call["arguments"] == {"filename": "a.md"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Failure behaviour
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_http_error_becomes_provider_error(registry, provider_id, collected_usage) -> None:
|
||||
"""Callers handle exactly one exception type; adapters must not leak
|
||||
transport- or JSON-level errors past their boundary."""
|
||||
failing = FakeStreamResponse(status_code=401, body='{"error": {"message": "bad key"}}')
|
||||
transport = ScriptedTransport([failing])
|
||||
provider = _build(registry, provider_id, transport)
|
||||
def test_anthropic_streams_text_and_returns_canonical_message(canned):
|
||||
canned(_sse(
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Hel"}},
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "lo"}},
|
||||
{"type": "message_stop"},
|
||||
))
|
||||
provider = AnthropicProvider({"base_url": "https://x.invalid",
|
||||
"api_key": "k", "model": "m"})
|
||||
chunks: List[str] = []
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "hi"}], on_text=chunks.append)
|
||||
|
||||
assert "".join(chunks) == "Hello"
|
||||
assert result["content"] == "Hello"
|
||||
assert result["role"] == "assistant"
|
||||
|
||||
|
||||
def test_anthropic_keeps_extended_thinking_out_of_the_answer(canned):
|
||||
canned(_sse(
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "thinking_delta", "thinking": "reasoning..."}},
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "text_delta", "text": "42"}},
|
||||
{"type": "message_stop"},
|
||||
))
|
||||
provider = AnthropicProvider({"base_url": "https://x.invalid",
|
||||
"api_key": "k", "model": "m"})
|
||||
reasoning: List[str] = []
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "q"}], on_reasoning=reasoning.append)
|
||||
|
||||
assert reasoning == ["reasoning..."]
|
||||
assert result["content"] == "42"
|
||||
|
||||
|
||||
def test_anthropic_returns_tool_calls_with_parsed_arguments(canned):
|
||||
canned(_sse(
|
||||
{"type": "content_block_start", "index": 0,
|
||||
"content_block": {"type": "tool_use", "id": "toolu_1", "name": "save_file"}},
|
||||
{"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"filename":"a.md"}'}},
|
||||
{"type": "message_stop"},
|
||||
))
|
||||
provider = AnthropicProvider({"base_url": "https://x.invalid",
|
||||
"api_key": "k", "model": "m"})
|
||||
|
||||
result = provider.chat([{"role": "user", "content": "save"}])
|
||||
|
||||
assert result["tool_calls"] == [
|
||||
{"id": "toolu_1", "name": "save_file", "arguments": {"filename": "a.md"}}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("factory", [
|
||||
lambda: OpenAICompatProvider({"base_url": "https://x.invalid/v1", "api_key": "k", "model": "m"}),
|
||||
lambda: AnthropicProvider({"base_url": "https://x.invalid", "api_key": "k", "model": "m"}),
|
||||
], ids=["openai_compat", "anthropic"])
|
||||
def test_transport_failure_surfaces_as_provider_error(canned, factory):
|
||||
"""Every failure mode must arrive as ProviderError so the agent loop needs
|
||||
exactly one except clause, whichever provider is active."""
|
||||
canned([], status_code=500, text="boom")
|
||||
|
||||
with pytest.raises(ProviderError):
|
||||
provider.chat(SAMPLE_MESSAGES)
|
||||
|
||||
assert failing.closed, "provider left a failed response open"
|
||||
factory().chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_list_models_and_test_connection_report_a_reason(registry, provider_id) -> None:
|
||||
"""A failed model load must explain itself: ``last_error`` is what Settings
|
||||
shows instead of an unexplained empty dropdown."""
|
||||
def _boom(*_args, **_kwargs):
|
||||
# A transport failure, i.e. what actually happens when the gateway is
|
||||
# unreachable — adapters translate this class of error, not arbitrary
|
||||
# programming errors, which must still surface as bugs.
|
||||
raise requests.ConnectionError("network down")
|
||||
def test_fake_provider_satisfies_the_same_streaming_contract():
|
||||
"""The double is only useful as a stand-in if it keeps the same promises the
|
||||
real adapters are held to above."""
|
||||
provider = FakeProvider([ScriptedTurn(text="Hello", reasoning="hmm")])
|
||||
text: List[str] = []
|
||||
reasoning: List[str] = []
|
||||
|
||||
provider = _build(registry, provider_id, _boom)
|
||||
result = provider.chat([{"role": "user", "content": "hi"}],
|
||||
on_text=text.append, on_reasoning=reasoning.append)
|
||||
|
||||
models = provider.list_models()
|
||||
|
||||
assert provider.last_error, f"{provider_id} swallowed a model-load failure"
|
||||
ok, message = provider.test_connection()
|
||||
assert ok is False
|
||||
assert message
|
||||
# Anthropic answers with a built-in fallback catalogue; a gateway answers
|
||||
# with nothing. Both are acceptable — the contract is only that a failure is
|
||||
# never reported as success.
|
||||
assert isinstance(models, list)
|
||||
assert "".join(text) == result["content"] == "Hello"
|
||||
assert reasoning == ["hmm"]
|
||||
assert result["role"] == "assistant"
|
||||
assert result["tool_calls"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_list_models_returns_ids_on_success(registry, provider_id) -> None:
|
||||
"""The happy path returns plain model-id strings, not raw API objects."""
|
||||
fixtures = _fixtures_for(registry, provider_id)
|
||||
transport = ScriptedTransport([
|
||||
FakeStreamResponse(status_code=200, payload=fixtures["models_payload"]),
|
||||
])
|
||||
provider = _build(registry, provider_id, transport)
|
||||
def test_fake_provider_raises_provider_error_like_the_real_ones():
|
||||
provider = FakeProvider([ScriptedTurn(error="gateway exploded")])
|
||||
|
||||
models = provider.list_models()
|
||||
|
||||
assert models == fixtures["expected_models"]
|
||||
assert provider.last_error == ""
|
||||
assert all(isinstance(m, str) for m in models)
|
||||
with pytest.raises(ProviderError):
|
||||
provider.chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||
def test_strip_think_removes_inline_reasoning(registry, provider_id) -> None:
|
||||
"""Reasoning must never leak into a final answer, whichever adapter ran."""
|
||||
provider = _build(registry, provider_id)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Registry <-> implementation agreement
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("descriptor", BUILT_IN_PROVIDERS, ids=lambda d: d.id)
|
||||
def test_every_descriptor_builds_a_working_provider(descriptor):
|
||||
"""A descriptor that cannot be built is a catalogue lying to the UI: Settings
|
||||
would list the provider and selecting it would fail at the first message."""
|
||||
registry = ProviderRegistry()
|
||||
conf = {"base_url": "https://x.invalid/v1", "api_key": "k"}
|
||||
|
||||
cleaned = provider.strip_think("<think>secret plan</think>Visible answer")
|
||||
provider = registry.build(descriptor.id, conf)
|
||||
|
||||
assert cleaned == "Visible answer"
|
||||
assert isinstance(provider, Provider)
|
||||
# The id, not the shared adapter class name: three descriptors map onto
|
||||
# OpenAICompatProvider, and usage/audit records must still tell them apart.
|
||||
assert provider.name == descriptor.id
|
||||
assert provider.model == descriptor.default_model
|
||||
|
||||
|
||||
@pytest.mark.parametrize("descriptor", BUILT_IN_PROVIDERS, ids=lambda d: d.id)
|
||||
def test_declared_vision_capability_matches_the_implementation(descriptor):
|
||||
"""``supports_vision`` decides whether an image block may be sent. A
|
||||
descriptor claiming vision for an adapter that cannot translate the block
|
||||
would route image turns into a guaranteed failure."""
|
||||
provider = ProviderRegistry().build(descriptor.id, {"base_url": "u", "api_key": "k"})
|
||||
|
||||
if descriptor.supports(ProviderCapability.VISION):
|
||||
assert provider.supports_vision is True
|
||||
|
||||
|
||||
def test_registry_build_never_mutates_the_caller_config():
|
||||
"""The routing layer runs one turn on a different model; if build() wrote
|
||||
that model back into the config dict it was handed, the override would
|
||||
silently become the user's saved default."""
|
||||
registry = ProviderRegistry()
|
||||
conf = {"base_url": "u", "api_key": "k", "model": "configured-model"}
|
||||
|
||||
provider = registry.build("openai_compat", conf, model="routed-model")
|
||||
|
||||
assert provider.model == "routed-model"
|
||||
assert conf["model"] == "configured-model"
|
||||
|
||||
|
||||
def test_registry_rejects_an_unknown_provider_with_provider_error():
|
||||
with pytest.raises(ProviderError) as excinfo:
|
||||
ProviderRegistry().build("does_not_exist", {})
|
||||
|
||||
# The message lists what IS known, so a typo in config is fixable from the
|
||||
# error alone without opening the source.
|
||||
assert "openai_compat" in str(excinfo.value)
|
||||
|
||||
+15
-4
@@ -1,5 +1,16 @@
|
||||
"""Test doubles and offline fakes package for Cowork Local test pyramid."""
|
||||
from .fake_provider import FakeProvider
|
||||
from .fake_tool_executor import FakeToolExecutor
|
||||
"""Offline test doubles for the refactoring safety net (R01-T02).
|
||||
|
||||
__all__ = ["FakeProvider", "FakeToolExecutor"]
|
||||
Every double here is deliberately Qt-free, network-free and disk-free so the
|
||||
unit/contract suites run in well under a second and give the same answer on a
|
||||
laptop, in CI and on a machine with no API keys configured.
|
||||
|
||||
* :class:`~tests.fakes.fake_provider.FakeProvider` - a scripted
|
||||
``providers.base.Provider`` that streams canned text/tool calls.
|
||||
* :class:`~tests.fakes.fake_tool_executor.FakeToolExecutor` - a scripted stand-in
|
||||
for the ``extra_executor`` callable that ``core.chat_agent.run_cowork`` routes
|
||||
MCP/connector tool calls to.
|
||||
"""
|
||||
from .fake_provider import FakeProvider, ScriptedTurn
|
||||
from .fake_tool_executor import FakeToolExecutor, ToolInvocation
|
||||
|
||||
__all__ = ["FakeProvider", "ScriptedTurn", "FakeToolExecutor", "ToolInvocation"]
|
||||
|
||||
+180
-80
@@ -1,58 +1,128 @@
|
||||
"""Fake LLM Provider for offline unit, contract, and characterization testing.
|
||||
"""FakeProvider - a scripted, offline stand-in for a real LLM provider (R01-T02).
|
||||
|
||||
Provides deterministic responses, stream simulation, tool-call dispatching,
|
||||
and fault injection without requiring any external network access or API keys.
|
||||
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
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
import itertools
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from providers.base import CancelFn, Provider, ProviderError, TextCallback, ToolSpec
|
||||
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):
|
||||
"""Deterministic test double mimicking real LLM Providers (OpenAI, Anthropic, Ollama)."""
|
||||
"""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, conf: Optional[Dict[str, Any]] = None) -> None:
|
||||
# Initialize base provider with default configuration if none provided
|
||||
super().__init__(conf or {"model": "fake-model-v1"})
|
||||
# History of all message batches sent across all chat calls
|
||||
self.call_history: List[List[Dict[str, Any]]] = []
|
||||
# Queue of programmed assistant responses to return sequentially
|
||||
self.response_queue: List[Dict[str, Any]] = []
|
||||
# Queue of exceptions to raise on corresponding calls
|
||||
self.error_queue: List[Exception] = []
|
||||
# Default text returned when response queue is empty
|
||||
self.default_text: str = "Fake model response."
|
||||
# Total number of chat invocations
|
||||
self.call_count: int = 0
|
||||
# Recorded tool specs passed into each turn
|
||||
self.last_tools: Optional[List[ToolSpec]] = None
|
||||
|
||||
def queue_response(
|
||||
def __init__(
|
||||
self,
|
||||
content: str = "",
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
reasoning: Optional[str] = None,
|
||||
chunks: Optional[List[str]] = None,
|
||||
) -> FakeProvider:
|
||||
"""Enqueue a pre-configured response structure for upcoming chat turns."""
|
||||
self.response_queue.append({
|
||||
"content": content,
|
||||
"tool_calls": tool_calls or [],
|
||||
"reasoning": reasoning,
|
||||
"chunks": chunks or ([content] if content else []),
|
||||
})
|
||||
return 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] = []
|
||||
|
||||
def queue_error(self, exc: Exception) -> FakeProvider:
|
||||
"""Enqueue an exception to simulate network/API errors on the next turn."""
|
||||
self.error_queue.append(exc)
|
||||
return self
|
||||
# -- 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]],
|
||||
@@ -61,53 +131,83 @@ class FakeProvider(Provider):
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_reasoning: Optional[TextCallback] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Simulate single LLM turn with full streaming and tool-call support."""
|
||||
self.call_count += 1
|
||||
self.call_history.append([dict(m) for m in messages])
|
||||
self.last_tools = tools
|
||||
"""Replay the next scripted turn, honouring cancel and both callbacks.
|
||||
|
||||
# 1. Check for injected errors
|
||||
if self.error_queue:
|
||||
raise self.error_queue.pop(0)
|
||||
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)
|
||||
|
||||
# 2. Check early cancellation before processing
|
||||
if cancel and cancel():
|
||||
raise ProviderError("Execution aborted by user cancel signal before response generation.")
|
||||
turn = self._next_turn()
|
||||
|
||||
# 3. Retrieve queued response or construct default response
|
||||
if self.response_queue:
|
||||
resp_spec = self.response_queue.pop(0)
|
||||
content = resp_spec.get("content", "")
|
||||
tool_calls = resp_spec.get("tool_calls", [])
|
||||
reasoning = resp_spec.get("reasoning")
|
||||
chunks = resp_spec.get("chunks", [content] if content else [])
|
||||
else:
|
||||
content = self.default_text
|
||||
tool_calls = []
|
||||
reasoning = None
|
||||
chunks = [content]
|
||||
# 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": []}
|
||||
|
||||
# 4. Stream reasoning chunks if provided
|
||||
if reasoning and on_reasoning:
|
||||
on_reasoning(reasoning)
|
||||
if turn.error:
|
||||
raise ProviderError(turn.error)
|
||||
|
||||
# 5. Stream text chunks, checking cancellation between fragments
|
||||
for chunk in chunks:
|
||||
if cancel and cancel():
|
||||
raise ProviderError("Execution cancelled during text chunk streaming.")
|
||||
if on_text and chunk:
|
||||
on_text(chunk)
|
||||
if turn.reasoning and on_reasoning:
|
||||
on_reasoning(turn.reasoning)
|
||||
|
||||
# 6. Return canonical assistant message payload
|
||||
assistant_msg: Dict[str, Any] = {
|
||||
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": content,
|
||||
"content": turn.text,
|
||||
"tool_calls": [
|
||||
{"id": f"call_{next(self._ids)}", "name": name, "arguments": dict(args)}
|
||||
for name, args in turn.tool_calls
|
||||
],
|
||||
}
|
||||
if tool_calls:
|
||||
assistant_msg["tool_calls"] = tool_calls
|
||||
|
||||
return assistant_msg
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Return available mock models for settings and validation tests."""
|
||||
return ["fake-model-v1", "fake-reasoner-pro", "fake-vision-plus"]
|
||||
"""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"]
|
||||
|
||||
@@ -1,71 +1,99 @@
|
||||
"""Fake Tool Executor for isolated, offline agent tool-call verification.
|
||||
"""FakeToolExecutor - offline stand-in for the extra-tool executor (R01-T02).
|
||||
|
||||
Allows tests to verify tool invocation arguments, mock tool return values,
|
||||
and simulate failures/delays without performing unsafe host disk or OS operations.
|
||||
``core.chat_agent.run_cowork`` routes any tool call whose name appears in
|
||||
``extra_tools`` to ``extra_executor(name, args)`` and expects back::
|
||||
|
||||
{"ok": bool, "output": str}
|
||||
|
||||
In production that callable reaches MCP servers, Microsoft 365 connectors and
|
||||
subprocesses. This double answers from a table instead, so the agent loop's tool
|
||||
branch is testable with no processes, no sockets and no credentials - and every
|
||||
invocation is recorded for assertions about what the agent actually asked for.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
|
||||
# A scripted answer is either the literal result dict, or a callable computing it
|
||||
# from the arguments (for tools whose output must depend on the input).
|
||||
ToolResult = Dict[str, Any]
|
||||
ScriptedResult = Union[ToolResult, Callable[[Dict[str, Any]], ToolResult]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolInvocation:
|
||||
"""One recorded ``extra_executor(name, args)`` call."""
|
||||
|
||||
name: str
|
||||
args: Dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeToolExecutor:
|
||||
"""Mock execution engine for agent tool-call dispatching."""
|
||||
"""Callable test double for ``run_cowork(extra_executor=...)``.
|
||||
|
||||
def __init__(self) -> None:
|
||||
# History of all executed tool invocations: List of {"name": str, "args": dict, "result": dict}
|
||||
self.call_log: List[Dict[str, Any]] = []
|
||||
# Custom handlers registered per tool name
|
||||
self.handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}
|
||||
# Pre-programmed fixed responses keyed by tool name
|
||||
self.mock_responses: Dict[str, Dict[str, Any]] = {}
|
||||
# Default response when no specific handler or response is found
|
||||
self.default_result: Dict[str, Any] = {"ok": True, "output": "Fake tool executed successfully."}
|
||||
Args:
|
||||
results: tool name -> scripted result (dict, or callable taking args).
|
||||
default: what to answer for a tool with no scripted result. ``None``
|
||||
(the default) answers with ``ok=False`` and an explicit message
|
||||
rather than raising - the production executor also reports unknown
|
||||
tools as a failed tool result, and matching that keeps the agent
|
||||
loop on its real code path instead of an exception path it would
|
||||
never take in production.
|
||||
"""
|
||||
|
||||
def register_handler(
|
||||
self,
|
||||
tool_name: str,
|
||||
handler: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||
) -> FakeToolExecutor:
|
||||
"""Register a dynamic handler function for a specific tool name."""
|
||||
self.handlers[tool_name] = handler
|
||||
return self
|
||||
results: Dict[str, ScriptedResult] = field(default_factory=dict)
|
||||
default: Optional[ScriptedResult] = None
|
||||
calls: List[ToolInvocation] = field(default_factory=list)
|
||||
|
||||
def set_mock_response(
|
||||
self,
|
||||
tool_name: str,
|
||||
result: Dict[str, Any],
|
||||
) -> FakeToolExecutor:
|
||||
"""Set a static return payload for a specific tool name."""
|
||||
self.mock_responses[tool_name] = result
|
||||
return self
|
||||
def __call__(self, name: str, args: Dict[str, Any]) -> ToolResult:
|
||||
"""Record the invocation and return its scripted result."""
|
||||
self.calls.append(ToolInvocation(name=name, args=dict(args or {})))
|
||||
scripted = self.results.get(name, self.default)
|
||||
if scripted is None:
|
||||
return {"ok": False, "output": f"No fake result scripted for tool '{name}'."}
|
||||
# A callable lets one entry serve many different arguments (e.g. echo the
|
||||
# path it was asked to read) without scripting every combination.
|
||||
resolved = scripted(dict(args or {})) if callable(scripted) else dict(scripted)
|
||||
resolved.setdefault("ok", True)
|
||||
resolved.setdefault("output", "")
|
||||
return resolved
|
||||
|
||||
def execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Execute a tool call using registered mocks and record invocation details."""
|
||||
# 1. Resolve result from handler, preset response, or default fallback
|
||||
if tool_name in self.handlers:
|
||||
result = self.handlers[tool_name](arguments)
|
||||
elif tool_name in self.mock_responses:
|
||||
result = self.mock_responses[tool_name]
|
||||
else:
|
||||
result = dict(self.default_result)
|
||||
result["tool"] = tool_name
|
||||
result["received_args"] = arguments
|
||||
# -- introspection helpers used by tests ---------------------------- #
|
||||
@property
|
||||
def call_names(self) -> List[str]:
|
||||
"""Tool names in call order - the usual thing a test asserts on."""
|
||||
return [c.name for c in self.calls]
|
||||
|
||||
# 2. Record execution trace for post-test assertions
|
||||
self.call_log.append({
|
||||
"name": tool_name,
|
||||
"args": dict(arguments),
|
||||
"result": dict(result),
|
||||
})
|
||||
def called(self, name: str) -> bool:
|
||||
"""True when ``name`` was invoked at least once."""
|
||||
return any(c.name == name for c in self.calls)
|
||||
|
||||
return result
|
||||
def args_for(self, name: str) -> List[Dict[str, Any]]:
|
||||
"""Every argument dict this tool was called with, in order."""
|
||||
return [c.args for c in self.calls if c.name == name]
|
||||
|
||||
def get_calls_for(self, tool_name: str) -> List[Dict[str, Any]]:
|
||||
"""Retrieve all recorded calls for a given tool name."""
|
||||
return [call for call in self.call_log if call["name"] == tool_name]
|
||||
def specs(self) -> List[ToolSpec]:
|
||||
"""``ToolSpec`` entries for the scripted tools, ready to pass as
|
||||
``run_cowork(extra_tools=...)``.
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear recorded logs and registered mock responses."""
|
||||
self.call_log.clear()
|
||||
self.handlers.clear()
|
||||
self.mock_responses.clear()
|
||||
The agent loop dispatches to ``extra_executor`` only for names present in
|
||||
``extra_tools``; generating the specs from the same table removes the
|
||||
chance of a test scripting a result the loop can never reach.
|
||||
"""
|
||||
return [
|
||||
ToolSpec(
|
||||
name=name,
|
||||
description=f"Fake tool '{name}' (test double).",
|
||||
# Permissive schema on purpose: these specs exist to register the
|
||||
# name with the agent loop, not to validate arguments.
|
||||
parameters={"type": "object", "properties": {}, "additionalProperties": True},
|
||||
)
|
||||
for name in self.results
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["FakeToolExecutor", "ToolInvocation"]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Integration tests: several real layers wired together, still fully offline.
|
||||
"""Integration tests: real widgets, real services, no network (R10-T01 layout).
|
||||
|
||||
Where unit tests pin one class against fakes and contract tests pin an interface
|
||||
across implementations, these exercise a real path end to end — e.g. the
|
||||
application routing service on top of the real ``core/routing`` engine — so a
|
||||
seam that only works against a mock is caught here.
|
||||
These build actual Qt widgets offscreen (``QT_QPA_PLATFORM=offscreen``) and run
|
||||
a turn end to end with a scripted :class:`FakeProvider`. They are slower than
|
||||
the unit suite - a QApplication has to exist - and are what proves the seams
|
||||
introduced by R03/R04 are actually wired into the screens, not just correct in
|
||||
isolation.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""End-to-end check that the Cowork screen really runs turns through the
|
||||
application layer (R04-T04).
|
||||
|
||||
The unit tests prove ``ConversationApplicationService`` behaves correctly; this
|
||||
one proves ``ui/cowork_tab.py::build_job`` actually goes through it, on a real
|
||||
(offscreen) widget, with a scripted provider instead of a network call.
|
||||
|
||||
It also pins the property that motivated R04-T01: the turn runs on the state
|
||||
captured at SUBMIT time, so a user editing the conversation while a turn is in
|
||||
flight cannot change what that turn sends.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core import chat_agent # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
from tests.fakes import FakeProvider, ScriptedTurn # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
"""One QApplication for the module - Qt allows only a single instance."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cowork_tab(qt_app, tmp_path: Path, monkeypatch):
|
||||
"""A real CoworkTab on a throwaway config, with ambient inputs neutralised."""
|
||||
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
||||
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
||||
from cowork_local.core import audit_log
|
||||
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
||||
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
# Agent Security's prompt validation is ON by default and spends an EXTRA
|
||||
# provider call reviewing the request before the agent loop starts (see
|
||||
# core/agent_security.py::enforce_prompt). That is real behaviour - pinned
|
||||
# by its own test below - but it would make every other test here script a
|
||||
# turn that has nothing to do with what it is checking.
|
||||
ctx.config.agent_security["enabled"] = False
|
||||
return CoworkTab(ctx)
|
||||
|
||||
|
||||
class _StubWorker:
|
||||
"""The slice of ``core.worker.AgentWorker`` a job actually touches."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: List[Dict[str, Any]] = []
|
||||
self.gates_requested = 0
|
||||
self._cancelled = False
|
||||
|
||||
def emit_event(self, payload: Dict[str, Any]) -> None:
|
||||
self.events.append(payload)
|
||||
|
||||
def is_cancelled(self) -> bool:
|
||||
return self._cancelled
|
||||
|
||||
def new_gate(self, _mode: str, **_kwargs) -> Any:
|
||||
self.gates_requested += 1
|
||||
return None
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
|
||||
def _run_job(tab, worker, provider, text="hello", messages=None, out_dir=None):
|
||||
"""Build the tab's job with ``provider`` pinned, then run it like the worker
|
||||
thread would."""
|
||||
tab.build_provider = lambda: provider # what routing/agent selection resolves to
|
||||
job = tab.build_job(text, messages if messages is not None
|
||||
else [{"role": "user", "content": text}], out_dir)
|
||||
return job(worker)
|
||||
|
||||
|
||||
def test_a_turn_runs_through_the_service_and_returns_history(cowork_tab, tmp_path):
|
||||
provider = FakeProvider([ScriptedTurn(text="Hello from the fake.")])
|
||||
worker = _StubWorker()
|
||||
|
||||
result = _run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
||||
|
||||
assert provider.call_count == 1
|
||||
# Same return contract as before the refactor - _cleanup_turn reads both keys.
|
||||
assert set(result) == {"messages", "turn_dir"}
|
||||
assert [m["role"] for m in result["messages"]] == ["system", "user", "assistant"]
|
||||
assert result["messages"][-1]["content"] == "Hello from the fake."
|
||||
|
||||
|
||||
def test_the_widget_still_receives_the_legacy_event_dicts(cowork_tab, tmp_path):
|
||||
"""The chat widgets consume dicts and are not migrated until EPIC R08, so
|
||||
the typed events must render back into exactly what they already handle -
|
||||
plus the new end-of-turn signal, which the if/elif dispatch ignores."""
|
||||
provider = FakeProvider([ScriptedTurn(text="Hi")])
|
||||
worker = _StubWorker()
|
||||
|
||||
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
||||
|
||||
assert [e["type"] for e in worker.events] == ["text", "assistant_done", "turn_completed"]
|
||||
assert worker.events[0] == {"type": "text", "delta": "Hi"}
|
||||
|
||||
|
||||
def test_the_turn_ignores_messages_added_after_it_was_submitted(cowork_tab, tmp_path):
|
||||
"""The bug ConversationExecutionRequest exists to prevent: the panel keeps
|
||||
appending to its own list while a turn is in flight."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
worker = _StubWorker()
|
||||
live_messages = [{"role": "user", "content": "first question"}]
|
||||
|
||||
job_result = _run_job(cowork_tab, worker, provider,
|
||||
messages=live_messages, out_dir=tmp_path / "turn")
|
||||
|
||||
# Simulate the user typing a second message DURING the turn by mutating the
|
||||
# list the panel handed over. The already-sent conversation must not include it.
|
||||
live_messages.append({"role": "user", "content": "typed while running"})
|
||||
|
||||
sent = provider.calls[0].messages
|
||||
assert [m["content"] for m in sent if m["role"] == "user"] == ["first question"]
|
||||
assert "typed while running" not in str(job_result["messages"])
|
||||
|
||||
|
||||
def test_a_failing_turn_still_raises_so_the_worker_reports_it(cowork_tab, tmp_path):
|
||||
"""core/worker.py turns an exception into the `failed` signal the chat panel
|
||||
already handles; swallowing it here would show a successful turn with no
|
||||
answer instead of an error."""
|
||||
provider = FakeProvider([ScriptedTurn(error="gateway down"),
|
||||
ScriptedTurn(error="gateway down")])
|
||||
worker = _StubWorker()
|
||||
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
||||
|
||||
assert "gateway down" in str(excinfo.value)
|
||||
# The error was still reported as an event before being re-raised.
|
||||
assert any(e["type"] == "error" for e in worker.events)
|
||||
|
||||
|
||||
def test_a_permission_gate_is_only_requested_when_the_workspace_asks_for_it(
|
||||
cowork_tab, tmp_path, monkeypatch):
|
||||
provider = FakeProvider([ScriptedTurn(text="ok"), ScriptedTurn(text="ok")])
|
||||
worker = _StubWorker()
|
||||
|
||||
monkeypatch.setattr(cowork_tab.ctx, "project_confirm_commands", lambda: False)
|
||||
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "a")
|
||||
assert worker.gates_requested == 0
|
||||
|
||||
monkeypatch.setattr(cowork_tab.ctx, "project_confirm_commands", lambda: True)
|
||||
_run_job(cowork_tab, worker, provider, out_dir=tmp_path / "b")
|
||||
assert worker.gates_requested == 1
|
||||
|
||||
|
||||
def test_a_tool_turn_writes_into_this_turns_own_output_folder(cowork_tab, tmp_path):
|
||||
"""Turn isolation: each turn writes into its own directory so parallel turns
|
||||
cannot clobber each other's files."""
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "n.md", "content": "x"})]),
|
||||
ScriptedTurn(text="Saved."),
|
||||
])
|
||||
worker = _StubWorker()
|
||||
turn_dir = tmp_path / "turn-1"
|
||||
|
||||
result = _run_job(cowork_tab, worker, provider, out_dir=turn_dir)
|
||||
|
||||
assert result["turn_dir"] == str(turn_dir)
|
||||
assert [p.name for p in turn_dir.iterdir()] and turn_dir.exists()
|
||||
assert any(e["type"] == "tool_result" and e["ok"] for e in worker.events)
|
||||
|
||||
|
||||
def test_agent_security_still_reviews_the_request_before_the_turn_runs(
|
||||
cowork_tab, tmp_path):
|
||||
"""Characterisation, not a new behaviour: with Agent Security enabled (the
|
||||
shipped default) a turn costs an EXTRA provider call, because the request is
|
||||
reviewed against the rulebase before the agent loop starts.
|
||||
|
||||
Pinned here because it is invisible from the call site and easy to break -
|
||||
routing a turn through the application layer must not skip the review.
|
||||
"""
|
||||
cowork_tab.ctx.config.agent_security["enabled"] = True
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(text="ALLOW"), # the security pre-flight review
|
||||
ScriptedTurn(text="the answer"), # the turn itself
|
||||
])
|
||||
worker = _StubWorker()
|
||||
|
||||
result = _run_job(cowork_tab, worker, provider, out_dir=tmp_path / "turn")
|
||||
|
||||
assert provider.call_count == 2
|
||||
assert result["messages"][-1]["content"] == "the answer"
|
||||
@@ -0,0 +1,256 @@
|
||||
"""The three chat surfaces really route through the shared service (R03-T04/T05).
|
||||
|
||||
The unit suite proves ``RoutingApplicationService`` decides correctly against a
|
||||
fake router. This file proves the three widgets that used to own a private copy
|
||||
of that algorithm now call it, on real (offscreen) widgets:
|
||||
|
||||
* ``ui/chat_panel.py::_apply_routing`` (Cowork)
|
||||
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E)
|
||||
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
|
||||
|
||||
It also pins the Manual-mode handshake, including the field contract the
|
||||
existing confirm dialog reads off the decision - the one place where the new
|
||||
``RoutingDecision`` has to look like the legacy ``SwitchDecision`` it replaced.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.application.model_routing import ( # noqa: E402
|
||||
RoutingApplicationService,
|
||||
RoutingDecision,
|
||||
RoutingMode,
|
||||
)
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path: Path) -> AppContext:
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
class _FakeRouteResult:
|
||||
"""Shaped like ``core.routing.service.RouteResult``."""
|
||||
|
||||
def __init__(self, provider: str, model: str, gain: float = 0.4,
|
||||
task: str = "coding") -> None:
|
||||
self.should_switch = True
|
||||
self._target = (provider, model)
|
||||
self.task_type = type("_T", (), {"value": task})()
|
||||
self.decision = type("_D", (), {"score_gain": gain, "reason": "better fit"})()
|
||||
|
||||
def target(self) -> Optional[Tuple[str, str]]:
|
||||
return self._target
|
||||
|
||||
|
||||
class _FakeRouter:
|
||||
"""Minimal RoutingPort: always proposes the same switch, records the surface."""
|
||||
|
||||
def __init__(self, provider="anthropic", model="claude-sonnet-4-6") -> None:
|
||||
self.result = _FakeRouteResult(provider, model)
|
||||
self.surfaces: List[str] = []
|
||||
|
||||
def route(self, surface, prompt, current_provider, current_model, **kwargs):
|
||||
self.surfaces.append(surface)
|
||||
return self.result
|
||||
|
||||
|
||||
def _install(ctx: AppContext, mode: str) -> _FakeRouter:
|
||||
"""Wire a fake router into the context and force ``mode`` on every surface."""
|
||||
router = _FakeRouter()
|
||||
service = RoutingApplicationService(router, mode_reader=lambda _surface: mode)
|
||||
ctx._routing_application = service # already-built instance; accessor returns it
|
||||
return router
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Cowork chat
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_cowork_applies_an_auto_switch_to_the_next_turn(ctx):
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = CoworkTab(ctx)
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert router.surfaces == [tab.kind]
|
||||
# build_provider() honours these for THIS turn only.
|
||||
assert (tab._routed_provider, tab._routed_model) == ("anthropic", "claude-sonnet-4-6")
|
||||
assert turn["bubbles"], "the user must be told the model was switched"
|
||||
|
||||
|
||||
def test_cowork_leaves_the_model_alone_when_routing_is_off(ctx):
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
router = _install(ctx, "off")
|
||||
tab = CoworkTab(ctx)
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert router.surfaces == []
|
||||
assert (tab._routed_provider, tab._routed_model) == (None, None)
|
||||
assert turn["bubbles"] == []
|
||||
|
||||
|
||||
def test_cowork_manual_mode_switches_only_after_the_dialog_approves(ctx, monkeypatch):
|
||||
from cowork_local.ui import chat_panel as chat_panel_module
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
_install(ctx, "manual")
|
||||
tab = CoworkTab(ctx)
|
||||
asked: List[Any] = []
|
||||
monkeypatch.setattr(tab, "_confirm_routing_switch",
|
||||
lambda decision: asked.append(decision) or True)
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert len(asked) == 1
|
||||
assert (tab._routed_provider, tab._routed_model) == ("anthropic", "claude-sonnet-4-6")
|
||||
|
||||
|
||||
def test_cowork_manual_mode_keeps_the_model_when_the_dialog_is_declined(ctx, monkeypatch):
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
_install(ctx, "manual")
|
||||
tab = CoworkTab(ctx)
|
||||
monkeypatch.setattr(tab, "_confirm_routing_switch", lambda _decision: False)
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert (tab._routed_provider, tab._routed_model) == (None, None)
|
||||
assert turn["bubbles"] == []
|
||||
|
||||
|
||||
def test_a_pinned_admin_agent_still_wins_over_routing(ctx):
|
||||
"""An explicitly chosen Admin agent pins its own provider/model; routing must
|
||||
not override a deliberate user choice."""
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = CoworkTab(ctx)
|
||||
tab._admin_agent = object()
|
||||
turn: dict = {"bubbles": []}
|
||||
|
||||
tab._apply_routing("write a function", turn)
|
||||
|
||||
assert router.surfaces == []
|
||||
assert (tab._routed_provider, tab._routed_model) == (None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Co4E
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_co4e_routes_on_its_own_surface_key_and_returns_the_model(ctx):
|
||||
from cowork_local.ui.co4e_tab import Co4ETab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = Co4ETab(ctx)
|
||||
|
||||
model = tab._apply_co4e_routing("build me a flow")
|
||||
|
||||
assert router.surfaces == ["co4e"]
|
||||
assert model == "claude-sonnet-4-6"
|
||||
assert tab._co4e_routed_provider == "anthropic"
|
||||
|
||||
|
||||
def test_co4e_returns_an_empty_model_when_routing_is_off(ctx):
|
||||
"""'' means "use the provider default" - the contract _run_chat_turn expects."""
|
||||
from cowork_local.ui.co4e_tab import Co4ETab
|
||||
|
||||
_install(ctx, "off")
|
||||
tab = Co4ETab(ctx)
|
||||
|
||||
assert tab._apply_co4e_routing("build me a flow") == ""
|
||||
assert tab._co4e_routed_provider is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# AI-Edit
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_ai_edit_routes_on_its_own_surface_key(ctx):
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
|
||||
assert router.surfaces == ["ai_edit"]
|
||||
assert (tab._ai_routed_provider, tab._ai_routed_model) == (
|
||||
"anthropic", "claude-sonnet-4-6")
|
||||
|
||||
|
||||
def test_ai_edit_pins_the_coding_task_type(ctx):
|
||||
"""An edit instruction is never a QA question, so AI-Edit skips
|
||||
classification entirely - the constraint has to survive the move into the
|
||||
shared service or it is silently dropped."""
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
|
||||
seen: List[Any] = []
|
||||
|
||||
class _Recorder(_FakeRouter):
|
||||
def route(self, surface, prompt, current_provider, current_model, **kwargs):
|
||||
seen.append(kwargs.get("task_type"))
|
||||
return super().route(surface, prompt, current_provider, current_model, **kwargs)
|
||||
|
||||
ctx._routing_application = RoutingApplicationService(
|
||||
_Recorder(), mode_reader=lambda _s: "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
|
||||
assert seen == [TaskType.CODING]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The confirm dialog's field contract
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_the_decision_exposes_exactly_what_the_confirm_dialog_reads():
|
||||
"""``ui/routing_toggle.py::confirm_switch`` is not migrated until EPIC R08,
|
||||
so it still reads ``from_model``/``to_model`` as ``provider/model`` candidate
|
||||
keys and splits them. A rename here would blow up inside a modal dialog -
|
||||
the one place a failure is hardest to see in a test run."""
|
||||
from cowork_local.core.routing.models import split_key
|
||||
|
||||
decision = RoutingDecision(
|
||||
mode=RoutingMode.MANUAL, provider="anthropic", model="claude-sonnet-4-6",
|
||||
switched=True, task_type="coding", score_gain=0.31, reason="better fit",
|
||||
previous_provider="openai_compat", previous_model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
assert split_key(decision.from_model)[1] == "gpt-4o-mini"
|
||||
assert split_key(decision.to_model)[1] == "claude-sonnet-4-6"
|
||||
assert decision.task_type == "coding"
|
||||
assert f"{decision.score_gain:.2f}" == "0.31"
|
||||
assert decision.reason == "better fit"
|
||||
|
||||
|
||||
def test_a_first_turn_with_no_current_model_yields_an_empty_from_model():
|
||||
"""split_key() is only called when from_model is truthy, so an unset current
|
||||
model must produce "" rather than a bare "provider/"."""
|
||||
decision = RoutingDecision(mode=RoutingMode.AUTO, provider="anthropic",
|
||||
model="claude", switched=True)
|
||||
|
||||
assert decision.from_model == ""
|
||||
@@ -1,249 +0,0 @@
|
||||
"""R03-T03/T04/T05 — the unified routing path over the REAL routing engine.
|
||||
|
||||
The unit tests drive ``RoutingApplicationService`` against fakes; this suite
|
||||
proves the same service produces correct outcomes on top of the actual
|
||||
``core/routing`` stack (classifier → assessment store → scorer → selector →
|
||||
switch controller), which is what the three chat surfaces now call.
|
||||
|
||||
Offline by construction: a fake probe client answers benchmarks and judging, and
|
||||
the assessment store is a temp file — no network, no Qt, no ``$HOME`` writes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from cowork_local.application.model_routing import (
|
||||
AppContextModeResolver,
|
||||
CoreRoutingEngine,
|
||||
RoutingApplicationService,
|
||||
RoutingMode,
|
||||
RoutingRequest,
|
||||
)
|
||||
from cowork_local.config import DEFAULT_CONFIG, AppConfig
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.routing.clients import CompletionResult
|
||||
from cowork_local.core.routing.service import RoutingService
|
||||
from cowork_local.core.routing.store import AssessmentStore
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
STRONG_ANSWER = "STRONG-DETAILED-CORRECT-ANSWER"
|
||||
WEAK_ANSWER = "weak"
|
||||
|
||||
|
||||
class FakeProbeClient:
|
||||
"""Deterministic stand-in for the provider layer used during assessment.
|
||||
|
||||
Mirrors ``tests/routing/test_service.py``'s client: benchmark prompts get a
|
||||
per-model canned answer, and judge prompts are graded by looking up that
|
||||
answer, so scores are stable and no model is ever really called.
|
||||
"""
|
||||
|
||||
def __init__(self, answers, quality) -> None:
|
||||
self.answers = answers
|
||||
self.quality = quality
|
||||
|
||||
def complete(self, provider, model_id, messages) -> CompletionResult:
|
||||
text = messages[0]["content"]
|
||||
if "grading an AI assistant" in text: # the judge rubric prompt
|
||||
score = 0.0
|
||||
for answer, value in self.quality.items():
|
||||
if answer and answer in text:
|
||||
score = value
|
||||
break
|
||||
return CompletionResult(text='{"score": %s}' % score)
|
||||
answer = self.answers.get((provider, model_id))
|
||||
if answer is None:
|
||||
return CompletionResult(error="unavailable")
|
||||
return CompletionResult(text=answer, tokens_out=len(answer) // 4)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ctx(tmp_path, monkeypatch):
|
||||
"""An AppContext with two assessable models and temp-only persistence."""
|
||||
# Keep workspace load/save off the developer's real ~/.cowork_local.
|
||||
monkeypatch.setattr(projects_mod, "PROJECTS_DIR", tmp_path / "projects")
|
||||
data = copy.deepcopy(DEFAULT_CONFIG)
|
||||
data["providers"] = {
|
||||
"anthropic": {"base_url": "x", "api_key": "x", "model": "strong-model"},
|
||||
}
|
||||
data["routing"]["candidates"] = [
|
||||
{"provider": "anthropic", "model_id": "strong-model", "tier": "powerful"},
|
||||
{"provider": "anthropic", "model_id": "weak-model", "tier": "fast"},
|
||||
]
|
||||
data["routing"]["judge_provider"] = "anthropic"
|
||||
data["routing"]["judge_model"] = "judge-model"
|
||||
data["routing"]["policy"] = "quality"
|
||||
data["routing"]["min_score_gain"] = 0.05
|
||||
return AppContext(AppConfig(data=data, path=tmp_path / "config.json"))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def routing_service(ctx, tmp_path) -> RoutingService:
|
||||
"""A real RoutingService with a populated assessment store."""
|
||||
client = FakeProbeClient(
|
||||
answers={
|
||||
("anthropic", "strong-model"): STRONG_ANSWER,
|
||||
("anthropic", "weak-model"): WEAK_ANSWER,
|
||||
},
|
||||
quality={STRONG_ANSWER: 0.95, WEAK_ANSWER: 0.35},
|
||||
)
|
||||
store = AssessmentStore(store_path=tmp_path / "assess.json",
|
||||
history_dir=tmp_path / "history")
|
||||
service = RoutingService(ctx, store=store, client=client)
|
||||
service.reassess() # populate real probe results + fit scores
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app_service(ctx, routing_service) -> RoutingApplicationService:
|
||||
"""The application service wired exactly the way the UI wires it."""
|
||||
return RoutingApplicationService(
|
||||
CoreRoutingEngine(routing_service),
|
||||
AppContextModeResolver(ctx),
|
||||
confirm_timeout_sec=lambda: float(ctx.config.routing["confirm_timeout_sec"]),
|
||||
)
|
||||
|
||||
|
||||
def coding_request(**overrides) -> RoutingRequest:
|
||||
"""A coding turn currently pinned to the weaker model."""
|
||||
fields = dict(
|
||||
surface="cowork",
|
||||
prompt="Write a Python function to reverse a linked list",
|
||||
current_provider="anthropic",
|
||||
current_model="weak-model",
|
||||
)
|
||||
fields.update(overrides)
|
||||
return RoutingRequest(**fields)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Auto / Off / Manual over the real engine
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_auto_switches_to_the_better_assessed_model(app_service) -> None:
|
||||
"""The real scorer must rank the strong model first and the service must
|
||||
hand that model back as this turn's override."""
|
||||
outcome = app_service.resolve(coding_request(mode=RoutingMode.AUTO))
|
||||
|
||||
assert outcome.switched is True
|
||||
assert outcome.provider == "anthropic"
|
||||
assert outcome.model == "strong-model"
|
||||
assert outcome.task_type == "coding" # classified from the prompt
|
||||
assert outcome.score_gain > 0
|
||||
|
||||
|
||||
def test_off_keeps_the_pinned_model(app_service) -> None:
|
||||
"""Off must not switch even when a clearly better model is assessed."""
|
||||
outcome = app_service.resolve(coding_request(mode=RoutingMode.OFF))
|
||||
|
||||
assert outcome.switched is False
|
||||
assert outcome.provider is None
|
||||
|
||||
|
||||
def test_manual_asks_before_switching(app_service) -> None:
|
||||
"""The confirm callback receives the engine's own decision object, which is
|
||||
what ``ui/routing_toggle.py::confirm_switch`` renders."""
|
||||
seen: list = []
|
||||
|
||||
outcome = app_service.resolve(
|
||||
coding_request(mode=RoutingMode.MANUAL),
|
||||
confirm=lambda decision, timeout: seen.append((decision, timeout)) or True,
|
||||
)
|
||||
|
||||
assert outcome.switched is True
|
||||
decision, timeout = seen[0]
|
||||
assert decision.to_model == "anthropic/strong-model"
|
||||
assert decision.reason # human-readable explanation
|
||||
assert timeout == pytest.approx(60.0) # from DEFAULT_CONFIG
|
||||
|
||||
|
||||
def test_manual_decline_keeps_the_pinned_model(app_service) -> None:
|
||||
outcome = app_service.resolve(
|
||||
coding_request(mode=RoutingMode.MANUAL),
|
||||
confirm=lambda decision, timeout: False,
|
||||
)
|
||||
|
||||
assert outcome.switched is False
|
||||
assert outcome.declined is True
|
||||
|
||||
|
||||
def test_already_best_model_is_left_alone(app_service) -> None:
|
||||
"""No pointless churn: being on the best model is not a switch."""
|
||||
outcome = app_service.resolve(
|
||||
coding_request(mode=RoutingMode.AUTO, current_model="strong-model"))
|
||||
|
||||
assert outcome.switched is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fallback over the real engine
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_fallback_keeps_an_assessed_model_even_though_a_better_one_exists(app_service) -> None:
|
||||
"""weak-model IS usable (it has a real probe score), so Fallback stays put
|
||||
where Auto would switch — the behavioural difference between the modes."""
|
||||
outcome = app_service.resolve(coding_request(mode=RoutingMode.FALLBACK))
|
||||
|
||||
assert outcome.switched is False
|
||||
|
||||
|
||||
def test_fallback_rescues_a_model_the_engine_cannot_serve(app_service) -> None:
|
||||
"""A model absent from the ranking (never assessed / unavailable) is exactly
|
||||
the situation Fallback exists for."""
|
||||
outcome = app_service.resolve(
|
||||
coding_request(mode=RoutingMode.FALLBACK, current_model="ghost-model"))
|
||||
|
||||
assert outcome.switched is True
|
||||
assert outcome.model == "strong-model"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Surface parity — the point of R03-T04/T05
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("surface", ["cowork", "co4e", "ai_edit"])
|
||||
def test_every_surface_gets_the_same_decision(app_service, surface) -> None:
|
||||
"""Chat, Co4E and AI-Edit used to hold three copies of this logic. Given the
|
||||
same inputs they must now be indistinguishable."""
|
||||
outcome = app_service.resolve(coding_request(surface=surface, mode=RoutingMode.AUTO))
|
||||
|
||||
assert outcome.switched is True
|
||||
assert outcome.model == "strong-model"
|
||||
|
||||
|
||||
def test_ai_edit_pinned_task_type_reaches_the_engine(app_service) -> None:
|
||||
"""AI-Edit pins "coding" instead of classifying; the engine must honour it
|
||||
even when the instruction text reads like something else entirely."""
|
||||
outcome = app_service.resolve(coding_request(
|
||||
surface="ai_edit",
|
||||
prompt="Write a poem about the ocean", # classifier would say "creative"
|
||||
task_type="coding",
|
||||
mode=RoutingMode.AUTO,
|
||||
))
|
||||
|
||||
assert outcome.task_type == "coding"
|
||||
|
||||
|
||||
def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None:
|
||||
"""With no explicit mode, the service reads the per-workspace setting — the
|
||||
lookup the widgets used to do themselves."""
|
||||
ctx.config.data["routing"]["switch_mode"] = "auto"
|
||||
|
||||
outcome = app_service.resolve(coding_request())
|
||||
|
||||
assert outcome.mode is RoutingMode.AUTO
|
||||
assert outcome.switched is True
|
||||
|
||||
|
||||
def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None:
|
||||
"""The new mode must be persistable, or the toggle could never select it."""
|
||||
ctx.config.set_routing_mode_for("cowork", "fallback")
|
||||
|
||||
assert ctx.config.routing_mode_for("cowork") == "fallback"
|
||||
assert ctx.project_routing_mode("cowork") == "fallback"
|
||||
|
||||
|
||||
def test_unknown_persisted_mode_degrades_to_off(ctx) -> None:
|
||||
"""A hand-edited config must not enable routing by accident."""
|
||||
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
|
||||
|
||||
assert ctx.config.routing_mode_for("cowork") == "off"
|
||||
@@ -0,0 +1,178 @@
|
||||
"""End-to-end check of the Schedule Task path after R04-T05.
|
||||
|
||||
``core/task_executors.py::_run_agent`` used to assemble its own ``run_cowork``
|
||||
call, in parallel with ``ui/cowork_tab.py`` doing the same thing slightly
|
||||
differently. It now goes through ``ConversationApplicationService``, and the
|
||||
things most at risk from that change are exactly what this file pins:
|
||||
|
||||
* the unattended run still returns the answer text the scheduler writes to output.md
|
||||
* History is still re-saved from the LIVE message list after every assistant
|
||||
message, so a long run shows progress when reopened mid-flight
|
||||
* ``update_plan`` tracking still works, so a task whose checklist is unfinished
|
||||
is not reported as done
|
||||
* a failed run still raises, because ``execute_task`` writes error.txt from it
|
||||
|
||||
No Qt and no network: the provider is scripted and History is redirected into a
|
||||
tmp folder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.core import audit_log, chat_agent, task_executors
|
||||
from cowork_local.state import AppContext
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def task_ctx(tmp_path: Path, monkeypatch):
|
||||
"""An AppContext whose History and audit log live in a tmp folder."""
|
||||
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
||||
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
# Same reason as the Cowork integration suite: the security pre-flight costs
|
||||
# an extra provider call that has nothing to do with what is being tested.
|
||||
ctx.config.agent_security["enabled"] = False
|
||||
monkeypatch.setattr(ctx.config, "history_dir", lambda: tmp_path / "history")
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def history_saves(monkeypatch) -> List[List[Dict[str, Any]]]:
|
||||
"""Capture a SNAPSHOT of the messages at each History save.
|
||||
|
||||
Snapshotting matters: the engine keeps appending to the same list, so
|
||||
storing the list itself would make every recorded save look identical to the
|
||||
final state and the "live progress" assertion would prove nothing.
|
||||
"""
|
||||
saves: List[List[Dict[str, Any]]] = []
|
||||
|
||||
def fake_save(_dir, _kind, _session_id, messages, **_kwargs):
|
||||
saves.append([dict(m) for m in messages])
|
||||
|
||||
from cowork_local.core import history
|
||||
|
||||
monkeypatch.setattr(history, "save_conversation", fake_save)
|
||||
return saves
|
||||
|
||||
|
||||
def _run(ctx, provider, prompt="do the thing", out_dir: Path = None, **kwargs):
|
||||
"""Run one unattended cowork task with ``provider`` pinned."""
|
||||
ctx.build_active_provider = lambda: provider
|
||||
events: List[Dict[str, Any]] = []
|
||||
result = task_executors._run_agent(
|
||||
ctx, "cowork", prompt, out_dir, events.append, lambda: False,
|
||||
title=kwargs.pop("title", "T1"), **kwargs)
|
||||
return result, events
|
||||
|
||||
|
||||
def test_an_unattended_cowork_run_returns_the_answer(task_ctx, tmp_path, history_saves):
|
||||
provider = FakeProvider([ScriptedTurn(text="task answer")])
|
||||
|
||||
(answer, timed_out, incomplete), events = _run(task_ctx, provider,
|
||||
out_dir=tmp_path / "out")
|
||||
|
||||
assert answer == "task answer"
|
||||
assert timed_out is False
|
||||
assert incomplete == ""
|
||||
assert provider.call_count == 1
|
||||
|
||||
|
||||
def test_the_scheduler_still_gets_history_ready_before_the_turn_events(
|
||||
task_ctx, tmp_path, history_saves):
|
||||
"""The scheduler refreshes the History panel on this event, so a running
|
||||
task's conversation shows up while it runs."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
|
||||
_, events = _run(task_ctx, provider, out_dir=tmp_path / "out")
|
||||
|
||||
assert [e["type"] for e in events] == [
|
||||
"history_ready", "text", "assistant_done", "turn_completed"]
|
||||
|
||||
|
||||
def test_history_is_resaved_from_the_live_conversation_during_the_run(
|
||||
task_ctx, tmp_path, history_saves):
|
||||
"""The reason ``begin_turn()`` exists: the service builds its own message
|
||||
list, and the scheduler needs THAT list - not the pre-turn copy - or the
|
||||
mid-run saves would only ever contain the original user message.
|
||||
"""
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "a.md", "content": "x"})]),
|
||||
ScriptedTurn(text="Saved."),
|
||||
])
|
||||
|
||||
_run(task_ctx, provider, out_dir=tmp_path / "out")
|
||||
|
||||
# At least one save DURING the run already carried an assistant message,
|
||||
# and the final save carries the whole conversation.
|
||||
assert len(history_saves) >= 3 # initial + per assistant_done + final
|
||||
assert any(any(m["role"] == "assistant" for m in save)
|
||||
for save in history_saves[1:-1])
|
||||
assert [m["role"] for m in history_saves[-1]] == [
|
||||
"system", "user", "assistant", "tool", "assistant"]
|
||||
|
||||
|
||||
def test_an_unfinished_plan_is_reported_so_the_task_is_not_marked_done(
|
||||
task_ctx, tmp_path, history_saves):
|
||||
"""plan_set tracking runs through the same emit path; losing it would let a
|
||||
task whose own checklist says "not finished" be reported as successful."""
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("update_plan", {"steps": [
|
||||
{"title": "step one", "status": "running"}]})]),
|
||||
ScriptedTurn(text="stopping here"),
|
||||
])
|
||||
|
||||
(_answer, _timed_out, incomplete), _events = _run(task_ctx, provider,
|
||||
out_dir=tmp_path / "out")
|
||||
|
||||
assert incomplete != ""
|
||||
|
||||
|
||||
def test_a_completed_plan_reports_no_incompleteness(task_ctx, tmp_path, history_saves):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("update_plan", {"steps": [
|
||||
{"title": "step one", "status": "done"}]})]),
|
||||
ScriptedTurn(text="all done"),
|
||||
])
|
||||
|
||||
(_answer, _timed_out, incomplete), _events = _run(task_ctx, provider,
|
||||
out_dir=tmp_path / "out")
|
||||
|
||||
assert incomplete == ""
|
||||
|
||||
|
||||
def test_a_failed_run_still_raises_so_execute_task_writes_error_txt(
|
||||
task_ctx, tmp_path, history_saves):
|
||||
provider = FakeProvider([ScriptedTurn(error="provider down"),
|
||||
ScriptedTurn(error="provider down")])
|
||||
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
_run(task_ctx, provider, out_dir=tmp_path / "out")
|
||||
|
||||
assert "provider down" in str(excinfo.value)
|
||||
# The partial conversation is still saved - it is exactly what the user
|
||||
# needs to see after a failure.
|
||||
assert history_saves
|
||||
|
||||
|
||||
def test_a_per_task_provider_override_is_honoured(task_ctx, tmp_path, history_saves):
|
||||
"""A task can pin its own provider/model; the service must use that one, not
|
||||
the machine's Settings default."""
|
||||
default_provider = FakeProvider([], strict=True)
|
||||
task_provider = FakeProvider([ScriptedTurn(text="from the pinned model")])
|
||||
task_ctx.build_active_provider = lambda: default_provider
|
||||
task_ctx.build_provider_for = lambda _name, _model: task_provider
|
||||
|
||||
(answer, _timed_out, _incomplete) = task_executors._run_agent(
|
||||
task_ctx, "cowork", "go", tmp_path / "out", lambda _e: None, lambda: False,
|
||||
title="T", provider_name="anthropic", model="claude")[0:3]
|
||||
|
||||
assert answer == "from the pinned model"
|
||||
assert default_provider.call_count == 0
|
||||
assert task_provider.call_count == 1
|
||||
@@ -1,9 +1,17 @@
|
||||
"""Pytest fixtures/shared helpers for the routing test suite.
|
||||
|
||||
Package importability is handled once and for all by ``tests/conftest.py``,
|
||||
which binds THIS checkout to the ``cowork_local`` name in ``sys.modules``.
|
||||
This file used to push the checkout's PARENT directory onto ``sys.path``, which
|
||||
let an unrelated sibling folder named ``cowork_local`` shadow the working copy —
|
||||
so that logic is intentionally gone; keep it that way.
|
||||
Ensures the ``cowork_local`` package is importable when pytest is invoked from
|
||||
the package directory itself (so ``import cowork_local.core.routing...`` works
|
||||
regardless of the working directory the suite is launched from).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# .../cowork_local/tests/routing/conftest.py → parent of the package dir
|
||||
_PKG_DIR = Path(__file__).resolve().parents[2] # .../cowork_local
|
||||
_REPO_ROOT = _PKG_DIR.parent # .../cowork_local_20260722
|
||||
for p in (str(_REPO_ROOT), str(_PKG_DIR)):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cowork_local.mcp_servers.project_context.foundation import (
|
||||
IdentityContext,
|
||||
ProjectContextRuntime,
|
||||
)
|
||||
from cowork_local.mcp_servers.project_context.registry import (
|
||||
TOOL_NAMES,
|
||||
tool_declarations,
|
||||
)
|
||||
from cowork_local.mcp_servers.project_context.runtime import require_supported_python
|
||||
from cowork_local.mcp_servers.project_context.server import dispatch
|
||||
from mcp import types
|
||||
|
||||
EXPECTED_TOOLS = {
|
||||
"get_project_issue_context",
|
||||
"search_project_knowledge",
|
||||
"get_project_change_context",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingPolicy:
|
||||
allowed: bool
|
||||
calls: int = 0
|
||||
|
||||
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool:
|
||||
self.calls += 1
|
||||
return self.allowed
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingResolver:
|
||||
provider: Any
|
||||
calls: int = 0
|
||||
|
||||
def resolve(self, identity: IdentityContext, tool_name: str) -> Any:
|
||||
self.calls += 1
|
||||
return self.provider
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FakeProvider:
|
||||
response: dict[str, Any]
|
||||
|
||||
def get_issue_context(self, **_: Any) -> dict[str, Any]:
|
||||
return dict(self.response)
|
||||
|
||||
def search_knowledge(self, **_: Any) -> dict[str, Any]:
|
||||
return dict(self.response)
|
||||
|
||||
def get_change_context(self, **_: Any) -> dict[str, Any]:
|
||||
return dict(self.response)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def identity() -> IdentityContext:
|
||||
return IdentityContext(
|
||||
actor_id="member-a",
|
||||
org_unit="fsg",
|
||||
customer="internal",
|
||||
project="cowork-local",
|
||||
granted_scopes=frozenset({"read"}),
|
||||
)
|
||||
|
||||
|
||||
def runtime(identity: IdentityContext, response: dict[str, Any], *, allowed: bool = True):
|
||||
policy = RecordingPolicy(allowed=allowed)
|
||||
resolver = RecordingResolver(provider=FakeProvider(response))
|
||||
return ProjectContextRuntime(
|
||||
identity=identity,
|
||||
policy=policy,
|
||||
credential_resolver=resolver,
|
||||
), policy, resolver
|
||||
|
||||
|
||||
def source() -> dict[str, str]:
|
||||
return {
|
||||
"system": "gitea",
|
||||
"url": "http://example.test/gitea-admin/cowork-local/issues/1",
|
||||
"revision": "main@abc123",
|
||||
"retrieved_at": "2026-08-20T10:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def test_template_exposes_exactly_three_provider_neutral_tools() -> None:
|
||||
assert set(TOOL_NAMES) == EXPECTED_TOOLS
|
||||
declarations = tool_declarations()
|
||||
assert {item["name"] for item in declarations} == EXPECTED_TOOLS
|
||||
assert all(item["inputSchema"]["additionalProperties"] is False for item in declarations)
|
||||
assert all(item["outputSchema"]["additionalProperties"] is False for item in declarations)
|
||||
assert all(types.Tool(**item).name in EXPECTED_TOOLS for item in declarations)
|
||||
|
||||
|
||||
def test_runtime_fails_fast_below_python_311() -> None:
|
||||
with pytest.raises(RuntimeError, match="requires Python 3.11"):
|
||||
require_supported_python((3, 9, 0))
|
||||
|
||||
|
||||
def test_denied_request_never_resolves_credentials_or_calls_provider(
|
||||
identity: IdentityContext,
|
||||
) -> None:
|
||||
app, policy, resolver = runtime(identity, {}, allowed=False)
|
||||
|
||||
result = dispatch(
|
||||
"get_project_issue_context",
|
||||
{"project_id": "other-project", "issue_key": "1"},
|
||||
app,
|
||||
)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.payload["error"]["code"] == "DENIED"
|
||||
assert policy.calls == 1
|
||||
assert resolver.calls == 0
|
||||
|
||||
|
||||
def test_invalid_input_is_rejected_before_policy(identity: IdentityContext) -> None:
|
||||
app, policy, resolver = runtime(identity, {})
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local"}, app)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.payload["error"]["code"] == "INVALID_INPUT"
|
||||
assert policy.calls == 0
|
||||
assert resolver.calls == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_name", "arguments", "response"),
|
||||
[
|
||||
(
|
||||
"get_project_issue_context",
|
||||
{"project_id": "cowork-local", "issue_key": "1"},
|
||||
{
|
||||
"project_id": "cowork-local",
|
||||
"issue_key": "1",
|
||||
"title": "MCP pilot",
|
||||
"status": "open",
|
||||
"description": "Build verifiable project context.",
|
||||
"acceptance_criteria": ["Every result has a source."],
|
||||
"related": [],
|
||||
"source": source(),
|
||||
"truncated": False,
|
||||
"returned": 1,
|
||||
"remaining": 0,
|
||||
"next_cursor": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"search_project_knowledge",
|
||||
{"project_id": "cowork-local", "query": "MCP setup"},
|
||||
{
|
||||
"project_id": "cowork-local",
|
||||
"query": "MCP setup",
|
||||
"items": [
|
||||
{
|
||||
"document_id": "README.md",
|
||||
"chunk_id": "README.md#setup",
|
||||
"title": "Setup",
|
||||
"excerpt": "Install the approved dependencies.",
|
||||
"score": 0.9,
|
||||
"source": source(),
|
||||
}
|
||||
],
|
||||
"truncated": False,
|
||||
"returned": 1,
|
||||
"remaining": 0,
|
||||
"next_cursor": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"get_project_change_context",
|
||||
{"project_id": "cowork-local", "change_id": "1"},
|
||||
{
|
||||
"project_id": "cowork-local",
|
||||
"change_id": "1",
|
||||
"change_type": "pull-request",
|
||||
"title": "Add MCP contract",
|
||||
"state": "merged",
|
||||
"summary": "Introduces the project context contract.",
|
||||
"authors": ["member-c"],
|
||||
"files": ["mcp/contract.yaml"],
|
||||
"commits": ["abc123"],
|
||||
"related_issues": ["1"],
|
||||
"source": source(),
|
||||
"truncated": False,
|
||||
"returned": 1,
|
||||
"remaining": 0,
|
||||
"next_cursor": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_each_member_template_has_a_valid_success_path(
|
||||
identity: IdentityContext,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
response: dict[str, Any],
|
||||
) -> None:
|
||||
app, policy, resolver = runtime(identity, response)
|
||||
|
||||
result = dispatch(tool_name, arguments, app)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.payload["project_id"] == "cowork-local"
|
||||
assert result.payload["correlation_id"]
|
||||
assert policy.calls == 1
|
||||
assert resolver.calls == 1
|
||||
|
||||
|
||||
def test_provider_output_must_match_contract(identity: IdentityContext) -> None:
|
||||
app, _, _ = runtime(identity, {"project_id": "cowork-local"})
|
||||
|
||||
result = dispatch(
|
||||
"get_project_issue_context",
|
||||
{"project_id": "cowork-local", "issue_key": "1"},
|
||||
app,
|
||||
)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.payload["error"]["code"] == "UPSTREAM_ERROR"
|
||||
|
||||
|
||||
def test_unexpected_provider_error_does_not_leak_exception(identity: IdentityContext) -> None:
|
||||
class LeakingProvider:
|
||||
def get_issue_context(self, **_: Any) -> dict[str, Any]:
|
||||
raise RuntimeError("secret provider-token-value")
|
||||
|
||||
policy = RecordingPolicy(allowed=True)
|
||||
resolver = RecordingResolver(provider=LeakingProvider())
|
||||
app = ProjectContextRuntime(identity=identity, policy=policy, credential_resolver=resolver)
|
||||
|
||||
result = dispatch(
|
||||
"get_project_issue_context",
|
||||
{"project_id": "cowork-local", "issue_key": "1"},
|
||||
app,
|
||||
)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.payload["error"]["code"] == "UPSTREAM_ERROR"
|
||||
assert "secret" not in str(result.payload)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Fast, isolated unit tests for the new 4-tier layers (R01/R03/R04, R10-T01).
|
||||
|
||||
Everything in this folder must run offline, without Qt and without touching the
|
||||
real user config directory, so the whole folder stays well under one second.
|
||||
"""
|
||||
@@ -1,59 +1,194 @@
|
||||
"""Unit tests for the Clean Architecture AST Import Guard (check_imports.py)."""
|
||||
"""Unit tests for the Clean Architecture Guard, ``scripts/check_imports.py`` (R01-T03).
|
||||
|
||||
The guard is what makes ADR-001 enforceable rather than aspirational, so it needs
|
||||
its own tests: a guard that silently passes everything is worse than no guard,
|
||||
because the CASAN Gate would then report a green architecture that isn't.
|
||||
|
||||
Both directions are covered - it must FLAG real violations (including the
|
||||
function-local and relative import spellings this codebase actually uses) and it
|
||||
must NOT flag legal code (Qt named only in a docstring, domain importing stdlib).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from scripts.check_imports import FORBIDDEN_MODULE_PREFIXES, scan_file
|
||||
|
||||
import pytest
|
||||
|
||||
_GUARD_PATH = Path(__file__).resolve().parents[2] / "scripts" / "check_imports.py"
|
||||
|
||||
|
||||
def test_clean_python_file_passes(tmp_path: Path) -> None:
|
||||
"""Verify that pure Python code without GUI imports produces 0 violations."""
|
||||
clean_code = """
|
||||
import os
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
def _load_guard():
|
||||
"""Import ``scripts/check_imports.py`` by path.
|
||||
|
||||
@dataclass
|
||||
class UserRequest:
|
||||
id: str
|
||||
prompt: str
|
||||
"""
|
||||
clean_file = tmp_path / "clean_service.py"
|
||||
clean_file.write_text(clean_code, encoding="utf-8")
|
||||
|
||||
violations = scan_file(clean_file, FORBIDDEN_MODULE_PREFIXES)
|
||||
assert len(violations) == 0
|
||||
``scripts/`` is deliberately not a package (it holds standalone CLI tools),
|
||||
so a normal import statement cannot reach it.
|
||||
"""
|
||||
name = "_check_imports_under_test"
|
||||
spec = importlib.util.spec_from_file_location(name, _GUARD_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Registered before exec_module because @dataclass resolves a class's own
|
||||
# module out of sys.modules while processing annotations; without this the
|
||||
# guard's Violation dataclass fails to build under a by-path import.
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_forbidden_pyside_import_detected(tmp_path: Path) -> None:
|
||||
"""Verify that PySide6 import is caught with correct line number."""
|
||||
dirty_code = """
|
||||
from dataclasses import dataclass
|
||||
from PySide6.QtWidgets import QWidget
|
||||
guard = _load_guard()
|
||||
|
||||
class BadService:
|
||||
pass
|
||||
"""
|
||||
dirty_file = tmp_path / "bad_service.py"
|
||||
dirty_file.write_text(dirty_code, encoding="utf-8")
|
||||
|
||||
violations = scan_file(dirty_file, FORBIDDEN_MODULE_PREFIXES)
|
||||
@pytest.fixture
|
||||
def fake_repo(tmp_path: Path, monkeypatch):
|
||||
"""A throwaway repo root the guard scans instead of the real one.
|
||||
|
||||
Pointing ``REPO_ROOT`` at a tmp dir keeps these tests independent of the
|
||||
actual state of ``domain/`` and ``application/`` - otherwise adding a real
|
||||
module later could flip a guard test red for no reason.
|
||||
"""
|
||||
monkeypatch.setattr(guard, "REPO_ROOT", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write(root: Path, rel: str, source: str) -> Path:
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(source, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Violations that must be caught
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_top_level_qt_import_in_domain_is_flagged(fake_repo):
|
||||
_write(fake_repo, "domain/agents/bad.py", "from PySide6 import QtWidgets\n")
|
||||
|
||||
violations = guard.run(["domain"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert violations[0].line_number == 3
|
||||
assert "PySide6" in violations[0].imported_module
|
||||
assert "PySide6" in violations[0].imported
|
||||
assert "pure Python" in violations[0].rule
|
||||
|
||||
|
||||
def test_forbidden_ui_and_app_import_detected(tmp_path: Path) -> None:
|
||||
"""Verify that importing concrete UI or app modules from domain is caught."""
|
||||
dirty_code = """
|
||||
import ui.chat_panel
|
||||
from app import MainWindow
|
||||
"""
|
||||
dirty_file = tmp_path / "cross_layer_leak.py"
|
||||
dirty_file.write_text(dirty_code, encoding="utf-8")
|
||||
def test_function_local_qt_import_is_flagged(fake_repo):
|
||||
"""This repo defers heavy imports into function bodies to speed up start-up,
|
||||
so the guard walks the whole tree - a deferred Qt import breaks the layer
|
||||
exactly as much as a top-level one."""
|
||||
_write(fake_repo, "application/conversations/bad.py",
|
||||
"def build():\n import PySide6.QtCore\n return PySide6\n")
|
||||
|
||||
violations = scan_file(dirty_file, FORBIDDEN_MODULE_PREFIXES)
|
||||
assert len(violations) == 2
|
||||
modules = [v.imported_module for v in violations]
|
||||
assert "ui.chat_panel" in modules
|
||||
assert "app" in modules
|
||||
violations = guard.run(["application"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert violations[0].line == 2
|
||||
|
||||
|
||||
def test_application_importing_ui_is_flagged(fake_repo):
|
||||
_write(fake_repo, "application/conversations/bad.py",
|
||||
"from cowork_local.ui.chat_panel import ChatPanel\n")
|
||||
|
||||
violations = guard.run(["application"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert "ui/" in violations[0].rule
|
||||
|
||||
|
||||
def test_relative_import_that_escapes_the_layer_is_flagged(fake_repo):
|
||||
"""``from ...ui import x`` inside ``domain/agents/`` resolves to the top-level
|
||||
``ui`` package. Only relative-import resolution catches this - the text
|
||||
``ui`` never appears as an absolute module name."""
|
||||
_write(fake_repo, "domain/agents/bad.py", "from ...ui import widgets\n")
|
||||
|
||||
violations = guard.run(["domain"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert violations[0].imported == "...ui"
|
||||
|
||||
|
||||
def test_domain_importing_core_is_flagged(fake_repo):
|
||||
"""``domain/`` is the innermost layer: it may not reach back into the legacy
|
||||
``core/`` package either, or the dependency arrow would point outward."""
|
||||
_write(fake_repo, "domain/models/bad.py", "from cowork_local.core import history\n")
|
||||
|
||||
violations = guard.run(["domain"])
|
||||
|
||||
assert len(violations) == 1
|
||||
|
||||
|
||||
def test_unparseable_file_is_reported_rather_than_skipped(fake_repo):
|
||||
"""A file the guard cannot read must fail the gate. Skipping it would let a
|
||||
broken file smuggle any import past the check."""
|
||||
_write(fake_repo, "domain/agents/broken.py", "def oops(:\n")
|
||||
|
||||
violations = guard.run(["domain"])
|
||||
|
||||
assert len(violations) == 1
|
||||
assert violations[0].imported == "<unparseable>"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Legal code that must NOT be flagged
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_qt_mentioned_only_in_a_docstring_is_not_flagged(fake_repo):
|
||||
"""The whole reason the guard parses an AST instead of grepping: several
|
||||
real modules explain in prose that they must not import PySide6."""
|
||||
_write(fake_repo, "domain/agents/ok.py",
|
||||
'"""This layer must never import PySide6 or PyQt6."""\n'
|
||||
'QT = "PySide6" # a string, not an import\n')
|
||||
|
||||
assert guard.run(["domain"]) == []
|
||||
|
||||
|
||||
def test_stdlib_and_intra_layer_imports_are_allowed(fake_repo):
|
||||
_write(fake_repo, "domain/agents/ok.py",
|
||||
"import json\n"
|
||||
"from dataclasses import dataclass\n"
|
||||
"from ..models.provider_descriptor import ProviderDescriptor\n")
|
||||
|
||||
assert guard.run(["domain"]) == []
|
||||
|
||||
|
||||
def test_application_may_import_domain_and_infrastructure(fake_repo):
|
||||
"""Application orchestrates: reaching down to domain is the point, and
|
||||
wiring an infrastructure adapter is allowed (only UI is forbidden)."""
|
||||
_write(fake_repo, "application/model_routing/ok.py",
|
||||
"from cowork_local.domain.models import provider_descriptor\n"
|
||||
"from cowork_local.infrastructure.providers import provider_registry\n")
|
||||
|
||||
assert guard.run(["application"]) == []
|
||||
|
||||
|
||||
def test_tests_folder_inside_a_layer_is_not_scanned(fake_repo):
|
||||
"""A test living next to the code may legitimately import Qt; holding tests
|
||||
to the production rule would only teach people to disable the gate."""
|
||||
_write(fake_repo, "domain/tests/test_thing.py", "from PySide6 import QtWidgets\n")
|
||||
|
||||
assert guard.run(["domain"]) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reporting / exit codes - what CI actually consumes
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_main_returns_nonzero_and_prints_ascii_only_on_failure(fake_repo, capsys):
|
||||
"""The team's Windows consoles run a legacy code page (cp932): a non-ASCII
|
||||
character in the failure output would raise UnicodeEncodeError and crash the
|
||||
gate on the very path it exists to report."""
|
||||
_write(fake_repo, "domain/agents/bad.py", "from PySide6 import QtWidgets\n")
|
||||
|
||||
exit_code = guard.main(["domain"])
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 1
|
||||
assert "FAIL" in out
|
||||
assert "domain/agents/bad.py:1" in out
|
||||
out.encode("cp932") # raises if any character is unprintable on the target console
|
||||
|
||||
|
||||
def test_main_returns_zero_on_a_clean_tree(fake_repo, capsys):
|
||||
_write(fake_repo, "domain/agents/ok.py", "import json\n")
|
||||
|
||||
exit_code = guard.main(["domain"])
|
||||
|
||||
assert exit_code == 0
|
||||
assert "PASS" in capsys.readouterr().out
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Unit tests for EPIC R04: the turn snapshot, the typed events and the service.
|
||||
|
||||
The service tests run against the REAL engine (``core.chat_agent.run_cowork``)
|
||||
driven by :class:`FakeProvider`, not against a stubbed runner. That is
|
||||
deliberate: the whole point of R04 is that the service produces the same turn
|
||||
the widget used to produce, and only an end-to-end path through the real engine
|
||||
can show that. It still costs milliseconds - no Qt, no network, no disk beyond
|
||||
a tmp folder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.conversations import ConversationApplicationService
|
||||
from cowork_local.core import chat_agent
|
||||
from cowork_local.domain.agents import (
|
||||
AssistantDoneEvent,
|
||||
ConversationExecutionRequest,
|
||||
ErrorEvent,
|
||||
ReasoningChunkEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
ToolCallStartedEvent,
|
||||
TurnCompletedEvent,
|
||||
collect_text,
|
||||
event_from_dict,
|
||||
)
|
||||
from tests.fakes import FakeProvider, FakeToolExecutor, ScriptedTurn
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R04-T01 - the immutable request snapshot
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_the_snapshot_cannot_be_changed_by_the_caller_afterwards():
|
||||
"""The motivating bug: the chat panel keeps appending to its own message
|
||||
list while a turn runs, and the turn must not see those later messages."""
|
||||
live_messages = [{"role": "user", "content": "first"}]
|
||||
request = ConversationExecutionRequest.create("first", live_messages)
|
||||
|
||||
live_messages.append({"role": "user", "content": "typed while running"})
|
||||
live_messages[0]["content"] = "edited"
|
||||
|
||||
assert len(request.messages) == 1
|
||||
assert request.messages[0]["content"] == "first"
|
||||
|
||||
|
||||
def test_message_list_hands_out_a_fresh_mutable_copy():
|
||||
"""The engine appends assistant/tool messages to the list it is given, so a
|
||||
copy is what keeps the snapshot immutable in practice, not just by
|
||||
declaration."""
|
||||
request = ConversationExecutionRequest.create("hi", [{"role": "user", "content": "hi"}])
|
||||
|
||||
first = request.message_list()
|
||||
first.append({"role": "assistant", "content": "reply"})
|
||||
|
||||
assert len(request.message_list()) == 1
|
||||
assert first is not request.message_list()
|
||||
|
||||
|
||||
def test_with_model_produces_a_new_pinned_snapshot():
|
||||
"""A routing switch must not mutate a request a turn may already be running."""
|
||||
original = ConversationExecutionRequest.create("hi", provider="openai_compat", model="a")
|
||||
|
||||
routed = original.with_model("anthropic", "claude")
|
||||
|
||||
assert (original.provider, original.model) == ("openai_compat", "a")
|
||||
assert (routed.provider, routed.model) == ("anthropic", "claude")
|
||||
assert routed.turn_id == original.turn_id # same turn, different target
|
||||
|
||||
|
||||
def test_every_turn_gets_its_own_id():
|
||||
a = ConversationExecutionRequest.create("x")
|
||||
b = ConversationExecutionRequest.create("x")
|
||||
|
||||
assert a.turn_id and b.turn_id and a.turn_id != b.turn_id
|
||||
|
||||
|
||||
def test_run_to_completion_raises_the_step_ceiling():
|
||||
interactive = ConversationExecutionRequest.create("x")
|
||||
flow_step = ConversationExecutionRequest.create("x", run_to_completion=True)
|
||||
|
||||
assert interactive.effective_max_steps == 30
|
||||
assert flow_step.effective_max_steps == 200
|
||||
|
||||
|
||||
def test_permission_scope_always_keeps_update_plan():
|
||||
"""update_plan has no side effects and drives the Plan panel; scoping it out
|
||||
would break the UI rather than restrict a capability."""
|
||||
request = ConversationExecutionRequest.create("x", allowed_tools=["read_file"])
|
||||
|
||||
assert request.allows_tool("read_file") is True
|
||||
assert request.allows_tool("update_plan") is True
|
||||
assert request.allows_tool("save_file") is False
|
||||
# No scope at all means every enabled tool is allowed.
|
||||
assert ConversationExecutionRequest.create("x").allows_tool("save_file") is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R04-T02 - typed events and the legacy bridge
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("payload,expected", [
|
||||
({"type": "text", "delta": "hi"}, TextChunkEvent),
|
||||
({"type": "reasoning", "delta": "hmm"}, ReasoningChunkEvent),
|
||||
({"type": "assistant_done", "content": "done"}, AssistantDoneEvent),
|
||||
({"type": "tool_proposed", "id": "1", "name": "save_file"}, ToolCallStartedEvent),
|
||||
({"type": "tool_result", "id": "1", "name": "save_file", "ok": True}, ToolCallFinishedEvent),
|
||||
])
|
||||
def test_legacy_emit_dicts_map_onto_typed_events(payload, expected):
|
||||
assert isinstance(event_from_dict(payload), expected)
|
||||
|
||||
|
||||
def test_an_unknown_event_tag_is_dropped_rather_than_raising():
|
||||
"""The engine is still being refactored and may grow an event first. Losing
|
||||
one bubble is survivable; aborting a turn that had succeeded is not."""
|
||||
assert event_from_dict({"type": "something_new_in_r08"}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
{"type": "text", "delta": "hi"},
|
||||
{"type": "tool_result", "id": "1", "name": "save_file", "ok": False, "output": "boom"},
|
||||
{"type": "plan_set", "steps": [{"title": "a"}]},
|
||||
{"type": "outputs_added", "paths": ["a.md"]},
|
||||
])
|
||||
def test_events_round_trip_back_into_the_legacy_shape(payload):
|
||||
"""Existing widgets still consume dicts; an event must render back into
|
||||
exactly what they already handle (EPIC R08 migrates them)."""
|
||||
event = event_from_dict(payload)
|
||||
|
||||
rendered = event.to_dict()
|
||||
|
||||
assert rendered["type"] == payload["type"]
|
||||
for key, value in payload.items():
|
||||
assert rendered[key] == value
|
||||
|
||||
|
||||
def test_events_are_immutable():
|
||||
"""They cross a thread boundary; a consumer must not be able to edit one
|
||||
out from under another consumer."""
|
||||
event = TextChunkEvent("hi")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
event.delta = "changed" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_collect_text_returns_the_answer_without_the_reasoning():
|
||||
events = [TextChunkEvent("Hel"), ReasoningChunkEvent("secret"), TextChunkEvent("lo")]
|
||||
|
||||
assert collect_text(events) == "Hello"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# R04-T03 - the service, running the real engine
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.fixture
|
||||
def isolated(monkeypatch, tmp_path: Path):
|
||||
"""Same ambient isolation the characterization suite uses."""
|
||||
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
|
||||
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
|
||||
from cowork_local.core import audit_log
|
||||
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _service(provider, **kwargs) -> ConversationApplicationService:
|
||||
return ConversationApplicationService(lambda _p, _m: provider, **kwargs)
|
||||
|
||||
|
||||
def _request(tmp_path: Path, prompt: str = "hi", **kwargs) -> ConversationExecutionRequest:
|
||||
return ConversationExecutionRequest.create(
|
||||
prompt, [{"role": "user", "content": prompt}],
|
||||
output_dir=str(tmp_path / "out"), **kwargs)
|
||||
|
||||
|
||||
def test_a_plain_turn_reports_text_and_a_final_answer(isolated):
|
||||
provider = FakeProvider([ScriptedTurn(text="Hello there.")])
|
||||
seen: List[Any] = []
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated), on_event=seen.append)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.final_text == "Hello there."
|
||||
assert [e.type for e in seen] == ["text", "assistant_done", "turn_completed"]
|
||||
# The conversation coming back is what the caller persists as new history.
|
||||
assert [m["role"] for m in result.messages] == ["system", "user", "assistant"]
|
||||
|
||||
|
||||
def test_a_turn_always_ends_with_exactly_one_completion_event(isolated):
|
||||
"""The end-of-turn signal the legacy engine never had: without it a
|
||||
cancelled turn and a failed turn look identical to a consumer."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
seen: List[Any] = []
|
||||
|
||||
_service(provider).run_turn(_request(isolated), on_event=seen.append)
|
||||
|
||||
completions = [e for e in seen if isinstance(e, TurnCompletedEvent)]
|
||||
assert len(completions) == 1
|
||||
assert seen[-1] is completions[0]
|
||||
|
||||
|
||||
def test_a_provider_failure_becomes_an_error_event_not_an_exception(isolated):
|
||||
"""Callers run this on a worker thread; an escaped exception kills the
|
||||
worker and the UI simply stops updating with nothing shown.
|
||||
|
||||
Two turns are scripted because the engine makes ONE silent recovery attempt
|
||||
before giving up (core/code_agent.py::_call_provider_with_recovery) - the
|
||||
service must report the failure only after that retry is also exhausted.
|
||||
"""
|
||||
provider = FakeProvider([ScriptedTurn(error="gateway exploded"),
|
||||
ScriptedTurn(error="gateway exploded")])
|
||||
seen: List[Any] = []
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated), on_event=seen.append)
|
||||
|
||||
assert provider.call_count == 2 # original + one silent retry
|
||||
assert result.ok is False
|
||||
assert "gateway exploded" in result.error
|
||||
assert any(isinstance(e, ErrorEvent) for e in seen)
|
||||
assert isinstance(seen[-1], TurnCompletedEvent) # still a clean end
|
||||
|
||||
|
||||
def test_a_transient_provider_failure_is_recovered_without_surfacing(isolated):
|
||||
"""The engine's single retry must stay invisible: a turn that succeeds on
|
||||
the second attempt reports no error at all."""
|
||||
provider = FakeProvider([ScriptedTurn(error="connection reset"),
|
||||
ScriptedTurn(text="recovered answer")])
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated))
|
||||
|
||||
assert result.ok is True
|
||||
assert result.final_text == "recovered answer"
|
||||
assert not [e for e in result.events if isinstance(e, ErrorEvent)]
|
||||
|
||||
|
||||
def test_a_cancelled_turn_is_reported_as_cancelled_not_failed(isolated):
|
||||
provider = FakeProvider([], strict=True)
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated), cancel=lambda: True)
|
||||
|
||||
assert result.cancelled is True
|
||||
assert result.error == ""
|
||||
assert provider.call_count == 0
|
||||
assert result.events[-1].cancelled is True
|
||||
|
||||
|
||||
def test_a_tool_turn_reports_the_full_lifecycle_and_writes_the_file(isolated):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("save_file", {"filename": "note.md", "content": "# hi"})]),
|
||||
ScriptedTurn(text="Saved."),
|
||||
])
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated, "make a note"))
|
||||
|
||||
assert [e.type for e in result.events] == [
|
||||
"assistant_done", "tool_proposed", "tool_result",
|
||||
"text", "assistant_done", "turn_completed",
|
||||
]
|
||||
finished = [e for e in result.events if isinstance(e, ToolCallFinishedEvent)]
|
||||
assert finished[0].ok is True and finished[0].name == "save_file"
|
||||
written = list((isolated / "out").iterdir())
|
||||
assert len(written) == 1 and written[0].read_text(encoding="utf-8") == "# hi"
|
||||
|
||||
|
||||
def test_external_tools_are_supplied_through_the_injected_tool_source(isolated):
|
||||
executor = FakeToolExecutor(results={"ms365_send_mail": {"output": "sent"}})
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("ms365_send_mail", {"to": "a@b.c"})]),
|
||||
ScriptedTurn(text="Mail sent."),
|
||||
])
|
||||
service = _service(provider, tool_source=lambda: (executor.specs(), executor))
|
||||
|
||||
result = service.run_turn(_request(isolated, "mail them"))
|
||||
|
||||
assert executor.call_names == ["ms365_send_mail"]
|
||||
assert result.ok is True
|
||||
|
||||
|
||||
def test_a_broken_tool_source_degrades_to_no_external_tools(isolated):
|
||||
"""An MCP server that will not start must not stop the user from chatting -
|
||||
the behaviour the chat panel already relies on today."""
|
||||
def exploding_tool_source():
|
||||
raise RuntimeError("mcp server did not start")
|
||||
|
||||
provider = FakeProvider([ScriptedTurn(text="still works")])
|
||||
service = _service(provider, tool_source=exploding_tool_source)
|
||||
|
||||
result = service.run_turn(_request(isolated))
|
||||
|
||||
assert result.ok is True
|
||||
assert result.final_text == "still works"
|
||||
|
||||
|
||||
def test_a_consumer_that_raises_does_not_abort_the_turn(isolated):
|
||||
"""A widget being torn down mid-turn must not take the turn with it."""
|
||||
provider = FakeProvider([ScriptedTurn(text="answer")])
|
||||
|
||||
def bad_consumer(_event):
|
||||
raise RuntimeError("widget already deleted")
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated), on_event=bad_consumer)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.final_text == "answer"
|
||||
|
||||
|
||||
def test_events_are_recorded_even_without_a_callback(isolated):
|
||||
"""Headless callers (the scheduler) read the event list afterwards instead
|
||||
of supplying a callback purely to collect it."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
|
||||
result = _service(provider).run_turn(_request(isolated))
|
||||
|
||||
assert [e.type for e in result.events] == ["text", "assistant_done", "turn_completed"]
|
||||
|
||||
|
||||
def test_the_request_permission_scope_reaches_the_engine(isolated):
|
||||
"""A read-only step must literally not be offered a writing tool - the scope
|
||||
has to survive the trip through the service or the restriction is silently
|
||||
dropped."""
|
||||
provider = FakeProvider([ScriptedTurn(text="ok")])
|
||||
|
||||
_service(provider).run_turn(_request(isolated, allowed_tools=["read_file"]))
|
||||
|
||||
advertised = set(provider.calls[0].tool_names)
|
||||
assert "save_file" not in advertised
|
||||
assert "update_plan" in advertised
|
||||
|
||||
|
||||
def test_the_permission_gate_is_only_built_when_the_request_asks_for_it(isolated):
|
||||
built: List[Any] = []
|
||||
provider = FakeProvider([ScriptedTurn(text="ok"), ScriptedTurn(text="ok")])
|
||||
service = _service(provider, gate_factory=lambda req: built.append(req) or object())
|
||||
|
||||
service.run_turn(_request(isolated))
|
||||
assert built == []
|
||||
|
||||
service.run_turn(_request(isolated, confirm_commands=True))
|
||||
assert len(built) == 1
|
||||
|
||||
|
||||
def test_a_non_streamed_answer_still_produces_a_final_text(isolated):
|
||||
"""A turn whose answer arrived without text events must still report an
|
||||
answer - the scheduler writes it into output.md, and an empty string there
|
||||
reads to the user as "(no output)"."""
|
||||
provider = FakeProvider([ScriptedTurn(text="")])
|
||||
service = _service(provider)
|
||||
request = _request(isolated)
|
||||
|
||||
result = service.run_turn(request)
|
||||
|
||||
# run_cowork substitutes a placeholder for a reasoning-only reply; the
|
||||
# service must surface that rather than an empty answer.
|
||||
assert result.final_text != ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bridge completeness - the failure mode that motivated this test
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_every_event_the_engine_emits_has_a_typed_counterpart():
|
||||
"""Scan the engine sources for ``emit({"type": "..."})`` tags and assert the
|
||||
bridge knows all of them.
|
||||
|
||||
Written after a real miss: the first version of the bridge had no
|
||||
``notice`` event, so routing turns through the service would have silently
|
||||
swallowed Agent Security warnings and auto-compaction notices - the user
|
||||
would simply never see that a request had been blocked. An unknown tag is
|
||||
dropped by design (see event_from_dict), which is safe for a NEW event but
|
||||
hides a forgotten one; this test is what turns that silence into a failure.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from cowork_local.domain.agents.agent_event import EVENT_TYPES
|
||||
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
sources = ["core/chat_agent.py", "core/code_agent.py", "core/agent_security.py",
|
||||
"core/context_budget.py", "core/task_executors.py"]
|
||||
emitted = set()
|
||||
for rel in sources:
|
||||
text = (repo / rel).read_text(encoding="utf-8")
|
||||
# Only tags inside an emit(...) call; a bare {"type": "object"} in a
|
||||
# JSON-Schema tool definition is not an event.
|
||||
for match in re.finditer(r'emit(?:_and_autosave)?\(\s*\{\s*"type":\s*"([a-z_]+)"', text):
|
||||
emitted.add(match.group(1))
|
||||
|
||||
missing = sorted(emitted - set(EVENT_TYPES))
|
||||
assert not missing, (
|
||||
f"engine emits {missing} but domain/agents/agent_event.py has no typed "
|
||||
"counterpart - those events would be silently dropped by event_from_dict"
|
||||
)
|
||||
@@ -1,220 +0,0 @@
|
||||
"""Unit tests for the adapters that bridge the routing engine to the app service.
|
||||
|
||||
The integration suite covers the happy path over the real engine; this file pins
|
||||
the translation edge cases that are hard to provoke there — malformed task
|
||||
types, a missing ranking, and the service-caching contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cowork_local.application.model_routing import (
|
||||
AppContextModeResolver,
|
||||
CoreRoutingEngine,
|
||||
RoutingApplicationService,
|
||||
RoutingMode,
|
||||
RoutingRequest,
|
||||
)
|
||||
from cowork_local.application.model_routing.core_routing_adapter import (
|
||||
build_routing_application_service,
|
||||
)
|
||||
from cowork_local.core.routing.models import SwitchDecision, SwitchMode, TaskType
|
||||
|
||||
|
||||
class FakeRanking:
|
||||
"""Just enough of ``selector.Ranking`` for the adapter's usability check."""
|
||||
|
||||
def __init__(self, scores) -> None:
|
||||
self._scores = dict(scores)
|
||||
|
||||
def score_of(self, key: str) -> float:
|
||||
return self._scores.get(key, 0.0)
|
||||
|
||||
|
||||
class FakeRouteResult:
|
||||
"""Stands in for ``core.routing.service.RouteResult``."""
|
||||
|
||||
def __init__(self, decision, task_type=TaskType.CODING, ranking=None, target=None) -> None:
|
||||
self.decision = decision
|
||||
self.task_type = task_type
|
||||
self.ranking = ranking
|
||||
self._target = target
|
||||
|
||||
@property
|
||||
def should_switch(self) -> bool:
|
||||
return self.decision.should_switch
|
||||
|
||||
def target(self):
|
||||
return self._target
|
||||
|
||||
|
||||
class FakeRoutingService:
|
||||
"""Records the arguments the adapter forwards to the engine."""
|
||||
|
||||
def __init__(self, result: FakeRouteResult) -> None:
|
||||
self.result = result
|
||||
self.calls: list = []
|
||||
|
||||
def route(self, surface, prompt, current_provider, current_model, **kwargs):
|
||||
self.calls.append({"surface": surface, "prompt": prompt,
|
||||
"current_provider": current_provider,
|
||||
"current_model": current_model, **kwargs})
|
||||
return self.result
|
||||
|
||||
|
||||
def make_decision(**overrides) -> SwitchDecision:
|
||||
fields = dict(
|
||||
should_switch=True,
|
||||
from_model="anthropic/weak-model",
|
||||
to_model="anthropic/strong-model",
|
||||
score_gain=0.3,
|
||||
reason="coding fit 0.9 > current 0.6",
|
||||
mode=SwitchMode.AUTO,
|
||||
task_type="coding",
|
||||
)
|
||||
fields.update(overrides)
|
||||
return SwitchDecision(**fields)
|
||||
|
||||
|
||||
def make_request(**overrides) -> RoutingRequest:
|
||||
fields = dict(surface="cowork", prompt="Fix this bug",
|
||||
current_provider="anthropic", current_model="weak-model")
|
||||
fields.update(overrides)
|
||||
return RoutingRequest(**fields)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CoreRoutingEngine translation
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_engine_flattens_the_route_result() -> None:
|
||||
"""No ``core.routing`` type may leak past the adapter — the application
|
||||
service and the widgets only ever see plain fields."""
|
||||
service = FakeRoutingService(FakeRouteResult(
|
||||
make_decision(),
|
||||
ranking=FakeRanking({"anthropic/weak-model": 0.6}),
|
||||
target=("anthropic", "strong-model"),
|
||||
))
|
||||
|
||||
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
|
||||
|
||||
assert evaluation.task_type == "coding" # str, not TaskType
|
||||
assert evaluation.should_switch is True
|
||||
assert evaluation.target_provider == "anthropic"
|
||||
assert evaluation.target_model == "strong-model"
|
||||
assert evaluation.score_gain == pytest.approx(0.3)
|
||||
assert evaluation.current_is_usable is True
|
||||
|
||||
|
||||
def test_engine_forwards_the_mode_as_a_plain_string() -> None:
|
||||
"""``RoutingService.route`` takes the mode as a string; handing it an enum
|
||||
would silently fall through to its "unknown mode -> off" branch."""
|
||||
service = FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
|
||||
|
||||
CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
|
||||
|
||||
assert service.calls[0]["mode_override"] == "auto"
|
||||
|
||||
|
||||
def test_engine_reports_an_unranked_model_as_unusable() -> None:
|
||||
"""This is the signal Fallback acts on: absent from the ranking means the
|
||||
selector already rejected it (unavailable / no probe / failed probe)."""
|
||||
service = FakeRoutingService(FakeRouteResult(
|
||||
make_decision(),
|
||||
ranking=FakeRanking({"anthropic/strong-model": 0.9}), # current is absent
|
||||
target=("anthropic", "strong-model"),
|
||||
))
|
||||
|
||||
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
|
||||
|
||||
assert evaluation.current_is_usable is False
|
||||
|
||||
|
||||
def test_engine_assumes_usable_without_a_ranking() -> None:
|
||||
"""No ranking (routing off, or the engine's own error path) is absence of
|
||||
evidence — it must not trigger a surprise Fallback switch."""
|
||||
service = FakeRoutingService(FakeRouteResult(make_decision(), ranking=None))
|
||||
|
||||
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
|
||||
|
||||
assert evaluation.current_is_usable is True
|
||||
|
||||
|
||||
def test_engine_assumes_usable_when_the_ranking_misbehaves() -> None:
|
||||
"""A broken ranking object must not fail the turn."""
|
||||
class BrokenRanking:
|
||||
def score_of(self, key):
|
||||
raise RuntimeError("corrupt ranking")
|
||||
|
||||
service = FakeRoutingService(FakeRouteResult(make_decision(), ranking=BrokenRanking()))
|
||||
|
||||
evaluation = CoreRoutingEngine(service).evaluate(make_request(), RoutingMode.AUTO)
|
||||
|
||||
assert evaluation.current_is_usable is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[("coding", TaskType.CODING), ("QA", TaskType.QA), (None, None), ("nonsense", None)],
|
||||
)
|
||||
def test_task_type_strings_are_coerced_or_dropped(raw, expected) -> None:
|
||||
"""A pinned task type is honoured; an unknown one falls back to letting the
|
||||
engine classify the prompt rather than raising mid-turn."""
|
||||
service = FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
|
||||
|
||||
CoreRoutingEngine(service).evaluate(make_request(task_type=raw), RoutingMode.AUTO)
|
||||
|
||||
assert service.calls[0]["task_type"] == expected
|
||||
|
||||
|
||||
def test_required_capabilities_are_passed_as_a_list_or_none() -> None:
|
||||
"""``rank_models`` filters on a list; an empty tuple must become None so it
|
||||
is treated as "no filter" rather than "require nothing, but filter"."""
|
||||
service = FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
|
||||
engine = CoreRoutingEngine(service)
|
||||
|
||||
engine.evaluate(make_request(required_capabilities=("vision",)), RoutingMode.AUTO)
|
||||
engine.evaluate(make_request(), RoutingMode.AUTO)
|
||||
|
||||
assert service.calls[0]["required_capabilities"] == ["vision"]
|
||||
assert service.calls[1]["required_capabilities"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mode resolver + wiring
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_mode_resolver_reads_the_per_workspace_mode() -> None:
|
||||
"""Per-workspace routing keeps working now that the lookup left the widgets."""
|
||||
class StubCtx:
|
||||
def project_routing_mode(self, surface):
|
||||
return "fallback" if surface == "co4e" else "off"
|
||||
|
||||
resolver = AppContextModeResolver(StubCtx())
|
||||
|
||||
assert resolver.mode_for("co4e") is RoutingMode.FALLBACK
|
||||
assert resolver.mode_for("cowork") is RoutingMode.OFF
|
||||
|
||||
|
||||
def test_service_is_built_once_and_cached_on_the_context() -> None:
|
||||
"""Every surface must share one instance, so future per-surface state (a
|
||||
cool-down, a switch history) is shared rather than duplicated per widget."""
|
||||
class StubCtx:
|
||||
def __init__(self):
|
||||
self.routing_calls = 0
|
||||
self.config = type("Cfg", (), {"routing": {"confirm_timeout_sec": 45}})()
|
||||
|
||||
def routing(self):
|
||||
self.routing_calls += 1
|
||||
return FakeRoutingService(FakeRouteResult(make_decision(should_switch=False)))
|
||||
|
||||
def project_routing_mode(self, surface):
|
||||
return "off"
|
||||
|
||||
ctx = StubCtx()
|
||||
first = build_routing_application_service(ctx)
|
||||
second = build_routing_application_service(ctx)
|
||||
|
||||
assert first is second
|
||||
assert ctx.routing_calls == 1
|
||||
assert isinstance(first, RoutingApplicationService)
|
||||
# The confirm timeout is read from config at call time, not frozen at build.
|
||||
assert first.confirm_timeout() == pytest.approx(45.0)
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Unit tests for FakeProvider and FakeToolExecutor test doubles."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from providers.base import ProviderError
|
||||
from tests.fakes.fake_provider import FakeProvider
|
||||
from tests.fakes.fake_tool_executor import FakeToolExecutor
|
||||
|
||||
|
||||
def test_fake_provider_text_streaming() -> None:
|
||||
"""Verify that FakeProvider streams text chunks to on_text callback."""
|
||||
provider = FakeProvider()
|
||||
provider.queue_response(content="Hello world", chunks=["Hello ", "world"])
|
||||
|
||||
streamed: list[str] = []
|
||||
response = provider.chat(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
on_text=lambda piece: streamed.append(piece),
|
||||
)
|
||||
|
||||
assert response["role"] == "assistant"
|
||||
assert response["content"] == "Hello world"
|
||||
assert "".join(streamed) == "Hello world"
|
||||
assert provider.call_count == 1
|
||||
|
||||
|
||||
def test_fake_provider_tool_calls_and_reasoning() -> None:
|
||||
"""Verify reasoning streaming and tool_calls payload emission."""
|
||||
provider = FakeProvider()
|
||||
tool_call = {
|
||||
"id": "call_123",
|
||||
"name": "save_file",
|
||||
"arguments": {"filename": "out.txt", "content": "data"},
|
||||
}
|
||||
provider.queue_response(
|
||||
content="Creating file",
|
||||
tool_calls=[tool_call],
|
||||
reasoning="User wants output in a file",
|
||||
)
|
||||
|
||||
reasoning_chunks: list[str] = []
|
||||
response = provider.chat(
|
||||
messages=[{"role": "user", "content": "Save to out.txt"}],
|
||||
on_reasoning=lambda piece: reasoning_chunks.append(piece),
|
||||
)
|
||||
|
||||
assert response["content"] == "Creating file"
|
||||
assert response["tool_calls"] == [tool_call]
|
||||
assert reasoning_chunks == ["User wants output in a file"]
|
||||
|
||||
|
||||
def test_fake_provider_error_injection() -> None:
|
||||
"""Verify that queued exceptions are raised on demand."""
|
||||
provider = FakeProvider()
|
||||
provider.queue_error(ProviderError("Rate limit exceeded (429)"))
|
||||
|
||||
with pytest.raises(ProviderError, match="Rate limit exceeded"):
|
||||
provider.chat(messages=[{"role": "user", "content": "Hi"}])
|
||||
|
||||
|
||||
def test_fake_provider_cancellation() -> None:
|
||||
"""Verify that cancellation stops execution immediately."""
|
||||
provider = FakeProvider()
|
||||
provider.queue_response(content="Long reply", chunks=["Part 1", "Part 2"])
|
||||
|
||||
is_cancelled = False
|
||||
|
||||
def cancel_fn() -> bool:
|
||||
return is_cancelled
|
||||
|
||||
is_cancelled = True
|
||||
with pytest.raises(ProviderError, match="aborted by user cancel"):
|
||||
provider.chat(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
cancel=cancel_fn,
|
||||
)
|
||||
|
||||
|
||||
def test_fake_tool_executor() -> None:
|
||||
"""Verify that FakeToolExecutor records calls and returns expected mock outputs."""
|
||||
executor = FakeToolExecutor()
|
||||
executor.set_mock_response("read_file", {"ok": True, "content": "file contents"})
|
||||
executor.register_handler("calc", lambda args: {"ok": True, "result": args.get("a", 0) + args.get("b", 0)})
|
||||
|
||||
res1 = executor.execute("read_file", {"path": "test.txt"})
|
||||
assert res1["ok"] is True
|
||||
assert res1["content"] == "file contents"
|
||||
|
||||
res2 = executor.execute("calc", {"a": 5, "b": 10})
|
||||
assert res2["result"] == 15
|
||||
|
||||
assert len(executor.call_log) == 2
|
||||
assert executor.get_calls_for("calc")[0]["args"] == {"a": 5, "b": 10}
|
||||
@@ -1,204 +0,0 @@
|
||||
"""R03-T02 — unit tests for ProviderDescriptor and the central ProviderRegistry.
|
||||
|
||||
Covers what the rest of the app now relies on the catalogue for: resolving ids
|
||||
and aliases, resolving a bare model id back to its provider, filling in default
|
||||
models, and refusing to let a duplicate registration silently hijack a built-in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cowork_local.domain.models.provider_descriptor import (
|
||||
AuthKind,
|
||||
ProviderDescriptor,
|
||||
WireProtocol,
|
||||
)
|
||||
from cowork_local.infrastructure.providers.provider_registry import (
|
||||
BUILTIN_DESCRIPTORS,
|
||||
ProviderNotFoundError,
|
||||
ProviderRegistry,
|
||||
)
|
||||
|
||||
|
||||
def make_descriptor(**overrides) -> ProviderDescriptor:
|
||||
"""A minimal valid descriptor; tests override just the field under test."""
|
||||
fields = dict(
|
||||
provider_id="demo",
|
||||
display_name="Demo provider",
|
||||
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||
default_model="demo-small",
|
||||
models=("demo-small", "demo-large"),
|
||||
)
|
||||
fields.update(overrides)
|
||||
return ProviderDescriptor(**fields)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ProviderDescriptor
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_descriptor_rejects_an_empty_id() -> None:
|
||||
"""An id-less descriptor could never be looked up, so it must not exist."""
|
||||
with pytest.raises(ValueError):
|
||||
make_descriptor(provider_id="")
|
||||
|
||||
|
||||
def test_descriptor_rejects_a_non_enum_protocol() -> None:
|
||||
"""The protocol drives adapter selection; a stray string would silently
|
||||
fall through to "no adapter" at build time instead of failing here."""
|
||||
with pytest.raises(TypeError):
|
||||
make_descriptor(wire_protocol="openai_compat")
|
||||
|
||||
|
||||
def test_descriptor_is_immutable() -> None:
|
||||
"""Descriptors are shared process-wide; a mutation would be visible to every
|
||||
other reader mid-iteration."""
|
||||
descriptor = make_descriptor()
|
||||
|
||||
with pytest.raises(Exception):
|
||||
descriptor.default_model = "hacked" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_id_matching_ignores_case_and_honours_aliases() -> None:
|
||||
"""Provider ids come from hand-edited config files and old app versions."""
|
||||
descriptor = make_descriptor(aliases=("legacy-demo",))
|
||||
|
||||
assert descriptor.matches("DEMO")
|
||||
assert descriptor.matches(" legacy-demo ")
|
||||
assert not descriptor.matches("other")
|
||||
|
||||
|
||||
def test_capabilities_use_the_routing_vocabulary() -> None:
|
||||
"""The set must be feedable straight into the routing selector's filter."""
|
||||
descriptor = make_descriptor(supports_vision=True, supports_tools=True,
|
||||
supports_streaming=False)
|
||||
|
||||
assert descriptor.capabilities == frozenset({"vision", "tools"})
|
||||
assert descriptor.has_capability("vision")
|
||||
assert not descriptor.has_capability("streaming")
|
||||
|
||||
|
||||
def test_average_cost_is_none_when_a_price_is_unknown() -> None:
|
||||
"""Unknown prices stay unknown — a guessed number would silently skew the
|
||||
routing scorer's cost term."""
|
||||
assert make_descriptor(cost_per_1k_input=0.5).avg_cost_per_1k is None
|
||||
priced = make_descriptor(cost_per_1k_input=1.0, cost_per_1k_output=3.0)
|
||||
# Same 1:3 input:output weighting as ModelMetadata.avg_cost_per_1k.
|
||||
assert priced.avg_cost_per_1k == pytest.approx((1.0 + 9.0) / 4.0)
|
||||
|
||||
|
||||
def test_resolve_model_prefers_the_caller_then_the_default() -> None:
|
||||
"""One place implements the "picked model or provider default" fallback that
|
||||
every chat surface used to re-implement inline."""
|
||||
descriptor = make_descriptor()
|
||||
|
||||
assert descriptor.resolve_model("demo-large") == "demo-large"
|
||||
assert descriptor.resolve_model("") == "demo-small"
|
||||
assert descriptor.resolve_model(" ") == "demo-small"
|
||||
|
||||
|
||||
def test_with_models_repoints_a_default_that_vanished() -> None:
|
||||
"""After discovery, the default must still name a model that exists."""
|
||||
descriptor = make_descriptor()
|
||||
|
||||
updated = descriptor.with_models(["demo-v2", "demo-v2", "demo-v3"])
|
||||
|
||||
assert updated.models == ("demo-v2", "demo-v3") # de-duplicated, order kept
|
||||
assert updated.default_model == "demo-v2"
|
||||
assert descriptor.models == ("demo-small", "demo-large"), "original was mutated"
|
||||
|
||||
|
||||
def test_with_models_keeps_a_default_that_survived() -> None:
|
||||
"""Discovery must not reshuffle a user's working selection."""
|
||||
updated = make_descriptor().with_models(["demo-large", "demo-small"])
|
||||
|
||||
assert updated.default_model == "demo-small"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ProviderRegistry
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_registry_resolves_ids_aliases_and_reports_unknowns() -> None:
|
||||
"""Lookup must be forgiving about form, but loud about genuinely unknown
|
||||
providers — a typo should fail at the call site, not as a None later."""
|
||||
registry = ProviderRegistry([make_descriptor(aliases=("legacy-demo",))])
|
||||
|
||||
assert registry.get("demo").provider_id == "demo"
|
||||
assert registry.get("legacy-demo").provider_id == "demo"
|
||||
assert registry.find("missing") is None
|
||||
assert "demo" in registry
|
||||
with pytest.raises(ProviderNotFoundError):
|
||||
registry.get("missing")
|
||||
|
||||
|
||||
def test_registry_refuses_to_overwrite_silently_but_replace_works() -> None:
|
||||
"""A second registration of the same id is almost always a bug; updating a
|
||||
descriptor is a deliberate act with its own method."""
|
||||
registry = ProviderRegistry([make_descriptor()])
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
registry.register(make_descriptor(display_name="Impostor"))
|
||||
|
||||
registry.replace(make_descriptor(display_name="Renamed"))
|
||||
assert registry.get("demo").display_name == "Renamed"
|
||||
assert len(registry) == 1
|
||||
|
||||
|
||||
def test_registry_re_registering_an_identical_descriptor_is_a_no_op() -> None:
|
||||
"""Idempotent registration keeps repeated bootstrap calls harmless."""
|
||||
registry = ProviderRegistry([make_descriptor()])
|
||||
|
||||
registry.register(make_descriptor())
|
||||
|
||||
assert len(registry) == 1
|
||||
|
||||
|
||||
def test_find_by_model_resolves_a_bare_model_id() -> None:
|
||||
"""Routing decisions and saved conversations sometimes carry only a model
|
||||
name; the registry is what turns that back into a provider."""
|
||||
registry = ProviderRegistry([make_descriptor()])
|
||||
|
||||
assert registry.find_by_model("demo-large").provider_id == "demo"
|
||||
# A gateway model we cannot enumerate offline is a miss, not an error — the
|
||||
# caller falls back to the configured active provider.
|
||||
assert registry.find_by_model("unknown-model") is None
|
||||
assert registry.find_by_model("") is None
|
||||
|
||||
|
||||
def test_builtin_catalogue_covers_every_configured_provider() -> None:
|
||||
"""The catalogue and DEFAULT_CONFIG must not drift: a provider users can
|
||||
configure but the registry cannot build is a dead Settings entry."""
|
||||
from cowork_local.config import DEFAULT_CONFIG
|
||||
|
||||
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
|
||||
|
||||
for provider_id in DEFAULT_CONFIG["providers"]:
|
||||
assert registry.find(provider_id) is not None, f"{provider_id} missing from registry"
|
||||
|
||||
|
||||
def test_build_fills_in_the_default_model() -> None:
|
||||
"""A half-written config must still produce a usable provider rather than an
|
||||
empty model id that only fails once the request reaches the gateway."""
|
||||
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
|
||||
|
||||
provider = registry.build("anthropic", {"api_key": "k"})
|
||||
|
||||
assert provider.model == registry.get("anthropic").default_model
|
||||
|
||||
|
||||
def test_build_respects_an_explicit_model() -> None:
|
||||
"""Per-tab model selection must win over the catalogue default."""
|
||||
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
|
||||
|
||||
provider = registry.build("anthropic", {"api_key": "k", "model": "claude-opus-4-8"})
|
||||
|
||||
assert provider.model == "claude-opus-4-8"
|
||||
|
||||
|
||||
def test_factory_still_raises_provider_error_for_unknown_ids() -> None:
|
||||
"""Existing call sites catch ProviderError; routing lookups through the
|
||||
registry must not change the exception type they see."""
|
||||
from cowork_local.providers import build_provider
|
||||
from cowork_local.providers.base import ProviderError
|
||||
|
||||
with pytest.raises(ProviderError):
|
||||
build_provider("definitely-not-a-provider", {})
|
||||
@@ -1,384 +1,346 @@
|
||||
"""R03-T03 — unit tests for the unified routing decision rules.
|
||||
"""Unit tests for :mod:`application.model_routing` (R03-T03).
|
||||
|
||||
The point of moving these rules out of the three chat widgets is that they can
|
||||
now be exercised without Qt, without the assessment store and without a network:
|
||||
the service talks to two narrow ports, so every mode is driven here by ~10-line
|
||||
fakes. Each test names the behaviour a chat surface depends on.
|
||||
These run against a hand-written fake router rather than ``core.routing``: the
|
||||
point of the service is the DECISION policy around the engine (mode handling,
|
||||
the manual confirm, never-raise behaviour, failure fallback), and mixing in the
|
||||
real scorer would test the wrong thing and drag the suite over its time budget.
|
||||
|
||||
No Qt, no config, no network - the whole file runs in milliseconds, which is the
|
||||
concrete payoff of moving this logic out of ``ui/chat_panel.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.model_routing import (
|
||||
RouteEvaluation,
|
||||
RoutingApplicationService,
|
||||
RoutingDecision,
|
||||
RoutingMode,
|
||||
RoutingOutcome,
|
||||
RoutingRequest,
|
||||
)
|
||||
|
||||
|
||||
class FakeDecisionPort:
|
||||
"""A routing engine that returns a canned verdict and records its input."""
|
||||
|
||||
def __init__(self, evaluation: RouteEvaluation) -> None:
|
||||
self.evaluation = evaluation
|
||||
self.calls: list = []
|
||||
|
||||
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
||||
self.calls.append((request, mode))
|
||||
return self.evaluation
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test doubles shaped like core.routing's RouteResult / SwitchDecision
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass
|
||||
class _TaskType:
|
||||
value: str
|
||||
|
||||
|
||||
class ExplodingDecisionPort:
|
||||
"""An engine that fails — proves routing degrades instead of breaking a turn."""
|
||||
|
||||
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
||||
raise RuntimeError("assessment store is corrupt")
|
||||
@dataclass
|
||||
class _Decision:
|
||||
score_gain: float = 0.0
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class FakeModeResolver:
|
||||
"""Per-surface mode lookup, standing in for the workspace settings."""
|
||||
@dataclass
|
||||
class _RouteResult:
|
||||
should_switch: bool
|
||||
to: Optional[Tuple[str, str]] = None
|
||||
task_type: Any = None
|
||||
decision: Any = None
|
||||
|
||||
def __init__(self, mode) -> None:
|
||||
self.mode = mode
|
||||
self.surfaces: list = []
|
||||
|
||||
def mode_for(self, surface: str):
|
||||
self.surfaces.append(surface)
|
||||
return self.mode
|
||||
def target(self) -> Optional[Tuple[str, str]]:
|
||||
return self.to
|
||||
|
||||
|
||||
def make_request(**overrides) -> RoutingRequest:
|
||||
"""A representative turn: Cowork chat, currently on a cheap OpenAI model."""
|
||||
fields = dict(
|
||||
surface="cowork",
|
||||
prompt="Refactor this function",
|
||||
current_provider="codex",
|
||||
current_model="gpt-4o-mini",
|
||||
class _FakeRouter:
|
||||
"""Records every route() call and replays a canned result."""
|
||||
|
||||
def __init__(self, result: Any = None, raises: bool = False) -> None:
|
||||
self._result = result or _RouteResult(should_switch=False, decision=_Decision())
|
||||
self._raises = raises
|
||||
self.calls: List[dict] = []
|
||||
|
||||
def route(self, surface, prompt, current_provider, current_model, **kwargs):
|
||||
self.calls.append({"surface": surface, "prompt": prompt,
|
||||
"provider": current_provider, "model": current_model, **kwargs})
|
||||
if self._raises:
|
||||
raise RuntimeError("assessment store is corrupt")
|
||||
return self._result
|
||||
|
||||
|
||||
def _switch_to(provider: str, model: str, gain: float = 0.2, task: str = "coding") -> _RouteResult:
|
||||
return _RouteResult(
|
||||
should_switch=True, to=(provider, model), task_type=_TaskType(task),
|
||||
decision=_Decision(score_gain=gain, reason=f"{task} fit beats current by {gain}"),
|
||||
)
|
||||
fields.update(overrides)
|
||||
return RoutingRequest(**fields)
|
||||
|
||||
|
||||
def switch_evaluation(**overrides) -> RouteEvaluation:
|
||||
"""An engine verdict that proposes a switch to a better coding model."""
|
||||
fields = dict(
|
||||
task_type="coding",
|
||||
should_switch=True,
|
||||
target_provider="anthropic",
|
||||
target_model="claude-sonnet-4-6",
|
||||
score_gain=0.21,
|
||||
reason="coding fit 0.88 > current 0.67",
|
||||
decision=object(),
|
||||
)
|
||||
fields.update(overrides)
|
||||
return RouteEvaluation(**fields)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Off
|
||||
# Mode parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_off_mode_never_consults_the_engine() -> None:
|
||||
"""Off must be free: no ranking, no store read, no decision at all."""
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.OFF))
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.switched is False
|
||||
assert outcome.provider is None and outcome.model is None
|
||||
assert port.calls == [], "Off mode must not call the routing engine"
|
||||
|
||||
|
||||
def test_missing_mode_resolver_defaults_to_off() -> None:
|
||||
"""Routing stays opt-in: with no way to read the mode, never switch."""
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port)
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.mode is RoutingMode.OFF
|
||||
assert outcome.switched is False
|
||||
|
||||
|
||||
def test_empty_prompt_is_not_routed() -> None:
|
||||
"""An empty message carries no signal to classify, so the engine is skipped."""
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
||||
|
||||
outcome = service.resolve(make_request(prompt=" "))
|
||||
|
||||
assert outcome.switched is False
|
||||
assert port.calls == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Auto
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_auto_mode_switches_silently() -> None:
|
||||
"""Auto applies the engine's verdict without asking the user."""
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.switched is True
|
||||
assert outcome.provider == "anthropic"
|
||||
assert outcome.model == "claude-sonnet-4-6"
|
||||
assert outcome.task_type == "coding"
|
||||
assert outcome.score_gain == pytest.approx(0.21)
|
||||
assert outcome.should_notify is True
|
||||
|
||||
|
||||
def test_auto_mode_keeps_current_when_nothing_is_better() -> None:
|
||||
"""No proposed switch means the surface's own selection is untouched."""
|
||||
port = FakeDecisionPort(switch_evaluation(
|
||||
should_switch=False, reason="current model is already best-fit"))
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.switched is False
|
||||
assert outcome.provider is None
|
||||
assert "already best-fit" in outcome.reason
|
||||
|
||||
|
||||
def test_switch_without_a_target_is_ignored() -> None:
|
||||
"""A verdict that says "switch" but names nothing is not actionable — a
|
||||
surface must never be handed an empty model id."""
|
||||
port = FakeDecisionPort(switch_evaluation(target_provider=None, target_model=None))
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.switched is False
|
||||
|
||||
|
||||
def test_same_provider_switch_keeps_the_current_provider() -> None:
|
||||
"""A model-only switch must not blank out the provider the surface uses."""
|
||||
port = FakeDecisionPort(switch_evaluation(target_provider=None, target_model="o3"))
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.switched is True
|
||||
assert outcome.provider == "codex" # unchanged, from the request
|
||||
assert outcome.model == "o3"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Manual
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_manual_mode_switches_only_after_approval() -> None:
|
||||
"""Manual's contract: ask first, then apply exactly what was approved."""
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(
|
||||
port, FakeModeResolver(RoutingMode.MANUAL),
|
||||
confirm_timeout_sec=lambda: 30.0,
|
||||
)
|
||||
asked: list = []
|
||||
|
||||
def confirm(decision, timeout):
|
||||
asked.append((decision, timeout))
|
||||
return True
|
||||
|
||||
outcome = service.resolve(make_request(), confirm=confirm)
|
||||
|
||||
assert outcome.switched is True
|
||||
assert len(asked) == 1
|
||||
# The configured timeout must reach the dialog, not a hard-coded default.
|
||||
assert asked[0][1] == pytest.approx(30.0)
|
||||
|
||||
|
||||
def test_manual_mode_decline_is_reported_distinctly() -> None:
|
||||
""""The user said no" must be distinguishable from "nothing better found",
|
||||
so a surface can stay quiet in one case and explain itself in the other."""
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
|
||||
|
||||
outcome = service.resolve(make_request(), confirm=lambda decision, timeout: False)
|
||||
|
||||
assert outcome.switched is False
|
||||
assert outcome.declined is True
|
||||
|
||||
|
||||
def test_manual_mode_without_a_callback_never_switches() -> None:
|
||||
"""Silently switching in Manual mode would violate the mode's promise."""
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
|
||||
|
||||
outcome = service.resolve(make_request(), confirm=None)
|
||||
|
||||
assert outcome.switched is False
|
||||
|
||||
|
||||
def test_manual_mode_treats_a_broken_dialog_as_a_decline() -> None:
|
||||
"""A crashing confirm dialog must not auto-approve a model change."""
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
|
||||
|
||||
def confirm(decision, timeout):
|
||||
raise RuntimeError("dialog blew up")
|
||||
|
||||
outcome = service.resolve(make_request(), confirm=confirm)
|
||||
|
||||
assert outcome.switched is False
|
||||
assert outcome.declined is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fallback
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_fallback_keeps_a_healthy_model_even_when_a_better_one_exists() -> None:
|
||||
"""Fallback is a resilience mode, not an optimiser: a usable pinned model
|
||||
wins over a higher-scoring candidate."""
|
||||
port = FakeDecisionPort(switch_evaluation(current_is_usable=True))
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.switched is False
|
||||
assert "healthy" in outcome.reason
|
||||
|
||||
|
||||
def test_fallback_switches_when_the_current_model_cannot_serve_the_turn() -> None:
|
||||
"""The one case Fallback exists for: rescue an unusable selection."""
|
||||
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.switched is True
|
||||
assert outcome.model == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
def test_fallback_asks_the_engine_with_auto_semantics() -> None:
|
||||
"""The engine only understands off/auto/manual, so Fallback must reach it as
|
||||
Auto — otherwise the engine would reject the unknown mode and rank nothing."""
|
||||
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
||||
|
||||
service.resolve(make_request())
|
||||
|
||||
assert port.calls[0][1] is RoutingMode.AUTO
|
||||
|
||||
|
||||
def test_fallback_never_confirms_with_the_user() -> None:
|
||||
"""Rescuing an unusable model is not a proposal — it happens silently."""
|
||||
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
||||
asked: list = []
|
||||
|
||||
outcome = service.resolve(
|
||||
make_request(), confirm=lambda decision, timeout: asked.append(1) or True)
|
||||
|
||||
assert outcome.switched is True
|
||||
assert asked == []
|
||||
|
||||
|
||||
def test_fallback_with_no_replacement_keeps_current() -> None:
|
||||
"""Nothing to fall back to means keep going with what we have and let the
|
||||
provider surface the real error, rather than blanking the model."""
|
||||
port = FakeDecisionPort(switch_evaluation(
|
||||
current_is_usable=False, target_provider=None, target_model=None))
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.switched is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Robustness & plumbing
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_engine_failure_degrades_to_keep_current() -> None:
|
||||
"""A broken assessment store must never stop a user sending a message."""
|
||||
service = RoutingApplicationService(
|
||||
ExplodingDecisionPort(), FakeModeResolver(RoutingMode.AUTO))
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert isinstance(outcome, RoutingOutcome)
|
||||
assert outcome.switched is False
|
||||
assert "error" in outcome.reason
|
||||
|
||||
|
||||
def test_mode_resolver_failure_degrades_to_off() -> None:
|
||||
"""An unreadable workspace config must not enable routing by accident."""
|
||||
class BrokenResolver:
|
||||
def mode_for(self, surface):
|
||||
raise OSError("workspace file unreadable")
|
||||
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port, BrokenResolver())
|
||||
|
||||
outcome = service.resolve(make_request())
|
||||
|
||||
assert outcome.mode is RoutingMode.OFF
|
||||
assert port.calls == []
|
||||
|
||||
|
||||
def test_explicit_request_mode_overrides_the_resolver() -> None:
|
||||
"""A surface may pin the mode for one turn (tests, replay, admin actions)."""
|
||||
resolver = FakeModeResolver(RoutingMode.OFF)
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port, resolver)
|
||||
|
||||
outcome = service.resolve(make_request(mode=RoutingMode.AUTO))
|
||||
|
||||
assert outcome.switched is True
|
||||
assert resolver.surfaces == [], "an explicit mode must skip the resolver"
|
||||
|
||||
|
||||
def test_request_is_forwarded_to_the_engine_unchanged() -> None:
|
||||
"""Surface, prompt and pinned task type must survive the hand-off — AI-Edit
|
||||
relies on its "coding" pin reaching the engine."""
|
||||
port = FakeDecisionPort(switch_evaluation())
|
||||
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
||||
request = make_request(surface="ai_edit", task_type="coding",
|
||||
required_capabilities=("vision",))
|
||||
|
||||
service.resolve(request)
|
||||
|
||||
forwarded = port.calls[0][0]
|
||||
assert forwarded is request
|
||||
assert forwarded.surface == "ai_edit"
|
||||
assert forwarded.task_type == "coding"
|
||||
assert forwarded.required_capabilities == ("vision",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
("auto", RoutingMode.AUTO),
|
||||
("MANUAL", RoutingMode.MANUAL),
|
||||
(" fallback ", RoutingMode.FALLBACK),
|
||||
("nonsense", RoutingMode.OFF),
|
||||
("", RoutingMode.OFF),
|
||||
(None, RoutingMode.OFF),
|
||||
],
|
||||
)
|
||||
def test_mode_parsing_is_forgiving(raw, expected) -> None:
|
||||
"""Config values are hand-edited; an unknown one must degrade, not raise."""
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("off", RoutingMode.OFF),
|
||||
("AUTO", RoutingMode.AUTO),
|
||||
(" manual ", RoutingMode.MANUAL),
|
||||
("fallback", RoutingMode.FALLBACK),
|
||||
])
|
||||
def test_parse_accepts_the_config_spellings(raw, expected):
|
||||
assert RoutingMode.parse(raw) is expected
|
||||
|
||||
|
||||
def test_confirm_timeout_falls_back_to_the_default_when_unusable() -> None:
|
||||
"""A corrupted timeout must not produce a zero-second dialog that declines
|
||||
every switch before the user can read it."""
|
||||
service = RoutingApplicationService(
|
||||
FakeDecisionPort(switch_evaluation()),
|
||||
FakeModeResolver(RoutingMode.MANUAL),
|
||||
confirm_timeout_sec=lambda: 0.0,
|
||||
)
|
||||
|
||||
assert service.confirm_timeout() == RoutingApplicationService.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||
@pytest.mark.parametrize("raw", ["", None, "nonsense", 0])
|
||||
def test_parse_degrades_unknown_values_to_off(raw):
|
||||
"""A corrupt setting must leave the user's own model alone rather than
|
||||
silently moving their work onto another model."""
|
||||
assert RoutingMode.parse(raw) is RoutingMode.OFF
|
||||
|
||||
|
||||
def test_routing_request_is_immutable() -> None:
|
||||
"""The snapshot must not change under a turn that is already in flight."""
|
||||
request = make_request()
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OFF
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_off_never_consults_the_engine():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
request.prompt = "something else" # type: ignore[misc]
|
||||
decision = service.route_turn("cowork", "hi", "openai_compat", "gpt-4o-mini",
|
||||
mode="off")
|
||||
|
||||
assert router.calls == [] # not even scored: OFF costs nothing
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
|
||||
|
||||
def test_blank_prompt_is_never_routed():
|
||||
"""An empty message carries no signal to classify; all three legacy copies
|
||||
guarded this and the guard has to survive the move."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", " ", "openai_compat", "m", mode="auto")
|
||||
|
||||
assert router.calls == []
|
||||
assert decision.switched is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# AUTO
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_auto_switches_silently_and_reports_the_target():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude-sonnet-4-6", gain=0.31))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "write a function", "openai_compat", "gpt-4o-mini",
|
||||
mode="auto")
|
||||
|
||||
assert decision.switched is True
|
||||
assert decision.target() == ("anthropic", "claude-sonnet-4-6")
|
||||
assert decision.task_type == "coding"
|
||||
assert decision.score_gain == pytest.approx(0.31)
|
||||
assert decision.should_notify is True
|
||||
|
||||
|
||||
def test_auto_keeps_the_current_model_when_no_candidate_wins():
|
||||
router = _FakeRouter(_RouteResult(should_switch=False, decision=_Decision(reason="no gain")))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "hello", "openai_compat", "gpt-4o-mini", mode="auto")
|
||||
|
||||
assert decision.switched is False
|
||||
# The decision still names a model to run on, so the call site never has to
|
||||
# re-derive the fallback itself - the exact drift the three copies suffered.
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
assert decision.should_notify is False
|
||||
|
||||
|
||||
def test_auto_never_asks_for_confirmation():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
asked: List[RoutingDecision] = []
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
service.route_turn("cowork", "q", "openai_compat", "m", mode="auto",
|
||||
confirm=lambda d: asked.append(d) or True)
|
||||
|
||||
assert asked == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MANUAL
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_manual_switches_only_after_the_user_approves():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
seen: List[RoutingDecision] = []
|
||||
|
||||
def confirm(proposal: RoutingDecision) -> bool:
|
||||
seen.append(proposal)
|
||||
return True
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "m",
|
||||
mode="manual", confirm=confirm)
|
||||
|
||||
assert decision.switched is True
|
||||
assert decision.target() == ("anthropic", "claude")
|
||||
# The dialog is handed the full proposal so it can explain the trade-off.
|
||||
assert seen[0].model == "claude"
|
||||
assert seen[0].score_gain > 0
|
||||
|
||||
|
||||
def test_manual_keeps_the_current_model_when_declined():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "gpt-4o-mini",
|
||||
mode="manual", confirm=lambda d: False)
|
||||
|
||||
assert decision.switched is False
|
||||
assert decision.declined is True
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
|
||||
|
||||
def test_manual_without_a_confirm_callback_does_not_switch():
|
||||
"""A headless caller (scheduler) has nobody to ask, so Manual must behave as
|
||||
"not approved" rather than as "approved by default"."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "m", mode="manual")
|
||||
|
||||
assert decision.switched is False
|
||||
assert decision.declined is True
|
||||
|
||||
|
||||
def test_a_confirm_dialog_that_raises_counts_as_declined():
|
||||
"""If the modal blows up (window closing mid-turn) the safe reading is that
|
||||
the user did NOT consent to running on another model."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
def confirm(_proposal):
|
||||
raise RuntimeError("dialog destroyed")
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "m",
|
||||
mode="manual", confirm=confirm)
|
||||
|
||||
assert decision.switched is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# FALLBACK
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_fallback_does_not_switch_up_front():
|
||||
"""The whole point of the mode: honour the user's model choice until it
|
||||
actually fails."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "gpt-4o-mini",
|
||||
mode="fallback")
|
||||
|
||||
assert router.calls == []
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
|
||||
|
||||
def test_fallback_switches_after_a_failure():
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude", gain=0.4))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
decision = service.fallback_after_failure("cowork", "q", "openai_compat", "gpt-4o-mini",
|
||||
mode="fallback")
|
||||
|
||||
assert decision is not None
|
||||
assert decision.switched is True
|
||||
assert decision.target() == ("anthropic", "claude")
|
||||
assert "failed" in decision.reason
|
||||
|
||||
|
||||
def test_fallback_never_returns_the_model_that_just_failed():
|
||||
"""Retrying the model that just went down would spin on the outage."""
|
||||
router = _FakeRouter(_switch_to("openai_compat", "gpt-4o-mini"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
assert service.fallback_after_failure(
|
||||
"cowork", "q", "openai_compat", "gpt-4o-mini", mode="fallback") is None
|
||||
|
||||
|
||||
def test_fallback_returns_none_when_there_is_no_alternative():
|
||||
router = _FakeRouter(_RouteResult(should_switch=False, decision=_Decision()))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
assert service.fallback_after_failure("cowork", "q", "openai_compat", "m",
|
||||
mode="auto") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["off", "manual"])
|
||||
def test_off_and_manual_do_not_auto_recover_from_a_failure(mode):
|
||||
"""Both modes exist to keep the user in control of which model runs their
|
||||
work; moving it on failure would break that promise silently."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
assert service.fallback_after_failure("cowork", "q", "openai_compat", "m",
|
||||
mode=mode) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Robustness - routing must never break a chat turn
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_engine_failure_degrades_to_keeping_the_current_model():
|
||||
service = RoutingApplicationService(_FakeRouter(raises=True))
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "gpt-4o-mini", mode="auto")
|
||||
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
||||
|
||||
|
||||
def test_engine_failure_during_fallback_returns_none():
|
||||
"""A broken router must not mask the original provider error with its own."""
|
||||
service = RoutingApplicationService(_FakeRouter(raises=True))
|
||||
|
||||
assert service.fallback_after_failure("cowork", "q", "p", "m", mode="auto") is None
|
||||
|
||||
|
||||
def test_a_malformed_route_result_is_treated_as_no_switch():
|
||||
"""The engine is a legacy module still under refactor; a missing attribute
|
||||
must degrade, not raise into the middle of a turn."""
|
||||
class _Garbage:
|
||||
should_switch = True # claims a switch but exposes no target()
|
||||
|
||||
service = RoutingApplicationService(_FakeRouter(_Garbage()))
|
||||
|
||||
decision = service.route_turn("cowork", "q", "openai_compat", "m", mode="auto")
|
||||
|
||||
assert decision.switched is False
|
||||
assert decision.target() == ("openai_compat", "m")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Per-surface mode lookup
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_mode_is_read_per_surface_when_not_passed_explicitly():
|
||||
"""Each screen has its own Off/Auto/Manual toggle, and workspaces override
|
||||
it - so the surface, not a global setting, decides."""
|
||||
router = _FakeRouter(_switch_to("anthropic", "claude"))
|
||||
modes = {"cowork": "auto", "ai_edit": "off"}
|
||||
service = RoutingApplicationService(router, mode_reader=modes.get)
|
||||
|
||||
assert service.route_turn("cowork", "q", "p", "m").switched is True
|
||||
assert service.route_turn("ai_edit", "q", "p", "m").switched is False
|
||||
|
||||
|
||||
def test_a_failing_mode_reader_falls_back_to_off():
|
||||
def broken(_surface):
|
||||
raise KeyError("config not loaded yet")
|
||||
|
||||
service = RoutingApplicationService(_FakeRouter(_switch_to("a", "b")),
|
||||
mode_reader=broken)
|
||||
|
||||
assert service.route_turn("cowork", "q", "p", "m").switched is False
|
||||
|
||||
|
||||
def test_required_capabilities_are_passed_through_to_the_engine():
|
||||
"""An image turn must only be routed to a vision-capable model; the filter
|
||||
has to reach the scorer or the constraint is silently dropped."""
|
||||
router = _FakeRouter()
|
||||
service = RoutingApplicationService(router)
|
||||
|
||||
service.route_turn("cowork", "describe this", "p", "m", mode="auto",
|
||||
required_capabilities=["vision"])
|
||||
|
||||
assert router.calls[0]["required_capabilities"] == ["vision"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user