diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index cb831c9..7ddbbc7 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -12,6 +12,7 @@ from app.schemas import ( DEADLINE_PERIODS, RECUR_KINDS, + AcceptIn, ApproveIn, SuggestIn, TaskCreate, @@ -285,28 +286,46 @@ task.tags = list(tags) # Пометка источника закрытия (геймификация): не поле задачи, в setattr не идёт earned_via = data.pop("earned_via", None) - # Комментарий к закрытию (ТЗ 3.20): уходит в журнал, агенту обязателен (7) + # Комментарий к закрытию (ТЗ 3.20): уходит в журнал, агенту обязателен close_comment = data.pop("close_comment", None) + # Агент закрывает задачу с отчётом: без него владельцу нечего принимать. + # Требование — на настоящий переход в done, а не на повторный PATCH + if ( + data.get("status") == "done" + and old_status != "done" + and actor.is_agent + and not (close_comment or "").strip() + ): + raise HTTPException(status_code=422, detail="Close comment is required for an AI agent") earned_event = None for field, value in data.items(): setattr(task, field, value) - if data.get("status") == "done" and task.done_at is None: - task.done_at = utcnow() - # Регулярная (ТЗ 3.5) + геймификация (ТЗ 3.13) — общий путь с MCP: - # закрытие агентом даёт те же спавн/XP/монеты, что и из UI - outcome = handle_task_closed(db, task, via_options=earned_via == "options") - event = outcome.event - if event is not None: - response.headers["X-Earned-XP"] = str(event.amount) - response.headers["X-Plant-Rarity"] = event.rarity - if event.via_options: - response.headers["X-Options-Close"] = "1" - if task.mentally_hard: - # «Ментально сложная» — фронт украшает праздник («сила воли») - response.headers["X-Mentally-Hard"] = "1" - response.headers["X-Earned-Coins"] = str(outcome.coins) - earned_event = event + if data.get("status") == "done": + if task.done_at is None: + task.done_at = utcnow() + # Регулярная (ТЗ 3.5) + геймификация (ТЗ 3.13) — общий путь с MCP: + # закрытие агентом даёт те же спавн/XP/монеты, что и из UI + outcome = handle_task_closed(db, task, via_options=earned_via == "options") + event = outcome.event + if event is not None: + response.headers["X-Earned-XP"] = str(event.amount) + response.headers["X-Plant-Rarity"] = event.rarity + if event.via_options: + response.headers["X-Options-Close"] = "1" + if task.mentally_hard: + # «Ментально сложная» — фронт украшает праздник («сила воли») + response.headers["X-Mentally-Hard"] = "1" + response.headers["X-Earned-Coins"] = str(outcome.coins) + earned_event = event + task.done_by_kind = actor.kind + # Приёмка (ТЗ 3.20) — отдельная плоскость: агенту работу ещё примут, + # владелец, закрывший задачу сам, приёмкой не занимается + task.accept_state = "pending" if actor.is_agent else None + elif not actor.is_agent: + # Владелец перезакрыл уже закрытую задачу — его закрытие снимает + # ожидание приёмки: принимать работу он будет не сам у себя + task.accept_state = None elif data.get("status") not in (None, "done") and task.done_at is not None: # Выход из done: отметка выполнения сбрасывается. Повторное закрытие # снова пройдёт через ветку наград, но XP grant_task_xp повторно не @@ -392,6 +411,64 @@ return task +def _ensure_owner_accepts(actor: Actor) -> None: + """Работу агента принимает и возвращает только владелец (ТЗ 3.20).""" + if actor.is_agent: + raise HTTPException(status_code=403, detail="Acceptance is the owner's decision") + + +@router.post("/{task_id}/accept", response_model=TaskOut) +async def accept_task( + task_id: int, + db: DbDep, + user_id: UserIdDep, + actor: ActorDep, + schema: AcceptIn | None = None, +) -> Task: + """Принять работу агента: pending → accepted. Задача остаётся закрытой.""" + task = _get_task_for_update(db, task_id, user_id) + _ensure_owner_accepts(actor) + if task.accept_state != "pending": + raise HTTPException(status_code=409, detail="Task is not waiting for acceptance") + task.accept_state = "accepted" + tasklog.log_event(db, task, tasklog.KIND_ACCEPTED, actor, schema.comment if schema else None) + db.flush() + db.refresh(task) + db.commit() + publish(user_id, "task.changed", {"id": task.id}) + return task + + +@router.post("/{task_id}/reject", response_model=TaskOut) +async def reject_task( + task_id: int, + db: DbDep, + user_id: UserIdDep, + actor: ActorDep, + schema: AcceptIn | None = None, +) -> Task: + """Вернуть работу агента в работу: задача снова to_do, аренда снята. + + XP и монеты не отзываются (ТЗ 3.13, «только позитив»), а spawned_at не + трогаем: повторное закрытие после возврата не даст ни второй награды + (идемпотентность по задаче), ни второго экземпляра регулярной (ТЗ 8.7). + """ + task = _get_task_for_update(db, task_id, user_id) + _ensure_owner_accepts(actor) + if task.accept_state != "pending": + raise HTTPException(status_code=409, detail="Task is not waiting for acceptance") + task.accept_state = "rejected" + task.status = "to_do" + task.done_at = None + claims.clear_claim(task) + tasklog.log_event(db, task, tasklog.KIND_REJECTED, actor, schema.comment if schema else None) + db.flush() + db.refresh(task) + db.commit() + publish(user_id, "task.changed", {"id": task.id}) + return task + + @router.post("/{task_id}/approve", response_model=TaskOut) async def approve_task( task_id: int, db: DbDep, user_id: UserIdDep, schema: ApproveIn | None = None diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 733d018..5ebb4e3 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -587,10 +587,23 @@ # XP/монеты/растение. Выход из done сбрасывает done_at. outcome = None if task.status == "done" and task.done_at is None: + # Агент закрывает задачу с отчётом: без него владельцу нечего принимать + # (ТЗ 3.20). Владельцу комментарий необязателен + if actor.is_agent and not (comment or "").strip(): + raise ValueError( + "comment is required: describe what you did — the owner reviews the work " + "(pass is_user=true if you close the task for the owner)" + ) task.done_at = utcnow() outcome = handle_task_closed(session, task) + task.done_by_kind = actor.kind + # Приёмка — отдельная плоскость (ТЗ 3.20): агентскую работу ещё примут + task.accept_state = "pending" if actor.is_agent else None elif task.status != "done" and task.done_at is not None: task.done_at = None + elif task.status == "done" and not actor.is_agent: + # Владелец перезакрыл закрытую задачу — приёмкой он себя не занимает + task.accept_state = None # Журнал (ТЗ 3.20) — тот же, что у HTTP-API: кто и когда закрыл задачу if status is not None and task.status != old_status: if task.status == "done" and old_status != "done": diff --git a/backend/tests/test_acceptance.py b/backend/tests/test_acceptance.py new file mode 100644 index 0000000..7800f3c --- /dev/null +++ b/backend/tests/test_acceptance.py @@ -0,0 +1,160 @@ +"""Тесты приёмки агентской работы: pending/accepted/rejected (ТЗ 3.20).""" + +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from app import mcp_server +from app.actor import VIA_API, agent_actor +from app.dependencies import require_actor +from tests.conftest import agent_ctx # type: ignore[attr-defined] + + +def _as_agent(client: TestClient, name: str = "Нави", token_id: int = 7) -> Any: + 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 _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 _closed_by_agent(client: TestClient, comment: str = "сделано") -> dict[str, Any]: + """Задача, закрытая агентом: ждёт приёмки.""" + ctx = agent_ctx(label="Нави") + task = mcp_server.create_task("агентская работа", ai_eligible=True, ctx=ctx) + closed = mcp_server.complete_task(task["id"], comment=comment, ctx=ctx) + assert closed["accept_state"] == "pending" + return closed + + +def _create(client: TestClient, **payload: Any) -> dict[str, Any]: + resp = client.post("/api/tasks", json={"title": "задача", **payload}) + assert resp.status_code == 200, resp.text + return client.get(f"/api/tasks/{resp.json()['id']}").json() + + +def test_agent_close_requires_comment(client: TestClient) -> None: + """Агент закрывает задачу с отчётом — иначе владельцу нечего принимать (3.20).""" + ctx = agent_ctx(label="Нави") + task = mcp_server.create_task("молчаливая", ai_eligible=True, ctx=ctx) + with pytest.raises(ValueError, match="comment is required"): + mcp_server.complete_task(task["id"], ctx=ctx) + # is_user=true — закрытие владельца: комментарий необязателен + mine = mcp_server.update_task(task["id"], status="done", is_user=True, ctx=ctx) + assert mine["status"] == "done" + + other = _create(client, ai_eligible=True) + saved = _as_agent(client) + try: + denied = client.patch(f"/api/tasks/{other['id']}", json={"status": "done"}) + assert denied.status_code == 422, denied.text + finally: + _restore_agent(client, saved) + + +def test_agent_close_marks_pending_and_owner_close_does_not(client: TestClient) -> None: + closed = _closed_by_agent(client) + assert closed["status"] == "done" + assert closed["accept_state"] == "pending" + # Кто закрыл последним — видно владельцу в задаче (агент в MCP-ответе его не получает) + assert client.get(f"/api/tasks/{closed['id']}").json()["done_by_kind"] == "agent" + # «Ждёт приёмки» — это фильтр списка задач, а не статус: задача остаётся done + waiting = client.get("/api/tasks", params={"accept_state": "pending"}).json() + assert [t["id"] for t in waiting] == [closed["id"]] + + mine = _create(client) + assert client.patch(f"/api/tasks/{mine['id']}", json={"status": "done"}).status_code == 200 + done = client.get(f"/api/tasks/{mine['id']}").json() + assert (done["accept_state"], done["done_by_kind"]) == (None, "user") + + +def test_accept_keeps_task_done(client: TestClient) -> None: + closed = _closed_by_agent(client, "позвонил в банк") + resp = client.post(f"/api/tasks/{closed['id']}/accept", json={"comment": "спасибо"}) + assert resp.status_code == 200, resp.text + accepted = resp.json() + assert (accepted["accept_state"], accepted["status"]) == ("accepted", "done") + rows = _events(client, kind="accepted") + assert rows[0]["comment"] == "спасибо" + # Принимать дважды нечего: задачи в pending уже нет + assert client.post(f"/api/tasks/{closed['id']}/accept").status_code == 409 + + +def test_reject_returns_task_to_work(client: TestClient) -> None: + closed = _closed_by_agent(client, "набросал черновик") + resp = client.post(f"/api/tasks/{closed['id']}/reject", json={"comment": "не то"}) + assert resp.status_code == 200, resp.text + back = resp.json() + assert back["status"] == "to_do" + assert back["accept_state"] == "rejected" + assert back["done_at"] is None + assert back["claimed_by"] is None # аренда снята: задача снова свободна + assert _events(client, kind="rejected")[0]["comment"] == "не то" + # Агент видит возврат и берёт задачу заново — с чистого листа + ctx = agent_ctx(label="Нави") + assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == [closed["id"]] + + +def test_reclose_after_reject_does_not_pay_twice(client: TestClient) -> None: + """Возврат в работу не отзывает награду и не даёт второй (3.13, 8.7).""" + tid = _create(client, ai_eligible=True)["id"] + client.patch( + f"/api/tasks/{tid}", + json={"task_type": "recurring", "recur_kind": "interval", "recur_interval_days": 2}, + ) + client.post(f"/api/tasks/{tid}/approve") + ctx = agent_ctx(label="Нави") + first = mcp_server.complete_task(tid, comment="первый раз", ctx=ctx) + assert first["spawned_next_id"] + xp_after_first = client.get("/api/xp").json() + + client.post(f"/api/tasks/{tid}/reject", json={"comment": "переделай"}) + again = mcp_server.complete_task(tid, comment="второй раз", ctx=ctx) + assert "spawned_next_id" not in again # spawned_at держит спавн + assert again["accept_state"] == "pending" # работа агента снова ждёт приёмки + summary = client.get("/api/xp").json() + assert (summary["total_xp"], summary["coins"]) == ( + xp_after_first["total_xp"], + xp_after_first["coins"], + ) + # Второго экземпляра регулярной тоже нет + open_tasks = client.get("/api/tasks", params={"status": "to_do"}).json() + spawned = [t for t in open_tasks if t["id"] != tid] + assert len(spawned) == 1 + + +def test_owner_close_cancels_pending(client: TestClient) -> None: + """Владелец, закрывший задачу сам, приёмкой не занимается — пометка снимается.""" + closed = _closed_by_agent(client) + resp = client.patch(f"/api/tasks/{closed['id']}", json={"status": "done"}) + assert resp.status_code == 200, resp.text + assert resp.json()["accept_state"] is None + assert client.post(f"/api/tasks/{closed['id']}/accept").status_code == 409 + + +def test_agent_cannot_accept_own_work(client: TestClient) -> None: + """Принимает работу владелец: агент себе приёмку не подписывает (3.20).""" + closed = _closed_by_agent(client) + saved = _as_agent(client) + try: + denied = client.post(f"/api/tasks/{closed['id']}/accept", json={"comment": "молодец"}) + assert denied.status_code == 403, denied.text + denied2 = client.post(f"/api/tasks/{closed['id']}/reject") + assert denied2.status_code == 403, denied2.text + finally: + _restore_agent(client, saved) + assert client.get(f"/api/tasks/{closed['id']}").json()["accept_state"] == "pending"