"""Tests for the `protocol` tool — run start, closing and the per-turn reminder."""
from datetime import datetime, timezone
import pytest
from navi.skills.models import parse_document
from navi.skills.runs import ProtocolRun
from navi.skills.store import SkillEntry
from navi.tools import protocol as protocol_mod
from navi.tools._internal.base import ToolContext
from navi.tools.protocol import ProtocolTool
PROTOCOL_DOC = """---
kind: protocol
name: Выключение
description: Порядок выключения.
area: infrastructure
steps: [Остановить сервисы, Отмонтировать диски]
post_checks: [Юнитов не осталось]
on_deviation: Остановиться и доложить.
on_blocked: Прервать и доложить.
on_already_done: Проверить и пропустить.
---
"""
def _entry(content: str = PROTOCOL_DOC, **kw) -> SkillEntry:
doc = parse_document(content)
base = dict(
id=kw.pop("id", "proto-1"),
kind=doc.kind,
name=doc.name,
description=doc.description,
triggers=doc.triggers,
tags=doc.tags,
area=doc.area,
visibility="personal",
owner_id="u1",
content=content,
version=kw.pop("version", 2),
content_hash="hash",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
base.update(kw)
return SkillEntry(**base)
class FakeSkills:
def __init__(self, entries=None):
self.entries = entries if entries is not None else [_entry()]
self.calls = []
async def find(self, *, user_id, name=None, kind=None, skill_id=None):
self.calls.append(("find", skill_id, name, kind))
for e in self.entries:
if skill_id and e.id != skill_id:
continue
if name and e.name.lower() != name.lower():
continue
if kind and e.kind != kind:
continue
return e
return None
class FakeRuns:
def __init__(self, active=None):
self._active = active
self.started = []
self.closed = []
self.calls = []
async def active(self, session_id):
self.calls.append(("active", session_id))
return self._active
async def start(self, **kw):
self.started.append(kw)
run = ProtocolRun(
id="run-1",
protocol_id=kw["protocol_id"],
protocol_name=kw["protocol_name"],
protocol_version=kw["protocol_version"],
session_id=kw["session_id"],
user_id=kw["user_id"],
status="running",
steps=list(kw["steps"]),
post_checks=list(kw.get("post_checks") or []),
started_at=datetime(2026, 11, 7, 10, 0, tzinfo=timezone.utc),
)
self._active = run
return run
async def close(self, run_id, *, status, report="", reason=""):
self.closed.append((run_id, status, report, reason))
run = self._active
if run is None:
return None
self._active = None
run.status = status
run.report = report
run.reason = reason
return run
def _run(**overrides) -> ProtocolRun:
base = dict(
id="run-1",
protocol_id="proto-1",
protocol_name="Выключение",
protocol_version=2,
session_id="s",
user_id="u1",
status="running",
steps=["Остановить сервисы", "Отмонтировать диски"],
post_checks=["Юнитов не осталось"],
started_at=datetime(2026, 11, 7, 10, 0, tzinfo=timezone.utc),
)
base.update(overrides)
return ProtocolRun(**base)
def _states(*statuses: str) -> list[dict]:
return [
{"index": i, "text": f"step {i}", "status": st, "validation": "", "step_id": f"s{i}"}
for i, st in enumerate(statuses, 1)
]
@pytest.fixture
def ctx():
return ToolContext(session_id="s", user_id="u1")
@pytest.fixture
def spliced(monkeypatch):
"""Capture insert_steps calls and return fixed plan indexes."""
calls = []
async def _insert(session_id, steps, **kw):
calls.append((session_id, list(steps), kw))
return list(range(1, len(steps) + 1))
monkeypatch.setattr(protocol_mod, "insert_steps", _insert)
return calls
@pytest.fixture
def step_states(monkeypatch):
"""Patch the plan read used by finish/status and let a test set the state."""
holder = {"states": []}
async def _states_of(session_id, run_id):
return holder["states"]
monkeypatch.setattr(protocol_mod, "protocol_step_states", _states_of)
return holder
@pytest.fixture
def closed_steps(monkeypatch):
calls = []
async def _close(session_id, run_id, *, note=""):
calls.append((session_id, run_id, note))
return [2]
monkeypatch.setattr(protocol_mod, "close_protocol_steps", _close)
return calls
# ── start ───────────────────────────────────────────────────────────────────
async def test_start_splices_the_steps_and_names_their_positions(ctx, spliced):
runs = FakeRuns()
tool = ProtocolTool(FakeSkills(), runs)
result = await tool.execute({"op": "start", "id": "proto-1"}, ctx)
assert result.success
assert "PROTOCOL STARTED" in result.output
assert "Выключение" in result.output
assert "positions 1–2" in result.output
assert "Остановиться и доложить." in result.output
assert "protocol(op='finish'" in result.output
assert result.metadata["run_id"] == "run-1"
assert result.metadata["step_indexes"] == [1, 2]
session_id, steps, kw = spliced[0]
assert session_id == "s"
assert steps == ["Остановить сервисы", "Отмонтировать диски"]
assert kw["source"] == "protocol"
assert kw["protocol_run_id"] == "run-1"
assert kw["step_ids"] == ["s1", "s2"]
async def test_start_records_a_snapshot_of_the_version(ctx, spliced):
runs = FakeRuns()
tool = ProtocolTool(FakeSkills(), runs)
await tool.execute({"op": "start", "name": "Выключение"}, ctx)
assert runs.started[0]["protocol_version"] == 2
assert runs.started[0]["protocol_name"] == "Выключение"
assert runs.started[0]["post_checks"] == ["Юнитов не осталось"]
async def test_start_by_name(ctx, spliced):
tool = ProtocolTool(FakeSkills(), FakeRuns())
result = await tool.execute({"op": "start", "name": "выключение"}, ctx)
assert result.success
async def test_start_refuses_while_another_run_is_open(ctx, spliced):
runs = FakeRuns(active=_run())
tool = ProtocolTool(FakeSkills(), runs)
result = await tool.execute({"op": "start", "id": "proto-1"}, ctx)
assert not result.success
assert "already running" in result.error
assert spliced == []
async def test_start_reports_an_unknown_protocol(ctx, spliced):
tool = ProtocolTool(FakeSkills(entries=[]), FakeRuns())
result = await tool.execute({"op": "start", "id": "nope"}, ctx)
assert not result.success
assert "No protocol" in result.error
async def test_start_needs_an_id_or_a_name(ctx, spliced):
tool = ProtocolTool(FakeSkills(), FakeRuns())
result = await tool.execute({"op": "start"}, ctx)
assert not result.success
assert "'id' or 'name'" in result.error
# ── finish ──────────────────────────────────────────────────────────────────
async def test_finish_requires_a_report(ctx, step_states):
runs = FakeRuns(active=_run())
tool = ProtocolTool(FakeSkills(), runs)
result = await tool.execute({"op": "finish"}, ctx)
assert not result.success
assert "report" in result.error
assert runs.closed == []
async def test_finish_refuses_while_steps_are_open(ctx, step_states):
runs = FakeRuns(active=_run())
tool = ProtocolTool(FakeSkills(), runs)
step_states["states"] = _states("done", "in_progress")
result = await tool.execute({"op": "finish", "report": "сделано"}, ctx)
assert not result.success
assert "still open" in result.error
assert "2 in_progress" in result.error
assert runs.closed == []
async def test_finish_refuses_a_failed_step_and_points_at_fail(ctx, step_states):
runs = FakeRuns(active=_run())
tool = ProtocolTool(FakeSkills(), runs)
step_states["states"] = _states("failed", "pending")
result = await tool.execute({"op": "finish", "report": "сделано"}, ctx)
assert not result.success
assert "marked failed" in result.error
assert "op='fail'" in result.error
assert runs.closed == []
async def test_finish_refuses_when_the_plan_lost_the_steps(ctx, step_states):
runs = FakeRuns(active=_run())
tool = ProtocolTool(FakeSkills(), runs)
step_states["states"] = _states("done") # one of two
result = await tool.execute({"op": "finish", "report": "сделано"}, ctx)
assert not result.success
assert "no longer contains all" in result.error
assert runs.closed == []
async def test_finish_closes_a_run_whose_steps_are_closed(ctx, step_states):
runs = FakeRuns(active=_run())
tool = ProtocolTool(FakeSkills(), runs)
step_states["states"] = _states("done", "skipped")
result = await tool.execute({"op": "finish", "report": "всё проверено"}, ctx)
assert result.success
assert "finished" in result.output
assert runs.closed == [("run-1", "done", "всё проверено", "")]
# ── cancel / fail ───────────────────────────────────────────────────────────
async def test_cancel_requires_a_reason_and_skips_open_steps(ctx, closed_steps):
runs = FakeRuns(active=_run())
tool = ProtocolTool(FakeSkills(), runs)
refused = await tool.execute({"op": "cancel"}, ctx)
assert not refused.success
assert "reason" in refused.error
assert runs.closed == []
result = await tool.execute({"op": "cancel", "reason": "передумали"}, ctx)
assert result.success
assert "cancelled" in result.output
assert closed_steps[0][1] == "run-1"
assert closed_steps[0][2] == "protocol cancelled: передумали"
assert runs.closed == [("run-1", "cancelled", "", "передумали")]
async def test_fail_closes_the_run_as_failed(ctx, closed_steps):
runs = FakeRuns(active=_run())
tool = ProtocolTool(FakeSkills(), runs)
result = await tool.execute({"op": "fail", "reason": "сервис не остановился"}, ctx)
assert result.success
assert "failed" in result.output
assert runs.closed == [("run-1", "failed", "", "сервис не остановился")]
async def test_closing_without_a_run_is_refused(ctx):
tool = ProtocolTool(FakeSkills(), FakeRuns())
result = await tool.execute({"op": "finish", "report": "x"}, ctx)
assert not result.success
assert "No protocol is running" in result.error
# ── status ──────────────────────────────────────────────────────────────────
async def test_status_without_a_run(ctx):
tool = ProtocolTool(FakeSkills(), FakeRuns())
result = await tool.execute({"op": "status"}, ctx)
assert result.success
assert "No protocol is running" in result.output
async def test_status_lists_the_run_and_its_steps(ctx, step_states):
tool = ProtocolTool(FakeSkills(), FakeRuns(active=_run()))
step_states["states"] = _states("done", "in_progress")
result = await tool.execute({"op": "status"}, ctx)
assert "ACTIVE PROTOCOL" in result.output
assert "1. step 1 (done)" in result.output
assert "2. step 2 (in_progress)" in result.output
async def test_unknown_op_names_the_options(ctx):
tool = ProtocolTool(FakeSkills(), FakeRuns())
result = await tool.execute({"op": "run"}, ctx)
assert not result.success
assert "start" in result.error and "fail" in result.error
async def test_a_broken_run_store_is_reported_not_raised(ctx, spliced):
class Broken:
async def active(self, session_id):
raise RuntimeError("db down")
tool = ProtocolTool(FakeSkills(), Broken())
for op in ({"op": "start", "id": "proto-1"}, {"op": "status"}, {"op": "finish", "report": "x"}):
result = await tool.execute(op, ctx)
assert not result.success
assert "Cannot read the protocol run" in result.error
# ── per-turn reminder ───────────────────────────────────────────────────────
async def test_no_reminder_without_a_run(monkeypatch, step_states):
ProtocolTool(FakeSkills(), FakeRuns())
assert await protocol_mod.get_protocol_message("s") is None
async def test_reminder_carries_the_open_steps(step_states):
ProtocolTool(FakeSkills(), FakeRuns(active=_run()))
step_states["states"] = _states("done", "pending")
msg = await protocol_mod.get_protocol_message("s")
assert msg.role == "system"
assert "PROTOCOL ACTIVE" in msg.content
assert "2 of 2 steps" in msg.content
assert "protocol(op='fail'" in msg.content
assert "do not end the turn" in msg.content
async def test_reminder_says_to_finish_when_all_steps_are_closed(step_states):
ProtocolTool(FakeSkills(), FakeRuns(active=_run()))
step_states["states"] = _states("done", "done")
msg = await protocol_mod.get_protocol_message("s")
assert "Every step is closed" in msg.content
assert "protocol(op='finish'" in msg.content
async def test_reminder_flags_a_plan_that_lost_the_steps(step_states):
ProtocolTool(FakeSkills(), FakeRuns(active=_run()))
step_states["states"] = []
msg = await protocol_mod.get_protocol_message("s")
assert "no longer in the plan" in msg.content
assert "op='fail'" in msg.content
async def test_reminder_is_silent_when_the_store_breaks(monkeypatch):
class Broken:
async def active(self, session_id):
raise RuntimeError("db down")
ProtocolTool(FakeSkills(), Broken())
assert await protocol_mod.get_protocol_message("s") is None
async def test_has_active_run_gates_the_final_intercept():
ProtocolTool(FakeSkills(), FakeRuns())
assert await protocol_mod.has_active_run("s") is False
ProtocolTool(FakeSkills(), FakeRuns(active=_run()))
assert await protocol_mod.has_active_run("s") is True
async def test_has_active_run_is_false_when_the_store_breaks():
class Broken:
async def active(self, session_id):
raise RuntimeError("db down")
ProtocolTool(FakeSkills(), Broken())
assert await protocol_mod.has_active_run("s") is False