From 0b6b220bd9c707e6ce5be86c53c8efdca3e4bf88 Mon Sep 17 00:00:00 2001 From: minhanhpkpro Date: Fri, 25 Sep 2026 14:40:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(routing):=20=C3=B4=20=C4=91=E1=BB=8Bnh=20t?= =?UTF-8?q?uy=E1=BA=BFn=20ch=E1=BB=89=20c=C3=B2n=20Auto=20v=C3=A0=20Manual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bỏ hai chế độ Off và Fallback ở ô định tuyến cạnh khung chat và ở mục Định tuyến trong Cài đặt. Mặc định chuyển sang Auto. - USER_ROUTING_MODES = ("auto", "manual"); giá trị off/fallback/lạ còn lưu trong config hay project đều được hiểu là Auto (routing_mode_for, project_routing_mode, set_*). Engine vẫn hiểu "off" khi truyền mode_override tường minh. - RoutingScheduler: định tuyến giờ luôn bật nên việc chấm điểm model định kỳ luôn chạy; muốn tắt thì đặt reassess_interval_hours = 0. - Cập nhật các test đang ghim hành vi "mặc định off". Co-Authored-By: Claude Opus 5.5 (1M context) --- config.py | 4 +- core/routing/scheduler.py | 22 +++------- .../config/json_config_repository.py | 16 ++++--- .../settings/routing_settings_widget.py | 10 ++--- state.py | 8 ++-- tests/integration/test_routing_unification.py | 18 ++++---- tests/routing/test_per_workspace_modes.py | 35 ++++++++++++---- tests/routing/test_service.py | 21 +++++++--- tests/ui/test_routing_toggle_modes.py | 42 +++++++++++++++++++ ui/routing_toggle.py | 17 ++++---- 10 files changed, 126 insertions(+), 67 deletions(-) create mode 100644 tests/ui/test_routing_toggle_modes.py diff --git a/config.py b/config.py index b05951e..f5a921a 100644 --- a/config.py +++ b/config.py @@ -236,7 +236,7 @@ DEFAULT_CONFIG: Dict[str, Any] = { # file (~/.cowork_local/assessments.json + assessments_history/), not here — # this section is only the behaviour config the user edits. "routing": { - "switch_mode": "off", # global default: "off" | "auto" | "manual" + "switch_mode": "auto", # global default: "auto" | "manual" "policy": "balanced", # "quality" | "cost" | "latency" | "balanced" "min_score_gain": 0.05, # only switch if the new model beats current by ≥ this "confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time @@ -246,7 +246,7 @@ DEFAULT_CONFIG: Dict[str, Any] = { "judge_model": "", # fixed cheap judge model ("" → a per-provider default) "candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers "auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added - # Per-surface Off/Auto/Manual toggle state (the chat-screen toggle). An + # Per-surface Auto/Manual toggle state (the chat-screen toggle). An # empty string means "follow the global switch_mode above". "surface_modes": { "cowork": "", diff --git a/core/routing/scheduler.py b/core/routing/scheduler.py index bc456d8..8070e8f 100644 --- a/core/routing/scheduler.py +++ b/core/routing/scheduler.py @@ -75,26 +75,16 @@ class RoutingScheduler(QObject): return None def _routing_enabled_anywhere(self) -> bool: - """Is routing actually in use? True if the global mode is auto/manual OR - any chat surface overrides to auto/manual. When everything is Off, the - assessment scores would never be consulted — so we don't spend tokens - probing for them (no surprise cost on a fresh install).""" - try: - routing = self.ctx.config.routing - if (routing.get("switch_mode") or "off") in ("auto", "manual"): - return True - for m in (routing.get("surface_modes") or {}).values(): - if m in ("auto", "manual"): - return True - except Exception: # noqa: BLE001 - pass - return False + """Is routing actually in use? Always, now: the Off mode was removed and + every stored value resolves to Auto or Manual (see + ``config.user_routing_mode``). Paid probing is switched off through + ``reassess_interval_hours = 0`` instead.""" + return True def is_due(self) -> bool: """Đã đến lúc chấm điểm lại chưa. - Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí, - không được tiêu tiền cho một tính năng người dùng đã tắt. + Dò model là lượt gọi có tính phí: đặt chu kỳ chấm lại = 0 thì không dò. """ if not self._routing_enabled_anywhere(): return False # routing off everywhere → don't probe (would be wasted cost) diff --git a/infrastructure/config/json_config_repository.py b/infrastructure/config/json_config_repository.py index 87f183a..6cb04b8 100644 --- a/infrastructure/config/json_config_repository.py +++ b/infrastructure/config/json_config_repository.py @@ -258,6 +258,14 @@ class JsonConfigRepository(ConfigSectionsMixin): #: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với #: ``config.py`` để hai bên lệch nhau là đỏ ngay. ROUTING_MODES = ("off", "auto", "manual", "fallback") + #: Chế độ người dùng còn chọn được. "off"/"fallback" đã bỏ khỏi giao diện; + #: giá trị cũ còn lưu trong config/project (hoặc giá trị lạ) đều hiểu là "auto". + USER_ROUTING_MODES = ("auto", "manual") + + @classmethod + def user_routing_mode(cls, mode: str) -> str: + """Quy một giá trị đã lưu về chế độ người dùng chọn được (mặc định "auto").""" + return mode if mode in cls.USER_ROUTING_MODES else "auto" @classmethod def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None): @@ -313,16 +321,14 @@ class JsonConfigRepository(ConfigSectionsMixin): """Chế độ có hiệu lực cho một bề mặt chat. Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung. - Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi - có người sửa tay file cấu hình.""" + Chỉ còn Auto/Manual: "off", "fallback" cũ hay giá trị lạ đều hiểu là "auto".""" routing = self.routing override = (routing.get("surface_modes", {}) or {}).get(surface, "") - mode = override or routing.get("switch_mode", "off") - return mode if mode in self.ROUTING_MODES else "off" + return self.user_routing_mode(override or routing.get("switch_mode", "")) def set_routing_mode_for(self, surface: str, mode: str) -> None: """Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay.""" - mode = mode if mode in self.ROUTING_MODES else "off" + mode = self.user_routing_mode(mode) self.routing.setdefault("surface_modes", {})[surface] = mode self.save() diff --git a/presentation/settings/routing_settings_widget.py b/presentation/settings/routing_settings_widget.py index 5a5890b..625e15d 100644 --- a/presentation/settings/routing_settings_widget.py +++ b/presentation/settings/routing_settings_widget.py @@ -12,11 +12,9 @@ from PySide6.QtWidgets import ( from ...i18n import tr -#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig -#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ -#: chỗ này thì người dùng không chọn được chế độ đó, mà không có lỗi nào báo. -MODE_KEYS = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"), - ("manual", "routing.mode_manual")) +#: Các chế độ người dùng chọn được — khớp ``AppConfig.USER_ROUTING_MODES``. +#: Off/Fallback đã bỏ; giá trị cũ còn lưu được hiểu là Auto. +MODE_KEYS = (("auto", "routing.mode_auto"), ("manual", "routing.mode_manual")) POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"), ("latency", "routing.policy_latency"), @@ -37,7 +35,7 @@ class RoutingSettingsWidget(QGroupBox): self.mode = QComboBox() for value, key in MODE_KEYS: self.mode.addItem(tr(key), value) - _select(self.mode, routing.get("switch_mode", "off")) + _select(self.mode, routing.get("switch_mode", "auto")) # off/fallback cũ → mục đầu (Auto) form.addRow(tr("routing.settings_mode"), self.mode) self.policy = QComboBox() diff --git a/state.py b/state.py index 3d66a81..c7d1429 100644 --- a/state.py +++ b/state.py @@ -90,7 +90,7 @@ class AppContext: return load_project(pid) def project_routing_mode(self, surface: str) -> str: - """Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface`` + """Effective Auto/Manual routing mode for a chat ``surface`` in the ACTIVE workspace: the workspace's own override wins; otherwise the global default (``config.routing_mode_for``). This is what makes each workspace keep its own routing mode. @@ -101,15 +101,15 @@ class AppContext: project = self._current_project() if project is not None: mode = (project.routing_modes or {}).get(surface, "") - if mode in self.config.ROUTING_MODES: - return mode + if mode in self.config.ROUTING_MODES: # old off/fallback → auto + return self.config.user_routing_mode(mode) return self.config.routing_mode_for(surface) def set_project_routing_mode(self, surface: str, mode: str) -> None: """Persist a surface's routing mode for the ACTIVE workspace. With no workspace selected, falls back to the global setting so behaviour outside a project stays global.""" - mode = mode if mode in self.config.ROUTING_MODES else "off" + mode = self.config.user_routing_mode(mode) project = self._current_project() if project is None: self.config.set_routing_mode_for(surface, mode) diff --git a/tests/integration/test_routing_unification.py b/tests/integration/test_routing_unification.py index 353ca55..f903d89 100644 --- a/tests/integration/test_routing_unification.py +++ b/tests/integration/test_routing_unification.py @@ -234,16 +234,16 @@ def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None assert outcome.switched is True -def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None: - """The new mode must be persistable, or the toggle could never select it.""" - ctx.config.set_routing_mode_for("cowork", "fallback") - - assert ctx.config.routing_mode_for("cowork") == "fallback" - assert ctx.project_routing_mode("cowork") == "fallback" +def test_removed_modes_resolve_to_auto(ctx) -> None: + """Off/Fallback were removed from the UI: a stored value resolves to Auto.""" + for legacy in ("off", "fallback"): + ctx.config.set_routing_mode_for("cowork", legacy) + assert ctx.config.routing_mode_for("cowork") == "auto" + assert ctx.project_routing_mode("cowork") == "auto" -def test_unknown_persisted_mode_degrades_to_off(ctx) -> None: - """A hand-edited config must not enable routing by accident.""" +def test_unknown_persisted_mode_degrades_to_auto(ctx) -> None: + """A hand-edited config resolves to the default mode (Auto).""" ctx.config.routing["surface_modes"]["cowork"] = "turbo" - assert ctx.config.routing_mode_for("cowork") == "off" + assert ctx.config.routing_mode_for("cowork") == "auto" diff --git a/tests/routing/test_per_workspace_modes.py b/tests/routing/test_per_workspace_modes.py index d9b1f31..a1dfd31 100644 --- a/tests/routing/test_per_workspace_modes.py +++ b/tests/routing/test_per_workspace_modes.py @@ -32,16 +32,29 @@ def _mk(ctx, name): def test_defaults_follow_global_when_no_override(ctx): a = _mk(ctx, "Alpha") ctx.active_project_id = a.project_id - # Global default switch_mode is "off". - assert ctx.project_routing_mode("cowork") == "off" + # Global default switch_mode is "auto" (Off was removed). + assert ctx.project_routing_mode("cowork") == "auto" # Change the GLOBAL default → project with no override follows it. - ctx.config.data["routing"]["switch_mode"] = "auto" + ctx.config.data["routing"]["switch_mode"] = "manual" + assert ctx.project_routing_mode("cowork") == "manual" + + +def test_legacy_off_and_fallback_resolve_to_auto(ctx): + a = _mk(ctx, "Alpha") + ctx.active_project_id = a.project_id + ctx.config.data["routing"]["switch_mode"] = "manual" + for legacy in ("off", "fallback"): + a.routing_modes = {"cowork": legacy} + save_project(a) + assert ctx.project_routing_mode("cowork") == "auto" + ctx.set_project_routing_mode("cowork", "off") assert ctx.project_routing_mode("cowork") == "auto" def test_per_workspace_routing_is_isolated(ctx): a = _mk(ctx, "Alpha") b = _mk(ctx, "Beta") + ctx.config.data["routing"]["switch_mode"] = "manual" ctx.active_project_id = a.project_id ctx.set_project_routing_mode("cowork", "auto") @@ -49,16 +62,19 @@ def test_per_workspace_routing_is_isolated(ctx): # Switching to workspace B must NOT see A's override (falls back to global). ctx.active_project_id = b.project_id - assert ctx.project_routing_mode("cowork") == "off" - - # B sets its own, independently. - ctx.set_project_routing_mode("cowork", "manual") assert ctx.project_routing_mode("cowork") == "manual" - # A is unchanged. + # B sets its own, independently. + ctx.set_project_routing_mode("cowork", "auto") ctx.active_project_id = a.project_id + ctx.set_project_routing_mode("cowork", "manual") + ctx.active_project_id = b.project_id assert ctx.project_routing_mode("cowork") == "auto" + # A keeps its own. + ctx.active_project_id = a.project_id + assert ctx.project_routing_mode("cowork") == "manual" + def test_per_surface_isolated_within_a_workspace(ctx): a = _mk(ctx, "Alpha") @@ -68,7 +84,8 @@ def test_per_surface_isolated_within_a_workspace(ctx): # co4e untouched → global default. assert ctx.project_routing_mode("cowork") == "auto" assert ctx.project_routing_mode("ai_edit") == "manual" - assert ctx.project_routing_mode("co4e") == "off" + ctx.config.data["routing"]["switch_mode"] = "manual" + assert ctx.project_routing_mode("co4e") == "manual" def test_routing_mode_persists_to_disk(ctx): diff --git a/tests/routing/test_service.py b/tests/routing/test_service.py index 43e3db1..694b6dd 100644 --- a/tests/routing/test_service.py +++ b/tests/routing/test_service.py @@ -88,12 +88,21 @@ def test_best_for_returns_strong(service): assert ranking.best.assessment.metadata.model_id == "strong-model" -def test_route_off_never_switches(service): +def test_route_off_explicit_override_never_switches(service): + # "off" is no longer user-selectable, but the engine still honours it + # when passed explicitly. + service.reassess() + r = service.route("cowork", "Write a Python function", "anthropic", "weak-model", + mode_override="off") + assert r.mode == SwitchMode.OFF + assert r.should_switch is False + + +def test_legacy_off_in_config_routes_as_auto(service): service.reassess() service.ctx.config.data["routing"]["switch_mode"] = "off" r = service.route("cowork", "Write a Python function", "anthropic", "weak-model") - assert r.mode == SwitchMode.OFF - assert r.should_switch is False + assert r.mode == SwitchMode.AUTO def test_route_auto_switches_to_strong(service): @@ -147,12 +156,12 @@ def test_route_never_raises_on_broken_store(ctx, tmp_path): def test_per_surface_mode_override(service): service.reassess() - service.ctx.config.data["routing"]["switch_mode"] = "off" + service.ctx.config.data["routing"]["switch_mode"] = "manual" service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto" - # cowork follows global (off); co4e overridden to auto + # cowork follows global (manual); co4e overridden to auto r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model") r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model") - assert r_cowork.mode == SwitchMode.OFF + assert r_cowork.mode == SwitchMode.MANUAL assert r_co4e.mode == SwitchMode.AUTO assert r_co4e.should_switch is True diff --git a/tests/ui/test_routing_toggle_modes.py b/tests/ui/test_routing_toggle_modes.py new file mode 100644 index 0000000..5daa753 --- /dev/null +++ b/tests/ui/test_routing_toggle_modes.py @@ -0,0 +1,42 @@ +"""Ô định tuyến chỉ còn Auto và Manual; giá trị off/fallback cũ hiện thành Auto.""" +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6") + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +def _toggle(stored: str): + from cowork_local.ui.routing_toggle import RoutingToggle + + saved = [] + t = RoutingToggle(None, "cowork", get_mode=lambda: stored, set_mode=saved.append) + return t, saved + + +def test_only_auto_and_manual_are_offered(qt_app): + t, _ = _toggle("auto") + items = [t._combo.itemData(i) for i in range(t._combo.count())] + assert items == ["auto", "manual"] + + +@pytest.mark.parametrize("stored", ["off", "fallback", "", "turbo"]) +def test_legacy_values_show_as_auto(qt_app, stored): + t, _ = _toggle(stored) + assert t.current_mode() == "auto" + + +def test_choosing_manual_persists_it(qt_app): + t, saved = _toggle("auto") + t._combo.setCurrentIndex(t._combo.findData("manual")) + assert saved == ["manual"] diff --git a/ui/routing_toggle.py b/ui/routing_toggle.py index 45048e9..fc13c85 100644 --- a/ui/routing_toggle.py +++ b/ui/routing_toggle.py @@ -40,7 +40,7 @@ class RoutingToggle(QWidget): Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches. """ - mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback" + mode_changed = Signal(str) # "auto" | "manual" def __init__( self, @@ -76,14 +76,11 @@ class RoutingToggle(QWidget): # it the width stays frozen at the language the widget was built in and # the longer translation is cut off. self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents) - # (data value, i18n key) — data is the persisted mode string. Order is - # least-to-most autonomous, with Fallback (R03-T03) last because it is - # the "only when something breaks" mode rather than a stronger Auto. + # (data value, i18n key) — data is the persisted mode string. Off and + # Fallback were dropped: only Auto and Manual remain selectable. self._modes = [ - ("off", "routing.mode_off"), ("auto", "routing.mode_auto"), ("manual", "routing.mode_manual"), - ("fallback", "routing.mode_fallback"), ] for value, key in self._modes: self._combo.addItem(tr(key), value) @@ -100,16 +97,16 @@ class RoutingToggle(QWidget): on_language_changed(self.retranslate) def current_mode(self) -> str: - """Chế độ định tuyến đang chọn; 'off' nếu chưa đặt.""" - return self._combo.currentData() or "off" + """Chế độ định tuyến đang chọn; 'auto' nếu chưa đặt.""" + return self._combo.currentData() or "auto" def refresh(self) -> None: """Re-read the backing mode (e.g. after switching workspace) and show it without emitting a spurious change.""" try: - mode = self._get_mode() or "off" + mode = self._get_mode() or "auto" except Exception: # noqa: BLE001 - mode = "off" + mode = "auto" idx = self._combo.findData(mode) if idx < 0: idx = 0