mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(a2a): CEO can DM the Auditor and PR reviewers (#623)
* feat(a2a): CEO can DM the Auditor and PR reviewers
A mid-flight PR reviewer or Auditor that's stuck was unreachable — the CEO
had no way to DM them. Both roles now carry dm/read_a2a, so the CEO can open
a 1:1 and they can reply in-thread through the existing CEO-reply path.
Scoped deliberately: the Auditor stays a silent observer to its peers — it
gains no peer-initiation surface (can_a2a_direct routes it through
_check_auditor_a2a, which refuses every initiation target; it can only reply
inside a CEO-opened DM). PR reviewers keep their owning-PM scope. Intake and
Secretary stay excluded — they have their own dedicated chat pages.
NO_COMMS_ROLES drops to {prompter, secretary}; the panel's EXCLUDE_NON_DM_ROLES
matches. KB/docs updated so the 'auditor/pr_reviewer have no dm' claim isn't
left stale.
* test(a2a): smoke guard checks _NO_COMMS_ROLES, not a hardcoded 'auditor'
The dm() runtime guard no longer names the auditor (it now carries dm to
reply to the CEO); it refuses the canonical _NO_COMMS_ROLES set. Assert on
that set so the smoke test tracks the guard, not a stale role name.
* chore(foundation): regenerate verb tables for auditor/pr_reviewer dm+read_a2a
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -2497,16 +2497,30 @@ async def test_agent_reply_to_ceo_creates_no_wake(a2a_setup: dict) -> None:
|
||||
async def test_ceo_dm_to_non_a2a_role_denied_at_conversation_creation(
|
||||
a2a_setup: dict,
|
||||
) -> None:
|
||||
"""A CEO DM to a role with no dm/read_a2a on its manifest (pr_reviewer,
|
||||
auditor) must be refused outright at conversation creation — the root-
|
||||
cause fix (can_a2a_direct's CEO branch now excludes NO_COMMS_ROLES)
|
||||
supersedes the old symptom-level fix of letting the conversation exist
|
||||
and only suppressing the wake notification (the recipient could never
|
||||
ack it, so it would be immortal, permanently suppress future wakes via
|
||||
the dedup pre-check, and drive futile respawns)."""
|
||||
"""A CEO DM to a role with no dm/read_a2a on its manifest (the human-only
|
||||
prompter/secretary — own dedicated chat pages) must be refused outright
|
||||
at conversation creation — the root-cause fix (can_a2a_direct's CEO
|
||||
branch excludes NO_COMMS_ROLES) supersedes the old symptom-level fix of
|
||||
letting the conversation exist and only suppressing the wake
|
||||
notification (the recipient could never ack it, so it would be
|
||||
immortal, permanently suppress future wakes via the dedup pre-check,
|
||||
and drive futile respawns)."""
|
||||
svc: A2AService = a2a_setup["svc"]
|
||||
with pytest.raises(A2AAccessDeniedError, match="no agent-comms surface"):
|
||||
await svc.get_or_create_conversation(agent_a="ceo", agent_b="pr-reviewer-1")
|
||||
await svc.get_or_create_conversation(agent_a="ceo", agent_b="secretary-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("target_slug", ["auditor", "pr-reviewer-1"])
|
||||
async def test_ceo_dm_to_auditor_or_pr_reviewer_conversation_allowed(
|
||||
a2a_setup: dict, target_slug: str
|
||||
) -> None:
|
||||
"""The auditor and PR reviewer now carry dm/read_a2a, so a CEO can open
|
||||
a DM with a mid-flight one — the conversation must be created, not
|
||||
refused, even though neither gains a peer-initiation surface."""
|
||||
svc: A2AService = a2a_setup["svc"]
|
||||
conv = await svc.get_or_create_conversation(agent_a="ceo", agent_b=target_slug)
|
||||
assert conv is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2523,11 +2537,46 @@ async def test_maybe_wake_ceo_recipient_still_noops_for_no_comms_role(
|
||||
with patch(
|
||||
"roboco.services.notification.NotificationService", return_value=mock_ns
|
||||
):
|
||||
await svc._maybe_wake_ceo_recipient("ceo", "pr-reviewer-1", None)
|
||||
await svc._maybe_wake_ceo_recipient("ceo", "secretary-1", None)
|
||||
|
||||
mock_ns.send_a2a_notification.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maybe_wake_ceo_recipient_wakes_auditor_now_that_it_has_read_a2a(
|
||||
a2a_setup: dict,
|
||||
) -> None:
|
||||
"""auditor now carries read_a2a, so a CEO DM to an offline auditor wakes
|
||||
it the same way it wakes any other reachable agent — the manifest check
|
||||
that used to no-op for it must now let the wake through."""
|
||||
svc: A2AService = a2a_setup["svc"]
|
||||
auditor = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Auditor",
|
||||
slug="auditor",
|
||||
role=AgentRole.AUDITOR,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="auditor",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session = svc.session
|
||||
db_session.add(auditor)
|
||||
await db_session.flush()
|
||||
|
||||
mock_ns = AsyncMock()
|
||||
mock_ns.send_a2a_notification = AsyncMock(return_value=None)
|
||||
with patch(
|
||||
"roboco.services.notification.NotificationService", return_value=mock_ns
|
||||
):
|
||||
await svc._maybe_wake_ceo_recipient("ceo", "auditor", None)
|
||||
|
||||
mock_ns.send_a2a_notification.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interject_as_ceo_wakes_only_addressed_participant(
|
||||
a2a_setup: dict,
|
||||
|
||||
@@ -79,14 +79,14 @@ def test_envelope_circuit_open_kind_distinct_from_tracing_gap() -> None:
|
||||
assert env_co.as_dict()["error"] != env_tg.as_dict()["error"]
|
||||
|
||||
|
||||
def test_auditor_silent_runtime_guard_in_dm() -> None:
|
||||
"""Spec §5.5: auditor dm refused at runtime (defense in depth).
|
||||
def test_no_comms_runtime_guard_in_dm() -> None:
|
||||
"""Spec §5.5: no-comms roles' dm() refused at runtime (defense in depth).
|
||||
|
||||
say() was retired with the channels/messaging subsystem; dm() (A2A) is
|
||||
the sole surviving agent-comms verb this guard still needs to cover.
|
||||
The auditor and pr_reviewer now carry dm/read_a2a (the CEO can DM a
|
||||
mid-flight one and it replies in-thread), so the runtime guard covers only
|
||||
the human-only prompter/secretary — checked against the canonical
|
||||
_NO_COMMS_ROLES set rather than a hardcoded role name. The behavioral test
|
||||
lives in tests/unit/gateway/test_auditor_silent_guard.py.
|
||||
"""
|
||||
# The actual guard test lives in tests/unit/gateway/test_auditor_silent_guard.py.
|
||||
# Smoke gate verifies the guard exists by checking the source for the
|
||||
# specific role-check pattern.
|
||||
dm_source = inspect.getsource(content_actions.ContentActions.dm)
|
||||
assert "auditor" in dm_source.lower(), "dm() missing auditor runtime guard"
|
||||
assert "_NO_COMMS_ROLES" in dm_source, "dm() missing no-comms runtime guard"
|
||||
|
||||
Reference in New Issue
Block a user