1. P0-1: get_default_router_config() возвращает чистую конфигурацию (0 профилей, 13 канонических ролей с пустыми цепочками). Миграция не внедряет фиктивные профили. 2. P0-2: Учетные данные (~/.hermes/*_profiles/, hub_settings.json) изолированы и никогда не затрагиваются при сбросе или установке. 3. P0-3: Профили создаются динамически при подключении аккаунтов (ag-1, codex-1, etc.). Пустые цепочки ролей являются нормальным рабочим состоянием. 4. P0-4: Добавлен экшен reset_router_config и кнопка «Начать настройку заново» в настройках с подтверждением и созданием бэкапа router_profiles.yaml.bak_<timestamp>. 5. P0-5: scripts/verify_multi_provider_router.py адаптирован и проходит 10/10 PASS как на пустой конфигурации, так и на заполненной. 6. tests/test_a41_clean_install.py: 6 тестов, 490 passed, ruff чисто.
141 lines
4.9 KiB
Python
141 lines
4.9 KiB
Python
"""
|
|
Hermes Hub — Model Choice & Discovery Persistence Tests.
|
|
Verifies P0-1 and P0-2 requirements of Task A18.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
from antigravity_provider.router.action_handler import ActionExecutor, do_set_model
|
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
|
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
|
from antigravity_provider.router.router_config import load_router_config, save_router_config
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_models_cache(tmp_path, monkeypatch):
|
|
"""Provide isolated models_cache.json."""
|
|
cache_file = tmp_path / "models_cache.json"
|
|
cache_file.write_text(
|
|
json.dumps({
|
|
"antigravity": {
|
|
"models": [
|
|
"gemini-3.7-flash-high",
|
|
"gemini-3.5-flash-high",
|
|
"gemini-3.1-pro-high",
|
|
"claude-sonnet-4-6",
|
|
],
|
|
"discovered_at": time.time(),
|
|
},
|
|
"opencode-go": {
|
|
"models": ["deepseek-r1", "deepseek-v4-pro", "qwen3.8-max"],
|
|
"discovered_at": time.time(),
|
|
},
|
|
}, ensure_ascii=False),
|
|
encoding="utf-8"
|
|
)
|
|
svc = ModelDiscoveryService(cache_path=cache_file)
|
|
monkeypatch.setattr(ModelDiscoveryService, "get", classmethod(lambda cls: svc))
|
|
return svc, cache_file
|
|
|
|
|
|
def test_set_model_success_and_config_persistence(temp_models_cache):
|
|
"""Verify setting valid discovered model succeeds and persists in router_config."""
|
|
svc, _ = temp_models_cache
|
|
profile_id = "ag-w1"
|
|
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
|
|
config = load_router_config()
|
|
assert profile_id in config.profiles
|
|
|
|
# Pick valid discovered model
|
|
target_model = "gemini-3.1-pro-high"
|
|
res = ActionExecutor.execute("set_model", {
|
|
"profile_id": profile_id,
|
|
"model": target_model,
|
|
"role_id": "developer-1",
|
|
})
|
|
|
|
assert res["ok"] is True
|
|
assert "успешно" in res["message"]
|
|
|
|
# Verify persistence on disk
|
|
reloaded = load_router_config()
|
|
assert reloaded.profiles[profile_id].preferred_models[0] == target_model
|
|
assert reloaded.roles["developer-1"].default_model == target_model
|
|
|
|
|
|
def test_set_model_rejects_nonexistent_model(temp_models_cache):
|
|
"""Verify nonexistent model is strictly rejected without modifying configuration."""
|
|
svc, _ = temp_models_cache
|
|
profile_id = "ag-w1"
|
|
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
|
|
config = load_router_config()
|
|
orig_models = list(config.profiles[profile_id].preferred_models)
|
|
|
|
# Attempt to set invalid/hallucinated model
|
|
res = ActionExecutor.execute("set_model", {
|
|
"profile_id": profile_id,
|
|
"model": "gemini-nonexistent-model-99",
|
|
})
|
|
|
|
assert res["ok"] is False
|
|
assert "отсутствует в списке" in res["message"] or "не поддерживается" in res["message"]
|
|
|
|
# Verify config was not corrupted
|
|
reloaded = load_router_config()
|
|
assert reloaded.profiles[profile_id].preferred_models == orig_models
|
|
|
|
|
|
def test_models_cache_disk_persistence_and_reload(tmp_path):
|
|
"""Verify models cache survives service recreation and disk reload."""
|
|
cache_file = tmp_path / "models_cache.json"
|
|
cache_file.write_text(
|
|
json.dumps({
|
|
"claude": {
|
|
"models": ["claude-3-7-sonnet", "claude-sonnet-4-6"],
|
|
"discovered_at": time.time(),
|
|
}
|
|
}, ensure_ascii=False),
|
|
encoding="utf-8"
|
|
)
|
|
|
|
svc1 = ModelDiscoveryService(cache_path=cache_file)
|
|
assert svc1.get_models("claude") == ["claude-3-7-sonnet", "claude-sonnet-4-6"]
|
|
|
|
# Simulate new process/service instance
|
|
svc2 = ModelDiscoveryService(cache_path=cache_file)
|
|
assert svc2.get_models("claude") == ["claude-3-7-sonnet", "claude-sonnet-4-6"]
|
|
|
|
|
|
def test_models_discovery_timeout_retains_existing_cache(tmp_path, monkeypatch):
|
|
"""Verify that timeout/probe failure does not erase previously cached models."""
|
|
cache_file = tmp_path / "models_cache.json"
|
|
cache_file.write_text(
|
|
json.dumps({
|
|
"grok": {
|
|
"models": ["grok-2-latest", "grok-beta"],
|
|
"discovered_at": time.time(),
|
|
}
|
|
}, ensure_ascii=False),
|
|
encoding="utf-8"
|
|
)
|
|
|
|
svc = ModelDiscoveryService(cache_path=cache_file)
|
|
|
|
# Mock _probe_provider to simulate a hang/timeout
|
|
def mock_hang_probe(provider):
|
|
time.sleep(2.0)
|
|
return ["invented-model"]
|
|
|
|
monkeypatch.setattr(svc, "_probe_provider", mock_hang_probe)
|
|
|
|
# Synchronous probe with 0.1s timeout
|
|
result = svc.discover_models_sync("grok", timeout=0.1)
|
|
|
|
# Result should retain existing cached models
|
|
assert result == ["grok-2-latest", "grok-beta"]
|
|
assert svc.get_models("grok") == ["grok-2-latest", "grok-beta"]
|