Files
cowork-local/tests/integration/test_routing_surfaces.py
T
vudt15andClaude Sonnet 5 0e51356a7d feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView
Team Hoa, EPIC R08 (UI/Application Separation) - Team Hoa scope only
(R08-T11 -> T14; R08-T01->T10 belong to Team Duy/Team Nam).

- R08-T11: ui/schedule_task_tab.py (795 lines) -> presentation/scheduling/
  {kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,
  ai_task_import_dialog,run_history_dialog}.py + schedule_task_tab.py
  shell. Kanban CRUD/drag-drop now goes through
  application/scheduling/task_application_service.py (R07-T04) instead of
  ~30 lines of inline if/elif per drag target.
- R08-T12: ui/folder_tab.py (1587 lines, the largest of the four) ->
  presentation/folder/{workspace_file_tree,document_preview_manager,
  code_editor,office_document_renderer,ai_file_editor_dialog,
  ai_edit_model_resolver,ai_edit_pipeline}.py + folder_tab.py shell.
  Closes the R06-T05 loop: FileWorkspaceService existed since R06 with
  zero production call sites (confirmed by grep); every plain-text write
  (save/create/write_content) now goes through it, gaining path
  containment and a Python-syntax warning the original code never had.
  Pure helpers (_read_text, _is_probably_text, _pptx_available,
  _split_code_block, _parse_ai_output) moved to
  application/workspaces/{file_preview_helpers,ai_edit_output}.py.
- R08-T13: ui/dashboard_tab.py (437 lines) -> presentation/dashboard/
  {token_usage_card_widget,usage_chart_widget,habits_widget}.py +
  dashboard_tab.py shell, backed by a new
  application/monitoring/dashboard_query_service.py (pricing/period/
  summary queries the three widgets used to each recompute separately).
  Directory-ownership note left in the checklist for Team Nam.
- R08-T14: ui/structure_graph_view.py (1035 lines) ->
  presentation/graph/{graph_scene_items,graph_renderer,
  graph_messages_view,graph_qa_widget}.py + structure_graph_view.py
  shell. Extraction helpers (_pdf_to_markdown, _extract_file_contents)
  moved to application/workspaces/graph_index_service.py (pure Python).
  Renderer and Q&A panel talk only through signals
  (node_selected/graph_rendered/raw_json_ready/project_changed) - neither
  imports the other.
- presentation/shared/web_engine_support.py: HAS_WEB_ENGINE, previously
  duplicated (folder_tab imported it FROM structure_graph_view.py) - now
  one shared flag instead of one screen importing another screen's module.

All four old ui/*.py files deleted; app.py and ui/workspace_tab.py updated
to the new import paths (each god-file only had 1-2 real construction
sites, so import sites were updated directly rather than kept as a
strangler-fig shim - unlike core/tools.py at R05, which had dozens).

pytest: 377 pass (+94 vs the R07 baseline of 328; same 4 pre-existing
failures as the R05/R06 baseline, unrelated to this work).
scripts/check_imports.py: PASS. python -c "import cowork_local.app": OK.
Every new file < 400 lines (largest: graph_renderer.py, 391).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:55:32 +09:00

262 lines
9.6 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)
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):
"""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(_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_panel.resolver.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 == ""