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>
262 lines
9.6 KiB
Python
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 == ""
|