chore(repo): initialize Cowork Local Gitea repository
CI / test (push) Canceled after 0s

This commit is contained in:
thanhnv
2026-08-09 20:12:05 +07:00
commit 414eaddca3
192 changed files with 48160 additions and 0 deletions
View File
+17
View File
@@ -0,0 +1,17 @@
"""Pytest fixtures/shared helpers for the routing test suite.
Ensures the ``cowork_local`` package is importable when pytest is invoked from
the package directory itself (so ``import cowork_local.core.routing...`` works
regardless of the working directory the suite is launched from).
"""
from __future__ import annotations
import sys
from pathlib import Path
# .../cowork_local/tests/routing/conftest.py → parent of the package dir
_PKG_DIR = Path(__file__).resolve().parents[2] # .../cowork_local
_REPO_ROOT = _PKG_DIR.parent # .../cowork_local_20260722
for p in (str(_REPO_ROOT), str(_PKG_DIR)):
if p not in sys.path:
sys.path.insert(0, p)
+60
View File
@@ -0,0 +1,60 @@
"""Tests for the prompt → TaskType classifier."""
from __future__ import annotations
import pytest
from cowork_local.core.routing.classifier import classify
from cowork_local.core.routing.models import TaskType
@pytest.mark.parametrize("text,expected", [
("Write a Python function to reverse a linked list", TaskType.CODING),
("Debug this traceback, my import fails", TaskType.CODING),
("Summarize this article in one sentence", TaskType.SUMMARIZATION),
("Write a poem about the ocean", TaskType.CREATIVE),
("Why does the bat and ball puzzle trip people up? Prove it step by step", TaskType.REASONING),
("What is the capital of France?", TaskType.QA),
])
def test_classifies_common_prompts(text, expected):
assert classify(text) == expected
def test_vietnamese_prompts():
assert classify("Viết hàm Python tính giai thừa") == TaskType.CODING
assert classify("Tóm tắt đoạn văn này giúp tôi") == TaskType.SUMMARIZATION
assert classify("Viết một bài thơ về mùa thu") == TaskType.CREATIVE
def test_ambiguous_defaults_to_qa():
assert classify("hello there") == TaskType.QA
assert classify("") == TaskType.QA
def test_llm_fallback_used_when_heuristic_unsure():
called = {"n": 0}
def fake_llm(text):
called["n"] += 1
return "reasoning"
# A prompt with no keywords → heuristic unsure → LLM fallback consulted.
result = classify("xyzzy plugh", llm_classifier=fake_llm)
assert called["n"] == 1
assert result == TaskType.REASONING
def test_llm_fallback_not_used_when_heuristic_confident():
called = {"n": 0}
def fake_llm(text):
called["n"] += 1
return "qa"
result = classify("Write a Python function", llm_classifier=fake_llm)
assert called["n"] == 0 # heuristic was confident; no LLM call
assert result == TaskType.CODING
def test_llm_fallback_bad_value_defaults_to_qa():
result = classify("xyzzy plugh", llm_classifier=lambda t: "not-a-task-type")
assert result == TaskType.QA
+167
View File
@@ -0,0 +1,167 @@
"""Orchestrator tests — fully mocked client + judge, no real API calls."""
from __future__ import annotations
import pytest
from cowork_local.core.routing.clients import CompletionResult
from cowork_local.core.routing.models import Policy, TaskType
from cowork_local.core.routing.orchestrator import build_assessment, check_and_update
from cowork_local.core.routing.prober import BENCHMARK_TASKS
from cowork_local.core.routing.store import AssessmentStore
class FakeClient:
"""Deterministic ProbeClient. Per-(provider,model) canned answers + a
scripted judge score; counts calls so we can assert probe vs judge volume.
"""
def __init__(self, answers, quality):
# answers: {(provider, model_id): "text" or Exception/None(error)}
# quality: {model_id: score} used when this client acts as the judge
self.answers = answers
self.quality = quality
self.calls = []
def complete(self, provider, model_id, messages) -> CompletionResult:
self.calls.append((provider, model_id))
# Judge calls carry the rubric (which contains "JSON object").
text = messages[0]["content"]
is_judge = "ONLY a JSON object" in text or "grading an AI assistant" in text
if is_judge:
# The rubric embeds the answer being graded; score by which model's
# canned answer text appears in it.
score = 0.0
for mid, q in self.quality.items():
if self.answers.get((_prov_of(self, mid), mid), "") and \
self.answers.get((_prov_of(self, mid), mid), "") in text:
score = q
return CompletionResult(text=f'{{"score": {score}}}')
# Normal completion.
val = self.answers.get((provider, model_id))
if val is None:
return CompletionResult(error="model unavailable")
return CompletionResult(text=val, tokens_out=len(val) // 4)
def _prov_of(client, model_id):
for (prov, mid) in client.answers:
if mid == model_id:
return prov
return ""
def _judge(scores):
"""A direct JudgeFn (bypasses the LLM judge) returning scripted scores by
matching the answer text — simplest for deterministic tests."""
def judge(task_type, prompt, answer):
return scores.get(answer, 0.0)
return judge
def test_build_assessment_scores_all_tasks():
from cowork_local.core.routing.models import ProbeResult
probes = {
tt.value: ProbeResult(latency_ms=200, success=True, quality_score=0.8, tokens_out=30)
for tt in TaskType
}
a = build_assessment("anthropic", "claude-x", "fast", probes, Policy.BALANCED)
assert set(a.fit_scores) == {tt.value for tt in TaskType}
assert all(0 < s <= 1 for s in a.fit_scores.values())
assert a.metadata.tier == "fast"
def test_build_assessment_all_failed_marks_unavailable():
from cowork_local.core.routing.models import ProbeResult
probes = {
tt.value: ProbeResult(latency_ms=0, success=False, error="down")
for tt in TaskType
}
a = build_assessment("anthropic", "dead", None, probes, Policy.BALANCED)
assert a.metadata.available is False
assert all(s == 0.0 for s in a.fit_scores.values())
def test_check_and_update_persists_and_scores(tmp_path):
candidates = [("anthropic", "good", "powerful"), ("anthropic", "weak", "fast")]
client = FakeClient(
answers={("anthropic", "good"): "GOOD-ANSWER", ("anthropic", "weak"): "weak-answer"},
quality={},
)
store = AssessmentStore(store_path=tmp_path / "a.json", history_dir=tmp_path / "h")
result = check_and_update(
candidates, client,
judge=_judge({"GOOD-ANSWER": 0.9, "weak-answer": 0.4}),
store=store, policy=Policy.QUALITY,
)
assert set(result) == {"anthropic/good", "anthropic/weak"}
# Persisted and reloadable.
reloaded = store.load()
assert set(reloaded) == {"anthropic/good", "anthropic/weak"}
# "good" should out-score "weak" on every task under QUALITY.
for tt in TaskType:
assert result["anthropic/good"].fit_for(tt) > result["anthropic/weak"].fit_for(tt)
def test_check_and_update_handles_dead_model(tmp_path):
candidates = [("anthropic", "alive", None), ("anthropic", "dead", None)]
client = FakeClient(
answers={("anthropic", "alive"): "hello", ("anthropic", "dead"): None}, # dead → error
quality={},
)
store = AssessmentStore(store_path=tmp_path / "a.json")
result = check_and_update(
candidates, client,
judge=_judge({"hello": 0.7}),
store=store, policy=Policy.BALANCED,
)
assert result["anthropic/dead"].metadata.available is False
assert all(s == 0.0 for s in result["anthropic/dead"].fit_scores.values())
assert result["anthropic/alive"].metadata.available is True
def test_idempotent_reassess_is_stable(tmp_path):
"""Running twice with the same deterministic client gives the same scores
and backs up the previous version (history has one entry after 2nd run)."""
candidates = [("anthropic", "m", None)]
client = FakeClient(answers={("anthropic", "m"): "answer"}, quality={})
store = AssessmentStore(store_path=tmp_path / "a.json", history_dir=tmp_path / "h")
judge = _judge({"answer": 0.6})
r1 = check_and_update(candidates, client, judge=judge, store=store, policy=Policy.BALANCED)
r2 = check_and_update(candidates, client, judge=judge, store=store, policy=Policy.BALANCED)
# Scores are STABLE across runs to within live-latency jitter — quality and
# cost are deterministic; only the measured latency term moves by µs, which
# is orders of magnitude below the routing min_score_gain (~0.05). Assert
# approximate, not exact, equality (exact would test the wall clock, not us).
s1, s2 = r1["anthropic/m"].fit_scores, r2["anthropic/m"].fit_scores
assert set(s1) == set(s2)
for tt in s1:
assert s1[tt] == pytest.approx(s2[tt], abs=1e-3)
assert len(store.history_files()) == 1 # first run backed up before second
def test_empty_candidates_returns_empty(tmp_path):
store = AssessmentStore(store_path=tmp_path / "a.json")
assert check_and_update([], FakeClient({}, {}), judge=_judge({}), store=store) == {}
def test_dry_run_does_not_persist(tmp_path):
candidates = [("anthropic", "m", None)]
client = FakeClient(answers={("anthropic", "m"): "answer"}, quality={})
store = AssessmentStore(store_path=tmp_path / "a.json")
check_and_update(candidates, client, judge=_judge({"answer": 0.6}),
store=store, persist=False)
assert store.load() == {} # nothing written
def test_probe_uses_all_benchmark_tasks(tmp_path):
"""Every TaskType is probed → one probe per (candidate, task)."""
candidates = [("anthropic", "m", None)]
client = FakeClient(answers={("anthropic", "m"): "answer"}, quality={})
store = AssessmentStore(store_path=tmp_path / "a.json")
result = check_and_update(candidates, client, judge=_judge({"answer": 0.5}),
store=store)
assert set(result["anthropic/m"].probes) == {tt.value for tt in TaskType}
assert len(BENCHMARK_TASKS) == len(TaskType)
+125
View File
@@ -0,0 +1,125 @@
"""Per-workspace mode resolution: each workspace keeps its own routing /
auto-run mode, falling back to the global default when unset.
Exercises AppContext.project_routing_mode / set_project_routing_mode /
project_confirm_commands / set_project_auto_run against a temp projects dir.
"""
from __future__ import annotations
import copy
import pytest
from cowork_local.config import DEFAULT_CONFIG, AppConfig
from cowork_local.core import projects as projects_mod
from cowork_local.core.projects import Project, new_project, save_project
from cowork_local.state import AppContext
@pytest.fixture()
def ctx(tmp_path, monkeypatch):
# Redirect the projects store to a temp dir so load/save hit tmp, not $HOME.
monkeypatch.setattr(projects_mod, "PROJECTS_DIR", tmp_path / "projects")
data = copy.deepcopy(DEFAULT_CONFIG)
cfg = AppConfig(data=data, path=tmp_path / "config.json")
return AppContext(cfg)
def _mk(ctx, name):
return new_project(name, directory=projects_mod.PROJECTS_DIR)
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"
# Change the GLOBAL default → project with no override follows it.
ctx.config.data["routing"]["switch_mode"] = "auto"
assert ctx.project_routing_mode("cowork") == "auto"
def test_per_workspace_routing_is_isolated(ctx):
a = _mk(ctx, "Alpha")
b = _mk(ctx, "Beta")
ctx.active_project_id = a.project_id
ctx.set_project_routing_mode("cowork", "auto")
assert ctx.project_routing_mode("cowork") == "auto"
# 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.
ctx.active_project_id = a.project_id
assert ctx.project_routing_mode("cowork") == "auto"
def test_per_surface_isolated_within_a_workspace(ctx):
a = _mk(ctx, "Alpha")
ctx.active_project_id = a.project_id
ctx.set_project_routing_mode("cowork", "auto")
ctx.set_project_routing_mode("ai_edit", "manual")
# 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"
def test_routing_mode_persists_to_disk(ctx):
a = _mk(ctx, "Alpha")
ctx.active_project_id = a.project_id
ctx.set_project_routing_mode("co4e", "auto")
# Reload the project from disk — the override survived.
reloaded = projects_mod.load_project(a.project_id, projects_mod.PROJECTS_DIR)
assert reloaded.routing_modes.get("co4e") == "auto"
def test_auto_run_per_workspace(ctx):
a = _mk(ctx, "Alpha")
b = _mk(ctx, "Beta")
# Global default: cowork_confirm_commands is False → auto-run (no confirm).
ctx.active_project_id = a.project_id
assert ctx.project_confirm_commands() is False
assert ctx.project_auto_run() is True
# A: require confirm (auto_run=False). B stays on the global default.
ctx.set_project_auto_run(False)
assert ctx.project_confirm_commands() is True
ctx.active_project_id = b.project_id
assert ctx.project_confirm_commands() is False # B unaffected by A
def test_auto_run_none_follows_global(ctx):
a = _mk(ctx, "Alpha")
ctx.active_project_id = a.project_id
# Turn the GLOBAL confirm setting on; project override is None → follows it.
ctx.config.data["agent_security"]["cowork_confirm_commands"] = True
assert ctx.project_confirm_commands() is True
# Explicit per-project auto-run overrides the global.
ctx.set_project_auto_run(True) # auto-approve
assert ctx.project_confirm_commands() is False
def test_no_active_project_uses_global(ctx):
# active_project_id points at a non-existent project → global fallback.
ctx.active_project_id = "does-not-exist"
ctx.config.data["routing"]["switch_mode"] = "manual"
assert ctx.project_routing_mode("cowork") == "manual"
# Setting a mode with no real project writes the GLOBAL setting.
ctx.set_project_routing_mode("cowork", "auto")
assert ctx.config.routing_mode_for("cowork") == "auto"
def test_project_dataclass_defaults():
# New fields have safe defaults and round-trip through asdict/load.
p = Project(project_id="x", name="X")
assert p.routing_modes == {}
assert p.auto_run is None
+134
View File
@@ -0,0 +1,134 @@
"""Tests for the fit-score formula and policy weights."""
from __future__ import annotations
import pytest
from cowork_local.core.routing.models import ModelMetadata, Policy, ProbeResult
from cowork_local.core.routing.scorer import POLICY_WEIGHTS, compute_fit_score
def _meta(**kw) -> ModelMetadata:
base = dict(
provider="anthropic",
model_id="claude-x",
cost_per_1k_input=0.001,
cost_per_1k_output=0.003,
max_context=200000,
)
base.update(kw)
return ModelMetadata(**base)
def _probe(**kw) -> ProbeResult:
base = dict(latency_ms=500.0, success=True, quality_score=0.8, tokens_out=100)
base.update(kw)
return ProbeResult(**base)
# --------------------------------------------------------------------------- #
# Failure / availability short-circuits
# --------------------------------------------------------------------------- #
def test_failed_probe_scores_zero():
probe = _probe(success=False, quality_score=0.9, error="boom")
for policy in Policy:
assert compute_fit_score(_meta(), probe, policy) == 0.0
def test_unavailable_model_scores_zero():
meta = _meta(available=False)
for policy in Policy:
assert compute_fit_score(meta, _probe(), policy) == 0.0
# --------------------------------------------------------------------------- #
# Range + monotonicity
# --------------------------------------------------------------------------- #
def test_score_within_unit_interval():
for policy in Policy:
s = compute_fit_score(_meta(), _probe(), policy)
assert 0.0 <= s <= 1.0
def test_higher_quality_scores_higher():
lo = compute_fit_score(_meta(), _probe(quality_score=0.2), Policy.QUALITY)
hi = compute_fit_score(_meta(), _probe(quality_score=0.9), Policy.QUALITY)
assert hi > lo
def test_lower_latency_scores_higher_under_latency_policy():
slow = compute_fit_score(_meta(), _probe(latency_ms=5000), Policy.LATENCY)
fast = compute_fit_score(_meta(), _probe(latency_ms=100), Policy.LATENCY)
assert fast > slow
def test_cheaper_scores_higher_under_cost_policy():
cheap = compute_fit_score(
_meta(cost_per_1k_input=0.0001, cost_per_1k_output=0.0002),
_probe(),
Policy.COST,
)
pricey = compute_fit_score(
_meta(cost_per_1k_input=0.05, cost_per_1k_output=0.15),
_probe(),
Policy.COST,
)
assert cheap > pricey
# --------------------------------------------------------------------------- #
# Policy weighting behaviour
# --------------------------------------------------------------------------- #
def test_all_policy_rows_sum_to_one():
for policy, weights in POLICY_WEIGHTS.items():
assert abs(sum(weights) - 1.0) < 1e-9, policy
def test_quality_policy_favors_smart_slow_model_over_fast_dumb():
"""Under QUALITY, a smart-but-slow model beats a fast-but-weak one."""
smart_slow = compute_fit_score(
_meta(), _probe(quality_score=0.95, latency_ms=4000), Policy.QUALITY
)
fast_dumb = compute_fit_score(
_meta(), _probe(quality_score=0.3, latency_ms=100), Policy.QUALITY
)
assert smart_slow > fast_dumb
def test_latency_policy_favors_fast_dumb_over_smart_slow():
"""Under LATENCY, the ordering flips — speed dominates."""
smart_slow = compute_fit_score(
_meta(), _probe(quality_score=0.95, latency_ms=8000), Policy.LATENCY
)
fast_dumb = compute_fit_score(
_meta(), _probe(quality_score=0.5, latency_ms=50), Policy.LATENCY
)
assert fast_dumb > smart_slow
# --------------------------------------------------------------------------- #
# Unknown-cost handling
# --------------------------------------------------------------------------- #
def test_unknown_cost_does_not_beat_known_cheap_model_under_cost_policy():
"""A model with unknown price must not be handed a free cost advantage."""
known_cheap = compute_fit_score(
_meta(cost_per_1k_input=0.0001, cost_per_1k_output=0.0001),
_probe(),
Policy.COST,
)
unknown = compute_fit_score(
_meta(cost_per_1k_input=None, cost_per_1k_output=None,
metadata_incomplete=True),
_probe(),
Policy.COST,
)
# Both are usable; the genuinely-cheap known model should not score below
# the unknown-price one (no fabricated cost=0 advantage).
assert known_cheap >= unknown
def test_quality_score_clamped():
"""A judge returning >1 or <0 must not push fit outside [0,1]."""
over = compute_fit_score(_meta(), _probe(quality_score=5.0), Policy.QUALITY)
under = compute_fit_score(_meta(), _probe(quality_score=-3.0), Policy.QUALITY)
assert 0.0 <= over <= 1.0
assert 0.0 <= under <= 1.0
+121
View File
@@ -0,0 +1,121 @@
"""Tests for the selector: ranking, filtering, capability gating, policy re-rank."""
from __future__ import annotations
import pytest
from cowork_local.core.routing.models import (
ModelAssessment,
ModelMetadata,
Policy,
ProbeResult,
TaskType,
)
from cowork_local.core.routing.selector import best_model, rank_models
def _assessment(
model_id,
*,
provider="anthropic",
quality=0.8,
latency_ms=500,
cost_in=0.001,
cost_out=0.003,
available=True,
caps=None,
task=TaskType.CODING,
probe_success=True,
) -> ModelAssessment:
meta = ModelMetadata(
provider=provider,
model_id=model_id,
cost_per_1k_input=cost_in,
cost_per_1k_output=cost_out,
max_context=100000,
capabilities=set(caps or []),
available=available,
)
probe = ProbeResult(
latency_ms=latency_ms, success=probe_success,
quality_score=quality, tokens_out=50,
)
return ModelAssessment(metadata=meta, probes={task.value: probe})
def test_empty_returns_no_best():
assert best_model([], TaskType.CODING) is None
def test_best_is_highest_quality_under_quality_policy():
weak = _assessment("weak", quality=0.3)
strong = _assessment("strong", quality=0.95)
best = best_model([weak, strong], TaskType.CODING, Policy.QUALITY)
assert best is not None
assert best.assessment.metadata.model_id == "strong"
def test_unavailable_excluded():
down = _assessment("down", quality=0.99, available=False)
up = _assessment("up", quality=0.5)
ranking = rank_models([down, up], TaskType.CODING)
keys = [c.assessment.metadata.model_id for c in ranking.ranked]
assert "down" not in keys
assert ranking.best.assessment.metadata.model_id == "up"
def test_failed_probe_excluded():
broken = _assessment("broken", quality=0.99, probe_success=False)
ok = _assessment("ok", quality=0.4)
best = best_model([broken, ok], TaskType.CODING)
assert best.assessment.metadata.model_id == "ok"
def test_missing_probe_for_task_excluded():
# Only has a CODING probe; asking for REASONING must exclude it.
coding_only = _assessment("c", task=TaskType.CODING)
assert best_model([coding_only], TaskType.REASONING) is None
def test_required_capability_filters_out_incapable():
no_vision = _assessment("text", quality=0.95, caps=[])
vision = _assessment("vision", quality=0.6, caps=["vision"])
best = best_model(
[no_vision, vision], TaskType.CODING, required_capabilities=["vision"]
)
assert best.assessment.metadata.model_id == "vision"
def test_policy_change_reranks_without_reprobe():
"""Same assessments, different policy → different winner, no re-probing."""
smart_pricey_slow = _assessment(
"opus", quality=0.95, latency_ms=6000, cost_in=0.015, cost_out=0.075
)
cheap_fast_ok = _assessment(
"haiku", quality=0.7, latency_ms=200, cost_in=0.0002, cost_out=0.0004
)
candidates = [smart_pricey_slow, cheap_fast_ok]
q_best = best_model(candidates, TaskType.CODING, Policy.QUALITY)
c_best = best_model(candidates, TaskType.CODING, Policy.COST)
l_best = best_model(candidates, TaskType.CODING, Policy.LATENCY)
assert q_best.assessment.metadata.model_id == "opus" # quality wins
assert c_best.assessment.metadata.model_id == "haiku" # cost wins
assert l_best.assessment.metadata.model_id == "haiku" # latency wins
def test_ranking_is_descending_and_stable():
a = _assessment("a", quality=0.9)
b = _assessment("b", quality=0.6)
c = _assessment("c", quality=0.3)
ranking = rank_models([b, c, a], TaskType.CODING, Policy.QUALITY)
scores = [rc.score for rc in ranking.ranked]
assert scores == sorted(scores, reverse=True)
assert [rc.assessment.metadata.model_id for rc in ranking.ranked] == ["a", "b", "c"]
def test_score_of_returns_zero_for_unranked():
a = _assessment("a", quality=0.9)
ranking = rank_models([a], TaskType.CODING)
assert ranking.score_of("anthropic/a") > 0
assert ranking.score_of("anthropic/missing") == 0.0
+166
View File
@@ -0,0 +1,166 @@
"""End-to-end tests for RoutingService with a fake client + temp store.
No real API calls: the fake client answers both benchmark probes and judge
calls deterministically, so reassess → score → route → confirm all run offline.
"""
from __future__ import annotations
import copy
import pytest
from cowork_local.config import DEFAULT_CONFIG, AppConfig
from cowork_local.core.routing.clients import CompletionResult
from cowork_local.core.routing.models import SwitchMode, TaskType, candidate_key
from cowork_local.core.routing.service import RoutingService
from cowork_local.core.routing.store import AssessmentStore
from cowork_local.state import AppContext
class FakeClient:
"""Answers probes per model and grades via an embedded-answer lookup."""
def __init__(self, answers, quality):
self.answers = answers # {(provider, model_id): "answer text"}
self.quality = quality # {"answer text": score}
def complete(self, provider, model_id, messages) -> CompletionResult:
text = messages[0]["content"]
if "grading an AI assistant" in text: # judge rubric
score = 0.0
for answer, q in self.quality.items():
if answer and answer in text:
score = q
break
return CompletionResult(text=f'{{"score": {score}}}')
answer = self.answers.get((provider, model_id))
if answer is None:
return CompletionResult(error="unavailable")
return CompletionResult(text=answer, tokens_out=len(answer) // 4)
@pytest.fixture()
def ctx(tmp_path):
data = copy.deepcopy(DEFAULT_CONFIG)
# Two candidates on one provider; pin a judge model that's NOT a candidate.
data["providers"] = {
"anthropic": {"base_url": "x", "api_key": "x", "model": "strong-model"},
}
data["routing"]["candidates"] = [
{"provider": "anthropic", "model_id": "strong-model", "tier": "powerful"},
{"provider": "anthropic", "model_id": "weak-model", "tier": "fast"},
]
data["routing"]["judge_provider"] = "anthropic"
data["routing"]["judge_model"] = "judge-model"
data["routing"]["policy"] = "quality"
data["routing"]["min_score_gain"] = 0.05
cfg = AppConfig(data=data, path=tmp_path / "config.json")
return AppContext(cfg)
@pytest.fixture()
def service(ctx, tmp_path):
client = FakeClient(
answers={
("anthropic", "strong-model"): "STRONG-DETAILED-CORRECT-ANSWER",
("anthropic", "weak-model"): "weak",
},
quality={"STRONG-DETAILED-CORRECT-ANSWER": 0.95, "weak": 0.35},
)
store = AssessmentStore(store_path=tmp_path / "assess.json", history_dir=tmp_path / "hist")
return RoutingService(ctx, store=store, client=client)
def test_reassess_scores_and_persists(service):
result = service.reassess()
assert set(result) == {"anthropic/strong-model", "anthropic/weak-model"}
# strong beats weak on coding under quality policy
strong = result["anthropic/strong-model"].fit_for(TaskType.CODING)
weak = result["anthropic/weak-model"].fit_for(TaskType.CODING)
assert strong > weak
assert service.status()["count"] == 2
def test_best_for_returns_strong(service):
service.reassess()
ranking = service.best_for(TaskType.CODING)
assert ranking.best is not None
assert ranking.best.assessment.metadata.model_id == "strong-model"
def test_route_off_never_switches(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
def test_route_auto_switches_to_strong(service):
service.reassess()
service.ctx.config.data["routing"]["switch_mode"] = "auto"
r = service.route("cowork", "Write a Python function to sort a list",
"anthropic", "weak-model")
assert r.mode == SwitchMode.AUTO
assert r.should_switch is True
assert r.target() == ("anthropic", "strong-model")
assert r.task_type == TaskType.CODING
def test_route_manual_needs_confirmation(service):
service.reassess()
service.ctx.config.data["routing"]["switch_mode"] = "manual"
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
assert r.mode == SwitchMode.MANUAL
assert r.needs_confirmation is True
def test_manual_confirm_flow_idempotent(service):
service.reassess()
service.ctx.config.data["routing"]["switch_mode"] = "manual"
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
pending = service.create_pending(r.decision, {"prompt": "Write a Python function"})
runs = {"n": 0}
def run(model_key, switched):
runs["n"] += 1
return {"model_key": model_key, "switched": switched}
out1 = service.resolve_pending(pending.request_id, approve=True, run=run)
out2 = service.resolve_pending(pending.request_id, approve=True, run=run)
assert out1["model_key"] == "anthropic/strong-model"
assert out1["switched"] is True
assert runs["n"] == 1 # idempotent — executed once
assert out1 == out2
def test_route_never_raises_on_broken_store(ctx, tmp_path):
# Point the store at a corrupt file; route must still return a safe result.
store = AssessmentStore(store_path=tmp_path / "bad.json")
store.store_path.write_text("{{ not json", encoding="utf-8")
svc = RoutingService(ctx, store=store, client=FakeClient({}, {}))
ctx.config.data["routing"]["switch_mode"] = "auto"
r = svc.route("cowork", "hello", "anthropic", "strong-model")
assert r.should_switch is False # nothing assessed → nothing to switch to
def test_per_surface_mode_override(service):
service.reassess()
service.ctx.config.data["routing"]["switch_mode"] = "off"
service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto"
# cowork follows global (off); 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_co4e.mode == SwitchMode.AUTO
assert r_co4e.should_switch is True
def test_add_candidate_appends_without_reassess(service):
added = service.add_candidate("anthropic", "new-model", "fast", reassess=False)
assert added is True
cands = service.candidates()
assert any(m == "new-model" for _, m, _ in cands)
# Adding the same one again is a no-op.
assert service.add_candidate("anthropic", "new-model", "fast", reassess=False) is False
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the assessment store: round-trip, atomic write, history backup."""
from __future__ import annotations
import json
import pytest
from cowork_local.core.routing.models import (
ModelAssessment,
ModelMetadata,
Policy,
ProbeResult,
TaskType,
)
from cowork_local.core.routing.store import AssessmentStore
def _assessment(provider="anthropic", model_id="claude-x", quality=0.8) -> ModelAssessment:
meta = ModelMetadata(
provider=provider,
model_id=model_id,
cost_per_1k_input=0.001,
cost_per_1k_output=0.003,
max_context=200000,
capabilities={"tools"},
)
probe = ProbeResult(latency_ms=400, success=True, quality_score=quality, tokens_out=50)
return ModelAssessment(
metadata=meta,
probes={TaskType.CODING.value: probe},
fit_scores={TaskType.CODING.value: 0.75},
assessed_at="2026-07-22T00:00:00+00:00",
)
@pytest.fixture()
def store(tmp_path):
return AssessmentStore(
store_path=tmp_path / "assessments.json",
history_dir=tmp_path / "history",
)
def test_load_missing_returns_empty(store):
assert store.load() == {}
assert store.last_updated() is None
def test_save_then_load_roundtrip(store):
a = _assessment()
store.save({a.key: a}, Policy.BALANCED)
loaded = store.load()
assert set(loaded) == {a.key}
got = loaded[a.key]
assert got.metadata.model_id == "claude-x"
assert got.metadata.capabilities == {"tools"}
assert got.fit_scores[TaskType.CODING.value] == 0.75
assert got.probes[TaskType.CODING.value].quality_score == 0.8
def test_save_writes_last_updated_and_policy(store):
a = _assessment()
store.save({a.key: a}, Policy.QUALITY, last_updated="2026-07-22T09:00:00+00:00")
assert store.last_updated() == "2026-07-22T09:00:00+00:00"
assert store.policy() == "quality"
def test_overwrite_backs_up_previous_to_history(store):
first = _assessment(quality=0.5)
store.save({first.key: first}, Policy.BALANCED)
assert store.history_files() == [] # nothing existed before the first write
second = _assessment(quality=0.9)
store.save({second.key: second}, Policy.BALANCED)
history = store.history_files()
assert len(history) == 1 # the first write got backed up before the second
backed_up = json.loads(history[0].read_text(encoding="utf-8"))
key = first.key
assert backed_up["results"][key]["probes"][TaskType.CODING.value]["quality_score"] == 0.5
# Live store now holds the second (degraded/improved) version.
assert store.load()[second.key].probes[TaskType.CODING.value].quality_score == 0.9
def test_corrupt_store_loads_as_empty(store):
store.store_path.parent.mkdir(parents=True, exist_ok=True)
store.store_path.write_text("{ this is not valid json ", encoding="utf-8")
assert store.load() == {} # corrupt file must not crash
def test_atomic_write_leaves_no_temp_files(store):
a = _assessment()
store.save({a.key: a}, Policy.BALANCED)
leftovers = list(store.store_path.parent.glob(".assessments-*.tmp"))
assert leftovers == []
def test_one_bad_entry_does_not_hide_good_ones(store):
a = _assessment(model_id="good")
store.save({a.key: a}, Policy.BALANCED)
# Inject a malformed sibling entry directly into the JSON.
raw = json.loads(store.store_path.read_text(encoding="utf-8"))
raw["results"]["anthropic/bad"] = {"metadata": {"oops": True}} # missing required fields
store.store_path.write_text(json.dumps(raw), encoding="utf-8")
loaded = store.load()
assert a.key in loaded
assert "anthropic/bad" not in loaded
def test_prune_history_keeps_newest(store):
a = _assessment()
# 5 overwrites → 4 history snapshots.
for i in range(5):
store.save({a.key: a}, Policy.BALANCED)
assert len(store.history_files()) == 4
store.prune_history(keep=2)
assert len(store.history_files()) == 2
+223
View File
@@ -0,0 +1,223 @@
"""Tests for switch decisions and the pending-switch registry.
Covers: Auto vs Manual vs Off, the min-score-gain threshold, confirm/reject,
timeout → keep current, and idempotent confirm (task runs exactly once).
"""
from __future__ import annotations
import pytest
from cowork_local.core.routing.models import (
ModelAssessment,
ModelMetadata,
ProbeResult,
SwitchMode,
SwitchStatus,
TaskType,
)
from cowork_local.core.routing.selector import rank_models
from cowork_local.core.routing.switch_controller import (
PendingSwitchRegistry,
decide,
)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _assessment(model_id, quality, *, provider="anthropic") -> ModelAssessment:
meta = ModelMetadata(
provider=provider, model_id=model_id,
cost_per_1k_input=0.001, cost_per_1k_output=0.003, max_context=100000,
)
probe = ProbeResult(latency_ms=300, success=True, quality_score=quality, tokens_out=40)
return ModelAssessment(metadata=meta, probes={TaskType.CODING.value: probe})
def _ranking(*assessments):
return rank_models(assessments, TaskType.CODING, task_type_policy())
def task_type_policy():
from cowork_local.core.routing.models import Policy
return Policy.QUALITY
# --------------------------------------------------------------------------- #
# decide() — pure decision logic
# --------------------------------------------------------------------------- #
def test_off_never_switches():
weak = _assessment("weak", 0.3)
strong = _assessment("strong", 0.95)
ranking = _ranking(weak, strong)
d = decide("anthropic/weak", ranking, SwitchMode.OFF, 0.05)
assert d.should_switch is False
assert "off" in d.reason.lower()
def test_auto_switches_when_gain_clears_threshold():
weak = _assessment("weak", 0.3)
strong = _assessment("strong", 0.95)
ranking = _ranking(weak, strong)
d = decide("anthropic/weak", ranking, SwitchMode.AUTO, 0.05)
assert d.should_switch is True
assert d.to_model == "anthropic/strong"
assert d.score_gain > 0.05
def test_no_switch_when_gain_below_threshold():
a = _assessment("a", 0.80)
b = _assessment("b", 0.82) # only marginally better
ranking = _ranking(a, b)
d = decide("anthropic/a", ranking, SwitchMode.AUTO, 0.20) # demand a big gain
assert d.should_switch is False
assert "keeping current" in d.reason.lower()
def test_no_switch_when_current_is_already_best():
a = _assessment("a", 0.95)
b = _assessment("b", 0.5)
ranking = _ranking(a, b)
d = decide("anthropic/a", ranking, SwitchMode.AUTO, 0.05)
assert d.should_switch is False
assert "already best-fit" in d.reason.lower()
def test_manual_decision_marks_mode_manual():
weak = _assessment("weak", 0.3)
strong = _assessment("strong", 0.95)
ranking = _ranking(weak, strong)
d = decide("anthropic/weak", ranking, SwitchMode.MANUAL, 0.05)
assert d.should_switch is True
assert d.mode == SwitchMode.MANUAL
def test_no_current_model_adopts_best():
strong = _assessment("strong", 0.9)
ranking = _ranking(strong)
d = decide(None, ranking, SwitchMode.AUTO, 0.05)
assert d.should_switch is True
assert d.to_model == "anthropic/strong"
def test_no_candidate_available():
ranking = rank_models([], TaskType.CODING)
d = decide("anthropic/x", ranking, SwitchMode.AUTO, 0.05)
assert d.should_switch is False
def test_reason_contains_scores_and_gain():
weak = _assessment("weak", 0.5)
strong = _assessment("strong", 0.9)
ranking = _ranking(weak, strong)
d = decide("anthropic/weak", ranking, SwitchMode.AUTO, 0.05)
# e.g. "coding fit 0.xx > current 0.yy, gain 0.zz — switch to strong"
assert "fit" in d.reason and "gain" in d.reason
# --------------------------------------------------------------------------- #
# PendingSwitchRegistry — manual confirm/reject/timeout/idempotency
# --------------------------------------------------------------------------- #
class FakeClock:
def __init__(self, t=1000.0):
self.t = t
def __call__(self):
return self.t
def advance(self, dt):
self.t += dt
def _decision():
weak = _assessment("weak", 0.5)
strong = _assessment("strong", 0.9)
ranking = _ranking(weak, strong)
return decide("anthropic/weak", ranking, SwitchMode.MANUAL, 0.05)
def test_confirm_runs_with_new_model():
reg = PendingSwitchRegistry()
ps = reg.create(_decision(), {"prompt": "hi"}, timeout_sec=60)
calls = []
def run(model_key, switched):
calls.append((model_key, switched))
return {"model": model_key, "switched": switched, "text": "done"}
result = reg.resolve(ps.request_id, approve=True, run=run)
assert result["model"] == "anthropic/strong"
assert result["switched"] is True
assert calls == [("anthropic/strong", True)]
assert reg.get(ps.request_id).status == SwitchStatus.CONFIRMED
def test_reject_runs_with_current_model():
reg = PendingSwitchRegistry()
ps = reg.create(_decision(), {"prompt": "hi"}, timeout_sec=60)
def run(model_key, switched):
return {"model": model_key, "switched": switched}
result = reg.resolve(ps.request_id, approve=False, run=run)
assert result["model"] == "anthropic/weak" # stayed on current
assert result["switched"] is False
assert reg.get(ps.request_id).status == SwitchStatus.REJECTED
def test_confirm_is_idempotent_runs_once():
reg = PendingSwitchRegistry()
ps = reg.create(_decision(), {"prompt": "hi"}, timeout_sec=60)
count = {"n": 0}
def run(model_key, switched):
count["n"] += 1
return {"run_number": count["n"], "model": model_key}
r1 = reg.resolve(ps.request_id, approve=True, run=run)
r2 = reg.resolve(ps.request_id, approve=True, run=run)
r3 = reg.resolve(ps.request_id, approve=True, run=run)
assert count["n"] == 1 # task executed exactly once
assert r1 == r2 == r3 # cached result replayed
def test_timeout_forces_current_model_on_resolve():
clock = FakeClock()
reg = PendingSwitchRegistry(clock=clock)
ps = reg.create(_decision(), {"prompt": "hi"}, timeout_sec=60)
clock.advance(120) # blow past the confirm window
assert reg.get(ps.request_id).status == SwitchStatus.EXPIRED
# Even an approve after expiry must run with the CURRENT model.
def run(model_key, switched):
return {"model": model_key, "switched": switched}
result = reg.resolve(ps.request_id, approve=True, run=run)
assert result["model"] == "anthropic/weak"
assert result["switched"] is False
def test_sweep_expired_marks_overdue():
clock = FakeClock()
reg = PendingSwitchRegistry(clock=clock)
ps = reg.create(_decision(), {}, timeout_sec=30)
assert reg.sweep_expired() == []
clock.advance(31)
assert reg.sweep_expired() == [ps.request_id]
assert reg.get(ps.request_id).status == SwitchStatus.EXPIRED
def test_resolve_unknown_id_returns_none():
reg = PendingSwitchRegistry()
assert reg.resolve("does-not-exist", approve=True, run=lambda k, s: {}) is None
def test_purge_removes_terminal_entries():
reg = PendingSwitchRegistry()
ps = reg.create(_decision(), {}, timeout_sec=60)
reg.resolve(ps.request_id, approve=False, run=lambda k, s: {"ok": True})
# keep_resolved=True retains entries that cached a result (idempotency).
assert reg.purge(keep_resolved=True) == 0
assert reg.purge(keep_resolved=False) == 1
assert reg.get(ps.request_id) is None