Проверка кандидата A28–A31 исполнением. 1. Дублирование ролей. RoleRegistry.migrate_legacy_roles написана верно, но НЕ ВЫЗЫВАЛАСЬ НИОТКУДА — проверено поиском по всему коду. Вместо неё работала «идемпотентная миграция» в router_config, дописывавшая недостающие умолчания и не убиравшая старые роли. На конфигурации владельца интерфейс показывал 19 агентов, причём шесть пар были неотличимы по названию: orchestrator и manager — оба «Менеджер проекта», reviewer и code-reviewer — оба «Ревьюер кода». Разложить аккаунты по такому списку невозможно. Миграция подключена. Внутри неё нашлась вторая ошибка: при обходе одним проходом пустая каноническая роль затирала цепочку, перенесённую из старой. У владельца сработало бы именно так — в manager попал бы codex-orch вместо выставленного им ag-orch-fallback. Старые роли теперь обрабатываются первыми: в них и лежит настроенный порядок аккаунтов. Проверено на живой конфигурации владельца: было 19 ролей, стало 13, все цепочки совпадают с исходными. 2. Сохранённый workflow мигрируется вместе с ролями. Идентификаторы агентов повторяют идентификаторы ролей, и без переименования рёбра ссылались бы на исчезнувших агентов — «Ребро ссылается на отсутствующего агента». 3. Поиск локальных серверов моделей. Раньше адрес вводился руками. Теперь опрашиваются известные порты на петле — Ollama 11434, LM Studio 1234, llama.cpp 8080-8082, vLLM 8000, Jan, GPT4All, Text Generation WebUI, — параллельно, девять портов за 1.5 с. Наружу идёт только то, что ответило; список моделей берётся у сервера. Порт, занятый чужим сервисом, показывается с причиной, закрытые не показываются вовсе. Действие discover_local_models. Роль в фикстурах test_workflow_service_a30 переименована в test-developer: «developer» — псевдоним канонической developer-1, и тест проверял бы работу псевдонимов вместо механики workflow. 475 passed, 2 skipped; ruff чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
8 KiB
Python
173 lines
8 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from antigravity_provider.router.router_config import (
|
||
RolePolicy,
|
||
RouterConfig,
|
||
RouterProfileConfig,
|
||
save_router_config,
|
||
)
|
||
from antigravity_provider.router.workflow_service import WorkflowService
|
||
|
||
|
||
# Роль в фикстурах названа test-developer намеренно: "developer" — это
|
||
# псевдоним канонической роли developer-1, и при загрузке конфигурации он
|
||
# переименовывается. Тест проверяет механику workflow, а не работу псевдонимов,
|
||
# поэтому имя взято такое же нейтральное, как у соседнего test-reviewer.
|
||
@pytest.fixture
|
||
def workflow_service(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
config = RouterConfig(
|
||
profiles={
|
||
"account-a": RouterProfileConfig(
|
||
profile_id="account-a",
|
||
provider="openai-codex",
|
||
preferred_models=["model-real-from-config"],
|
||
)
|
||
},
|
||
roles={"test-developer": RolePolicy(role_name="test-developer", preferred_chain=["account-a"])},
|
||
)
|
||
save_router_config(config)
|
||
return WorkflowService(tmp_path / "workflow_state.json")
|
||
|
||
|
||
def test_router_roles_migrate_to_agents_and_create_real_files(workflow_service, tmp_path):
|
||
snapshot = workflow_service.snapshot()
|
||
assert "test-developer" in [agent["id"] for agent in snapshot["agents"]]
|
||
agent = next(item for item in snapshot["agents"] if item["id"] == "test-developer")
|
||
assert agent["agent_file"] == "agents/test-developer.md"
|
||
assert agent["agent_file_exists"] is True
|
||
assert (tmp_path / "agents" / "test-developer.md").is_file()
|
||
assert agent["execution_config"]["account"] == "account-a"
|
||
assert agent["execution_config"]["model"] == "model-real-from-config"
|
||
|
||
|
||
def test_create_update_file_and_restart_persistence(workflow_service, tmp_path):
|
||
created = workflow_service.create_agent({
|
||
"name": "Security Reviewer",
|
||
"role": "security-reviewer",
|
||
"account": "account-a",
|
||
"model": "model-real-from-config",
|
||
"description": "Проверяет безопасность",
|
||
})
|
||
workflow_service.save_agent_file(created.id, "# Security\n\nOnly measured facts.")
|
||
workflow_service.update_agent(created.id, {"timeout": 91, "position": {"x": 33, "y": 44}})
|
||
|
||
restarted = WorkflowService(tmp_path / "workflow_state.json")
|
||
item = next(agent for agent in restarted.snapshot()["agents"] if agent["id"] == created.id)
|
||
assert item["timeout"] == 91
|
||
assert item["position"] == {"x": 33, "y": 44}
|
||
assert restarted.read_agent_file(created.id)["content"].endswith("Only measured facts.")
|
||
|
||
|
||
def test_delete_requires_explicit_confirmation_when_referenced(workflow_service):
|
||
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
|
||
workflow_service.save_workflow({
|
||
"start_agent_id": "test-developer",
|
||
"max_iterations": 3,
|
||
"edges": [{"id": "review", "source": "test-developer", "target": "test-reviewer", "condition": "SUCCESS"}],
|
||
})
|
||
warning = workflow_service.delete_agent("test-reviewer")
|
||
assert warning["confirmation_required"] is True
|
||
assert warning["consequences"]["workflow_edges"] == ["review"]
|
||
result = workflow_service.delete_agent("test-reviewer", force=True)
|
||
assert result["deleted"] is True
|
||
assert workflow_service.workflow.edges == []
|
||
|
||
|
||
def test_cycles_are_valid_and_iteration_limit_is_persisted(workflow_service):
|
||
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
|
||
definition = workflow_service.save_workflow({
|
||
"start_agent_id": "test-developer",
|
||
"max_iterations": 2,
|
||
"edges": [
|
||
{"source": "test-developer", "target": "test-reviewer", "condition": "SUCCESS"},
|
||
{"source": "test-reviewer", "target": "test-developer", "condition": "REVIEW_FAILED"},
|
||
],
|
||
})
|
||
assert definition.max_iterations == 2
|
||
assert definition.edges[1].condition == "REVIEW_FAILED"
|
||
payload = json.loads(workflow_service.state_path.read_text(encoding="utf-8"))
|
||
assert payload["workflow"]["max_iterations"] == 2
|
||
|
||
|
||
def test_invalid_edge_and_unknown_model_are_rejected(workflow_service):
|
||
with pytest.raises(ValueError, match="отсутствующего агента"):
|
||
workflow_service.save_workflow({"edges": [{"source": "test-developer", "target": "missing"}]})
|
||
with pytest.raises(ValueError, match="не доступна"):
|
||
workflow_service.update_agent("test-developer", {"account": "account-a", "model": "invented-model"})
|
||
|
||
|
||
def test_interrupted_run_is_reported_not_silently_completed(workflow_service, tmp_path):
|
||
workflow_service.run.update({"id": "run-1", "status": "running", "current_agent_id": "test-developer"})
|
||
workflow_service._save()
|
||
restarted = WorkflowService(tmp_path / "workflow_state.json")
|
||
assert restarted.run["status"] == "interrupted"
|
||
assert "перезапуском" in restarted.run["error"]
|
||
assert restarted.events[-1].type == "WORKFLOW_INTERRUPTED"
|
||
|
||
|
||
def test_live_cycle_stops_with_explicit_iteration_limit_event(workflow_service, monkeypatch):
|
||
workflow_service.create_agent({"name": "Loop Reviewer", "role": "loop-reviewer", "account": "account-a"})
|
||
workflow_service.save_workflow({
|
||
"start_agent_id": "test-developer",
|
||
"max_iterations": 2,
|
||
"edges": [
|
||
{"source": "test-developer", "target": "loop-reviewer", "condition": "SUCCESS"},
|
||
{"source": "loop-reviewer", "target": "test-developer", "condition": "REVIEW_FAILED"},
|
||
],
|
||
})
|
||
|
||
class FakeEngine:
|
||
def reload_config(self):
|
||
return None
|
||
|
||
def route_request(self, request, role=None, session_id=None):
|
||
status = "REVIEW_FAILED" if role == "loop-reviewer" else "SUCCESS"
|
||
return {
|
||
"choices": [{"message": {"content": status}}],
|
||
"router_metadata": {
|
||
"provider": "measured-provider",
|
||
"profile_id": "account-a",
|
||
"selection_trace": {"selected_model": "model-real-from-config"},
|
||
},
|
||
}
|
||
|
||
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: FakeEngine())
|
||
workflow_service.start("Проверить реальный цикл")
|
||
thread = workflow_service._thread
|
||
assert thread is not None
|
||
thread.join(timeout=3)
|
||
assert not thread.is_alive(), "Workflow execution thread leaked after join"
|
||
|
||
assert workflow_service.run["status"] == "failed"
|
||
assert workflow_service.run["error"] == "Достигнут предел итераций: 2"
|
||
assert any(event.type == "WORKFLOW_MAX_ITERATIONS" for event in workflow_service.events)
|
||
|
||
|
||
def test_provider_error_text_reaches_run_and_events(workflow_service, monkeypatch):
|
||
provider_text = "Provider Error: authentication token missing for account-a"
|
||
|
||
class ErrorEngine:
|
||
def reload_config(self):
|
||
return None
|
||
|
||
def route_request(self, request, role=None, session_id=None):
|
||
return {"choices": [{"message": {"content": f"ERROR\n{provider_text}"}}]}
|
||
|
||
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: ErrorEngine())
|
||
workflow_service.workflow.start_agent_id = "test-developer"
|
||
workflow_service.workflow.edges = []
|
||
workflow_service.start("Проверить ошибку")
|
||
thread = workflow_service._thread
|
||
assert thread is not None
|
||
thread.join(timeout=3)
|
||
assert not thread.is_alive(), "Workflow execution thread leaked after join"
|
||
|
||
assert workflow_service.run["status"] == "failed"
|
||
assert provider_text in workflow_service.run["error"]
|
||
assert any(provider_text in (event.error or "") for event in workflow_service.events)
|
||
assert workflow_service.snapshot()["agents"][0]["runtime_state"] == "error"
|