306 lines
11 KiB
Python
306 lines
11 KiB
Python
"""End-to-end test for parent -> sub-agent file passing (mock LLM).
|
|
|
|
Proves the full #900 path:
|
|
|
|
1. A file is uploaded to a PARENT session (capture its file id + bytes).
|
|
2. The parent agent calls ``sys_session_send`` with that file id in the
|
|
object-args ``file_ids`` list (scripted via the mock LLM tool call).
|
|
3. The runner copies the parent's file into the spawned CHILD session and
|
|
appends an ``input_file`` block (referencing the copied, child-scoped
|
|
id) to the child's first message.
|
|
4. The CHILD session ends up with its OWN file: a DISTINCT new id whose
|
|
bytes byte-for-byte match the original parent upload.
|
|
|
|
This is the integration-level cousin of the U1-U3 unit/route tests
|
|
(``tests/runner/test_file_tool_dispatch.py`` and the server copy-route
|
|
tests cover the edge/error cases); here we drive the whole stack with a
|
|
live server + runner + mock LLM, mirroring ``test_subagent_autowake_e2e``.
|
|
|
|
Excluded from default ``pytest`` runs via ``--ignore=tests/e2e``. Invoke
|
|
with::
|
|
|
|
uv run --extra dev python -m pytest \\
|
|
tests/e2e/test_files_to_subagent_e2e.py -p no:cacheprovider
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
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
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
_TEST_MD_PATH = _REPO_ROOT / "tests" / "resources" / "test.md"
|
|
|
|
# Each flow is several serial mock-LLM turns (parent dispatch, child turn,
|
|
# parent auto-wake), so allow plenty of headroom under signal-based timeout.
|
|
pytestmark = pytest.mark.timeout(600, method="signal")
|
|
|
|
|
|
def _find_child_session_id(
|
|
http_client: httpx.Client,
|
|
*,
|
|
parent_session_id: str,
|
|
child_title: str,
|
|
timeout: float = 180.0,
|
|
) -> str:
|
|
"""Poll the sub-agent session list until the parent's child appears.
|
|
|
|
sys_session_send spawns the child asynchronously, so the child
|
|
conversation (``kind=sub_agent``) shows up after the parent's
|
|
dispatch turn ends. The runner mints the child with a deterministic
|
|
``"{agent}:{title}"`` title, so we match on that, then confirm via
|
|
the single-session snapshot (which DOES carry ``parent_session_id``,
|
|
unlike the list item) that the lineage is correct.
|
|
|
|
:param http_client: HTTP client pointed at the live server.
|
|
:param parent_session_id: The dispatching parent session id.
|
|
:param child_title: The minted child title, ``"{agent}:{title}"``.
|
|
:param timeout: Max seconds to wait for the child to appear.
|
|
:returns: The child (sub-agent) session id.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
resp = http_client.get(
|
|
"/v1/sessions",
|
|
params={"kind": "sub_agent", "limit": 1000},
|
|
)
|
|
resp.raise_for_status()
|
|
for item in resp.json().get("data", []):
|
|
if item.get("title") != child_title:
|
|
continue
|
|
candidate = str(item["id"])
|
|
snap = http_client.get(f"/v1/sessions/{candidate}")
|
|
snap.raise_for_status()
|
|
if snap.json().get("parent_session_id") == parent_session_id:
|
|
return candidate
|
|
time.sleep(POLL_INTERVAL_S)
|
|
# Diagnostic dump: show the sub_agent list and the parent's items so a
|
|
# missing child (failed dispatch vs. mismatched title) is debuggable.
|
|
sub_list = http_client.get("/v1/sessions", params={"kind": "sub_agent", "limit": 1000})
|
|
parent_snap = http_client.get(f"/v1/sessions/{parent_session_id}")
|
|
raise AssertionError(
|
|
f"No sub-agent child session titled {child_title!r} for parent "
|
|
f"{parent_session_id!r} appeared within {timeout:.0f}s.\n"
|
|
f"sub_agent sessions: {sub_list.text[:1500]}\n"
|
|
f"parent items: {parent_snap.text[:2000]}"
|
|
)
|
|
|
|
|
|
def _wait_for_child_file(
|
|
http_client: httpx.Client,
|
|
*,
|
|
child_session_id: str,
|
|
timeout: float = 120.0,
|
|
) -> dict:
|
|
"""Poll the child session's file list until a file appears.
|
|
|
|
:param http_client: HTTP client pointed at the live server.
|
|
:param child_session_id: The spawned child session id.
|
|
:param timeout: Max seconds to wait for the copied file to land.
|
|
:returns: The child file resource dict.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
resp = http_client.get(f"/v1/sessions/{child_session_id}/resources/files")
|
|
resp.raise_for_status()
|
|
data = resp.json().get("data", [])
|
|
if data:
|
|
return data[0]
|
|
time.sleep(POLL_INTERVAL_S)
|
|
raise AssertionError(
|
|
f"No file copied into child session {child_session_id!r} within {timeout:.0f}s."
|
|
)
|
|
|
|
|
|
@pytest.mark.flaky(reruns=2, reruns_delay=5)
|
|
def test_file_passes_from_parent_agent_to_subagent(
|
|
http_client: httpx.Client,
|
|
live_runner_id: str,
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""A file uploaded to the parent reaches the spawned sub-agent.
|
|
|
|
Happy path:
|
|
|
|
- Upload ``test.md`` to the parent session; capture its id + bytes.
|
|
- Drive the parent (mock LLM) to emit ``sys_session_send`` with
|
|
``args={"input": ..., "file_ids": [parent_file_id]}``.
|
|
- Assert the child session has a file whose id != the parent id and
|
|
whose downloaded bytes match the original.
|
|
- Assert the child's first user message carries an ``input_file``
|
|
block referencing the child-scoped file id.
|
|
"""
|
|
assert _TEST_MD_PATH.exists(), f"Test file missing at {_TEST_MD_PATH}. Restore from git."
|
|
original_bytes = _TEST_MD_PATH.read_bytes()
|
|
|
|
uid = uuid.uuid4().hex[:6]
|
|
parent_model = f"mock-file-parent-{uid}"
|
|
child_model = f"mock-file-child-{uid}"
|
|
child_marker = "CHILD_FILE_OK_2026"
|
|
mock_base = f"{mock_llm_server_url}/v1"
|
|
|
|
reset_mock_llm(mock_llm_server_url)
|
|
|
|
parent_name = register_inline_agent(
|
|
http_client,
|
|
name=f"file-parent-{uid}",
|
|
harness="openai-agents",
|
|
model=parent_model,
|
|
profile="",
|
|
prompt=(
|
|
"You are the file-passing E2E parent. Dispatch the analyst "
|
|
"sub-agent via sys_session_send, forwarding the uploaded file."
|
|
),
|
|
mock_llm_base_url=mock_base,
|
|
extra_config={
|
|
"tools": {
|
|
"analyst": {
|
|
"type": "agent",
|
|
"description": "Test-fixture analyst that reads the forwarded file.",
|
|
"executor": {
|
|
"harness": "openai-agents",
|
|
"model": child_model,
|
|
"auth": {
|
|
"type": "api_key",
|
|
"api_key": "mock-key",
|
|
"base_url": mock_base,
|
|
},
|
|
},
|
|
"prompt": (
|
|
"You are the test-fixture analyst. Include "
|
|
f"{child_marker} verbatim in your response."
|
|
),
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
# Create the parent session and upload the file BEFORE dispatch so the
|
|
# parent owns a real file id to forward.
|
|
parent_session_id = create_runner_bound_session(
|
|
http_client,
|
|
agent_name=parent_name,
|
|
runner_id=live_runner_id,
|
|
)
|
|
upload_resp = http_client.post(
|
|
f"/v1/sessions/{parent_session_id}/resources/files",
|
|
files={"file": (_TEST_MD_PATH.name, original_bytes, "text/markdown")},
|
|
)
|
|
upload_resp.raise_for_status()
|
|
parent_file_id = upload_resp.json()["id"]
|
|
|
|
# Parent mock queue:
|
|
# 1. dispatch: sys_session_send forwarding file_ids
|
|
# 2. ack after the tool result
|
|
# 3. auto-wake continuation quoting the child marker
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[
|
|
{
|
|
"tool_calls": [
|
|
{
|
|
"call_id": "call_dispatch",
|
|
"name": "sys_session_send",
|
|
"arguments": json.dumps(
|
|
{
|
|
"agent": "analyst",
|
|
"title": "file-analysis",
|
|
"args": {
|
|
"input": "Analyze the attached markdown file.",
|
|
"file_ids": [parent_file_id],
|
|
},
|
|
}
|
|
),
|
|
},
|
|
],
|
|
},
|
|
{"text": "Dispatched analyst, waiting for result."},
|
|
{"text": f"The analyst returned: {child_marker}"},
|
|
],
|
|
key=parent_model,
|
|
)
|
|
# Child mock queue: one turn that quotes the marker.
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[{"text": f"File analyzed. {child_marker}"}],
|
|
key=child_model,
|
|
)
|
|
|
|
dispatch_response_id = send_user_message_to_session(
|
|
http_client,
|
|
session_id=parent_session_id,
|
|
content="Dispatch the analyst sub-agent with the uploaded file.",
|
|
)
|
|
poll_session_until_terminal(
|
|
http_client,
|
|
session_id=parent_session_id,
|
|
response_id=dispatch_response_id,
|
|
timeout=180,
|
|
)
|
|
|
|
# Discover the spawned child and assert it received a copied file.
|
|
# The runner mints the child title as "{agent}:{title}".
|
|
child_session_id = _find_child_session_id(
|
|
http_client,
|
|
parent_session_id=parent_session_id,
|
|
child_title="analyst:file-analysis",
|
|
)
|
|
child_file = _wait_for_child_file(
|
|
http_client,
|
|
child_session_id=child_session_id,
|
|
)
|
|
child_file_id = child_file["id"]
|
|
|
|
# DISTINCT new id: the child reads its OWN copy, not the parent's row.
|
|
assert child_file_id != parent_file_id, (
|
|
f"Child file id {child_file_id!r} must differ from the parent's "
|
|
f"{parent_file_id!r} — the copy creates a child-scoped row."
|
|
)
|
|
|
|
# MATCHING bytes: download the child's copy and compare to the original.
|
|
content_resp = http_client.get(
|
|
f"/v1/sessions/{child_session_id}/resources/files/{child_file_id}/content"
|
|
)
|
|
content_resp.raise_for_status()
|
|
assert content_resp.content == original_bytes, (
|
|
"Child file bytes do not match the original parent upload — "
|
|
"the copy did not preserve content."
|
|
)
|
|
|
|
# The copied file must NOT be visible under the parent's id in the child,
|
|
# and the parent must still own its original row (no cross-session move).
|
|
parent_file_resp = http_client.get(
|
|
f"/v1/sessions/{parent_session_id}/resources/files/{parent_file_id}"
|
|
)
|
|
parent_file_resp.raise_for_status()
|
|
|
|
# The child's first user message should carry an input_file block
|
|
# referencing the child-scoped (copied) id, proving the file resolves
|
|
# into the child's first turn.
|
|
snap = http_client.get(f"/v1/sessions/{child_session_id}")
|
|
snap.raise_for_status()
|
|
blob = json.dumps(snap.json().get("items", []))
|
|
assert child_file_id in blob, (
|
|
f"Child file id {child_file_id!r} not referenced in the child's "
|
|
f"conversation items — the file block did not reach the first turn."
|
|
)
|
|
assert "input_file" in blob, (
|
|
"No input_file content block in the child's first message — "
|
|
"the forwarded file was not attached as a content block."
|
|
)
|