371 lines
13 KiB
Python
371 lines
13 KiB
Python
"""End-to-end test for sub-agent auto-wake (mock LLM).
|
|
|
|
When a sub-agent finishes, the runner delivers its result to the parent's
|
|
inbox AND posts a ``[System: ... waiting in inbox]`` wake notice to the
|
|
parent's event stream, so an idle orchestrator takes a continuation turn and
|
|
surfaces the result -- without the user sending another message.
|
|
|
|
The wake notice substring ``waiting in inbox`` is produced ONLY by the
|
|
auto-wake path (``_format_subagent_wake_notice``); it is distinct from the
|
|
``sys_read_inbox`` drain message. So its presence is an auto-wake-specific
|
|
signal.
|
|
|
|
All tests use mock-LLM keyed queues. The parent agent is registered via
|
|
``register_inline_agent`` with ``mock_llm_base_url`` so the parent harness
|
|
always calls the mock server. The inline researcher sub-agent spec carries
|
|
``auth.base_url`` pointing at the mock server — now propagated through
|
|
``_agent_tool_to_sub_spec`` via the ``raw_executor`` parameter so child
|
|
sub-agents never reach the real LLM API even under CI's
|
|
``--llm-api-key`` / ``--profile`` mode.
|
|
|
|
Excluded from default ``pytest`` runs via ``--ignore=tests/e2e``. Invoke
|
|
with::
|
|
|
|
pytest tests/e2e/test_subagent_autowake_e2e.py -v --timeout=60
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import uuid
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from tests.e2e.conftest import (
|
|
configure_mock_llm,
|
|
create_runner_bound_session,
|
|
poll_session_until_terminal,
|
|
register_inline_agent,
|
|
reset_mock_llm,
|
|
send_user_message_to_session,
|
|
)
|
|
from tests.e2e.helpers import POLL_INTERVAL_S
|
|
|
|
# The auto-wake notice is the ONLY place this substring is emitted.
|
|
_WAKE_NOTICE_SIGNATURE = "waiting in inbox"
|
|
_RESEARCHER_MARKER = "RESEARCHER_MARKER_2025"
|
|
|
|
# Each test is 3+ serial gateway turns, so 600s absorbs potential backoff.
|
|
# These tests use per-sub-agent mock-LLM routing (each child on its own mock
|
|
# model + auth.base_url), which a server < 0.3.0 does not propagate (fixed in
|
|
# #779, which landed ~2h after v0.2.0 was tagged) — the child reaches the real
|
|
# gateway and fails, so its result never surfaces (see
|
|
# test_named_sub_agent_persistence.py for the verified mechanism). A mock-LLM
|
|
# test-infra gap, not a product regression. The backwards-compat matrix skips
|
|
# these against servers < 0.3.0; they run unchanged on main.
|
|
pytestmark = [
|
|
pytest.mark.timeout(600, method="signal"),
|
|
pytest.mark.min_server_version("0.3.0"),
|
|
]
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def autowake_test_agent(
|
|
http_client: httpx.Client,
|
|
mock_llm_server_url: str,
|
|
) -> tuple[str, str, str]:
|
|
"""Register parent + researcher with mock LLM URLs.
|
|
|
|
Returns ``(parent_name, parent_model, researcher_model)``.
|
|
|
|
``mock_llm_base_url`` bakes the mock server URL directly into each
|
|
executor's ``auth.base_url``. The child researcher's auth propagates
|
|
through ``_agent_tool_to_sub_spec`` (``raw_executor`` fix) so it
|
|
never reaches the real LLM API.
|
|
|
|
:param http_client: HTTP client pointed at the live server.
|
|
:param mock_llm_server_url: Mock LLM server base URL.
|
|
:returns: Tuple ``(parent_name, parent_model, researcher_model)``.
|
|
"""
|
|
uid = uuid.uuid4().hex[:6]
|
|
parent_model = f"mock-aw-parent-{uid}"
|
|
researcher_model = f"mock-aw-researcher-{uid}"
|
|
|
|
# openai-agents harness expects /v1 in the base URL.
|
|
mock_base = f"{mock_llm_server_url}/v1"
|
|
|
|
parent_name = register_inline_agent(
|
|
http_client,
|
|
name=f"aw-parent-{uid}",
|
|
harness="openai-agents",
|
|
model=parent_model,
|
|
profile="",
|
|
prompt=(
|
|
"You are the auto-wake E2E test fixture parent. Dispatch the "
|
|
"researcher sub-agent via sys_session_send and report the "
|
|
"literal marker string it returns."
|
|
),
|
|
mock_llm_base_url=mock_base,
|
|
extra_config={
|
|
"tools": {
|
|
"researcher": {
|
|
"type": "agent",
|
|
"description": "Test-fixture researcher. Returns RESEARCHER_MARKER_2025.",
|
|
"executor": {
|
|
"harness": "openai-agents",
|
|
"model": researcher_model,
|
|
"auth": {
|
|
"type": "api_key",
|
|
"api_key": "mock-key",
|
|
"base_url": mock_base,
|
|
},
|
|
},
|
|
"prompt": (
|
|
"You are the test-fixture researcher. Include "
|
|
"RESEARCHER_MARKER_2025 verbatim in your response."
|
|
),
|
|
},
|
|
},
|
|
},
|
|
)
|
|
return parent_name, parent_model, researcher_model
|
|
|
|
|
|
# ─── Mock helpers ────────────────────────────────────────────
|
|
|
|
|
|
def _sys_session_send_tool_call(
|
|
agent: str,
|
|
title: str,
|
|
child_args: str,
|
|
*,
|
|
call_id: str = "call_1",
|
|
) -> dict:
|
|
"""Build a tool_calls response entry for ``sys_session_send``."""
|
|
return {
|
|
"call_id": call_id,
|
|
"name": "sys_session_send",
|
|
"arguments": json.dumps({"agent": agent, "title": title, "args": child_args}),
|
|
}
|
|
|
|
|
|
def _session_items_blob(http_client: httpx.Client, session_id: str) -> str:
|
|
"""Return all items in a session snapshot as one JSON string."""
|
|
resp = http_client.get(f"/v1/sessions/{session_id}")
|
|
resp.raise_for_status()
|
|
return json.dumps(resp.json().get("items", []))
|
|
|
|
|
|
def _count_wake_notices(http_client: httpx.Client, session_id: str) -> int:
|
|
"""Count auto-wake notices in a session snapshot."""
|
|
return _session_items_blob(http_client, session_id).count(_WAKE_NOTICE_SIGNATURE)
|
|
|
|
|
|
def _configure_dispatch_flow(
|
|
mock_url: str,
|
|
*,
|
|
parent_model: str,
|
|
researcher_model: str,
|
|
title: str = "auth",
|
|
child_args: str = "Research auth patterns",
|
|
) -> None:
|
|
"""Configure mock queues for a single dispatch-and-autowake flow.
|
|
|
|
Parent queue (keyed by parent_model):
|
|
1. dispatch: sys_session_send tool call.
|
|
2. after tool result: text acknowledging dispatch.
|
|
3. auto-wake continuation: text quoting the marker.
|
|
|
|
Researcher queue (keyed by researcher_model):
|
|
1. child turn: text containing the marker.
|
|
"""
|
|
configure_mock_llm(
|
|
mock_url,
|
|
[
|
|
{
|
|
"tool_calls": [
|
|
_sys_session_send_tool_call("researcher", title, child_args),
|
|
],
|
|
},
|
|
{"text": "Dispatched researcher, waiting for result."},
|
|
# Parent auto-wake continuation.
|
|
{"text": f"The researcher returned: {_RESEARCHER_MARKER}"},
|
|
],
|
|
key=parent_model,
|
|
)
|
|
configure_mock_llm(
|
|
mock_url,
|
|
[{"text": f"Research complete. {_RESEARCHER_MARKER}"}],
|
|
key=researcher_model,
|
|
)
|
|
|
|
|
|
# ─── Tests ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_subagent_completion_auto_wakes_idle_parent(
|
|
http_client: httpx.Client,
|
|
live_runner_id: str,
|
|
autowake_test_agent: tuple[str, str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""Dispatching a sub-agent then sending nothing else still surfaces its
|
|
result, because the parent is auto-woken when the sub-agent completes.
|
|
|
|
Flow:
|
|
1. One user message tells the parent to dispatch the researcher.
|
|
2. The dispatch turn goes terminal (sub-agent runs async).
|
|
3. With NO further user input, the sub-agent completes, the runner
|
|
posts the wake notice, and the parent takes a continuation turn.
|
|
"""
|
|
parent_name, parent_model, researcher_model = autowake_test_agent
|
|
reset_mock_llm(mock_llm_server_url)
|
|
_configure_dispatch_flow(
|
|
mock_llm_server_url,
|
|
parent_model=parent_model,
|
|
researcher_model=researcher_model,
|
|
)
|
|
|
|
session_id = create_runner_bound_session(
|
|
http_client,
|
|
agent_name=parent_name,
|
|
runner_id=live_runner_id,
|
|
)
|
|
dispatch_response_id = send_user_message_to_session(
|
|
http_client,
|
|
session_id=session_id,
|
|
content="Dispatch the researcher sub-agent.",
|
|
)
|
|
|
|
# Dispatch turn goes terminal on its own.
|
|
poll_session_until_terminal(
|
|
http_client,
|
|
session_id=session_id,
|
|
response_id=dispatch_response_id,
|
|
timeout=180,
|
|
)
|
|
|
|
# From here we send NOTHING. The wake notice and marker can only
|
|
# appear via the auto-wake continuation turn.
|
|
deadline = time.monotonic() + 240
|
|
wake_seen = False
|
|
marker_seen = False
|
|
while time.monotonic() < deadline:
|
|
blob = _session_items_blob(http_client, session_id)
|
|
wake_seen = wake_seen or _WAKE_NOTICE_SIGNATURE in blob
|
|
marker_seen = _RESEARCHER_MARKER in blob
|
|
if wake_seen and marker_seen:
|
|
break
|
|
time.sleep(POLL_INTERVAL_S)
|
|
|
|
assert wake_seen, (
|
|
f"No auto-wake notice ({_WAKE_NOTICE_SIGNATURE!r}) appeared in session "
|
|
f"{session_id} after the dispatch turn ended."
|
|
)
|
|
assert marker_seen, (
|
|
f"Researcher marker {_RESEARCHER_MARKER!r} never surfaced in session {session_id}."
|
|
)
|
|
|
|
|
|
def test_subagent_completion_auto_wakes_parent_on_a_second_round(
|
|
http_client: httpx.Client,
|
|
live_runner_id: str,
|
|
autowake_test_agent: tuple[str, str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""Re-dispatching the SAME sub-agent in a second round wakes the parent again.
|
|
|
|
Coarse CUJ for the multi-round auto-wake path: round 1 dispatches and
|
|
the parent is auto-woken; round 2 re-dispatches and the parent must be
|
|
auto-woken AGAIN, asserted by the wake-notice count strictly increasing.
|
|
"""
|
|
parent_name, parent_model, researcher_model = autowake_test_agent
|
|
reset_mock_llm(mock_llm_server_url)
|
|
|
|
# Queue responses for round 1 AND round 2 on the parent model queue.
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[
|
|
# Round 1: dispatch
|
|
{
|
|
"tool_calls": [
|
|
_sys_session_send_tool_call("researcher", "round1", "Research round 1"),
|
|
],
|
|
},
|
|
{"text": "Dispatched, waiting."},
|
|
# Round 1: parent auto-wake continuation
|
|
{"text": f"Round 1 result: {_RESEARCHER_MARKER}"},
|
|
# Round 2: dispatch
|
|
{
|
|
"tool_calls": [
|
|
_sys_session_send_tool_call("researcher", "round2", "Research round 2"),
|
|
],
|
|
},
|
|
{"text": "Re-dispatched, waiting."},
|
|
# Round 2: parent auto-wake continuation
|
|
{"text": f"Round 2 result: {_RESEARCHER_MARKER}"},
|
|
],
|
|
key=parent_model,
|
|
)
|
|
# Child responses for round 1 and 2 on the researcher model queue.
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[
|
|
{"text": f"Round 1 done. {_RESEARCHER_MARKER}"},
|
|
{"text": f"Round 2 done. {_RESEARCHER_MARKER}"},
|
|
],
|
|
key=researcher_model,
|
|
)
|
|
|
|
session_id = create_runner_bound_session(
|
|
http_client,
|
|
agent_name=parent_name,
|
|
runner_id=live_runner_id,
|
|
)
|
|
|
|
# ── Round 1 ──
|
|
round1_response_id = send_user_message_to_session(
|
|
http_client,
|
|
session_id=session_id,
|
|
content="Dispatch the researcher sub-agent.",
|
|
)
|
|
poll_session_until_terminal(
|
|
http_client,
|
|
session_id=session_id,
|
|
response_id=round1_response_id,
|
|
timeout=180,
|
|
)
|
|
|
|
deadline = time.monotonic() + 240
|
|
round1_wakes = 0
|
|
marker_seen = False
|
|
while time.monotonic() < deadline:
|
|
round1_wakes = _count_wake_notices(http_client, session_id)
|
|
marker_seen = _RESEARCHER_MARKER in _session_items_blob(http_client, session_id)
|
|
if round1_wakes >= 1 and marker_seen:
|
|
break
|
|
time.sleep(POLL_INTERVAL_S)
|
|
|
|
assert round1_wakes >= 1 and marker_seen, (
|
|
f"Round 1 did not auto-wake the parent in session {session_id} "
|
|
f"(wakes={round1_wakes}, marker_seen={marker_seen})."
|
|
)
|
|
|
|
# ── Round 2: re-dispatch ──
|
|
round2_response_id = send_user_message_to_session(
|
|
http_client,
|
|
session_id=session_id,
|
|
content="Dispatch the researcher sub-agent again.",
|
|
)
|
|
poll_session_until_terminal(
|
|
http_client,
|
|
session_id=session_id,
|
|
response_id=round2_response_id,
|
|
timeout=180,
|
|
)
|
|
|
|
deadline = time.monotonic() + 240
|
|
round2_wakes = round1_wakes
|
|
while time.monotonic() < deadline:
|
|
round2_wakes = _count_wake_notices(http_client, session_id)
|
|
if round2_wakes > round1_wakes:
|
|
break
|
|
time.sleep(POLL_INTERVAL_S)
|
|
|
|
assert round2_wakes > round1_wakes, (
|
|
f"No NEW auto-wake notice for round 2 in session {session_id} "
|
|
f"(round1_wakes={round1_wakes}, round2_wakes={round2_wakes})."
|
|
)
|