Files
omnigent-ai--omnigent/tests/runner/test_native_subagent_inbox_delivery.py
2026-07-13 13:12:00 +08:00

351 lines
13 KiB
Python

"""Native sub-agent completions must reach the parent inbox.
A native CLI sub-agent's completion is the forwarder's ``external_session_status:
idle`` (or ``failed``) event POSTed to the child's ``/events``. The runner turns
that into a ``sub_agent`` payload in the parent's async inbox (``sys_read_inbox``)
so the orchestrator wakes instead of busy-polling ``sys_session_get_history``.
Delivery is dropped whenever the runner's in-memory work entry for the child is
missing — a reconnect / restart wiped ``_subagent_work_by_child`` mid-turn, or a
``sys_session_create`` child never registered one (the server records a
``parent_session_id`` but no ``sub_agent_name``). The old code then returned
HTTP 204 and lost the completion. The fix rebuilds the entry from the server
snapshot before delivering, and returns 503 (so the forwarder retries) when
delivery still can't be confirmed on this runner.
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from typing import Any
import pytest
from omnigent.runner import app as runner_app
from omnigent.runner import create_runner_app
from omnigent.spec.types import AgentSpec, ExecutorSpec
# Reuse the proven runner-turn stubs from the sessions-native suite.
from tests.runner.helpers import NullServerClient
from tests.runner.test_app_sessions_native import (
_FakeProcessManager,
_runner_client,
_ScriptedHarnessClient,
)
PARENT_SESSION_ID = "conv_parent_orchestrator"
CHILD_SESSION_ID = "conv_child_reviewer"
@pytest.fixture
def _clean_subagent_registry() -> Iterator[None]:
"""Snapshot and restore the process-wide sub-agent / inbox maps.
The sub-agent work registry and inbox queues live in module-level dicts on
``omnigent.runner.app`` that otherwise leak across tests. Clear them before
the test and restore the originals after.
"""
saved = (
dict(runner_app._subagent_work_by_child),
{k: set(v) for k, v in runner_app._subagent_work_by_parent.items()},
dict(runner_app._session_inboxes_ref),
set(runner_app._drained_delivered_subagent_children),
)
runner_app._subagent_work_by_child.clear()
runner_app._subagent_work_by_parent.clear()
runner_app._session_inboxes_ref.clear()
runner_app._drained_delivered_subagent_children.clear()
try:
yield
finally:
runner_app._subagent_work_by_child.clear()
runner_app._subagent_work_by_child.update(saved[0])
runner_app._subagent_work_by_parent.clear()
runner_app._subagent_work_by_parent.update(saved[1])
runner_app._session_inboxes_ref.clear()
runner_app._session_inboxes_ref.update(saved[2])
runner_app._drained_delivered_subagent_children.clear()
runner_app._drained_delivered_subagent_children.update(saved[3])
class _SnapshotServerClient(NullServerClient):
"""Server client whose ``GET /v1/sessions/{child}`` carries the sub-agent snapshot.
Mirrors ``SessionResponse`` (server routes/sessions.py): the authoritative
source the runner uses to rebuild a lost sub-agent work entry. The body is
configurable so a test can model a declared sub-agent (``sub_agent_name``
set), a ``sys_session_create`` child (``sub_agent_name`` null but
``parent_session_id`` set + ``agent_name``), or a top-level session (no
parent). All other endpoints fall through to the empty-200 base.
"""
def __init__(self, child_body: dict[str, Any]) -> None:
"""Configure the JSON body returned for the child session GET."""
self._child_body = child_body
class _Resp:
def __init__(self, payload: dict[str, Any]) -> None:
self.status_code = 200
self._payload = payload
def json(self) -> dict[str, Any]:
return self._payload
def raise_for_status(self) -> None:
return None
async def get(self, url: str, **kwargs: Any) -> Any:
del kwargs
if url.rstrip("/").endswith(CHILD_SESSION_ID):
return self._Resp(self._child_body)
if url.rstrip("/").endswith("/items"):
return self._Resp({"data": [], "has_more": False})
return self._Response()
def _child_snapshot(
*,
sub_agent_name: str | None,
parent_session_id: str | None,
agent_name: str | None = "cursor-native-ui",
) -> dict[str, Any]:
"""Build a child ``SessionResponse``-shaped body."""
return {
"id": CHILD_SESSION_ID,
"agent_id": "ag_reviewer",
"agent_name": agent_name,
"sub_agent_name": sub_agent_name,
"parent_session_id": parent_session_id,
"created_at": 0,
"workspace": None,
}
async def _post_native_idle(
*,
child_body: dict[str, Any],
seed_parent_inbox: bool,
register_work: bool,
output: str = "review complete: LGTM",
) -> tuple[int, list[dict[str, Any]]]:
"""POST a native ``external_session_status: idle`` and return (http, inbox items).
Models the forwarder reporting a finished native sub-agent turn.
``register_work`` seeds the in-memory work entry (the healthy case); leaving
it ``False`` models a reconnect-wiped map or a ``sys_session_create`` child
the dispatch never registered. ``seed_parent_inbox`` controls whether the
parent's inbox queue is present on this runner.
"""
if seed_parent_inbox:
runner_app._session_inboxes_ref[PARENT_SESSION_ID] = asyncio.Queue()
if register_work:
runner_app.register_subagent_work(
parent_session_id=PARENT_SESSION_ID,
child_session_id=CHILD_SESSION_ID,
agent="reviewer",
title="review",
)
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="reviewer",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
)
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=_SnapshotServerClient(child_body), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
resp = await client.post(
f"/v1/sessions/{CHILD_SESSION_ID}/events",
json={
"type": "external_session_status",
"data": {"status": "idle", "output": output},
},
)
inbox = runner_app._session_inboxes_ref.get(PARENT_SESSION_ID)
items: list[dict[str, Any]] = []
if inbox is not None:
while not inbox.empty():
items.append(inbox.get_nowait())
return resp.status_code, items
@pytest.mark.asyncio
async def test_native_completion_recovers_reconnect_wiped_work_entry(
_clean_subagent_registry: None,
) -> None:
"""A declared native sub-agent still delivers after its work entry was lost.
With no in-memory work entry (a reconnect wiped it mid-turn), the idle edge
dropped silently on the old code. The fix rebuilds the entry from the
snapshot's ``parent_session_id`` + ``sub_agent_name`` and delivers.
"""
http, items = await _post_native_idle(
child_body=_child_snapshot(sub_agent_name="reviewer", parent_session_id=PARENT_SESSION_ID),
seed_parent_inbox=True,
register_work=False,
)
assert items, (
"native sub-agent reported idle but nothing was delivered to the parent "
"inbox: the work entry was missing (reconnect-wiped) and the idle edge "
f"was silently 204-acked. (http={http})"
)
payload = items[0]
assert payload["type"] == "sub_agent"
assert payload["conversation_id"] == CHILD_SESSION_ID
assert payload["status"] == "completed"
assert payload["output"] == "review complete: LGTM"
@pytest.mark.asyncio
async def test_sys_session_create_child_without_sub_agent_name_delivers(
_clean_subagent_registry: None,
) -> None:
"""A ``sys_session_create`` child (no ``sub_agent_name``) still wakes the parent.
The child has ``agent_name: cursor-native-ui`` but ``sub_agent_name: null``,
and the dispatch never registered a work entry. The fix recovers the parent
link from the snapshot (keying on ``parent_session_id``) and labels the work
with the agent name.
"""
http, items = await _post_native_idle(
child_body=_child_snapshot(
sub_agent_name=None,
parent_session_id=PARENT_SESSION_ID,
agent_name="cursor-native-ui",
),
seed_parent_inbox=True,
register_work=False,
)
assert items, (
f"sys_session_create child reported idle but the parent inbox stayed empty. (http={http})"
)
assert items[0]["status"] == "completed"
assert items[0]["agent"] == "cursor-native-ui"
@pytest.mark.asyncio
async def test_healthy_registered_work_entry_still_delivers(
_clean_subagent_registry: None,
) -> None:
"""Control: the normal path (work entry present) keeps delivering.
Guards against the fix regressing the common case where dispatch already
registered the work entry on this runner.
"""
http, items = await _post_native_idle(
child_body=_child_snapshot(sub_agent_name="reviewer", parent_session_id=PARENT_SESSION_ID),
seed_parent_inbox=True,
register_work=True,
)
assert http == 204
assert items and items[0]["status"] == "completed"
@pytest.mark.asyncio
async def test_undeliverable_native_completion_returns_503_not_silent_204(
_clean_subagent_registry: None,
) -> None:
"""A recoverable sub-agent whose parent inbox is elsewhere must 503, not 204.
When the parent inbox is not on this runner (the parent lives on a different
runner, or the runner restarted and lost it), delivery cannot be confirmed.
The handler must return 503 so the forwarder retries and server-side recovery
re-routes to the parent's runner — instead of a silent 204 that drops it.
"""
http, items = await _post_native_idle(
child_body=_child_snapshot(sub_agent_name="reviewer", parent_session_id=PARENT_SESSION_ID),
seed_parent_inbox=False,
register_work=False,
)
assert http == 503, (
"an undeliverable native sub-agent completion was acked with "
f"http={http}; expected 503 so the forwarder retries. Items={items!r}"
)
@pytest.mark.asyncio
async def test_replayed_idle_after_drain_does_not_redeliver(
_clean_subagent_registry: None,
) -> None:
"""The recovery must not re-deliver a child already delivered and drained.
Guards the snapshot-recovery arm against a duplicate: once a completion was
delivered and the parent drained it, the runner keeps a delivered tombstone.
A replayed idle whose snapshot *does* carry a ``parent_session_id`` (the
production shape) must NOT rebuild the work entry and re-enqueue — it stays a
benign already-delivered 204. (The existing suite's dedup test uses a stub
snapshot with no parent, so it would not catch a recovery-induced re-deliver.)
"""
child_body = _child_snapshot(sub_agent_name="reviewer", parent_session_id=PARENT_SESSION_ID)
# First completion delivers normally.
http1, items1 = await _post_native_idle(
child_body=child_body, seed_parent_inbox=True, register_work=True
)
assert http1 == 204
assert len(items1) == 1 # drained by the helper
# Mark the child delivered-and-drained, exactly as sys_read_inbox does.
runner_app.unregister_subagent_work(CHILD_SESSION_ID, remember_drained_delivery=True)
assert runner_app.get_subagent_work(CHILD_SESSION_ID) is None
# Replay the idle — snapshot carries a parent, so a naive recovery would
# rebuild the entry and re-deliver. The tombstone guard must prevent that.
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="reviewer",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
)
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=_SnapshotServerClient(child_body), # type: ignore[arg-type]
)
inbox = runner_app._session_inboxes_ref[PARENT_SESSION_ID]
async with _runner_client(app) as client:
replay = await client.post(
f"/v1/sessions/{CHILD_SESSION_ID}/events",
json={"type": "external_session_status", "data": {"status": "idle", "output": "x"}},
)
assert replay.status_code == 204
assert inbox.qsize() == 0, "replayed idle re-delivered a duplicate to the parent inbox"
@pytest.mark.asyncio
async def test_top_level_session_idle_is_noop(
_clean_subagent_registry: None,
) -> None:
"""A top-level session (no parent) idle edge stays a quiet 204 no-op.
Ensures the recovery arm does not mis-classify a non-sub-agent sender as a
sub-agent and start 503-ing or fabricating inbox deliveries.
"""
http, items = await _post_native_idle(
child_body=_child_snapshot(sub_agent_name=None, parent_session_id=None),
seed_parent_inbox=True,
register_work=False,
)
assert http == 204
assert items == []