Files
roboco/tests/unit/gateway/test_content_actions_librarian.py
T

563 lines
19 KiB
Python
Raw Normal View History

"""roboco.services.gateway.content_actions.propose_playbook_drafts —
Auditor-gated Librarian playbook-mining authoring. Mirrors
test_content_actions_sentinel.py for the validation truth table and the
complete-at-propose asymmetry, and test_content_actions_coroner.py for the
direct-PlaybookService-draft precedent (never the draft_playbook do-verb)."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus
from roboco.services.base import ConflictError
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
TWO = 2
class _FakeTask:
"""Minimal stand-in for the ORM TaskTable row — carries just what
``propose_playbook_drafts`` touches."""
def __init__(
self,
*,
assigned_to: Any,
orchestration_markers: dict[str, Any] | None = None,
status: Any = TaskStatus.PENDING,
) -> None:
self.id = uuid4()
self.assigned_to = assigned_to
self.orchestration_markers = orchestration_markers
self.status = status
def _actions(role: str, *, notification_delivery: Any = None) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
task.agent_for = AsyncMock(return_value=agent)
task.session = MagicMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
notification_delivery=notification_delivery,
)
return ContentActions(deps)
def _valid_draft(idx: int) -> dict[str, Any]:
return {
"title": f"Playbook title number {idx}",
"body": f"A substantive procedure body for draft {idx} " * 3,
"pattern_evidence": f"Recurred across at least two journal entries {idx}",
}
def _valid_drafts(n: int) -> list[dict[str, Any]]:
return [_valid_draft(i) for i in range(n)]
def _no_existing_titles() -> Any:
"""Patch target for LibrarianEngine.existing_playbook_titles_lower —
empty by default so the field-validation truth table below isolates each
check without also needing a live DB dedup pass."""
engine = MagicMock()
engine.existing_playbook_titles_lower = AsyncMock(return_value=set())
return patch(
"roboco.services.librarian_engine.get_librarian_engine",
return_value=engine,
)
# --------------------------------------------------------------------------- #
# Role gate
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_playbook_drafts_forbidden_for_product_owner() -> None:
env = await _actions("product_owner").propose_playbook_drafts(
agent_id=uuid4(), drafts=_valid_drafts(1)
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_forbidden_for_head_marketing() -> None:
env = await _actions("head_marketing").propose_playbook_drafts(
agent_id=uuid4(), drafts=_valid_drafts(1)
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_forbidden_for_developer() -> None:
env = await _actions("developer").propose_playbook_drafts(
agent_id=uuid4(), drafts=_valid_drafts(1)
)
assert env.error == "not_authorized"
# --------------------------------------------------------------------------- #
# Count bounds (1-3)
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_empty_drafts() -> None:
env = await _actions("auditor").propose_playbook_drafts(agent_id=uuid4(), drafts=[])
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_too_many_drafts() -> None:
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=_valid_drafts(4)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_accepts_three_drafts_shape(
monkeypatch: pytest.MonkeyPatch,
) -> None:
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=_valid_drafts(3)
)
# No open cycle exists — proves the count itself was accepted (3 is the
# upper bound, not rejected by the count check).
assert env.error == "invalid_state"
assert "no open Librarian mining task" in (env.message or "")
# --------------------------------------------------------------------------- #
# Per-draft field validation
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_non_dict_draft() -> None:
bad_drafts: list[Any] = ["not a dict"]
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=bad_drafts
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_draft_missing_title() -> None:
bad = _valid_draft(0)
del bad["title"]
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=[bad]
)
assert env.error == "invalid_state"
assert "title" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_draft_missing_body() -> None:
bad = _valid_draft(0)
del bad["body"]
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=[bad]
)
assert env.error == "invalid_state"
assert "body" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_draft_missing_pattern_evidence() -> None:
bad = _valid_draft(0)
del bad["pattern_evidence"]
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=[bad]
)
assert env.error == "invalid_state"
assert "pattern_evidence" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_soup_title() -> None:
bad = _valid_draft(0)
bad["title"] = "asdf"
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=[bad]
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_oversized_title() -> None:
bad = _valid_draft(0)
bad["title"] = "x" * 201
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=[bad]
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_oversized_body() -> None:
bad = _valid_draft(0)
bad["body"] = "x" * 4001
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=[bad]
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_oversized_pattern_evidence() -> None:
bad = _valid_draft(0)
bad["pattern_evidence"] = "x" * 501
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=[bad]
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_thin_pattern_evidence() -> None:
bad = _valid_draft(0)
bad["pattern_evidence"] = "too short"
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=[bad]
)
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# In-batch duplicate-title dedup
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_duplicate_titles_in_batch() -> None:
first = _valid_draft(0)
second = _valid_draft(1)
second["title"] = first["title"].upper() # case-insensitive collision
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=[first, second]
)
assert env.error == "invalid_state"
assert "duplicates another draft in this batch" in (env.message or "")
# --------------------------------------------------------------------------- #
# Open-cycle lookup
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_playbook_drafts_no_open_cycle_is_invalid_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=_valid_drafts(1)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_ignores_cycle_assigned_to_another_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
other_agent = uuid4()
cycle_task = _FakeTask(assigned_to=other_agent)
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("auditor").propose_playbook_drafts(
agent_id=uuid4(), drafts=_valid_drafts(1)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_ignores_already_authored_cycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
authored = _FakeTask(
assigned_to=agent_id,
orchestration_markers={"playbook_drafts": {"drafts": []}},
)
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[authored])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("auditor").propose_playbook_drafts(
agent_id=agent_id, drafts=_valid_drafts(1)
)
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# Existing-title dedup (live DB check, past the field-validation stage)
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_playbook_drafts_rejects_title_duplicating_existing_playbook(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
draft = _valid_draft(0)
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
engine = MagicMock()
engine.existing_playbook_titles_lower = AsyncMock(
return_value={draft["title"].lower()}
)
with patch(
"roboco.services.librarian_engine.get_librarian_engine",
return_value=engine,
):
env = await _actions("auditor").propose_playbook_drafts(
agent_id=agent_id, drafts=[draft]
)
assert env.error == "invalid_state"
assert "duplicates an existing playbook" in (env.message or "")
# --------------------------------------------------------------------------- #
# Happy path — direct PlaybookService.draft(), never draft_playbook
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_playbook_drafts_creates_playbooks_and_completes_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
actions = _actions("auditor")
actions.task.session.flush = AsyncMock()
drafted_1, drafted_2 = MagicMock(), MagicMock()
drafted_1.id, drafted_1.title = uuid4(), "Playbook title number 0"
drafted_2.id, drafted_2.title = uuid4(), "Playbook title number 1"
playbook_svc = MagicMock()
playbook_svc.draft = AsyncMock(side_effect=[drafted_1, drafted_2])
with (
_no_existing_titles(),
patch(
"roboco.services.playbook.get_playbook_service",
return_value=playbook_svc,
),
):
env = await actions.propose_playbook_drafts(
agent_id=agent_id, drafts=_valid_drafts(2)
)
assert env.error is None
assert env.status == "playbook_drafts_proposed"
assert env.task_id == str(cycle_task.id)
assert playbook_svc.draft.await_count == TWO
payload = markers.get_playbook_drafts(cycle_task)
assert payload is not None
assert len(payload["drafts"]) == TWO
assert payload["drafts"][0]["title"] == "Playbook title number 0"
# Complete-at-propose: the mining task completes in THIS call — mirrors
# the x_feature/periscope/sentinel asymmetry.
assert cycle_task.status == TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_propose_playbook_drafts_calls_playbook_service_draft_directly(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The invariant this whole verb exists to preserve: each playbook is
created directly via PlaybookService.draft() — the do-verb never routes
through the draft_playbook content-tool path (which the Auditor doesn't
carry on its manifest, see test_playbook_verbs.py's invariant)."""
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
actions = _actions("auditor")
actions.task.session.flush = AsyncMock()
drafted = MagicMock()
drafted.id, drafted.title = uuid4(), "Playbook title number 0"
playbook_svc = MagicMock()
playbook_svc.draft = AsyncMock(return_value=drafted)
with (
_no_existing_titles(),
patch(
"roboco.services.playbook.get_playbook_service",
return_value=playbook_svc,
) as get_playbook_svc,
):
env = await actions.propose_playbook_drafts(
agent_id=agent_id, drafts=_valid_drafts(1)
)
assert env.error is None
get_playbook_svc.assert_called_once()
playbook_svc.draft.assert_awaited_once()
create_call = playbook_svc.draft.await_args
assert create_call.kwargs["created_by"] == agent_id
@pytest.mark.asyncio
async def test_propose_playbook_drafts_conflict_error_is_clean_rejection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A same-tick race past the live pre-check (ConflictError from
PlaybookService.draft itself) aborts with a clean rejection — mirrors
test_propose_postmortem_playbook_title_conflict_is_clean_rejection."""
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
actions = _actions("auditor")
actions.task.session.flush = AsyncMock()
playbook_svc = MagicMock()
playbook_svc.draft = AsyncMock(
side_effect=ConflictError("dup slug", resource_type="playbook")
)
with (
_no_existing_titles(),
patch(
"roboco.services.playbook.get_playbook_service",
return_value=playbook_svc,
),
):
env = await actions.propose_playbook_drafts(
agent_id=agent_id, drafts=_valid_drafts(1)
)
assert env.error == "invalid_state"
assert cycle_task.status != TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_propose_playbook_drafts_sends_telegram_push_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
notify = AsyncMock()
actions = _actions("auditor", notification_delivery=notify)
actions.task.session.flush = AsyncMock()
drafted = MagicMock()
drafted.id, drafted.title = uuid4(), "Playbook title number 0"
playbook_svc = MagicMock()
playbook_svc.draft = AsyncMock(return_value=drafted)
with (
_no_existing_titles(),
patch(
"roboco.services.playbook.get_playbook_service",
return_value=playbook_svc,
),
):
env = await actions.propose_playbook_drafts(
agent_id=agent_id, drafts=_valid_drafts(1)
)
assert env.error is None
notify.notify_ceo_of_librarian_drafts.assert_awaited_once()
call = notify.notify_ceo_of_librarian_drafts.await_args
assert call.kwargs["task"] is cycle_task
assert call.kwargs["task_id"] == cycle_task.id
assert call.kwargs["titles"] == ["Playbook title number 0"]
@pytest.mark.asyncio
async def test_propose_playbook_drafts_survives_telegram_push_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
notify = MagicMock()
notify.notify_ceo_of_librarian_drafts = AsyncMock(side_effect=RuntimeError("boom"))
actions = _actions("auditor", notification_delivery=notify)
actions.task.session.flush = AsyncMock()
drafted = MagicMock()
drafted.id, drafted.title = uuid4(), "Playbook title number 0"
playbook_svc = MagicMock()
playbook_svc.draft = AsyncMock(return_value=drafted)
with (
_no_existing_titles(),
patch(
"roboco.services.playbook.get_playbook_service",
return_value=playbook_svc,
),
):
env = await actions.propose_playbook_drafts(
agent_id=agent_id, drafts=_valid_drafts(1)
)
assert env.error is None
assert env.status == "playbook_drafts_proposed"
@pytest.mark.asyncio
async def test_propose_playbook_drafts_no_notification_delivery_is_a_no_op(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
actions = _actions("auditor", notification_delivery=None)
actions.task.session.flush = AsyncMock()
drafted = MagicMock()
drafted.id, drafted.title = uuid4(), "Playbook title number 0"
playbook_svc = MagicMock()
playbook_svc.draft = AsyncMock(return_value=drafted)
with (
_no_existing_titles(),
patch(
"roboco.services.playbook.get_playbook_service",
return_value=playbook_svc,
),
):
env = await actions.propose_playbook_drafts(
agent_id=agent_id, drafts=_valid_drafts(1)
)
assert env.error is None