"""Tests for the `skills` tool (search / load / write / propose) against a fake catalog."""
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from navi.skills.models import parse_document
from navi.skills.store import SkillEntry
from navi.tools._internal.base import ToolContext
from navi.tools.skills import SkillTool
SKILL_DOC = """---
kind: skill
name: Разбор логов
description: Быстрый поиск причины в логах.
area: workflows
tags: [logs]
triggers: [нужно найти ошибку]
---
Грепай ERROR, потом сужай по времени.
"""
PROTOCOL_DOC = """---
kind: protocol
name: Выключение
description: Порядок выключения.
area: infrastructure
steps: [Остановить сервисы, Отмонтировать диски]
post_checks: [Юнитов не осталось]
on_deviation: Остановиться и доложить.
on_blocked: Прервать и доложить.
on_already_done: Проверить и пропустить.
---
"""
def _entry(content: str, *, kind: str | None = None, **kw) -> SkillEntry:
doc = parse_document(content, kind=kind)
base = dict(
id=kw.pop("id", doc.kind + "-1"),
kind=doc.kind,
name=doc.name,
description=doc.description,
triggers=doc.triggers,
tags=doc.tags,
area=doc.area,
visibility=kw.pop("visibility", "personal"),
owner_id=kw.pop("owner_id", "u1"),
content=content,
version=kw.pop("version", 1),
content_hash="hash",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
base.update(kw)
return SkillEntry(**base)
class FakeStore:
def __init__(self, entries):
self.entries = entries
self.calls = []
async def list(self, *, user_id, kind=None, query=None, tag=None, area=None, limit=100):
self.calls.append(("list", kind, query, tag, area))
out = []
for e in self.entries:
if e.owner_id != user_id and e.visibility != "shared":
continue
if kind and e.kind != kind:
continue
if tag and tag not in e.tags:
continue
if area and e.area != area:
continue
if query and query.lower() not in (e.name + " " + e.description).lower():
continue
out.append(e)
return out
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 e.owner_id != user_id and e.visibility != "shared":
continue
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
async def get(self, skill_id):
self.calls.append(("get", skill_id))
for e in self.entries:
if e.id == skill_id:
return e
return None
async def create(self, *, owner_id, content, visibility="personal", edited_by="",
reason=None, kind=None):
self.calls.append(("create", owner_id, content))
entry = _entry(content, kind=kind, owner_id=owner_id)
self.entries.append(entry)
return entry
async def update(self, skill_id, *, content, edited_by="", reason=None):
self.calls.append(("update", skill_id, content))
for e in self.entries:
if e.id == skill_id:
return e
return None
async def create_proposal(self, *, owner_id, content, session_id="", replaces_id=None):
doc = parse_document(content, kind="protocol") # as the real store: validate first
self.calls.append(("propose", owner_id, session_id, replaces_id))
self.proposal = SimpleNamespace(id="pr1", name=doc.name)
return self.proposal
@pytest.fixture
def ctx():
return ToolContext(session_id="s", user_id="u1")
async def test_search_lists_both_kinds(ctx):
tool = SkillTool(FakeStore([_entry(SKILL_DOC), _entry(PROTOCOL_DOC)]))
result = await tool.execute({"op": "search"}, ctx)
assert result.success
assert "2 matching entries" in result.output
assert "[skill] Разбор логов" in result.output
assert "[protocol] Выключение" in result.output
assert "id=skill-1" in result.output
async def test_search_filters(ctx):
store = FakeStore([_entry(SKILL_DOC), _entry(PROTOCOL_DOC)])
tool = SkillTool(store)
result = await tool.execute({"op": "search", "kind": "protocol", "area": "infrastructure"}, ctx)
assert "[protocol]" in result.output
assert "[skill]" not in result.output
assert store.calls[-1] == ("list", "protocol", None, None, "infrastructure")
async def test_search_empty_explains_what_to_try(ctx):
tool = SkillTool(FakeStore([]))
result = await tool.execute({"op": "search", "query": "nothing"}, ctx)
assert result.success
assert "No matching skills or protocols" in result.output
async def test_load_by_id_renders_protocol(ctx):
tool = SkillTool(FakeStore([_entry(PROTOCOL_DOC)]))
result = await tool.execute({"op": "load", "id": "protocol-1"}, ctx)
assert result.success
assert result.output.startswith("PROTOCOL: Выключение")
assert "1. Остановить сервисы" in result.output
assert "If the run goes off plan" in result.output
assert result.metadata["kind"] == "protocol"
async def test_load_by_name(ctx):
tool = SkillTool(FakeStore([_entry(SKILL_DOC)]))
result = await tool.execute({"op": "load", "name": "разбор логов"}, ctx)
assert result.success
assert "Грепай ERROR" in result.output
async def test_load_missing_points_at_search(ctx):
tool = SkillTool(FakeStore([]))
result = await tool.execute({"op": "load", "id": "nope"}, ctx)
assert not result.success
assert "Search first" in result.error
async def test_load_requires_id_or_name(ctx):
tool = SkillTool(FakeStore([]))
result = await tool.execute({"op": "load"}, ctx)
assert not result.success
assert "'id' or 'name'" in result.error
async def test_unknown_op(ctx):
tool = SkillTool(FakeStore([]))
result = await tool.execute({"op": "delete"}, ctx)
assert not result.success
assert "Unknown op" in result.error
class TestWrite:
async def test_create_saves_a_skill_for_the_caller(self, ctx):
store = FakeStore([])
result = await SkillTool(store).execute(
{"op": "create", "content": SKILL_DOC}, ctx,
)
assert result.success
assert result.metadata["skill_id"] == "skill-1"
assert store.calls[-1][0] == "create"
assert store.calls[-1][1] == "u1"
async def test_create_refuses_a_protocol(self, ctx):
store = FakeStore([])
result = await SkillTool(store).execute(
{"op": "create", "content": PROTOCOL_DOC}, ctx,
)
assert not result.success
assert "op='propose'" in result.error
assert store.calls == []
async def test_create_accepts_a_skill_whose_front_matter_omits_kind(self, ctx):
store = FakeStore([])
content = SKILL_DOC.replace("kind: skill\n", "")
result = await SkillTool(store).execute(
{"op": "create", "content": content}, ctx,
)
assert result.success
assert result.metadata["kind"] == "skill"
assert store.calls[-1][0] == "create"
async def test_create_reports_a_malformed_document(self, ctx):
store = FakeStore([])
result = await SkillTool(store).execute(
{"op": "create", "content": "no front matter"}, ctx,
)
assert not result.success
assert "front matter" in result.error
assert store.calls == []
async def test_update_writes_a_new_version(self, ctx):
store = FakeStore([_entry(SKILL_DOC)])
result = await SkillTool(store).execute(
{"op": "update", "id": "skill-1", "content": SKILL_DOC}, ctx,
)
assert result.success
assert "v1" in result.output
assert store.calls[-1][0] == "update"
async def test_update_sends_a_protocol_to_propose(self, ctx):
store = FakeStore([_entry(PROTOCOL_DOC)])
result = await SkillTool(store).execute(
{"op": "update", "id": "protocol-1", "content": PROTOCOL_DOC}, ctx,
)
assert not result.success
assert "replaces_id='protocol-1'" in result.error
assert store.calls[-1][0] == "get"
async def test_update_refuses_someone_elses_entry(self, ctx):
store = FakeStore([_entry(SKILL_DOC, owner_id="u2")])
result = await SkillTool(store).execute(
{"op": "update", "id": "skill-1", "content": SKILL_DOC}, ctx,
)
assert not result.success
assert "belongs to someone else" in result.error
assert not any(c[0] == "update" for c in store.calls)
class TestPropose:
async def test_propose_stores_it_and_renders_the_card(self, ctx):
store = FakeStore([])
result = await SkillTool(store).execute(
{"op": "propose", "content": PROTOCOL_DOC}, ctx,
)
assert result.success
assert "NOT in the catalog yet" in result.output
assert result.metadata["proposal_id"] == "pr1"
card = result.metadata["ui_component"]
assert card["component"] == "skill_proposal"
assert card["payload"] == {"proposal_id": "pr1"}
assert store.calls[-1] == ("propose", "u1", "s", None)
async def test_propose_passes_the_protocol_being_replaced(self, ctx):
store = FakeStore([])
await SkillTool(store).execute(
{"op": "propose", "content": PROTOCOL_DOC, "replaces_id": "protocol-1"}, ctx,
)
assert store.calls[-1][3] == "protocol-1"
async def test_propose_refuses_a_skill(self, ctx):
store = FakeStore([])
result = await SkillTool(store).execute(
{"op": "propose", "content": SKILL_DOC}, ctx,
)
assert not result.success
assert store.calls == []