Files
cowork-local/tests/unit/test_routing_application_service.py
anhtnm1andClaude Opus 5 f61c5474b0 feat(R03): unify model routing and centralise the provider catalogue
EPIC R03 (Team Duy) — Model Providers & Routing. All six tasks done.

R03-T02 — Provider catalogue
  domain/models/provider_descriptor.py     ProviderDescriptor (frozen), WireProtocol, AuthKind
  infrastructure/providers/provider_registry.py
                                           thread-safe registry: id/alias lookup, dynamic
                                           lookup by model id, adapter selection by protocol
  providers/factory.py                     drops its own _REGISTRY table and delegates to the
                                           registry, still raising ProviderError for callers

R03-T03 — RoutingApplicationService (pure Python, 4 modes)
  application/model_routing/routing_models.py
                                           RoutingMode (off/auto/manual/fallback),
                                           RoutingRequest (immutable snapshot), RouteEvaluation,
                                           RoutingOutcome
  application/model_routing/routing_application_service.py
                                           the single decision flow, reached through two narrow
                                           ports plus a caller-supplied confirm callback, so no
                                           Qt import is needed
  application/model_routing/core_routing_adapter.py
                                           binds the ports to core/routing and AppContext

  Fallback is a new resilience mode: keep the selected model while it can serve the turn,
  re-route only when it cannot. Wired end to end through config.py, state.py,
  ui/routing_toggle.py and i18n.py (EN/JA/VI).

R03-T04 / T05 — Remove the duplicated routing flow
  ui/chat_panel.py (#L638), ui/co4e_tab.py, ui/folder_tab.py each drop ~35 lines of copied
  logic and call the shared service; the widgets now only build a RoutingRequest, host the
  Manual-mode modal and render the outcome.

R03-T06 — Token usage as an event
  infrastructure/telemetry/usage_sink.py   UsageEvent + UsageEventSink protocol, with tracker,
                                           in-memory and composite sinks
  providers/openai_compat.py, providers/anthropic.py
                                           publish a UsageEvent instead of writing to the
                                           usage tracker themselves
  core/usage_tracker.py                    adds current_context() so a sink can borrow and
                                           restore a thread's attribution

R03-T01 — Contract tests
  tests/contracts/test_providers.py parametrises over every provider in the registry: chat()
  signature, canonical assistant message, normalised tool calls, response closed, tool schema
  translation, ProviderError, list_models/test_connection, one UsageEvent per turn.

Test infrastructure fix (required to verify any of the above): tests/conftest.py used to put
the repository's PARENT directory on sys.path, so `import cowork_local.*` resolved against
whichever sibling folder happened to carry that name — on a dev machine, an unrelated older
checkout. The suite reported green while exercising different code. The conftest now binds
this checkout to the cowork_local name in sys.modules.

Verification
  pytest tests/                    236 passed in ~1.8s (102 before this change)
  scripts/check_imports.py         PASS, 0 forbidden imports in domain/ and application/
  new production files             largest is 288 lines, all under the 400 LOC ceiling
  new tests                        134 (50 contract, 70 unit, 14 integration), all offline

scripts/run_quality_gate.py does not exist yet (R10-T02), so DoD item 7 was covered by
check_imports.py plus the full suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:36:20 +09:00

385 lines
14 KiB
Python

"""R03-T03 — unit tests for the unified routing decision rules.
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.
"""
from __future__ import annotations
import pytest
from cowork_local.application.model_routing import (
RouteEvaluation,
RoutingApplicationService,
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
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")
class FakeModeResolver:
"""Per-surface mode lookup, standing in for the workspace settings."""
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 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",
)
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
# --------------------------------------------------------------------------- #
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."""
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
def test_routing_request_is_immutable() -> None:
"""The snapshot must not change under a turn that is already in flight."""
request = make_request()
with pytest.raises(Exception):
request.prompt = "something else" # type: ignore[misc]