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>
385 lines
14 KiB
Python
385 lines
14 KiB
Python
"""R03-T03 — unit tests for the unified routing decision rules.
|
|
|
|
The point of moving these rules out of the three chat widgets is that they can
|
|
now be exercised without Qt, without the assessment store and without a network:
|
|
the service talks to two narrow ports, so every mode is driven here by ~10-line
|
|
fakes. Each test names the behaviour a chat surface depends on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from cowork_local.application.model_routing import (
|
|
RouteEvaluation,
|
|
RoutingApplicationService,
|
|
RoutingMode,
|
|
RoutingOutcome,
|
|
RoutingRequest,
|
|
)
|
|
|
|
|
|
class FakeDecisionPort:
|
|
"""A routing engine that returns a canned verdict and records its input."""
|
|
|
|
def __init__(self, evaluation: RouteEvaluation) -> None:
|
|
self.evaluation = evaluation
|
|
self.calls: list = []
|
|
|
|
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
|
self.calls.append((request, mode))
|
|
return self.evaluation
|
|
|
|
|
|
class ExplodingDecisionPort:
|
|
"""An engine that fails — proves routing degrades instead of breaking a turn."""
|
|
|
|
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
|
raise RuntimeError("assessment store is corrupt")
|
|
|
|
|
|
class FakeModeResolver:
|
|
"""Per-surface mode lookup, standing in for the workspace settings."""
|
|
|
|
def __init__(self, mode) -> None:
|
|
self.mode = mode
|
|
self.surfaces: list = []
|
|
|
|
def mode_for(self, surface: str):
|
|
self.surfaces.append(surface)
|
|
return self.mode
|
|
|
|
|
|
def make_request(**overrides) -> RoutingRequest:
|
|
"""A representative turn: Cowork chat, currently on a cheap OpenAI model."""
|
|
fields = dict(
|
|
surface="cowork",
|
|
prompt="Refactor this function",
|
|
current_provider="codex",
|
|
current_model="gpt-4o-mini",
|
|
)
|
|
fields.update(overrides)
|
|
return RoutingRequest(**fields)
|
|
|
|
|
|
def switch_evaluation(**overrides) -> RouteEvaluation:
|
|
"""An engine verdict that proposes a switch to a better coding model."""
|
|
fields = dict(
|
|
task_type="coding",
|
|
should_switch=True,
|
|
target_provider="anthropic",
|
|
target_model="claude-sonnet-4-6",
|
|
score_gain=0.21,
|
|
reason="coding fit 0.88 > current 0.67",
|
|
decision=object(),
|
|
)
|
|
fields.update(overrides)
|
|
return RouteEvaluation(**fields)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Off
|
|
# --------------------------------------------------------------------------- #
|
|
def test_off_mode_never_consults_the_engine() -> None:
|
|
"""Off must be free: no ranking, no store read, no decision at all."""
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.OFF))
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.switched is False
|
|
assert outcome.provider is None and outcome.model is None
|
|
assert port.calls == [], "Off mode must not call the routing engine"
|
|
|
|
|
|
def test_missing_mode_resolver_defaults_to_off() -> None:
|
|
"""Routing stays opt-in: with no way to read the mode, never switch."""
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port)
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.mode is RoutingMode.OFF
|
|
assert outcome.switched is False
|
|
|
|
|
|
def test_empty_prompt_is_not_routed() -> None:
|
|
"""An empty message carries no signal to classify, so the engine is skipped."""
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
|
|
|
outcome = service.resolve(make_request(prompt=" "))
|
|
|
|
assert outcome.switched is False
|
|
assert port.calls == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Auto
|
|
# --------------------------------------------------------------------------- #
|
|
def test_auto_mode_switches_silently() -> None:
|
|
"""Auto applies the engine's verdict without asking the user."""
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.switched is True
|
|
assert outcome.provider == "anthropic"
|
|
assert outcome.model == "claude-sonnet-4-6"
|
|
assert outcome.task_type == "coding"
|
|
assert outcome.score_gain == pytest.approx(0.21)
|
|
assert outcome.should_notify is True
|
|
|
|
|
|
def test_auto_mode_keeps_current_when_nothing_is_better() -> None:
|
|
"""No proposed switch means the surface's own selection is untouched."""
|
|
port = FakeDecisionPort(switch_evaluation(
|
|
should_switch=False, reason="current model is already best-fit"))
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.switched is False
|
|
assert outcome.provider is None
|
|
assert "already best-fit" in outcome.reason
|
|
|
|
|
|
def test_switch_without_a_target_is_ignored() -> None:
|
|
"""A verdict that says "switch" but names nothing is not actionable — a
|
|
surface must never be handed an empty model id."""
|
|
port = FakeDecisionPort(switch_evaluation(target_provider=None, target_model=None))
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.switched is False
|
|
|
|
|
|
def test_same_provider_switch_keeps_the_current_provider() -> None:
|
|
"""A model-only switch must not blank out the provider the surface uses."""
|
|
port = FakeDecisionPort(switch_evaluation(target_provider=None, target_model="o3"))
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.switched is True
|
|
assert outcome.provider == "codex" # unchanged, from the request
|
|
assert outcome.model == "o3"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Manual
|
|
# --------------------------------------------------------------------------- #
|
|
def test_manual_mode_switches_only_after_approval() -> None:
|
|
"""Manual's contract: ask first, then apply exactly what was approved."""
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(
|
|
port, FakeModeResolver(RoutingMode.MANUAL),
|
|
confirm_timeout_sec=lambda: 30.0,
|
|
)
|
|
asked: list = []
|
|
|
|
def confirm(decision, timeout):
|
|
asked.append((decision, timeout))
|
|
return True
|
|
|
|
outcome = service.resolve(make_request(), confirm=confirm)
|
|
|
|
assert outcome.switched is True
|
|
assert len(asked) == 1
|
|
# The configured timeout must reach the dialog, not a hard-coded default.
|
|
assert asked[0][1] == pytest.approx(30.0)
|
|
|
|
|
|
def test_manual_mode_decline_is_reported_distinctly() -> None:
|
|
""""The user said no" must be distinguishable from "nothing better found",
|
|
so a surface can stay quiet in one case and explain itself in the other."""
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
|
|
|
|
outcome = service.resolve(make_request(), confirm=lambda decision, timeout: False)
|
|
|
|
assert outcome.switched is False
|
|
assert outcome.declined is True
|
|
|
|
|
|
def test_manual_mode_without_a_callback_never_switches() -> None:
|
|
"""Silently switching in Manual mode would violate the mode's promise."""
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
|
|
|
|
outcome = service.resolve(make_request(), confirm=None)
|
|
|
|
assert outcome.switched is False
|
|
|
|
|
|
def test_manual_mode_treats_a_broken_dialog_as_a_decline() -> None:
|
|
"""A crashing confirm dialog must not auto-approve a model change."""
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.MANUAL))
|
|
|
|
def confirm(decision, timeout):
|
|
raise RuntimeError("dialog blew up")
|
|
|
|
outcome = service.resolve(make_request(), confirm=confirm)
|
|
|
|
assert outcome.switched is False
|
|
assert outcome.declined is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fallback
|
|
# --------------------------------------------------------------------------- #
|
|
def test_fallback_keeps_a_healthy_model_even_when_a_better_one_exists() -> None:
|
|
"""Fallback is a resilience mode, not an optimiser: a usable pinned model
|
|
wins over a higher-scoring candidate."""
|
|
port = FakeDecisionPort(switch_evaluation(current_is_usable=True))
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.switched is False
|
|
assert "healthy" in outcome.reason
|
|
|
|
|
|
def test_fallback_switches_when_the_current_model_cannot_serve_the_turn() -> None:
|
|
"""The one case Fallback exists for: rescue an unusable selection."""
|
|
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.switched is True
|
|
assert outcome.model == "claude-sonnet-4-6"
|
|
|
|
|
|
def test_fallback_asks_the_engine_with_auto_semantics() -> None:
|
|
"""The engine only understands off/auto/manual, so Fallback must reach it as
|
|
Auto — otherwise the engine would reject the unknown mode and rank nothing."""
|
|
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
|
|
|
service.resolve(make_request())
|
|
|
|
assert port.calls[0][1] is RoutingMode.AUTO
|
|
|
|
|
|
def test_fallback_never_confirms_with_the_user() -> None:
|
|
"""Rescuing an unusable model is not a proposal — it happens silently."""
|
|
port = FakeDecisionPort(switch_evaluation(current_is_usable=False))
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
|
asked: list = []
|
|
|
|
outcome = service.resolve(
|
|
make_request(), confirm=lambda decision, timeout: asked.append(1) or True)
|
|
|
|
assert outcome.switched is True
|
|
assert asked == []
|
|
|
|
|
|
def test_fallback_with_no_replacement_keeps_current() -> None:
|
|
"""Nothing to fall back to means keep going with what we have and let the
|
|
provider surface the real error, rather than blanking the model."""
|
|
port = FakeDecisionPort(switch_evaluation(
|
|
current_is_usable=False, target_provider=None, target_model=None))
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.FALLBACK))
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.switched is False
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Robustness & plumbing
|
|
# --------------------------------------------------------------------------- #
|
|
def test_engine_failure_degrades_to_keep_current() -> None:
|
|
"""A broken assessment store must never stop a user sending a message."""
|
|
service = RoutingApplicationService(
|
|
ExplodingDecisionPort(), FakeModeResolver(RoutingMode.AUTO))
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert isinstance(outcome, RoutingOutcome)
|
|
assert outcome.switched is False
|
|
assert "error" in outcome.reason
|
|
|
|
|
|
def test_mode_resolver_failure_degrades_to_off() -> None:
|
|
"""An unreadable workspace config must not enable routing by accident."""
|
|
class BrokenResolver:
|
|
def mode_for(self, surface):
|
|
raise OSError("workspace file unreadable")
|
|
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port, BrokenResolver())
|
|
|
|
outcome = service.resolve(make_request())
|
|
|
|
assert outcome.mode is RoutingMode.OFF
|
|
assert port.calls == []
|
|
|
|
|
|
def test_explicit_request_mode_overrides_the_resolver() -> None:
|
|
"""A surface may pin the mode for one turn (tests, replay, admin actions)."""
|
|
resolver = FakeModeResolver(RoutingMode.OFF)
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port, resolver)
|
|
|
|
outcome = service.resolve(make_request(mode=RoutingMode.AUTO))
|
|
|
|
assert outcome.switched is True
|
|
assert resolver.surfaces == [], "an explicit mode must skip the resolver"
|
|
|
|
|
|
def test_request_is_forwarded_to_the_engine_unchanged() -> None:
|
|
"""Surface, prompt and pinned task type must survive the hand-off — AI-Edit
|
|
relies on its "coding" pin reaching the engine."""
|
|
port = FakeDecisionPort(switch_evaluation())
|
|
service = RoutingApplicationService(port, FakeModeResolver(RoutingMode.AUTO))
|
|
request = make_request(surface="ai_edit", task_type="coding",
|
|
required_capabilities=("vision",))
|
|
|
|
service.resolve(request)
|
|
|
|
forwarded = port.calls[0][0]
|
|
assert forwarded is request
|
|
assert forwarded.surface == "ai_edit"
|
|
assert forwarded.task_type == "coding"
|
|
assert forwarded.required_capabilities == ("vision",)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw, expected",
|
|
[
|
|
("auto", RoutingMode.AUTO),
|
|
("MANUAL", RoutingMode.MANUAL),
|
|
(" fallback ", RoutingMode.FALLBACK),
|
|
("nonsense", RoutingMode.OFF),
|
|
("", RoutingMode.OFF),
|
|
(None, RoutingMode.OFF),
|
|
],
|
|
)
|
|
def test_mode_parsing_is_forgiving(raw, expected) -> None:
|
|
"""Config values are hand-edited; an unknown one must degrade, not raise."""
|
|
assert RoutingMode.parse(raw) is expected
|
|
|
|
|
|
def test_confirm_timeout_falls_back_to_the_default_when_unusable() -> None:
|
|
"""A corrupted timeout must not produce a zero-second dialog that declines
|
|
every switch before the user can read it."""
|
|
service = RoutingApplicationService(
|
|
FakeDecisionPort(switch_evaluation()),
|
|
FakeModeResolver(RoutingMode.MANUAL),
|
|
confirm_timeout_sec=lambda: 0.0,
|
|
)
|
|
|
|
assert service.confirm_timeout() == RoutingApplicationService.DEFAULT_CONFIRM_TIMEOUT_SEC
|
|
|
|
|
|
def test_routing_request_is_immutable() -> None:
|
|
"""The snapshot must not change under a turn that is already in flight."""
|
|
request = make_request()
|
|
|
|
with pytest.raises(Exception):
|
|
request.prompt = "something else" # type: ignore[misc]
|