CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
255 lines
9.0 KiB
Python
255 lines
9.0 KiB
Python
"""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)
|
|
* ``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver.apply_routing`` (AI-Edit)
|
|
|
|
On this branch the Manual-mode confirm dialog (``ui/routing_toggle.py::
|
|
confirm_switch``) still reads its decision straight off the engine's own
|
|
``core/routing/models.py::SwitchDecision`` - ``RoutingOutcome.decision`` passes
|
|
it through unwrapped rather than translating it into an application-layer
|
|
type, so there is no separate field contract to pin here.
|
|
"""
|
|
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,
|
|
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 _FakeDecisionPort:
|
|
"""A :class:`RoutingDecisionPort` that always proposes the same switch and
|
|
records the surface (and task type) it was asked to evaluate."""
|
|
|
|
def __init__(self, provider="anthropic", model="claude-sonnet-4-6",
|
|
gain: float = 0.4) -> None:
|
|
self.provider = provider
|
|
self.model = model
|
|
self.gain = gain
|
|
self.surfaces: List[str] = []
|
|
|
|
def evaluate(self, request, mode):
|
|
from cowork_local.application.model_routing import RouteEvaluation
|
|
|
|
self.surfaces.append(request.surface)
|
|
return RouteEvaluation(
|
|
task_type=request.task_type or "coding",
|
|
should_switch=True,
|
|
target_provider=self.provider,
|
|
target_model=self.model,
|
|
score_gain=self.gain,
|
|
reason="better fit",
|
|
)
|
|
|
|
|
|
class _FixedModeResolver:
|
|
"""A :class:`ModeResolver` that reports the same mode for every surface."""
|
|
|
|
def __init__(self, mode: str) -> None:
|
|
self._mode = mode
|
|
|
|
def mode_for(self, surface: str) -> str:
|
|
return self._mode
|
|
|
|
|
|
def _install(ctx: AppContext, mode: str) -> _FakeDecisionPort:
|
|
"""Wire a fake decision port into the context and force ``mode`` on every
|
|
surface.
|
|
|
|
Every surface reaches the service through
|
|
``build_routing_application_service(ctx)``, which memoises its instance on
|
|
``ctx._routing_app_service`` (see ``core_routing_adapter.py``) — pre-seeding
|
|
that exact attribute is what makes the surfaces under test see this fake
|
|
instead of building a real one against ``ctx.routing()``.
|
|
"""
|
|
router = _FakeDecisionPort()
|
|
service = RoutingApplicationService(router, mode_resolver=_FixedModeResolver(mode))
|
|
ctx._routing_app_service = 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):
|
|
"""Manual mode's confirm dialog is ``ui/routing_toggle.py::confirm_switch``,
|
|
imported locally inside ``_apply_routing`` at call time — patching the
|
|
source module's attribute is what a local import actually re-reads."""
|
|
from cowork_local.ui.cowork_tab import CoworkTab
|
|
|
|
_install(ctx, "manual")
|
|
tab = CoworkTab(ctx)
|
|
asked: List[Any] = []
|
|
|
|
def fake_confirm(parent, decision, timeout):
|
|
asked.append(decision)
|
|
return True
|
|
|
|
monkeypatch.setattr("cowork_local.ui.routing_toggle.confirm_switch", fake_confirm)
|
|
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("cowork_local.ui.routing_toggle.confirm_switch",
|
|
lambda parent, decision, timeout: 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):
|
|
"""R08-T12: the routing call this test pins moved from
|
|
``ui/folder_tab.py::FolderTab._ai_apply_routing`` to
|
|
``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver.
|
|
apply_routing`` - same RoutingApplicationService call, same surface key,
|
|
now independently testable without the whole FolderTab widget tree."""
|
|
from cowork_local.presentation.folder.folder_tab import FolderTab
|
|
|
|
router = _install(ctx, "auto")
|
|
tab = FolderTab(ctx)
|
|
|
|
tab.ai_panel.resolver.apply_routing("rename this variable")
|
|
|
|
assert router.surfaces == ["ai_edit"]
|
|
assert (tab.ai_panel.resolver.routed_provider, tab.ai_panel.resolver.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.presentation.folder.folder_tab import FolderTab
|
|
|
|
seen: List[Any] = []
|
|
|
|
class _Recorder(_FakeDecisionPort):
|
|
def evaluate(self, request, mode):
|
|
seen.append(request.task_type)
|
|
return super().evaluate(request, mode)
|
|
|
|
ctx._routing_app_service = RoutingApplicationService(
|
|
_Recorder(), mode_resolver=_FixedModeResolver("auto"))
|
|
tab = FolderTab(ctx)
|
|
|
|
tab.ai_panel.resolver.apply_routing("rename this variable")
|
|
|
|
assert seen == [TaskType.CODING]
|