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

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."
)