hermes-hub/tests/test_workflow_service_a30.py
Hermes Team 529192baec fix(roles): 19 агентов вместо 13, шесть пар неотличимы по названию; поиск локальных серверов
Проверка кандидата 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>
2026-08-30 20:46:25 +07:00

173 lines
8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"