Newer
Older
navi-1 / tests / unit / tools / test_protocol.py
"""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