Quality Gates

This commit is contained in:
Renn F
2026-05-05 03:19:43 +02:00
parent 4829f93a68
commit 85ef124c8f
23 changed files with 2459 additions and 456 deletions
+139 -18
View File
@@ -72,8 +72,9 @@ class _StubGit:
message: str,
task_id: UUID,
files: list[str] | None = None,
actor_agent_id: Any = None,
) -> dict[str, Any]:
del branch_name, files
del branch_name, files, actor_agent_id
sha = uuid4().hex[:40]
commits = list(self._task.commits or [])
commits.append({"sha": sha, "message": message, "task_id": str(task_id)})
@@ -87,23 +88,38 @@ class _StubGit:
"deletions": 0,
}
async def push_branch(self, branch_name: str) -> tuple[str, int]:
del branch_name
async def push_branch(
self, branch_name: str, *, actor_agent_id: Any = None
) -> tuple[str, int]:
del branch_name, actor_agent_id
return ("ok", 0)
async def create_pr(
self, branch_name: str, *, parent: str, is_root_pr: bool
self,
branch_name: str,
*,
parent: str,
is_root_pr: bool,
actor_agent_id: Any = None,
) -> dict[str, Any]:
del branch_name, parent
del branch_name, parent, actor_agent_id
self._task.pr_number = _PR_NUMBER
self._task.pr_url = _PR_URL
# Mirrors git._record_pr_atomically — production sets pr_created
# via mark_pr_created which is what the parallel-completion gate
# in _maybe_advance_to_pm_review reads.
self._task.pr_created = True
await self._session.flush()
return {"pr_number": _PR_NUMBER, "pr_url": _PR_URL, "is_root_pr": is_root_pr}
async def diff(self, *, branch_name: str) -> str: # noqa: ARG002
async def diff(
self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None
) -> str:
del branch_name, base, actor_agent_id
return "stub diff"
async def pr_target(self, pr_number: int) -> str: # noqa: ARG002
async def pr_target(self, pr_number: int, *, actor_agent_id: Any = None) -> str:
del pr_number, actor_agent_id
return "main"
async def pr_merge(self, **kwargs: Any) -> dict[str, Any]:
@@ -204,7 +220,33 @@ async def lifecycle_setup(
permissions={},
metrics={},
)
db_session.add_all([dev_agent, qa_agent])
doc_agent = AgentTable(
id=uuid4(),
name="BE Doc",
slug=f"be-doc-{uuid4().hex[:8]}",
role=AgentRole.DOCUMENTER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="doc",
capabilities=["docs"],
permissions={},
metrics={},
)
cell_pm_agent = AgentTable(
id=uuid4(),
name="BE Cell PM",
slug=f"be-pm-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="cell_pm",
capabilities=["coord"],
permissions={},
metrics={},
)
db_session.add_all([dev_agent, qa_agent, doc_agent, cell_pm_agent])
await db_session.flush()
task = TaskTable(
@@ -233,6 +275,8 @@ async def lifecycle_setup(
"project": project,
"dev_agent": dev_agent,
"qa_agent": qa_agent,
"doc_agent": doc_agent,
"cell_pm_agent": cell_pm_agent,
"task": task,
}
@@ -337,13 +381,90 @@ async def test_dev_full_chain_through_awaiting_qa(
assert final.self_verified is True, "P1-3: self_verified set by auto-verify"
# TODO P2-1 follow-up — extend the chain past awaiting_qa:
# - QA: claim_review → pass → awaiting_documentation
# - Documenter: claim_doc_task → i_documented → awaiting_pm_review
# - Cell PM: complete on the leaf → completed (or submit_up to a parent)
# - Main PM: complete on the root → awaiting_ceo_approval
# Each stage needs the role's agent seeded (lifecycle_setup already has
# dev + qa; add doc + cell_pm + main_pm) plus journal entries with the
# right scope (pass needs journal:learning; complete needs journal:decision).
# The _StubGit class above already covers commit/push/pr_create/pr_target
# /pr_merge for the merge stages.
@pytest.mark.asyncio
async def test_full_chain_through_doc_handoff(
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
) -> None:
"""Extend the dev chain: QA pass → documenter → awaiting_pm_review.
Verifies QA pass clears active_claimant_id (P1-4 + P1-5),
docs_complete transitions to awaiting_pm_review, and reassignment
to the cell PM happens on hand-off.
"""
task = lifecycle_setup["task"]
dev_agent = lifecycle_setup["dev_agent"]
qa_agent = lifecycle_setup["qa_agent"]
doc_agent = lifecycle_setup["doc_agent"]
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
task_service = TaskService(db_session)
stub_git = _StubGit(db_session, task)
deps = ChoreographerDeps(
task=task_service,
work_session=_mock_work_session(),
git=stub_git,
a2a=AsyncMock(),
journal=_mock_journal_with_reflect(),
audit=AsyncMock(),
evidence_repo=_mock_evidence_repo(),
)
c = Choreographer(deps)
# Drive the dev side first (same as test_dev_full_chain_through_awaiting_qa).
await c.i_will_work_on(dev_agent.id, task.id, plan="add the route")
await stub_git.commit(
branch_name=_BRANCH,
message=f"[{str(task.id)[:8]}] feat(api): add /healthz",
task_id=task.id,
)
await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz")
await c.submit_for_qa(dev_agent.id, task.id)
env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works")
assert env.error is None
assert env.status == "awaiting_qa"
# QA path: claim_review → pass.
env = await c.claim_review(qa_agent.id, task.id)
assert env.error is None, f"claim_review failed: {env.message}"
qa_notes = (
"Reviewed the diff; route returns 200 OK with timestamp. Tests cover "
"both acceptance criteria. Approving."
)
env = await c.pass_review(qa_agent.id, task.id, notes=qa_notes)
assert env.error is None, f"pass_review failed: {env.message}"
assert env.status == "awaiting_documentation"
after_qa = await task_service.get(task.id)
assert after_qa is not None
assert after_qa.active_claimant_id is None, (
"P1-4 + P1-5: QA pass must clear active_claimant_id for next role"
)
# Documenter path: claim_doc_task → i_documented.
env = await c.claim_doc_task(doc_agent.id, task.id)
assert env.error is None, f"claim_doc_task failed: {env.message}"
env = await c.i_documented(
doc_agent.id,
task.id,
notes="Documented /healthz behaviour in docs/api/health.md",
files=["docs/api/health.md"],
)
assert env.error is None, f"i_documented failed: {env.message}"
assert env.status == "awaiting_pm_review", (
"P2-1: i_documented must transition awaiting_documentation → awaiting_pm_review"
)
after_docs = await task_service.get(task.id)
assert after_docs is not None
assert after_docs.assigned_to == cell_pm_agent.id, (
"P2-1: docs_complete must reassign to the cell PM for the team"
)
# TODO P2-1 follow-up — final stages (cell_pm complete + main_pm complete +
# CEO approval) require additional setup: a parent task hierarchy for
# the merge chain, plus a real `git.pr_merge` simulation that updates
# the underlying repo. The _StubGit class covers the API surface; what's
# missing is the seeded parent task + main_pm agent.
@@ -0,0 +1,148 @@
"""P0-7 / S-01: branch creation atomicity.
When ``_ensure_branch_for_task`` raises (git checkout fails, push fails,
no token, etc.), ``_finalize_claim`` must roll back the claim fields it
just flushed — otherwise the task is left CLAIMED with branch_name=NULL
and the next claim attempt collides on a non-idempotent
``git checkout -b``.
This test exercises the rollback path against a real Postgres session
by patching ``_ensure_branch_for_task`` to raise.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.services.task import TaskService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def claim_setup(db_session: AsyncSession) -> AsyncIterator[dict[str, Any]]:
system_agent = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system_agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="Atom Test",
slug=f"atom-{uuid4().hex[:8]}",
git_url="https://github.com/example/atom.git",
default_branch="main",
protected_branches=["main"],
assigned_cell=Team.BACKEND,
created_by=system_agent.id,
is_active=True,
)
db_session.add(project)
await db_session.flush()
dev = AgentTable(
id=uuid4(),
name="BE Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=["python"],
permissions={},
metrics={},
)
db_session.add(dev)
await db_session.flush()
task = TaskTable(
id=uuid4(),
title="Task that will fail at branch creation",
description="",
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev.id,
acceptance_criteria=["does the thing"],
# No branch_name — claim path will try to create one.
)
db_session.add(task)
await db_session.flush()
yield {"task": task, "dev": dev, "project": project}
@pytest.mark.asyncio
async def test_finalize_claim_rolls_back_on_branch_failure(
db_session: AsyncSession, claim_setup: dict[str, Any]
) -> None:
"""git failure during _ensure_branch_for_task must revert claim fields.
Without rollback the task is left CLAIMED with branch_name=NULL and
`git checkout -b` is non-idempotent on retry.
"""
task = claim_setup["task"]
dev = claim_setup["dev"]
svc = TaskService(db_session)
# Snapshot pre-claim state so we can assert exact rollback.
pre_status = task.status
pre_assigned = task.assigned_to
pre_claimed_by = task.claimed_by
pre_claimed_at = task.claimed_at
pre_heartbeat = task.last_heartbeat_at
pre_claimant = task.active_claimant_id
async def boom(_self: Any, _task: Any, _agent_id: Any) -> str:
raise RuntimeError("simulated: git checkout -b failed")
with (
patch.object(TaskService, "_ensure_branch_for_task", boom),
pytest.raises(RuntimeError, match="git checkout -b failed"),
):
await svc.claim(task.id, dev.id)
# Re-read the task from a clean state via a fresh fetch.
refreshed = await svc.get(task.id)
assert refreshed is not None
assert refreshed.status == pre_status, "P0-7: status must roll back"
assert refreshed.assigned_to == pre_assigned, "P0-7: assigned_to must roll back"
assert refreshed.claimed_by == pre_claimed_by, "P0-7: claimed_by must roll back"
assert refreshed.claimed_at == pre_claimed_at, "P0-7: claimed_at must roll back"
assert refreshed.last_heartbeat_at == pre_heartbeat, (
"P0-7: heartbeat must roll back"
)
assert refreshed.active_claimant_id == pre_claimant, (
"P1-4 + P0-7: active_claimant_id must roll back too"
)
@@ -0,0 +1,149 @@
"""P2-8: startup orphan-claim reconciler.
The orchestrator's `_reconcile_orphan_claims_on_startup` rolls back
tasks left in CLAIMED/IN_PROGRESS with `branch_name IS NULL` — the
half-state from a pre-P0-7 crash where `_finalize_claim` flushed
status=CLAIMED before branch creation failed.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import TaskService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def orphan_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict[str, Any]]:
system_agent = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system_agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="Reconciler Test",
slug=f"recon-{uuid4().hex[:8]}",
git_url="https://github.com/example/recon.git",
default_branch="main",
protected_branches=["main"],
assigned_cell=Team.BACKEND,
created_by=system_agent.id,
is_active=True,
)
db_session.add(project)
await db_session.flush()
dev = AgentTable(
id=uuid4(),
name="BE Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=["python"],
permissions={},
metrics={},
)
db_session.add(dev)
await db_session.flush()
# ORPHAN: status=CLAIMED, assigned_to=dev, branch_name=NULL.
orphan = TaskTable(
id=uuid4(),
title="Orphan from prior crash",
description="",
status=TaskStatus.CLAIMED,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev.id,
claimed_by=dev.id,
acceptance_criteria=[""],
)
# HEALTHY: status=CLAIMED with branch — must NOT be rolled back.
healthy = TaskTable(
id=uuid4(),
title="Healthy claim",
description="",
status=TaskStatus.CLAIMED,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev.id,
claimed_by=dev.id,
branch_name="feature/backend/healthy",
acceptance_criteria=[""],
)
db_session.add_all([orphan, healthy])
await db_session.flush()
yield {"orphan": orphan, "healthy": healthy, "dev": dev}
@pytest.mark.asyncio
async def test_reconciler_rolls_back_orphan_claims(
db_session: AsyncSession, orphan_setup: dict[str, Any]
) -> None:
"""CLAIMED task with branch_name=NULL → reconciled to PENDING."""
orphan = orphan_setup["orphan"]
healthy = orphan_setup["healthy"]
svc = TaskService(db_session)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
# Drive the logic directly via the test-injectable helper so we avoid
# the orchestrator's session-factory dance.
await orch._reconcile_with_service(svc)
refreshed_orphan = await svc.get(orphan.id)
refreshed_healthy = await svc.get(healthy.id)
assert refreshed_orphan is not None
assert str(refreshed_orphan.status) == "pending", (
"P2-8: orphan must be rolled back to pending"
)
assert refreshed_orphan.assigned_to is None
assert refreshed_orphan.active_claimant_id is None
# Healthy claim untouched.
assert refreshed_healthy is not None
assert str(refreshed_healthy.status) == "claimed"
assert refreshed_healthy.branch_name == "feature/backend/healthy"
+261
View File
@@ -0,0 +1,261 @@
"""ProjectService coverage — register/list/update/delete + token round-trip.
Driven by the real Postgres ``db_session`` fixture so the test exercises
the same SQLAlchemy paths the production code does.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.project import ProjectService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def project_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict]:
"""Seed a system agent so created_by FK is satisfied."""
system = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system)
await db_session.flush()
svc = ProjectService(db_session)
yield {"svc": svc, "creator_id": system.id}
def _project_payload(slug_suffix: str) -> ProjectCreate:
return ProjectCreate(
name=f"Project {slug_suffix}",
slug=f"proj-{slug_suffix}",
git_url=f"https://github.com/example/{slug_suffix}.git",
assigned_cell=Team.BACKEND,
)
@pytest.mark.asyncio
async def test_create_project(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
assert project.id is not None
@pytest.mark.asyncio
async def test_create_project_with_git_token_encrypts(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
payload_dict = payload.model_dump()
payload_dict["git_token"] = "ghp_test_token"
project = await svc.create(
ProjectCreate(**payload_dict), project_setup["creator_id"]
)
assert project.git_token_encrypted is not None
assert project.git_token_encrypted != "ghp_test_token"
@pytest.mark.asyncio
async def test_create_project_duplicate_slug_raises(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
await svc.create(payload, project_setup["creator_id"])
with pytest.raises(ConflictError):
await svc.create(payload, project_setup["creator_id"])
@pytest.mark.asyncio
async def test_get_returns_project(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
fetched = await svc.get(project.id)
assert fetched is not None
assert fetched.id == project.id
@pytest.mark.asyncio
async def test_get_returns_none_for_missing(project_setup: dict) -> None:
svc = project_setup["svc"]
assert await svc.get(uuid4()) is None
@pytest.mark.asyncio
async def test_get_by_slug(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
created = await svc.create(payload, project_setup["creator_id"])
fetched = await svc.get_by_slug(payload.slug)
assert fetched is not None
assert fetched.id == created.id
@pytest.mark.asyncio
async def test_get_or_raise_raises(project_setup: dict) -> None:
svc = project_setup["svc"]
with pytest.raises(NotFoundError):
await svc.get_or_raise(uuid4())
@pytest.mark.asyncio
async def test_update_changes_name(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
new_name = f"renamed-{uuid4().hex[:6]}"
updated = await svc.update(project.id, ProjectUpdate(name=new_name))
assert updated is not None
assert updated.name == new_name
@pytest.mark.asyncio
async def test_update_clear_git_token(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
pd["git_token"] = "ghp_initial"
project = await svc.create(ProjectCreate(**pd), project_setup["creator_id"])
assert project.git_token_encrypted is not None
updated = await svc.update(project.id, ProjectUpdate(git_token=""))
assert updated is not None
assert updated.git_token_encrypted is None
@pytest.mark.asyncio
async def test_update_set_git_token(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
updated = await svc.update(project.id, ProjectUpdate(git_token="ghp_new"))
assert updated is not None
assert updated.git_token_encrypted is not None
@pytest.mark.asyncio
async def test_update_returns_none_for_missing(project_setup: dict) -> None:
svc = project_setup["svc"]
assert (await svc.update(uuid4(), ProjectUpdate(name="ghost"))) is None
@pytest.mark.asyncio
async def test_delete_project(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
await svc.delete(project.id)
assert await svc.get(project.id) is None
@pytest.mark.asyncio
async def test_list_all(project_setup: dict) -> None:
svc = project_setup["svc"]
a = await svc.create(_project_payload(uuid4().hex[:6]), project_setup["creator_id"])
b = await svc.create(_project_payload(uuid4().hex[:6]), project_setup["creator_id"])
rows = await svc.list_all()
ids = {p.id for p in rows}
assert a.id in ids
assert b.id in ids
@pytest.mark.asyncio
async def test_list_by_cell(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
rows = await svc.list_by_cell(Team.BACKEND)
assert project.id in {p.id for p in rows}
@pytest.mark.asyncio
async def test_set_workspace_path(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
updated = await svc.set_workspace_path(project.id, "/tmp/test-ws")
assert updated is not None
assert updated.workspace_path == "/tmp/test-ws"
@pytest.mark.asyncio
async def test_get_decrypted_token_round_trip(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
pd["git_token"] = "ghp_secret"
project = await svc.create(ProjectCreate(**pd), project_setup["creator_id"])
decrypted = await svc.get_decrypted_token(project.id)
assert decrypted == "ghp_secret"
@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_when_unset(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
assert await svc.get_decrypted_token(project.id) is None
@pytest.mark.asyncio
async def test_get_decrypted_token_by_slug_round_trip(
project_setup: dict,
) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
pd["git_token"] = "ghp_slug_secret"
await svc.create(ProjectCreate(**pd), project_setup["creator_id"])
decrypted = await svc.get_decrypted_token_by_slug(payload.slug)
assert decrypted == "ghp_slug_secret"
@pytest.mark.asyncio
async def test_check_agent_access_returns_bool(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
has_access = await svc.check_agent_access(project.id, uuid4(), Team.BACKEND)
assert isinstance(has_access, bool)
@pytest.mark.asyncio
async def test_add_and_remove_allowed_agent(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
new_agent_id = uuid4()
added = await svc.add_allowed_agent(project.id, new_agent_id)
assert added is not None
removed = await svc.remove_allowed_agent(project.id, new_agent_id)
assert removed is not None
+303
View File
@@ -0,0 +1,303 @@
"""ProviderService coverage — list/get/create/update/delete/decrypt.
Drives a real `db_session` via the project's Postgres-backed conftest.
Provider rows are encrypted at rest with Fernet; tests round-trip
plaintext ciphertext plaintext through `get_decrypted_token` and
exercise the tri-state semantics of ``ProviderUpdate.auth_token``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import ModelAssignmentTable
from roboco.models.base import ModelProvider
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.provider import (
ProviderCreate,
ProviderService,
ProviderUpdate,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def provider_svc(db_session: AsyncSession) -> AsyncIterator[ProviderService]:
yield ProviderService(db_session)
@pytest.mark.asyncio
async def test_create_provider_with_token_encrypts(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"anthropic-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token="sk-test-secret",
)
)
assert row.auth_token_encrypted is not None
assert row.auth_token_encrypted != "sk-test-secret"
@pytest.mark.asyncio
async def test_create_provider_without_token_leaves_null(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"p-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
)
assert row.auth_token_encrypted is None
@pytest.mark.asyncio
async def test_create_provider_duplicate_name_raises(
provider_svc: ProviderService,
) -> None:
name = f"dup-{uuid4().hex[:6]}"
await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
)
with pytest.raises(ConflictError):
await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.OPENAI)
)
@pytest.mark.asyncio
async def test_get_provider_returns_row(provider_svc: ProviderService) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"g-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
fetched = await provider_svc.get_provider(row.id)
assert fetched is not None
assert fetched.id == row.id
@pytest.mark.asyncio
async def test_get_provider_returns_none_when_missing(
provider_svc: ProviderService,
) -> None:
assert await provider_svc.get_provider(uuid4()) is None
@pytest.mark.asyncio
async def test_get_provider_or_raise_raises(provider_svc: ProviderService) -> None:
with pytest.raises(NotFoundError):
await provider_svc.get_provider_or_raise(uuid4())
@pytest.mark.asyncio
async def test_get_by_name(provider_svc: ProviderService) -> None:
name = f"by-name-{uuid4().hex[:6]}"
row = await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
)
found = await provider_svc.get_by_name(name)
assert found is not None
assert found.id == row.id
@pytest.mark.asyncio
async def test_list_providers_excludes_disabled_by_default(
provider_svc: ProviderService,
) -> None:
enabled = await provider_svc.create_provider(
ProviderCreate(
name=f"on-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC, enabled=True
)
)
disabled = await provider_svc.create_provider(
ProviderCreate(
name=f"off-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
)
)
visible = await provider_svc.list_providers()
visible_ids = {p.id for p in visible}
assert enabled.id in visible_ids
assert disabled.id not in visible_ids
@pytest.mark.asyncio
async def test_list_providers_include_disabled(provider_svc: ProviderService) -> None:
disabled = await provider_svc.create_provider(
ProviderCreate(
name=f"x-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
)
)
every = await provider_svc.list_providers(include_disabled=True)
assert disabled.id in {p.id for p in every}
@pytest.mark.asyncio
async def test_update_provider_changes_name(provider_svc: ProviderService) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"old-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
new_name = f"new-{uuid4().hex[:6]}"
updated = await provider_svc.update_provider(row.id, ProviderUpdate(name=new_name))
assert updated is not None
assert updated.name == new_name
@pytest.mark.asyncio
async def test_update_provider_duplicate_name_raises(
provider_svc: ProviderService,
) -> None:
a = await provider_svc.create_provider(
ProviderCreate(name=f"a-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
b = await provider_svc.create_provider(
ProviderCreate(name=f"b-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
with pytest.raises(ConflictError):
await provider_svc.update_provider(b.id, ProviderUpdate(name=a.name))
@pytest.mark.asyncio
async def test_update_provider_clears_base_url_with_empty_string(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"url-{uuid4().hex[:6]}",
type=ModelProvider.OLLAMA_CLOUD,
base_url="https://example.com",
)
)
updated = await provider_svc.update_provider(row.id, ProviderUpdate(base_url=""))
assert updated is not None
assert updated.base_url is None
@pytest.mark.asyncio
async def test_update_provider_token_tristate_clear(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"t-{uuid4().hex[:6]}",
type=ModelProvider.OLLAMA_CLOUD,
auth_token="initial",
)
)
updated = await provider_svc.update_provider(
row.id, ProviderUpdate(clear_auth_token=True)
)
assert updated is not None
assert updated.auth_token_encrypted is None
@pytest.mark.asyncio
async def test_update_provider_token_tristate_set(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"s-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC)
)
updated = await provider_svc.update_provider(
row.id, ProviderUpdate(auth_token="new-secret")
)
assert updated is not None
assert updated.auth_token_encrypted is not None
@pytest.mark.asyncio
async def test_update_provider_token_tristate_unchanged(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"u-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token="initial",
)
)
original_token = row.auth_token_encrypted
updated = await provider_svc.update_provider(
row.id,
ProviderUpdate(enabled=False), # no auth_token field
)
assert updated is not None
assert updated.auth_token_encrypted == original_token # unchanged
assert updated.enabled is False
@pytest.mark.asyncio
async def test_update_provider_returns_none_for_missing(
provider_svc: ProviderService,
) -> None:
assert (
await provider_svc.update_provider(uuid4(), ProviderUpdate(enabled=False))
is None
)
@pytest.mark.asyncio
async def test_delete_provider(provider_svc: ProviderService) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"d-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
await provider_svc.delete_provider(row.id)
assert await provider_svc.get_provider(row.id) is None
@pytest.mark.asyncio
async def test_delete_provider_raises_when_referenced(
db_session: AsyncSession, provider_svc: ProviderService
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"r-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
# Insert a model assignment that references this provider.
assignment = ModelAssignmentTable(
id=uuid4(),
scope="role",
scope_value="developer",
provider_config_id=row.id,
model_name="claude-haiku-4-5",
)
db_session.add(assignment)
await db_session.flush()
with pytest.raises(ConflictError):
await provider_svc.delete_provider(row.id)
@pytest.mark.asyncio
async def test_get_decrypted_token_round_trip(provider_svc: ProviderService) -> None:
plaintext = "sk-roundtrip-secret"
row = await provider_svc.create_provider(
ProviderCreate(
name=f"rt-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token=plaintext,
)
)
decrypted = await provider_svc.get_decrypted_token(row.id)
assert decrypted == plaintext
@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_when_unset(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"nt-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
)
assert await provider_svc.get_decrypted_token(row.id) is None
@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_for_missing_provider(
provider_svc: ProviderService,
) -> None:
assert await provider_svc.get_decrypted_token(uuid4()) is None
@@ -0,0 +1,88 @@
"""P2-9: the prompt composer injects the autogen verb table.
`compose_prompt` reads `agents/prompts/_generated/<role>.md` and
includes it as a composition layer (between role and team). This pins
that contract: when the file exists, its content appears in the
composed prompt.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from roboco.agents.factories._base import compose_prompt
from roboco.models.base import AgentRole, Team
if TYPE_CHECKING:
from pathlib import Path
def _write_layer(root: Path, rel: str, body: str) -> None:
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body)
@pytest.fixture
def fake_prompts(tmp_path: Path) -> Path:
"""Build a fake prompts directory with a known _generated/<role>.md."""
_write_layer(tmp_path, "base.md", "# BASE LAYER")
_write_layer(tmp_path, "roles/developer.md", "# ROLE LAYER (developer)")
_write_layer(tmp_path, "teams/backend.md", "# TEAM LAYER (backend)")
_write_layer(
tmp_path,
"_generated/developer.md",
"# AUTOGEN LAYER (developer)\n\n"
"## Verbs available to you (autogenerated source of truth)\n\n"
"| Verb | Body schema |\n|------|-------------|\n"
"| `give_me_work` | `give_me_work()` |\n",
)
_write_layer(tmp_path, "identities/be-dev-1.md", "# IDENTITY (be-dev-1)")
return tmp_path
def test_compose_prompt_includes_autogen_layer(fake_prompts: Path) -> None:
composed = compose_prompt(
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
agent_slug="be-dev-1",
base_path=fake_prompts,
)
assert "AUTOGEN LAYER (developer)" in composed
assert "give_me_work()" in composed
def test_compose_prompt_orders_layers_correctly(fake_prompts: Path) -> None:
"""base → role → autogen → team → identity (composer's documented order)."""
composed = compose_prompt(
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
agent_slug="be-dev-1",
base_path=fake_prompts,
)
base_idx = composed.index("BASE LAYER")
role_idx = composed.index("ROLE LAYER (developer)")
autogen_idx = composed.index("AUTOGEN LAYER (developer)")
team_idx = composed.index("TEAM LAYER (backend)")
identity_idx = composed.index("IDENTITY (be-dev-1)")
assert base_idx < role_idx < autogen_idx < team_idx < identity_idx
def test_compose_prompt_omits_autogen_when_file_missing(tmp_path: Path) -> None:
"""No _generated/<role>.md → composer skips that layer cleanly."""
_write_layer(tmp_path, "base.md", "# BASE")
_write_layer(tmp_path, "roles/developer.md", "# DEV ROLE")
_write_layer(tmp_path, "identities/be-dev-1.md", "# IDENTITY")
composed = compose_prompt(
role=AgentRole.DEVELOPER,
team=None,
agent_slug="be-dev-1",
base_path=tmp_path,
)
assert "BASE" in composed
assert "DEV ROLE" in composed
assert "IDENTITY" in composed
assert "AUTOGEN" not in composed # no autogen layer file
+111
View File
@@ -0,0 +1,111 @@
"""P2-7: every gateway.rejected audit row carries an attempt_id.
The attempt_id (uuid4 per rejection) lets post-mortem queries group
all attempts on a task within a window, even when multiple calls share
a correlation_id from a single inbound request.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock
from uuid import UUID, uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
repo = base["evidence_repo"]
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
"journal_highlights_for_task",
):
getattr(repo, method).return_value = []
return ChoreographerDeps(**base)
def _is_uuid(s: str) -> bool:
try:
UUID(s)
except (ValueError, TypeError):
return False
return True
@pytest.mark.asyncio
async def test_rejection_includes_attempt_id() -> None:
"""A `not_found` rejection on i_am_done emits an audit row with attempt_id."""
aid = uuid4()
tid = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None # → not_found
audit_svc = AsyncMock()
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
env = await c.i_am_done(aid, tid, notes="x")
assert env.error == "not_found"
audit_svc.log_event.assert_awaited()
args = audit_svc.log_event.await_args
details = args.kwargs["details"]
assert "attempt_id" in details, "P2-7: audit row must include attempt_id"
assert _is_uuid(details["attempt_id"]), "P2-7: attempt_id must be a UUID string"
@pytest.mark.asyncio
async def test_distinct_rejections_emit_distinct_attempt_ids() -> None:
"""Two rejections in sequence get different attempt_ids."""
aid = uuid4()
tid = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
audit_svc = AsyncMock()
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
await c.i_am_done(aid, tid, notes="x")
await c.i_am_done(aid, tid, notes="x")
expected_distinct_ids = 2
calls = audit_svc.log_event.await_args_list
ids = {call.kwargs["details"]["attempt_id"] for call in calls}
assert len(ids) == expected_distinct_ids, (
"P2-7: each rejection emits its own attempt_id"
)
@pytest.mark.asyncio
async def test_success_envelope_does_not_emit_audit() -> None:
"""Confirms the contract: audit rows fire on rejection only.
attempt_id machinery doesn't trip on success (no row to stamp).
"""
aid = uuid4()
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
audit_svc = AsyncMock()
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
env = await c.give_me_work(aid)
assert env.error is None
audit_svc.log_event.assert_not_awaited()
@@ -0,0 +1,149 @@
"""P0-6 / D-13: MCP _post() surfaces envelope body on 4xx.
The pre-fix path called ``response.raise_for_status()`` then ``.json()``,
which discarded the body on any 4xx agents saw a Python
``httpx.HTTPStatusError`` traceback instead of the orchestrator's
``{error, message, remediate, missing}`` envelope. These tests pin the
fixed contract: 2xx and 4xx both return the parsed JSON; only an
unparseable body produces a synthetic ``transport_error`` envelope.
"""
from __future__ import annotations
import importlib
import json
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
import types
_MANIFEST = {
"agent_id": "00000000-0000-0000-0000-000000000001",
"role": "developer",
"team": "backend",
"workspace_path": "/tmp/test",
"flow_tools": ["give_me_work", "i_will_work_on", "i_am_done"],
"do_tools": ["commit", "note"],
"read_tools": [],
"write_tools": [],
"bash_allowed": True,
"subagent_allowed": False,
"subagent_model": None,
"env": {},
}
def _seed_env(monkeypatch: pytest.MonkeyPatch) -> None:
manifest_path = Path(tempfile.mkdtemp()) / "tool-manifest.json"
manifest_path.write_text(json.dumps(_MANIFEST))
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
def _fake_client_with(status: int, body: Any) -> MagicMock:
"""httpx.Client context-manager whose post() returns the given response."""
fake_response = MagicMock()
fake_response.status_code = status
if isinstance(body, dict):
fake_response.json.return_value = body
else:
fake_response.json.side_effect = ValueError("not json")
fake_client = MagicMock()
fake_client.__enter__ = MagicMock(return_value=fake_client)
fake_client.__exit__ = MagicMock(return_value=False)
fake_client.post.return_value = fake_response
return fake_client
@pytest.fixture
def flow_module(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
_seed_env(monkeypatch)
import roboco.mcp.flow_server as srv
importlib.reload(srv)
return srv
@pytest.fixture
def do_module(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
_seed_env(monkeypatch)
import roboco.mcp.do_server as srv
importlib.reload(srv)
return srv
def test_flow_post_returns_envelope_on_422(flow_module: types.ModuleType) -> None:
"""422 with envelope body must surface as the envelope, not raise."""
envelope_body = {
"error": "tracing_gap",
"message": "missing plan",
"remediate": "call i_will_work_on(task_id=..., plan='...')",
"missing": ["plan"],
}
client = _fake_client_with(422, envelope_body)
with patch("httpx.Client", return_value=client):
result = flow_module.give_me_work()
assert result == envelope_body
def test_flow_post_returns_envelope_on_400(flow_module: types.ModuleType) -> None:
"""400 with envelope body (e.g. role-gate rejection from do_server)."""
body = {
"error": "not_authorized",
"message": "role 'cell_pm' may not commit code",
"remediate": "PMs delegate; use delegate(...)",
"missing": [],
}
client = _fake_client_with(400, body)
with patch("httpx.Client", return_value=client):
result = flow_module.i_will_work_on("task-id", plan="x")
assert result["error"] == "not_authorized"
def test_flow_post_returns_envelope_on_404(flow_module: types.ModuleType) -> None:
"""Even 404 must surface body; only the body's content matters to the agent."""
body = {
"error": "not_found",
"message": "task abc not found",
"remediate": "call give_me_work() to find an actionable task",
"missing": [],
}
client = _fake_client_with(404, body)
with patch("httpx.Client", return_value=client):
result = flow_module.i_am_done("task-abc", notes="done")
assert result["error"] == "not_found"
def test_flow_post_synthesizes_transport_error_when_body_unparseable(
flow_module: types.ModuleType,
) -> None:
"""No JSON body → synthetic transport_error envelope (NOT a raise)."""
client = _fake_client_with(502, body=None) # body=None → ValueError on .json()
with patch("httpx.Client", return_value=client):
result = flow_module.give_me_work()
assert result["error"] == "transport_error"
assert "502" in result["message"]
assert "remediate" in result
def test_do_post_returns_envelope_on_400(do_module: types.ModuleType) -> None:
"""do_server mirrors flow_server: envelope surfaces on rejection."""
body = {
"error": "not_authorized",
"message": "role 'cell_pm' may not commit",
"remediate": "PMs delegate via delegate()",
"missing": [],
}
client = _fake_client_with(400, body)
with patch("httpx.Client", return_value=client):
result = do_module.commit("any message")
assert result["error"] == "not_authorized"
+252
View File
@@ -0,0 +1,252 @@
"""PermissionService coverage — RBAC for channels, notifications, tasks, KB.
Pure-logic checks driven by ``agents_config`` constants no DB needed.
The service is a SingletonService, so we instantiate it directly with
``object.__new__`` to bypass session-management.
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from roboco.models import AgentRole, Team
from roboco.models.permissions import (
AgentContext,
KBAction,
PermissionLevel,
TaskAction,
)
from roboco.services.permissions import PermissionService
@pytest.fixture
def svc() -> PermissionService:
"""PermissionService is a SingletonService — bypass __init__ for unit tests."""
return object.__new__(PermissionService)
def _ctx(role: AgentRole, team: Team | None = None) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=team)
# ---------------------------------------------------------------------------
# Channel read access
# ---------------------------------------------------------------------------
def test_auditor_can_read_any_channel(svc: PermissionService) -> None:
"""AUDITOR has silent read on every channel."""
auditor = _ctx(AgentRole.AUDITOR)
assert svc.can_read_channel(auditor, "backend-cell")
assert svc.can_read_channel(auditor, "main-pm-board")
assert svc.can_read_channel(auditor, "any-channel-name")
def test_ceo_can_read_any_channel(svc: PermissionService) -> None:
ceo = _ctx(AgentRole.CEO)
assert svc.can_read_channel(ceo, "backend-cell")
def test_main_pm_can_read_any_channel(svc: PermissionService) -> None:
main_pm = _ctx(AgentRole.MAIN_PM)
assert svc.can_read_channel(main_pm, "backend-cell")
assert svc.can_read_channel(main_pm, "frontend-cell")
# ---------------------------------------------------------------------------
# Channel write access
# ---------------------------------------------------------------------------
def test_ceo_can_write_any_channel(svc: PermissionService) -> None:
ceo = _ctx(AgentRole.CEO)
assert svc.can_write_channel(ceo, "backend-cell")
def test_auditor_can_write_any_channel(svc: PermissionService) -> None:
"""Auditor write returns True (cover-maintenance is a convention)."""
auditor = _ctx(AgentRole.AUDITOR)
assert svc.can_write_channel(auditor, "backend-cell")
def test_main_pm_can_write_any_channel(svc: PermissionService) -> None:
main_pm = _ctx(AgentRole.MAIN_PM)
assert svc.can_write_channel(main_pm, "backend-cell")
# ---------------------------------------------------------------------------
# Channel listing
# ---------------------------------------------------------------------------
def test_get_accessible_channels_for_auditor(svc: PermissionService) -> None:
"""Auditor sees every configured channel."""
auditor = _ctx(AgentRole.AUDITOR)
channels = svc.get_accessible_channels(auditor)
assert len(channels) > 0
def test_get_writable_channels_for_ceo(svc: PermissionService) -> None:
ceo = _ctx(AgentRole.CEO)
channels = svc.get_writable_channels(ceo)
assert len(channels) > 0
# ---------------------------------------------------------------------------
# Notifications
# ---------------------------------------------------------------------------
def test_main_pm_can_send_notifications(svc: PermissionService) -> None:
main_pm = _ctx(AgentRole.MAIN_PM)
assert svc.can_send_notifications(main_pm) is True
def test_developer_cannot_send_notifications(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert svc.can_send_notifications(dev) is False
def test_auditor_send_notifications_returns_bool(svc: PermissionService) -> None:
"""Auditor's notification permission is read from agents_config."""
auditor = _ctx(AgentRole.AUDITOR)
assert isinstance(svc.can_send_notifications(auditor), bool)
def test_can_notify_pm_to_dev(svc: PermissionService) -> None:
sender = _ctx(AgentRole.MAIN_PM)
recipient = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert svc.can_notify(sender, recipient) is True
# ---------------------------------------------------------------------------
# Communication matrix
# ---------------------------------------------------------------------------
def test_can_communicate_within_cell(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
qa = _ctx(AgentRole.QA, team=Team.BACKEND)
assert svc.can_communicate(dev, qa) is True
def test_can_communicate_across_cells_via_pm(svc: PermissionService) -> None:
"""Communication matrix returns a bool — exact result depends on the matrix."""
main_pm = _ctx(AgentRole.MAIN_PM)
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert isinstance(svc.can_communicate(main_pm, dev), bool)
# ---------------------------------------------------------------------------
# Task action permissions
# ---------------------------------------------------------------------------
def test_developer_can_claim_in_own_team(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert svc.can_perform_task_action(dev, TaskAction.CLAIM, Team.BACKEND) is True
def test_qa_can_view_all(svc: PermissionService) -> None:
qa = _ctx(AgentRole.QA, team=Team.BACKEND)
# QA must be able to view tasks in their cell.
assert isinstance(
svc.can_perform_task_action(qa, TaskAction.VIEW_OWN, Team.BACKEND),
bool,
)
def test_can_perform_task_action_returns_bool(svc: PermissionService) -> None:
"""Action permission returns a bool — exact value depends on TASK_PERMISSIONS."""
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert isinstance(
svc.can_perform_task_action(dev, TaskAction.CLOSE, Team.BACKEND), bool
)
def test_cell_pm_can_close_in_own_cell(svc: PermissionService) -> None:
cell_pm = _ctx(AgentRole.CELL_PM, team=Team.BACKEND)
assert svc.can_perform_task_action(cell_pm, TaskAction.CLOSE, Team.BACKEND) is True
def test_get_task_actions_returns_set(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
actions = svc.get_task_actions(dev)
assert hasattr(actions, "__iter__")
# ---------------------------------------------------------------------------
# Permission levels
# ---------------------------------------------------------------------------
def test_ceo_has_highest_level(svc: PermissionService) -> None:
assert svc.get_permission_level(AgentRole.CEO) == PermissionLevel.CEO
def test_developer_is_cell_member_level(svc: PermissionService) -> None:
assert svc.get_permission_level(AgentRole.DEVELOPER) == PermissionLevel.CELL_MEMBER
def test_main_pm_is_main_pm_level(svc: PermissionService) -> None:
assert svc.get_permission_level(AgentRole.MAIN_PM) == PermissionLevel.MAIN_PM
# ---------------------------------------------------------------------------
# Combined check_all
# ---------------------------------------------------------------------------
def test_check_all_returns_dict(svc: PermissionService) -> None:
"""check_all returns a permission summary dict."""
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
result = svc.check_all(dev)
assert isinstance(result, dict)
assert "role" in result
assert "level" in result
# ---------------------------------------------------------------------------
# Slug-based shortcuts
# ---------------------------------------------------------------------------
def test_can_agent_read_channel_known_slug(svc: PermissionService) -> None:
"""Pass a known agent slug; service should resolve role+team and decide."""
# be-dev-1 is in AGENT_ROLE_MAP as a developer in backend.
result = svc.can_agent_read_channel("be-dev-1", "backend-cell")
assert isinstance(result, bool)
def test_can_agent_read_channel_unknown_slug(svc: PermissionService) -> None:
"""Unknown slug → False (deny by default)."""
assert svc.can_agent_read_channel("ghost-agent", "backend-cell") is False
def test_can_agent_send_notifications_known_slug(svc: PermissionService) -> None:
"""main-pm slug should be able to send."""
assert svc.can_agent_send_notifications("main-pm") is True
def test_can_agent_send_notifications_unknown_slug(svc: PermissionService) -> None:
"""Unknown slug → False."""
assert svc.can_agent_send_notifications("ghost-agent") is False
# ---------------------------------------------------------------------------
# KB permissions
# ---------------------------------------------------------------------------
def test_get_kb_actions_returns_collection(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
actions = svc.get_kb_actions(dev)
# Returns a collection of allowed KB actions.
assert hasattr(actions, "__iter__")
def test_can_perform_kb_action_developer(svc: PermissionService) -> None:
"""KB SEARCH is generally allowed for developers."""
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert isinstance(svc.can_perform_kb_action(dev, KBAction.SEARCH), bool)