Files
cowork-local/tests/unit/test_routing_wiring.py
T
anhtnm1andClaude Opus 5 96bec976e7 feat(R03): unify provider catalogue, routing decisions and usage telemetry
EPIC R03 (Team Duy) - one provider catalogue, one routing flow, one usage seam.

R03-T01 tests/contracts/test_providers.py
  29 contract tests every provider must satisfy: canonical assistant message,
  streamed text == returned content, reasoning never joins the answer, parsed
  tool arguments, ProviderError for every failure. Real adapters exercised
  offline by stubbing Provider._request.
R03-T02 domain/models/provider_descriptor.py
        infrastructure/providers/provider_registry.py
  Provider facts declared once (was split across providers/factory.py,
  DEFAULT_CONFIG and PROVIDER_LABELS). ProviderRegistry.build() also stamps the
  descriptor id onto the instance, so ollama/github_copilot/codex usage is no
  longer all attributed to "openai_compat", and never mutates the caller config.
R03-T03 application/model_routing/routing_application_service.py
  Pure-Python routing policy with four modes: Off, Auto, Manual and the new
  Fallback (switch only AFTER the current model fails). Depends on a RoutingPort
  protocol; production wires the existing core.routing engine underneath.
R03-T04/T05 ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py
  Three near-identical routing copies (~40 lines each) replaced by a call to
  ctx.routing_application() plus a confirm callback. Mode vocabulary now lives
  in one place (normalize_mode/is_valid_mode) instead of four literal tuples.
R03-T06 infrastructure/telemetry/usage_sink.py
  Token usage extracted from both providers into UsageEvent + UsageEventSink.
  Estimation pinned against core.usage_tracker so no recorded number changes.

Also fixes a deadlock introduced while wiring AppContext: routing_application()
held _routing_lock and called routing(), which takes the same non-reentrant lock.

Suite: 186 passed, 1.22s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

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

113 lines
4.1 KiB
Python

"""Integration test for the AppContext routing wiring (R03-T04 / R03-T05).
The three chat surfaces now call ``ctx.routing_application()`` instead of each
carrying their own copy of the routing algorithm. The unit tests cover the
policy; this file covers the WIRING, which unit tests with a fake router cannot
see:
* the service is built and memoised on the context
* it reads the per-workspace mode through ``project_routing_mode``
* the legacy ``core.routing.RoutingService`` is what sits underneath it
* ``fallback`` survives a round trip through the per-workspace mode store
Still Qt-free: ``AppContext`` itself imports no widgets, and the config is
written into a tmp dir so nothing touches ``~/.cowork_local``.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from cowork_local.application.model_routing import (
RoutingApplicationService,
RoutingMode,
)
from cowork_local.config import AppConfig
from cowork_local.state import AppContext
@pytest.fixture
def ctx(tmp_path: Path) -> AppContext:
"""An AppContext backed by a throwaway config file."""
return AppContext(AppConfig.load(tmp_path / "config.json"))
def test_routing_application_is_built_and_memoised(ctx):
"""One instance per app: the pending-switch registry underneath it must be
shared by every surface, so a second call has to return the same object."""
first = ctx.routing_application()
assert isinstance(first, RoutingApplicationService)
assert ctx.routing_application() is first
def test_the_legacy_engine_sits_underneath_the_new_service():
"""Strangler-fig check (ADR-001 section 4): the scoring engine is reused, not
reimplemented. If this ever stops holding, the assessment scores the
scheduler probes would no longer be the ones routing decisions use."""
from cowork_local.core.routing.service import RoutingService
config = AppConfig.load(Path("does-not-exist.json"))
context = AppContext(config)
service = context.routing_application()
assert isinstance(service._router, RoutingService)
assert service._router is context.routing()
def test_mode_is_read_through_the_per_workspace_lookup(ctx, monkeypatch):
seen = []
def fake_mode(surface: str) -> str:
seen.append(surface)
return "off"
monkeypatch.setattr(ctx, "project_routing_mode", fake_mode)
# Built after the patch so the service captures the patched reader.
service = RoutingApplicationService(ctx.routing(), mode_reader=ctx.project_routing_mode)
decision = service.route_turn("co4e", "hello", "openai_compat", "gpt-4o-mini")
assert seen == ["co4e"]
assert decision.switched is False
def test_routing_off_by_default_leaves_the_selected_model_alone(ctx):
"""Default config has routing off on every surface, so a fresh install must
never move a turn to another model."""
decision = ctx.routing_application().route_turn(
"cowork", "write a function", "openai_compat", "gpt-4o-mini")
assert decision.mode is RoutingMode.OFF
assert decision.switched is False
assert decision.target() == ("openai_compat", "gpt-4o-mini")
@pytest.mark.parametrize("mode", ["off", "auto", "manual", "fallback"])
def test_every_mode_survives_a_round_trip_through_the_config(ctx, mode):
"""``fallback`` is new (R03-T03); the per-surface store used to whitelist
only three values and would have silently downgraded it to "off"."""
ctx.set_project_routing_mode("cowork", mode)
assert ctx.project_routing_mode("cowork") == mode
def test_an_unknown_mode_still_falls_back_to_off(ctx):
ctx.set_project_routing_mode("cowork", "turbo")
assert ctx.project_routing_mode("cowork") == "off"
def test_a_real_route_call_never_raises_without_any_assessments(ctx):
"""The store is empty on a fresh install. Routing must degrade to "keep the
current model" rather than raise into the middle of the first message."""
ctx.set_project_routing_mode("cowork", "auto")
decision = ctx.routing_application().route_turn(
"cowork", "hello there", "openai_compat", "gpt-4o-mini")
assert decision.switched is False
assert decision.target() == ("openai_compat", "gpt-4o-mini")