"""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 == ""