"""Тесты мандата ИИ-агента: доступность задачи, гейт агента, аренда (ТЗ 3.20)."""
from datetime import timedelta
from types import SimpleNamespace
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import update
from app import mcp_server
from app.actor import VIA_API, agent_actor
from app.dependencies import actor_of, require_actor, require_user
from app.models import Task, utcnow
from tests.conftest import _test_session_factory, agent_ctx, mcp_ctx # type: ignore[attr-defined]
def _create(client: TestClient, **payload: Any) -> dict[str, Any]:
resp = client.post("/api/tasks", json={"title": "задача", **payload})
assert resp.status_code == 200, resp.text
got = client.get(f"/api/tasks/{resp.json()['id']}")
assert got.status_code == 200, got.text
return got.json()
def _as_agent(client: TestClient, name: str = "Нави", token_id: int = 7) -> Any:
"""Действие REST — агентское: подменяем актора, авторизация остаётся.
Заголовок `X-Actor` разбирает actor_of() по пути; здесь подменён именно актор,
поэтому правило заголовка проверяется отдельным тестом. token_id разводит
агентов: аренда — на токен, а не на имя (label не уникален).
"""
overrides = client.app.dependency_overrides # type: ignore[attr-defined]
saved = overrides.get(require_actor)
overrides[require_actor] = lambda: agent_actor(name, token_id, "Владелец", VIA_API)
return saved
def _restore_agent(client: TestClient, saved: Any) -> None:
overrides = client.app.dependency_overrides # type: ignore[attr-defined]
if saved is None:
overrides.pop(require_actor, None)
else:
overrides[require_actor] = saved
def _shift_lease(task_id: int, minutes: int) -> None:
"""Сдвинуть срок аренды от «сейчас»: −1 мин — протухла, +1 мин — вот-вот.
Ждать два часа в тесте нечем, а протухание в проекте ленивое — по времени
(см. Task.claim_is_alive), так что ход времени подделываем здесь.
"""
with _test_session_factory() as session:
session.execute(
update(Task)
.where(Task.id == task_id)
.values(claim_expires_at=utcnow() + timedelta(minutes=minutes))
)
session.commit()
def test_flag_defaults_off(client: TestClient) -> None:
task = _create(client)
assert task["ai_eligible"] is False
assert task["accept_state"] is None
assert task["claimed_by"] is None
def test_owner_sets_flag_by_patch(client: TestClient) -> None:
task = _create(client)
resp = client.patch(f"/api/tasks/{task['id']}", json={"ai_eligible": True})
assert resp.status_code == 200, resp.text
assert resp.json()["ai_eligible"] is True
def test_agent_needs_flag(client: TestClient) -> None:
task = _create(client)
flagged = _create(client, ai_eligible=True)
saved = _as_agent(client)
try:
denied = client.patch(f"/api/tasks/{task['id']}", json={"priority": 5})
assert denied.status_code == 403, denied.text
allowed = client.patch(f"/api/tasks/{flagged['id']}", json={"priority": 5})
assert allowed.status_code == 200, allowed.text
finally:
_restore_agent(client, saved)
def test_owner_is_not_limited(client: TestClient) -> None:
"""Владелец (сессия SSO) правит любую задачу — флаг для него не преграда."""
task = _create(client)
assert client.patch(f"/api/tasks/{task['id']}", json={"priority": 5}).status_code == 200
def test_agent_cannot_grant_itself_the_flag(client: TestClient) -> None:
flagged = _create(client, ai_eligible=True)
saved = _as_agent(client)
try:
# Ни на помеченной задаче, ни с объявлением «действую как владелец»
denied = client.patch(f"/api/tasks/{flagged['id']}", json={"ai_eligible": False})
assert denied.status_code == 403, denied.text
denied2 = client.patch(
f"/api/tasks/{flagged['id']}",
json={"ai_eligible": False},
headers={"X-Actor": "user"},
)
assert denied2.status_code == 403, denied2.text
finally:
_restore_agent(client, saved)
def test_agent_may_create_flagged_task(client: TestClient) -> None:
"""Своя задача — другое дело: при создании агент вправе попросить мандат."""
saved = _as_agent(client)
try:
task = _create(client, ai_eligible=True)
assert task["ai_eligible"] is True
assert task["created_by_kind"] == "agent"
assert task["created_by_name"] == "Нави"
finally:
_restore_agent(client, saved)
def test_list_filters_by_flag_and_author(client: TestClient) -> None:
flagged = _create(client, ai_eligible=True)
plain = _create(client)
assert [t["id"] for t in client.get("/api/tasks", params={"ai_eligible": True}).json()] == [
flagged["id"]
]
owners = [t["id"] for t in client.get("/api/tasks", params={"created_by_kind": "user"}).json()]
assert set(owners) == {flagged["id"], plain["id"]}
assert client.get("/api/tasks", params={"created_by_kind": "agent"}).json() == []
def test_bearer_token_is_agent_until_declared(client: TestClient) -> None:
"""REST с Bearer: по умолчанию агент, `X-Actor: user` объявляет действие владельца."""
token = client.post("/api/mcp-tokens", json={"label": "Нави"}).json()["token"]
plain = _create(client)
saved = client.app.dependency_overrides.pop(require_user, None) # type: ignore[attr-defined]
auth = {"Authorization": f"Bearer {token}"}
try:
denied = client.patch(f"/api/tasks/{plain['id']}", json={"priority": 5}, headers=auth)
assert denied.status_code == 403, denied.text
allowed = client.patch(
f"/api/tasks/{plain['id']}",
json={"priority": 5},
headers={**auth, "X-Actor": "user"},
)
assert allowed.status_code == 200, allowed.text
# Создано агентом (расширение объявило бы себя владельцем) — виден актор
mine = client.post("/api/tasks", json={"title": "от агента"}, headers=auth).json()
got = client.get(f"/api/tasks/{mine['id']}", headers=auth).json()
assert (got["created_by_kind"], got["created_by_name"]) == ("agent", "Нави")
finally:
if saved is not None:
client.app.dependency_overrides[require_user] = saved # type: ignore[attr-defined]
def test_x_actor_header_rule() -> None:
"""Правило actor_of: агент за заголовком не читается, «user» делает его владельцем."""
state = SimpleNamespace(gntodo_actor=agent_actor("Нави", 7, "Владелец", VIA_API))
assert actor_of(SimpleNamespace(state=state, headers={})).is_agent
declared = actor_of(SimpleNamespace(state=state, headers={"x-actor": "user"}))
assert (declared.kind, declared.name, declared.via) == ("user", "Владелец", "api")
def test_mcp_tool_refuses_unflagged_task(client: TestClient) -> None:
ctx = agent_ctx(label="Нави")
plain = mcp_server.create_task("без флага", ctx=ctx)
with pytest.raises(ValueError, match="not available to an AI agent"):
mcp_server.update_task(plain["id"], priority=5, ctx=ctx)
# ... и то же самое с is_user=true проходит: так действует владелец
assert mcp_server.update_task(plain["id"], priority=5, is_user=True, ctx=ctx)["priority"] == 5
def test_mcp_flag_is_owner_only(client: TestClient) -> None:
ctx = agent_ctx(label="Нави")
task = mcp_server.create_task("своя", ai_eligible=True, ctx=ctx)
with pytest.raises(ValueError, match="ai_eligible is set by the owner"):
mcp_server.update_task(task["id"], ai_eligible=False, ctx=ctx)
# Владелец (is_user=true) флаг снять вправе
closed = mcp_server.update_task(task["id"], ai_eligible=False, is_user=True, ctx=ctx)
assert closed["ai_eligible"] is False
def test_mcp_compacts_expose_mandate(client: TestClient) -> None:
ctx = mcp_ctx()
created = mcp_server.create_task("задача", ai_eligible=True, ctx=ctx)
full = mcp_server.get_task(created["id"], ctx=ctx)
assert full["ai_eligible"] is True
assert full["created_by_kind"] == "user"
assert full["accept_state"] is None
assert full["claimed_by"] is None
def _events(client: TestClient, **params: Any) -> list[dict[str, Any]]:
resp = client.get("/api/task-events", params=params)
assert resp.status_code == 200, resp.text
return list(resp.json()["items"])
def test_lease_flow_over_mcp(client: TestClient) -> None:
"""Полный круг агента: список свободных → взял → вернул (ТЗ 3.20)."""
ctx = agent_ctx(label="Нави", token_id=7)
task = mcp_server.create_task("своя", ai_eligible=True, ctx=ctx)
assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == [task["id"]]
taken = mcp_server.claim_task(task["id"], ctx=ctx)
assert taken["claimed_by"] == "Нави"
assert taken["claimed_by_me"] is True
assert taken["claim_expires_at"] is not None
assert taken["renewed"] is False
# Своя живая аренда не прячет задачу из списка: агенту виден срок, до которого он занят
assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == [task["id"]]
back = mcp_server.release_task(task["id"], reason="нужен доступ к банку", ctx=ctx)
assert back["released"] is True
assert back["claimed_by"] is None
assert back["claim_expires_at"] is None
rows = _events(client, task_id=task["id"])
assert [row["kind"] for row in rows] == ["released", "claimed", "created"]
assert rows[0]["comment"] == "нужен доступ к банку"
# Повторный возврат — не ошибка и не событие: аренды уже нет
assert mcp_server.release_task(task["id"], ctx=ctx)["released"] is False
assert [r["kind"] for r in _events(client, kind="released")] == ["released"]
def test_claim_refuses_unflagged_and_started(client: TestClient) -> None:
ctx = agent_ctx(label="Нави")
plain = mcp_server.create_task("без флага", ctx=ctx)
with pytest.raises(ValueError, match="not available to an AI agent"):
mcp_server.claim_task(plain["id"], ctx=ctx)
started = mcp_server.create_task("начатая", ai_eligible=True, ctx=ctx)
mcp_server.update_task(started["id"], status="in_progress", is_user=True, ctx=ctx)
with pytest.raises(ValueError, match="is not in to_do"):
mcp_server.claim_task(started["id"], ctx=ctx)
assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == []
def test_claim_is_for_agents_only(client: TestClient) -> None:
"""Владелец не «берёт» задачу: аренда — способ агента заявить, что он занят."""
ctx = mcp_ctx()
task = mcp_server.create_task("моя", ai_eligible=True, ctx=ctx)
with pytest.raises(ValueError, match="Task claim is for AI agents"):
mcp_server.claim_task(task["id"], ctx=ctx)
def test_heartbeat_renews_lease_without_second_event(client: TestClient) -> None:
ctx = agent_ctx(label="Нави")
task = mcp_server.create_task("долгая", ai_eligible=True, ctx=ctx)
taken = mcp_server.claim_task(task["id"], ctx=ctx)
_shift_lease(task["id"], 1)
renewed = mcp_server.claim_task(task["id"], ctx=ctx)
assert renewed["renewed"] is True
assert renewed["claim_expires_at"] > taken["claim_expires_at"]
# Продление — не событие: журнал не забивается пингами
assert [r["kind"] for r in _events(client, kind="claimed")] == ["claimed"]
def test_foreign_agent_cannot_claim_or_touch(client: TestClient) -> None:
"""Живая аренда чужая — и взять нельзя, и изменить нельзя (ТЗ 3.20)."""
first = agent_ctx(label="Нави", token_id=7)
second = agent_ctx(label="Другой", token_id=9)
task = mcp_server.create_task("общая", ai_eligible=True, ctx=first)
mcp_server.claim_task(task["id"], ctx=first)
with pytest.raises(ValueError, match="claimed by Нави"):
mcp_server.claim_task(task["id"], ctx=second)
with pytest.raises(ValueError, match="another agent is working on it"):
mcp_server.update_task(task["id"], priority=5, ctx=second)
def test_expired_lease_is_free(client: TestClient) -> None:
"""Протухшая аренда считается свободной — задачу не «заклинивает» (ТЗ 3.20)."""
first = agent_ctx(label="Нави", token_id=7)
second = agent_ctx(label="Другой", token_id=9)
task = mcp_server.create_task("брошенная", ai_eligible=True, ctx=first)
mcp_server.claim_task(task["id"], ctx=first)
_shift_lease(task["id"], -1)
assert mcp_server.get_task(task["id"], ctx=second)["claimed_by"] is None
taken = mcp_server.claim_task(task["id"], ctx=second)
assert taken["claimed_by"] == "Другой"
assert taken["renewed"] is False
def test_agent_keeps_lease_while_working_and_loses_it_on_close(client: TestClient) -> None:
ctx = agent_ctx(label="Нави")
task = mcp_server.create_task("в работе", ai_eligible=True, ctx=ctx)
mcp_server.claim_task(task["id"], ctx=ctx)
working = mcp_server.update_task(task["id"], status="in_progress", ctx=ctx)
assert working["claimed_by"] == "Нави"
# Закрытая задача аренду не держит: иначе heartbeat «оживил» бы закрытую
closed = mcp_server.update_task(task["id"], status="done", comment="готово", ctx=ctx)
assert closed["claimed_by"] is None
def test_owner_action_releases_foreign_lease(client: TestClient) -> None:
"""Вмешательство владельца снимает аренду: задача снова свободна для агентов."""
ctx = agent_ctx(label="Нави")
task = mcp_server.create_task("чужая работа", ai_eligible=True, ctx=ctx)
mcp_server.claim_task(task["id"], ctx=ctx)
assert mcp_server.update_task(task["id"], priority=7, is_user=True, ctx=ctx)["priority"] == 7
second = agent_ctx(label="Другой", token_id=9)
assert mcp_server.claim_task(task["id"], ctx=second)["claimed_by"] == "Другой"
def test_rest_claim_and_release(client: TestClient) -> None:
"""REST тех же правил: агент берёт и возвращает задачу, аренда видна в TaskOut."""
task = _create(client, ai_eligible=True)
saved = _as_agent(client)
try:
taken = client.post(f"/api/tasks/{task['id']}/claim")
assert taken.status_code == 200, taken.text
assert taken.json()["claimed_by"] == "Нави"
assert taken.json()["claim_expires_at"] is not None
released = client.post(f"/api/tasks/{task['id']}/release", params={"reason": "не по силам"})
assert released.status_code == 200, released.text
assert released.json()["claimed_by"] is None
finally:
_restore_agent(client, saved)
# Владелец (сессия) взять задачу не может — это не его механика
assert client.post(f"/api/tasks/{task['id']}/claim").status_code == 403
def test_rest_foreign_lease_conflicts(client: TestClient) -> None:
"""Занятая чужим агентом задача — 409 (конфликт), а не «нельзя» (403)."""
task = _create(client, ai_eligible=True)
mine = _as_agent(client, "Нави")
try:
assert client.post(f"/api/tasks/{task['id']}/claim").status_code == 200
finally:
_restore_agent(client, mine)
other = _as_agent(client, "Другой", token_id=9)
try:
taken = client.post(f"/api/tasks/{task['id']}/claim")
assert taken.status_code == 409, taken.text
touched = client.patch(f"/api/tasks/{task['id']}", json={"priority": 5})
assert touched.status_code == 409, touched.text
assert client.post(f"/api/tasks/{task['id']}/release").status_code == 409
finally:
_restore_agent(client, other)
def test_rest_agent_claim_requires_flag(client: TestClient) -> None:
task = _create(client)
saved = _as_agent(client)
try:
denied = client.post(f"/api/tasks/{task['id']}/claim")
assert denied.status_code == 403, denied.text
finally:
_restore_agent(client, saved)