Files
2026-07-13 13:12:00 +08:00

7194 lines
279 KiB
Python

"""End-to-end runner-dispatch tests: server → runner → spawned harness.
The load-bearing assertion: the runner FastAPI app, when given a
real :class:`HarnessProcessManager`, accepts a
POST /v1/sessions/{conversation_id}/events?stream=true,
spawns a harness subprocess (using the existing
``omnigent/runtime/harnesses/`` machinery — NOT a parallel impl),
forwards the request to the harness via UDS, and streams the
harness's SSE response back through the runner's own SSE response.
Architecture verified end-to-end:
- A real ``HarnessProcessManager`` is started (writes its instance
dir, runs orphan sweep, starts the idle reaper).
- A test-only harness module is registered in ``_HARNESS_MODULES``.
- The runner FastAPI app is built with ``process_manager=mgr``.
- The test posts to the runner's
/v1/sessions/{conversation_id}/events?stream=true with a message
body + harness name.
- The runner calls ``mgr.get_client()`` → spawns a uvicorn
subprocess running the test harness on a UDS → returns the
per-conversation httpx client.
- The runner POSTs the request to the harness via that client.
- The harness drives an LLM call via ``run_turn`` and streams SSE
back.
- The runner relays each SSE chunk through to the test client.
The OpenAI-key-gated test runs the full chain against gpt-4o-mini.
The unkeyed test asserts on plumbing only (handler is reached,
503 on bad harness name).
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import tempfile
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace, TracebackType
from typing import Any, cast
import httpx
import pytest
from fastapi import FastAPI
from omnigent.runner import create_runner_app
from omnigent.runner.app import (
_build_spawn_env_from_spec,
_forward_harness_response,
_resolve_harness_config,
)
from omnigent.runtime.harnesses import _HARNESS_MODULES
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
from omnigent.session_lifecycle import CLOSED_LABEL_KEY, CLOSED_LABEL_VALUE
from omnigent.spec.types import AgentSpec, ExecutorSpec, SharePolicy
from tests.runner.helpers import NullServerClient
_TEST_HARNESS_NAME = "runner-test-default"
_TEST_HARNESS_MODULE = "tests._fixtures.runner_test_harness"
@pytest.fixture(autouse=True)
def _assume_harness_clis_installed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Neutralize the sub-agent dispatch CLI preflight for hermetic tests.
The named-mode child-create path refuses to spawn a sub-agent whose
harness CLI (``claude`` / ``codex`` / ``pi``) is absent from ``PATH``
(see ``missing_harness_cli``, dispatched from ``tool_dispatch``). These
dispatch tests run in a hermetic environment where those binaries may be
absent (e.g. CI), so without this stub they would fail at the preflight
instead of exercising the create / continue logic under test. Tests that
specifically assert the preflight re-patch ``missing_harness_cli`` in
their own body, which wins over this autouse default.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setattr(
"omnigent.onboarding.harness_install.missing_harness_cli",
lambda harness: None,
)
@asynccontextmanager
async def _runner_test_client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
"""Create a test client against a runner ASGI app.
:param app: Runner app under test.
:returns: Async context manager yielding an ``httpx.AsyncClient``.
"""
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://runner") as client:
yield client
class _FakeHarnessStream:
"""
Async context manager that yields scripted harness SSE chunks.
:param chunks: SSE chunks returned by ``aiter_text``.
:param status_code: HTTP status exposed to the runner.
"""
def __init__(self, chunks: list[str], status_code: int = 200) -> None:
"""
Store scripted stream state.
:param chunks: SSE chunks returned by ``aiter_text``.
:param status_code: HTTP status exposed to the runner.
"""
self._chunks = chunks
self.status_code = status_code
async def __aenter__(self) -> _FakeHarnessStream:
"""
Enter the fake stream context.
:returns: This fake stream.
"""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
"""
Exit the fake stream without suppressing exceptions.
:param exc_type: Exception type from the context, if any.
:param exc: Exception value from the context, if any.
:param tb: Traceback from the context, if any.
:returns: None.
"""
del exc_type, exc, tb
async def aiter_text(self) -> AsyncIterator[str]:
"""
Yield scripted text chunks.
:returns: Async iterator of SSE chunks.
"""
for chunk in self._chunks:
yield chunk
async def _await_bg_turn_task(conv: str, *, timeout: float = 10.0) -> None:
"""Await the fire-and-forget background turn task for *conv* before draining.
The ``POST /events`` background path returns 202 before its turn task
(named ``turn-{conv}``) finishes publishing the terminal ``session.status``.
Awaiting that task by name removes the race where a status-queue drain's
timeout expires under heavy CI load before the task completes. A task that
already finished is absent from ``asyncio.all_tasks()`` (it published its
terminal status synchronously on the way out), so a ``None`` lookup is a
safe no-op.
:param conv: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param timeout: Hard cap in seconds for awaiting the task.
"""
turn_task = next(
(t for t in asyncio.all_tasks() if t.get_name() == f"turn-{conv}"),
None,
)
if turn_task is not None:
await asyncio.wait_for(turn_task, timeout=timeout)
async def _drain_published_statuses(
conv: str,
*,
until: str,
timeout: float,
) -> list[str]:
"""Collect ``session.status`` values a runner published for a session.
Reads the runner's module-level per-session event queue
(``omnigent.runner.app._session_event_queues_ref``) — the same queue
the SSE ``/stream`` endpoint drains — and returns the ordered list of
``session.status`` values seen, stopping once *until* is published. This
polls the in-process queue rather than a concurrent SSE ``GET`` because
``httpx.ASGITransport`` does not interleave a streaming response with a
concurrent ``POST`` on the same client, so a live SSE subscriber would
never observe the background turn's events.
:param conv: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param until: Stop once this ``session.status`` value is observed,
e.g. ``"failed"``.
:param timeout: Hard cap in seconds — if *until* never arrives the poll
gives up and returns what it saw, so a hang regression fails the
assertion instead of spinning forever.
:returns: Ordered ``session.status`` values published for *conv*.
"""
from omnigent.runner.app import _session_event_queues_ref
statuses: list[str] = []
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
queue = _session_event_queues_ref.get(conv)
drained = False
while queue is not None and not queue.empty():
event = queue.get_nowait()
drained = True
if isinstance(event, dict) and event.get("type") == "session.status":
status = event.get("status")
if isinstance(status, str):
statuses.append(status)
if until in statuses:
return statuses
if not drained:
# Let the background turn task make progress before re-polling.
await asyncio.sleep(0.02)
return statuses
async def _drain_failed_status_event(
conv: str,
*,
timeout: float,
) -> dict[str, Any] | None:
"""Return the first ``session.status: failed`` event a runner published.
Mirrors :func:`_drain_published_statuses` but returns the full event
dict (not just the status string) so a test can assert the carried
``error`` payload. Used to prove a SETUP-phase failure forwards its
error message on the terminal ``failed`` event instead of dropping it.
:param conv: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param timeout: Hard cap in seconds; returns ``None`` if no failed
event arrives so a regression fails the assertion rather than
hanging.
:returns: The ``session.status: failed`` event dict, or ``None``.
"""
from omnigent.runner.app import _session_event_queues_ref
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
queue = _session_event_queues_ref.get(conv)
drained = False
while queue is not None and not queue.empty():
event = queue.get_nowait()
drained = True
if (
isinstance(event, dict)
and event.get("type") == "session.status"
and event.get("status") == "failed"
):
return event
if not drained:
await asyncio.sleep(0.02)
return None
class _FakeHarnessClient:
"""
Harness client stub exposing ``stream`` for runner proxy tests.
:param chunks: SSE chunks returned by the fake stream.
"""
def __init__(self, chunks: list[str]) -> None:
"""
Store scripted stream chunks.
:param chunks: SSE chunks returned by the fake stream.
"""
self._chunks = chunks
def stream(
self,
method: str,
url: str,
*,
json: dict[str, object],
timeout: float | None,
) -> _FakeHarnessStream:
"""
Return a fake streaming response.
:param method: HTTP method, e.g. ``"POST"``.
:param url: Harness endpoint path.
:param json: JSON body sent to the harness.
:param timeout: Request timeout.
:returns: Fake stream context manager.
"""
del method, url, json, timeout
return _FakeHarnessStream(self._chunks)
class _FakeProcessManager:
"""
Process manager stub for runner dispatch tests.
:param harness_client: Optional harness client to return.
"""
def __init__(self, harness_client: _FakeHarnessClient | None = None) -> None:
"""
Store the optional harness client.
:param harness_client: Optional harness client to return.
"""
self._harness_client = harness_client
async def get_client(
self,
conversation_id: str,
harness_name: str,
*,
env: dict[str, str] | None = None,
) -> _FakeHarnessClient:
"""
Return the configured fake harness client.
:param conversation_id: Omnigent conversation id.
:param harness_name: Harness name requested by the runner.
:param env: Optional spawn environment.
:returns: Configured fake harness client.
:raises AssertionError: If no fake client was configured.
"""
del conversation_id, harness_name, env
if self._harness_client is None:
raise AssertionError("get_client should not be called")
return self._harness_client
def mark_in_flight(self, conversation_id: str, response_id: str) -> None:
"""Reaper in-flight marker — no-op for this stub (issue #1414)."""
del conversation_id, response_id
def clear_in_flight(self, conversation_id: str) -> None:
"""Reaper in-flight clear — no-op for this stub (issue #1414)."""
del conversation_id
@pytest.fixture
async def started_manager() -> AsyncIterator[HarnessProcessManager]:
"""A real, started HarnessProcessManager with the test harness registered.
Uses a short ``/tmp/oa-rtest`` parent rather than pytest's
``tmp_path`` because UDS paths on Linux are capped at 108 chars
and the manager's per-conversation socket layout
(``<parent>/ap-<uuid32>/<conv_id>.sock``) blows past that when
nested under pytest's already-long ``/tmp/pytest-of-.../...``
tree.
Yields the started manager; on teardown, shuts it down so any
spawned subprocesses are reaped before the test ends.
"""
import shutil
import uuid
short_parent = Path(f"/tmp/oa-rtest-{uuid.uuid4().hex[:8]}")
short_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
# Inject the test-only harness module into the registry. We
# mutate the dict directly per the registry's documented test-
# injection pattern; restore on teardown to avoid leaking into
# other tests.
_HARNESS_MODULES[_TEST_HARNESS_NAME] = _TEST_HARNESS_MODULE
mgr = HarnessProcessManager(tmp_parent=short_parent)
await mgr.start()
try:
yield mgr
finally:
await mgr.shutdown()
_HARNESS_MODULES.pop(_TEST_HARNESS_NAME, None)
shutil.rmtree(short_parent, ignore_errors=True)
# ── Plumbing tests (no LLM key required) ─────────────────
def test_forward_harness_response_preserves_no_body_responses() -> None:
"""204/304 harness side-channel responses must not serialize JSON null.
Returning ``JSONResponse(status_code=204, content=None)`` writes ``b"null"``
even though Uvicorn/HTTP semantics require an empty body for 204. That
manifests under uvicorn as ``Response content longer than Content-Length``.
"""
response = _forward_harness_response(httpx.Response(204, content=b""))
assert response.status_code == 204
assert response.body == b""
assert b"content-length" not in dict(response.raw_headers)
def test_forward_harness_response_preserves_json_body() -> None:
response = _forward_harness_response(httpx.Response(404, json={"error": "not_found"}))
assert response.status_code == 404
assert response.body == b'{"error":"not_found"}'
assert dict(response.raw_headers)[b"content-length"] == b"21"
@pytest.mark.asyncio
async def test_runner_post_without_manager_returns_501() -> None:
"""Scaffold-mode preserved when no manager is wired up."""
app = create_runner_app(server_client=NullServerClient()) # type: ignore[arg-type] # no process_manager → scaffold
async with _runner_test_client(app) as http:
response = await http.post(
"/v1/sessions/conv_x/events?stream=true",
json={
"type": "message",
"role": "user",
"harness": _TEST_HARNESS_NAME,
"model": "fake/model",
"content": [],
},
)
assert response.status_code == 501
assert "HarnessProcessManager" in response.json()["detail"]
@pytest.mark.asyncio
async def test_runner_resolves_harness_from_fallback_when_no_agent_id(
started_manager: HarnessProcessManager,
) -> None:
"""Without agent_id or server_base_url, runner falls back to the
test-default harness. Verifies the fallback path doesn't crash."""
app = create_runner_app(
process_manager=started_manager,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_test_client(app) as http:
# No agent_id → runner falls back to "runner-test-default"
# harness. With that registered in _HARNESS_MODULES, the
# runner must spawn the harness and return its SSE stream.
# Missing LLM credentials are represented inside that stream
# as ``response.failed``, not as a runner spawn failure.
response = await http.post(
"/v1/sessions/c_fallback/events?stream=true",
json={
"type": "message",
"role": "user",
"model": "x",
"content": [{"role": "user", "content": "test"}],
},
)
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
assert "event: response.created" in response.text
class _RecordingProcessManager:
"""
Process manager stub that records the harness name get_client saw.
Unlike :class:`_FakeProcessManager`, this captures the resolved
harness name so a test can assert which harness the runner chose,
and signals an event when the background turn reaches dispatch.
:param captured: Dict the recorded harness name is written into
under the ``"harness"`` key.
:param reached: Event set once ``get_client`` is called.
"""
def __init__(self, captured: dict[str, str], reached: asyncio.Event) -> None:
"""
Store the capture sink and the reached-dispatch event.
:param captured: Dict the harness name is written into.
:param reached: Event set once ``get_client`` is called.
"""
self._captured = captured
self._reached = reached
async def get_client(
self,
conversation_id: str,
harness_name: str,
*,
env: dict[str, str] | None = None,
) -> _FakeHarnessClient:
"""
Record the harness name and return an empty fake harness client.
:param conversation_id: Omnigent conversation id.
:param harness_name: Harness name the runner resolved — the
value under test.
:param env: Optional spawn environment (ignored).
:returns: A fake harness client with an empty SSE stream so the
background turn completes immediately.
"""
del conversation_id, env
self._captured["harness"] = harness_name
self._reached.set()
return _FakeHarnessClient([])
def mark_in_flight(self, conversation_id: str, response_id: str) -> None:
"""Reaper in-flight marker — no-op for this stub (issue #1414)."""
del conversation_id, response_id
def clear_in_flight(self, conversation_id: str) -> None:
"""Reaper in-flight clear — no-op for this stub (issue #1414)."""
del conversation_id
@pytest.mark.asyncio
async def test_runner_resolves_agent_from_server_snapshot_when_msg_lacks_agent_id() -> None:
"""A turn-triggering message that races ahead of session assignment
arrives with no ``agent_id`` and an empty spec cache. The runner must
resolve the agent from the authoritative server snapshot
(``GET /v1/sessions/{id}``) rather than falling through to the
test-only ``runner-test-default`` harness, which would silently drop
the turn (the first-message race).
"""
conv = "conv_ondemand_race"
resolved_agent_id = "ag_resolved_from_snapshot"
resolved_harness = "runner-test-resolved"
def _server_handler(request: httpx.Request) -> httpx.Response:
"""
Stub Omnigent server: the session snapshot carries the agent_id.
:param request: Outbound request from the runner.
:returns: Snapshot with ``agent_id`` for the session GET; benign
payloads otherwise so the background turn can proceed.
"""
if request.method == "GET" and request.url.path == f"/v1/sessions/{conv}":
return httpx.Response(200, json={"id": conv, "agent_id": resolved_agent_id})
if request.url.path.endswith("/items"):
return httpx.Response(
200,
json={
"object": "list",
"data": [],
"first_id": None,
"last_id": None,
"has_more": False,
},
)
return httpx.Response(200, json={})
server_client = httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
)
async def _snapshot_spec_resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""
Resolve the spec for the agent_id read from the snapshot.
:param agent_id: Agent id the runner resolved. MUST equal the
snapshot's agent_id — the message body carried none, so any
other value means the on-demand snapshot path didn't run.
:param session_id: Session id (unused).
:returns: A minimal spec whose harness is ``resolved_harness``.
"""
# The agent_id can only come from the server snapshot here — the
# POST body below omits it. If this fires with a different value
# (or not at all), the on-demand resolution path is broken.
assert agent_id == resolved_agent_id
return AgentSpec(
spec_version=1,
name="ondemand-agent",
executor=ExecutorSpec(type="omnigent", config={"harness": resolved_harness}),
)
captured: dict[str, str] = {}
reached_dispatch = asyncio.Event()
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_RecordingProcessManager(captured, reached_dispatch),
),
spec_resolver=_snapshot_spec_resolver,
server_client=server_client,
)
try:
async with _runner_test_client(app) as http:
response = await http.post(
# No ``?stream=true`` → background turn, the production
# path the Omnigent server uses to forward session messages.
f"/v1/sessions/{conv}/events",
json={
"type": "message",
"role": "user",
"model": "x",
"content": [{"role": "user", "content": "hi"}],
# No agent_id — this is the race condition under test.
},
)
# Background turn accepted; dispatch happens asynchronously.
assert response.status_code == 202
await asyncio.wait_for(reached_dispatch.wait(), timeout=10.0)
finally:
await server_client.aclose()
# The harness came from the snapshot-resolved spec, proving the
# runner fetched agent_id from the server when the message lacked it.
# Without the fix this is "runner-test-default" (the fallback) and the
# real turn never dispatches.
assert captured["harness"] == resolved_harness
class _ContentCapturingProcessManager:
"""
Process manager stub that captures the body sent to the harness.
Returns a harness client whose ``stream`` records the JSON body
(which carries the turn's ``content`` history) into a shared sink
and yields an empty SSE stream so the background turn completes
immediately.
:param captured: Dict the harness request body is written into
under the ``"body"`` key.
:param reached: Event set once the harness stream is opened.
"""
def __init__(self, captured: dict[str, Any], reached: asyncio.Event) -> None:
"""
Store the capture sink and the reached-dispatch event.
:param captured: Dict the harness request body is written into.
:param reached: Event set once the harness stream is opened.
"""
self._captured = captured
self._reached = reached
async def get_client(
self,
conversation_id: str,
harness_name: str,
*,
env: dict[str, str] | None = None,
) -> _ContentCapturingHarnessClient:
"""
Return a harness client that records the body it is sent.
:param conversation_id: Omnigent conversation id (unused).
:param harness_name: Harness name the runner resolved (unused).
:param env: Optional spawn environment (unused).
:returns: A capturing harness client.
"""
del conversation_id, harness_name, env
return _ContentCapturingHarnessClient(self._captured, self._reached)
def mark_in_flight(self, conversation_id: str, response_id: str) -> None:
"""Reaper in-flight marker — no-op for this stub (issue #1414)."""
del conversation_id, response_id
def clear_in_flight(self, conversation_id: str) -> None:
"""Reaper in-flight clear — no-op for this stub (issue #1414)."""
del conversation_id
class _ContentCapturingHarnessClient:
"""
Harness client stub that records the JSON body of each stream.
:param captured: Dict the request body is written into under
``"body"``.
:param reached: Event set once ``stream`` is invoked.
"""
def __init__(self, captured: dict[str, Any], reached: asyncio.Event) -> None:
"""
Store the capture sink and reached event.
:param captured: Dict the request body is written into.
:param reached: Event set once ``stream`` is invoked.
"""
self._captured = captured
self._reached = reached
def stream(
self,
method: str,
url: str,
*,
json: dict[str, Any],
timeout: float | None,
) -> _FakeHarnessStream:
"""
Record the body and return an empty SSE stream.
:param method: HTTP method (unused).
:param url: Harness endpoint path (unused).
:param json: JSON body sent to the harness — captured here.
:param timeout: Request timeout (unused).
:returns: An empty fake stream so the turn completes at once.
"""
del method, url, timeout
self._captured["body"] = json
self._reached.set()
return _FakeHarnessStream([])
@pytest.mark.asyncio
async def test_runner_reloads_full_history_on_cold_cache_after_restart() -> None:
"""A message to a cold session reloads prior history, not just itself.
Regression for an agent (e.g. nessie) losing all chat context after a
server/runner restart. On restart the runner's in-memory
``_session_histories`` cache is empty; the old code seeded it with ONLY
the incoming message (``setdefault(conv, []).append(...)``), so the
harness ran the turn with no prior context. This is acute for the
claude-sdk harness, which on a cold SDK session replays the in-memory
history verbatim as the prompt — a one-message cache erased the whole
conversation.
The fix rehydrates the full history from the store on the first touch of
a conversation. The stub server models invariant I1 (persist-before-
forward): its ``GET /items`` returns the prior turns AND the just-posted
message (``item_3``), and the forwarded body carries
``persisted_item_id="item_3"`` — so the reload drops that exact item by
id and appends the runner's copy, proving no duplication.
"""
from omnigent.runner import app as runner_app
conv = "conv_restart_history_reload"
prior_user = "what is the capital of France?"
prior_assistant = "Paris."
new_user = "and of Germany?"
def _server_handler(request: httpx.Request) -> httpx.Response:
"""
Stub Omnigent server: snapshot + full persisted history on ``/items``.
:param request: Outbound request from the runner.
:returns: Snapshot for the session GET; the persisted history
(prior turns + the new message, per invariant I1) for
``/items``; benign payloads otherwise.
"""
if request.method == "GET" and request.url.path == f"/v1/sessions/{conv}":
return httpx.Response(200, json={"id": conv, "agent_id": "ag_restart"})
if request.url.path.endswith("/items"):
return httpx.Response(
200,
json={
"object": "list",
"data": [
{
"id": "item_1",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": prior_user}],
},
{
"id": "item_2",
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": prior_assistant}],
},
# Persist-before-forward (I1): the new message is
# already in the store when the runner reloads.
{
"id": "item_3",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": new_user}],
},
],
"first_id": "item_1",
"last_id": "item_3",
"has_more": False,
},
)
return httpx.Response(200, json={})
server_client = httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
)
async def _spec_resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""
Resolve a minimal spec for the restarted session.
:param agent_id: Agent id resolved from the snapshot (unused).
:param session_id: Session id (unused).
:returns: A minimal spec on a benign test harness.
"""
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="restart-agent",
executor=ExecutorSpec(
type="omnigent",
config={"harness": "runner-test-resolved"},
),
)
captured: dict[str, Any] = {}
reached = asyncio.Event()
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_ContentCapturingProcessManager(captured, reached),
),
spec_resolver=_spec_resolver,
server_client=server_client,
)
# Simulate a fresh runner process: no cached history for this conv.
runner_app._session_histories_ref.pop(conv, None)
try:
async with _runner_test_client(app) as http:
response = await http.post(
# No ``?stream=true`` → background turn, the production path
# the Omnigent server uses to forward session messages.
f"/v1/sessions/{conv}/events",
json={
"type": "message",
"role": "user",
"model": "x",
# The store id the Omnigent server persisted for this turn
# (matches ``item_3`` from the stub ``/items``), so the
# cold-cache reload drops that exact item and the dedup
# fires (no duplicate).
"persisted_item_id": "item_3",
"content": [{"type": "input_text", "text": new_user}],
},
)
assert response.status_code == 202
await asyncio.wait_for(reached.wait(), timeout=10.0)
finally:
await server_client.aclose()
runner_app._session_histories_ref.pop(conv, None)
content = captured["body"]["content"]
texts = [
block.get("text")
for item in content
if isinstance(item, dict)
for block in item.get("content", [])
if isinstance(block, dict)
]
# Prior context survived the restart...
assert prior_user in texts, f"prior user turn missing from reloaded history: {texts}"
assert prior_assistant in texts, f"prior assistant turn missing: {texts}"
# ...and the new message is present exactly once (reload didn't dup it).
assert texts.count(new_user) == 1, f"new message not delivered exactly once: {texts}"
# The full 3-item history reached the harness, not just the new message.
assert len(content) == 3, f"expected full history, got {len(content)} items: {content}"
@pytest.mark.asyncio
async def test_runner_cold_cache_appends_message_when_store_lacks_it() -> None:
"""A cold-cache message NOT yet in the store is appended, not dropped.
Not every forward is persist-before-forward (invariant I1): native-
terminal web injections (claude-native/codex-native) are forwarded
WITHOUT persisting first, so a fresh ``GET /items`` returns the prior
turns but NOT the just-posted message. If the cold-cache reload simply
overwrote ``_session_histories`` with that load, the new input would be
dropped — and the native executor, which types only the LATEST user
message into its pane, would inject stale text.
This drives the cold path with a stub server whose history reload
excludes the new message and asserts the harness still receives it,
appended as the latest turn, with prior context preserved.
"""
from omnigent.runner import app as runner_app
conv = "conv_cold_cache_append"
prior_user = "first question"
prior_assistant = "first answer"
new_user = "second question not yet persisted"
def _server_handler(request: httpx.Request) -> httpx.Response:
"""
Stub Omnigent server: history reload that does NOT include the new message.
:param request: Outbound request from the runner.
:returns: Snapshot for the session GET; prior turns only (no
new message, modeling a forward-without-persist) for
``/items``; benign payloads otherwise.
"""
if request.method == "GET" and request.url.path == f"/v1/sessions/{conv}":
return httpx.Response(200, json={"id": conv, "agent_id": "ag_cold"})
if request.url.path.endswith("/items"):
return httpx.Response(
200,
json={
"object": "list",
"data": [
{
"id": "item_1",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": prior_user}],
},
{
"id": "item_2",
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": prior_assistant}],
},
# No item for ``new_user`` — the forward did not
# persist it before reaching the runner.
],
"first_id": "item_1",
"last_id": "item_2",
"has_more": False,
},
)
return httpx.Response(200, json={})
server_client = httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
)
async def _spec_resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""
Resolve a minimal spec for the session.
:param agent_id: Agent id resolved from the snapshot (unused).
:param session_id: Session id (unused).
:returns: A minimal spec on a benign test harness.
"""
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="cold-cache-agent",
executor=ExecutorSpec(
type="omnigent",
config={"harness": "runner-test-resolved"},
),
)
captured: dict[str, Any] = {}
reached = asyncio.Event()
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_ContentCapturingProcessManager(captured, reached),
),
spec_resolver=_spec_resolver,
server_client=server_client,
)
runner_app._session_histories_ref.pop(conv, None)
try:
async with _runner_test_client(app) as http:
response = await http.post(
f"/v1/sessions/{conv}/events",
json={
"type": "message",
"role": "user",
"model": "x",
# No ``persisted_item_id``: the native-terminal forward
# skipped persist-before-forward, so there's nothing in the
# store to drop — the runner must append, not dedup.
"content": [{"type": "input_text", "text": new_user}],
},
)
assert response.status_code == 202
await asyncio.wait_for(reached.wait(), timeout=10.0)
finally:
await server_client.aclose()
runner_app._session_histories_ref.pop(conv, None)
content = captured["body"]["content"]
texts = [
block.get("text")
for item in content
if isinstance(item, dict)
for block in item.get("content", [])
if isinstance(block, dict)
]
# Prior context preserved, and the not-yet-persisted message was
# appended (not dropped) as the latest turn — present exactly once.
assert texts == [prior_user, prior_assistant, new_user], (
f"expected prior history + appended new message, got {texts}"
)
@pytest.mark.asyncio
async def test_runner_cold_cache_keeps_trailing_user_when_no_persisted_id() -> None:
"""A real trailing user message is kept when no ``persisted_item_id`` is sent.
Regression for the id-based dedup replacing the old role heuristic. The
earlier fix unconditionally popped a trailing *user* item, assuming it was
always this turn's persisted input. That's wrong when the forward did NOT
persist-before-forward AND the store legitimately ends with a user
message — e.g. a crash mid-turn where the prior user prompt was persisted
but its assistant reply never was, or a native-terminal injection. Popping
there deletes real history.
With id-based dedup, no ``persisted_item_id`` means nothing is dropped: the
real trailing user message survives and the new message is appended.
"""
from omnigent.runner import app as runner_app
conv = "conv_cold_cache_keep_user"
# A prior user prompt whose assistant reply was never persisted (e.g. the
# runner crashed mid-turn), so the store ends on a USER message.
prior_user = "prompt whose reply was lost to a crash"
new_user = "follow-up not persisted before forward"
def _server_handler(request: httpx.Request) -> httpx.Response:
"""
Stub Omnigent server: history reload ending on a real prior user message.
:param request: Outbound request from the runner.
:returns: Snapshot for the session GET; a single prior user item
(no assistant reply, no new message) for ``/items``; benign
payloads otherwise.
"""
if request.method == "GET" and request.url.path == f"/v1/sessions/{conv}":
return httpx.Response(200, json={"id": conv, "agent_id": "ag_keep"})
if request.url.path.endswith("/items"):
return httpx.Response(
200,
json={
"object": "list",
"data": [
{
"id": "item_1",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": prior_user}],
},
],
"first_id": "item_1",
"last_id": "item_1",
"has_more": False,
},
)
return httpx.Response(200, json={})
server_client = httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
)
async def _spec_resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""
Resolve a minimal spec for the session.
:param agent_id: Agent id (unused).
:param session_id: Session id (unused).
:returns: A minimal spec on a benign test harness.
"""
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="keep-user-agent",
executor=ExecutorSpec(
type="omnigent",
config={"harness": "runner-test-resolved"},
),
)
captured: dict[str, Any] = {}
reached = asyncio.Event()
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_ContentCapturingProcessManager(captured, reached),
),
spec_resolver=_spec_resolver,
server_client=server_client,
)
runner_app._session_histories_ref.pop(conv, None)
try:
async with _runner_test_client(app) as http:
response = await http.post(
f"/v1/sessions/{conv}/events",
json={
"type": "message",
"role": "user",
"model": "x",
# No ``persisted_item_id`` → the trailing user item is NOT
# this turn's input, so it must be kept.
"content": [{"type": "input_text", "text": new_user}],
},
)
assert response.status_code == 202
await asyncio.wait_for(reached.wait(), timeout=10.0)
finally:
await server_client.aclose()
runner_app._session_histories_ref.pop(conv, None)
content = captured["body"]["content"]
texts = [
block.get("text")
for item in content
if isinstance(item, dict)
for block in item.get("content", [])
if isinstance(block, dict)
]
# The real prior user message survived (old role heuristic would drop it)
# and the new message was appended.
assert texts == [prior_user, new_user], f"trailing user message must be preserved, got {texts}"
@pytest.mark.asyncio
async def test_runner_cold_cache_uses_resolved_message_not_stored_file_id() -> None:
"""Cold-cache reload of a media turn uses the resolved block, not the store copy.
The server persists the PRE-resolution body (``file_id`` blocks) and the
runner resolves ``file_id`` → ``image_url`` itself. So on a cold cache the
``GET /items`` tail is the just-posted message in its *unresolved* form,
while ``message_body`` is the *resolved* form. Any content-equality dedup
would never match (the blocks differ), which would both duplicate the
message AND leave an unresolved ``file_id`` block in the history forwarded
to the harness.
The fix drops the persisted item by id (``persisted_item_id``, forwarded by
the server) and appends the runner-resolved message, so the harness sees
exactly one, fully resolved copy regardless of the content mismatch.
"""
from omnigent.runner import app as runner_app
conv = "conv_cold_cache_media"
prior_user = "earlier question"
prior_assistant = "earlier answer"
prompt_text = "what is this?"
def _server_handler(request: httpx.Request) -> httpx.Response:
"""
Stub Omnigent server: file resolution, snapshot, and a history reload
whose tail is the UNRESOLVED (``file_id``) copy of this message.
:param request: Outbound request from the runner.
:returns: File metadata / bytes for resolution; the persisted
history (ending with the unresolved media message, per I1)
for ``/items``; snapshot for the session GET.
"""
path = request.url.path
if path == f"/v1/sessions/{conv}":
return httpx.Response(200, json={"id": conv, "agent_id": "ag_media"})
if path.endswith("/resources/files/file_img/content"):
return httpx.Response(200, content=b"png-bytes", headers={"content-type": "image/png"})
if path.endswith("/resources/files/file_img"):
return httpx.Response(
200,
json={"id": "file_img", "filename": "photo.png", "content_type": "image/png"},
)
if path.endswith("/items"):
return httpx.Response(
200,
json={
"object": "list",
"data": [
{
"id": "item_1",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": prior_user}],
},
{
"id": "item_2",
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": prior_assistant}],
},
# I1 persisted THIS message — but in its unresolved
# (file_id) form, exactly as received.
{
"id": "item_3",
"type": "message",
"role": "user",
"content": [
{
"type": "input_image",
"file_id": "file_img",
"filename": "photo.png",
},
{"type": "input_text", "text": prompt_text},
],
},
],
"first_id": "item_1",
"last_id": "item_3",
"has_more": False,
},
)
return httpx.Response(200, json={})
server_client = httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
)
async def _spec_resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""
Resolve a minimal spec for the session.
:param agent_id: Agent id (unused).
:param session_id: Session id (unused).
:returns: A minimal spec on a benign test harness.
"""
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="media-agent",
executor=ExecutorSpec(
type="omnigent",
config={"harness": "runner-test-resolved"},
),
)
captured: dict[str, Any] = {}
reached = asyncio.Event()
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_ContentCapturingProcessManager(captured, reached),
),
spec_resolver=_spec_resolver,
server_client=server_client,
)
runner_app._session_histories_ref.pop(conv, None)
try:
async with _runner_test_client(app) as http:
response = await http.post(
f"/v1/sessions/{conv}/events",
json={
"type": "message",
"role": "user",
"model": "x",
# Server forwards the id of the (unresolved) item it
# persisted; the runner drops it from the reload by id.
"persisted_item_id": "item_3",
"content": [
{"type": "input_image", "file_id": "file_img", "filename": "photo.png"},
{"type": "input_text", "text": prompt_text},
],
},
)
assert response.status_code == 202
await asyncio.wait_for(reached.wait(), timeout=10.0)
finally:
await server_client.aclose()
runner_app._session_histories_ref.pop(conv, None)
content = captured["body"]["content"]
# Prior context + the media turn = three items, not four (no duplicate).
assert len(content) == 3, f"expected no duplicate of the media message: {content}"
image_msg = content[-1]
image_block = image_msg["content"][0]
# The stored unresolved copy was dropped; the resolved block is used —
# no ``file_id`` leaks to the harness.
assert "file_id" not in image_block, f"unresolved file_id leaked to harness: {image_block}"
assert image_block.get("image_url", "").startswith("data:image/png;base64,"), (
f"expected resolved image_url block, got {image_block}"
)
# No unresolved file_id anywhere in the forwarded history.
flat = json.dumps(content)
assert "file_id" not in flat, f"unresolved file_id present in forwarded history: {flat}"
@pytest.mark.asyncio
async def test_runner_post_returns_503_when_spec_resolver_fails(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Spec resolver failures are surfaced as structured 503 errors.
:param caplog: Pytest log capture, used to confirm the raw cause is
logged server-side (the other half of the log-and-genericize
contract).
:returns: None.
"""
async def _failing_spec_resolver(
agent_id: str, session_id: str | None = None
) -> AgentSpec | None:
"""
Raise the resolver failure under test.
:param agent_id: Agent id requested by the runner.
:returns: Never returns.
:raises RuntimeError: Always.
"""
raise RuntimeError(f"spec resolver unavailable for {agent_id}")
app = create_runner_app(
process_manager=cast(HarnessProcessManager, _FakeProcessManager()),
spec_resolver=_failing_spec_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_test_client(app) as http:
with caplog.at_level(logging.WARNING, logger="omnigent.runner.app"):
response = await http.post(
"/v1/sessions/conv_spec_resolver_failed/events?stream=true",
json={
"type": "message",
"role": "user",
"agent_id": "ag_missing",
"model": "x",
"content": [],
},
)
assert response.status_code == 503
body = response.json()
# The structured error slug is preserved for the caller; the detail is a
# fixed client-safe string. The raw resolver exception text must not leak
# into the HTTP body (it is logged on the runner instead).
assert body["error"] == "spec_resolver_failed"
assert body["detail"] == "Request failed on the runner; see runner logs for details."
assert "spec resolver unavailable" not in body["detail"]
# The other half of the contract: the raw cause IS logged for operators.
# If this fails, log-and-genericize logged nothing and the detail is the
# only record of the failure — defeating the diagnostic path.
assert "spec resolver unavailable for ag_missing" in caplog.text
@pytest.mark.asyncio
async def test_runner_stream_emits_failed_when_tool_spec_resolver_fails() -> None:
"""Streaming spec resolver failures emit ``response.failed`` SSE.
:returns: None.
"""
chunks = [
(
"event: response.created\ndata: "
'{"type":"response.created","response":{"id":"resp_1"}}\n\n'
),
(
'event: response.output_item.done\ndata: {"type":"response.output_item.done",'
'"item":{"type":"function_call","status":"action_required",'
'"name":"sys_os_read","call_id":"call_1","arguments":"{}"}}\n\n'
),
]
async def _failing_spec_resolver(
agent_id: str, session_id: str | None = None
) -> AgentSpec | None:
"""
Raise during local tool dispatch spec resolution.
:param agent_id: Agent id requested by the runner.
:returns: Never returns.
:raises RuntimeError: Always.
"""
raise RuntimeError(f"stream spec resolver unavailable for {agent_id}")
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_FakeProcessManager(_FakeHarnessClient(chunks)),
),
spec_resolver=_failing_spec_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_test_client(app) as http:
response = await http.post(
"/v1/sessions/conv_stream_spec_resolver_failed/events?stream=true",
json={
"type": "message",
"role": "user",
"harness": _TEST_HARNESS_NAME,
"agent_id": "ag_stream",
"model": "x",
"content": [],
},
)
assert response.status_code == 200
assert "event: response.created" in response.text
assert "event: response.failed" in response.text
# The exception class (a safe, generic label) is still surfaced as the
# error ``type``; the failure message is a fixed client-safe string. The
# raw resolver exception text must not leak into the SSE stream (it is
# logged on the runner instead).
assert "RuntimeError" in response.text
assert "Failed to resolve the agent spec for this turn." in response.text
assert "stream spec resolver unavailable for ag_stream" not in response.text
def test_build_spawn_env_applies_model_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A per-session ``/model`` override overrides ``HARNESS_<H>_MODEL``.
Regression: ``/model`` was recorded in the session readout but the turn
still used the provider/catalog default, because the SDK harnesses take
their model from the spawn-env env var (which only the native CLIs'
``--model`` path honored). The override must override the baked-in
``HARNESS_CLAUDE_SDK_MODEL`` so the switch actually takes effect.
:param tmp_path: Pytest temp dir for an isolated provider config.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("OMNIGENT_DISABLE_KEYRING", "1")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
(tmp_path / "config.yaml").write_text(
"providers:\n"
" anthropic:\n"
" kind: key\n"
" default: true\n"
" anthropic:\n"
" base_url: https://api.anthropic.com\n"
" api_key: $ANTHROPIC_API_KEY\n"
)
spec = AgentSpec(
spec_version=1,
name="x",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-sdk"}),
)
base = _build_spawn_env_from_spec(spec, "claude-sdk")
overridden = _build_spawn_env_from_spec(spec, "claude-sdk", model_override="claude-sonnet-4-6")
assert base is not None and overridden is not None
# Baseline uses the provider/catalog default (not the override) …
assert base["HARNESS_CLAUDE_SDK_MODEL"] != "claude-sonnet-4-6"
# … and the override wins, landing in the model env var the SDK reads.
assert overridden["HARNESS_CLAUDE_SDK_MODEL"] == "claude-sonnet-4-6"
@pytest.mark.asyncio
async def test_resolve_harness_config_applies_harness_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A per-session ``harness_override`` replaces the spec's brain harness.
The web UI's new-chat picker persists the override on the session and
the server forwards it in the message body; the runner must spawn THAT
harness (with its spawn-env shape), not the spec's declared one — else
the snapshot would claim "pi" while claude-sdk actually runs.
:param tmp_path: Pytest temp dir for an isolated provider config.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("OMNIGENT_DISABLE_KEYRING", "1")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
(tmp_path / "config.yaml").write_text(
"providers:\n"
" anthropic:\n"
" kind: key\n"
" default: true\n"
" anthropic:\n"
" base_url: https://api.anthropic.com\n"
" api_key: $ANTHROPIC_API_KEY\n"
)
spec = AgentSpec(
spec_version=1,
name="x",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-sdk"}),
)
async def _resolver(_agent_id: str, _session_id: str | None) -> AgentSpec:
return spec
# Baseline: no override resolves the spec's declared harness.
harness, spawn_env = await _resolve_harness_config(
agent_id="ag_x", spec_resolver=_resolver, session_id="conv_x"
)
assert harness == "claude-sdk"
assert spawn_env is not None and "HARNESS_CLAUDE_SDK_MODEL" in spawn_env
# Override: the harness AND its spawn-env shape follow the override.
harness, spawn_env = await _resolve_harness_config(
agent_id="ag_x",
spec_resolver=_resolver,
session_id="conv_x",
harness_override="pi",
)
assert harness == "pi", (
f"harness_override='pi' resolved to {harness!r} — the override "
f"was ignored and the spec's declared harness won."
)
# The spawn env must be built FOR the overridden harness (pi env keys),
# not the spec's claude-sdk shape — a claude env here means the harness
# name and env were resolved inconsistently.
assert spawn_env is not None and "HARNESS_PI_MODEL" in spawn_env, (
f"Expected a pi spawn-env; got keys {sorted(spawn_env or {})!r}"
)
@pytest.mark.asyncio
async def test_runner_background_turn_emits_failed_when_spawn_env_build_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A spawn-env build failure must end the turn, never hang on "running".
Regression for the silent-hang failure mode: when
``_build_claude_sdk_spawn_env`` raises (e.g. a generic provider routed
to the claude-sdk harness has no resolvable model, raising
``OmnigentError``), the setup phase of ``_run_turn_bg`` failed before
the streaming block's own error handling. Because the background turn
task's only done-callback was ``_background_tasks.discard``, the
exception was swallowed: ``_active_turns`` stayed set and no terminal
``session.status`` event was published, so the REPL spun on "working"
forever with no output.
The fix wraps the setup phase so any pre-stream exception routes through
``_on_proxy_stream_end``, which clears the active turn and publishes
``session.status: failed``. This test drives the background-turn path
(no ``?stream=true`` — the production path the Omnigent server uses) and
asserts the ``failed`` status reaches the session SSE stream.
:param monkeypatch: pytest fixture used to force the spawn-env build to
raise the same error class the no-model provider path produces.
"""
from omnigent.errors import ErrorCode, OmnigentError
conv = "conv_spawn_env_build_raises"
async def _spec_resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""
Return a claude-sdk spec so the runner takes the claude-sdk
spawn-env builder path.
:param agent_id: Agent id requested by the runner (unused).
:param session_id: Session id (unused).
:returns: A minimal claude-sdk spec.
"""
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="claude-sdk-agent",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-sdk"}),
)
def _raising_build(spec: object, *, workdir: object = None) -> dict[str, str]:
"""
Stand in for ``_build_claude_sdk_spawn_env`` and fail the way the
no-model generic-provider path does.
:param spec: The agent spec (unused).
:param workdir: Bundle workdir (unused).
:returns: Never returns.
:raises OmnigentError: Always — mirrors the no-model provider error.
"""
del spec, workdir
raise OmnigentError(
"No model resolved for the 'claude-sdk' harness on a generic provider.",
code=ErrorCode.INVALID_INPUT,
)
# ``_build_spawn_env_from_spec`` imports this from workflow at call time,
# so patching the workflow attribute reaches the runner's call site.
monkeypatch.setattr(
"omnigent.runtime.workflow._build_claude_sdk_spawn_env",
_raising_build,
)
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_FakeProcessManager(_FakeHarnessClient([])),
),
spec_resolver=_spec_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_test_client(app) as http:
response = await http.post(
# No ``?stream=true`` → background turn (the production path the
# Omnigent server uses to forward session messages).
f"/v1/sessions/{conv}/events",
json={
"type": "message",
"role": "user",
"agent_id": "ag_claude_sdk",
"model": "x",
"content": [{"role": "user", "content": "hi"}],
},
)
assert response.status_code == 202
await _await_bg_turn_task(conv)
statuses = await _drain_published_statuses(conv, until="failed", timeout=2.0)
# The turn published "running" then "failed" — it reached a terminal
# state and cleared. Without the fix, the setup-phase OmnigentError is
# swallowed: only "running" is published, ``_active_turns`` stays set, and
# the session hangs on "working" forever (the silent-hang regression).
assert statuses == ["running", "failed"]
@pytest.mark.asyncio
async def test_runner_failed_status_carries_setup_error_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A SETUP-phase failure forwards its error message on the ``failed`` event.
Regression for the silent-REPL failure mode: ending the turn on a
pre-stream error stopped the spinner but rendered no text, because the
runner published a bare ``session.status: failed`` with no error
detail — the message never left the runner. This drives the same
spawn-env-build failure as
:func:`test_runner_background_turn_emits_failed_when_spawn_env_build_raises`
and asserts the published ``failed`` event now carries the normalized
``{code, message}`` error so Omnigent and the REPL can render it.
:param monkeypatch: pytest fixture used to force the spawn-env build to
raise the no-model provider error.
"""
from omnigent.errors import ErrorCode, OmnigentError
conv = "conv_failed_status_carries_error"
raised_message = "No model resolved for the 'claude-sdk' harness on a generic provider."
async def _spec_resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""
Return a claude-sdk spec so the spawn-env builder path is taken.
:param agent_id: Agent id (unused).
:param session_id: Session id (unused).
:returns: A minimal claude-sdk spec.
"""
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="claude-sdk-agent",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-sdk"}),
)
def _raising_build(spec: object, *, workdir: object = None) -> dict[str, str]:
"""
Fail the spawn-env build the way the no-model provider path does.
:param spec: The agent spec (unused).
:param workdir: Bundle workdir (unused).
:returns: Never returns.
:raises OmnigentError: Always.
"""
del spec, workdir
raise OmnigentError(raised_message, code=ErrorCode.INVALID_INPUT)
monkeypatch.setattr(
"omnigent.runtime.workflow._build_claude_sdk_spawn_env",
_raising_build,
)
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_FakeProcessManager(_FakeHarnessClient([])),
),
spec_resolver=_spec_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_test_client(app) as http:
response = await http.post(
f"/v1/sessions/{conv}/events",
json={
"type": "message",
"role": "user",
"agent_id": "ag_claude_sdk",
"model": "x",
"content": [{"role": "user", "content": "hi"}],
},
)
assert response.status_code == 202
await _await_bg_turn_task(conv)
failed_event = await _drain_failed_status_event(conv, timeout=2.0)
# The failed event must carry the real setup error message — not a
# bare status. Without the fix ``error`` is absent and the REPL
# renders nothing.
assert failed_event is not None
error = failed_event.get("error")
assert isinstance(error, dict)
# The raised OmnigentError message is wrapped as "turn setup
# failed: <message>" by _run_turn_bg's setup-phase handler.
assert raised_message in error["message"]
# Normalized shape always has a code so the wire ErrorDetail validates.
assert error["code"]
# ── Harness-stream failure → terminal session.status ────
async def _drain_status_events(
conv: str,
*,
until: str,
timeout: float,
) -> list[dict[str, Any]]:
"""Collect full ``session.status`` events a runner published for a session.
Like :func:`_drain_published_statuses` but returns the full event dicts,
so one drain can assert both the status order and the carried ``error``
payload — the queue is consumed by reading, so a test cannot drain twice.
:param conv: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param until: Stop once this ``session.status`` value is observed,
e.g. ``"failed"``.
:param timeout: Hard cap in seconds — if *until* never arrives the poll
gives up and returns what it saw, so a hang regression fails the
assertion instead of spinning forever.
:returns: Ordered ``session.status`` event dicts published for *conv*.
"""
from omnigent.runner.app import _session_event_queues_ref
events: list[dict[str, Any]] = []
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
queue = _session_event_queues_ref.get(conv)
drained = False
while queue is not None and not queue.empty():
event = queue.get_nowait()
drained = True
if isinstance(event, dict) and event.get("type") == "session.status":
events.append(event)
if any(event.get("status") == until for event in events):
return events
if not drained:
# Let the background turn task make progress before re-polling.
await asyncio.sleep(0.02)
return events
_STREAM_FAILURE_MESSAGE = "harness turn failed: executor stream disconnected"
_SSE_RESPONSE_CREATED = (
'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_sf_1"}}\n\n'
)
_SSE_RESPONSE_FAILED = (
"event: response.failed\ndata: "
'{"type":"response.failed","response":{"status":"failed"},'
f'"error":{{"message":"{_STREAM_FAILURE_MESSAGE}","code":"executor_error"}}}}\n\n'
)
_SSE_RESPONSE_COMPLETED = (
"event: response.completed\ndata: "
'{"type":"response.completed","response":{"id":"resp_sf_1","status":"completed"}}\n\n'
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("harness", "frames", "until", "expected_statuses"),
[
pytest.param(
"codex-native",
[_SSE_RESPONSE_CREATED, _SSE_RESPONSE_FAILED],
"failed",
["running", "failed"],
id="codex-native-failed-stream",
),
pytest.param(
_TEST_HARNESS_NAME,
[_SSE_RESPONSE_CREATED, _SSE_RESPONSE_FAILED],
"failed",
["running", "failed"],
id="subprocess-harness-failed-stream",
),
pytest.param(
_TEST_HARNESS_NAME,
[_SSE_RESPONSE_CREATED, _SSE_RESPONSE_FAILED, _SSE_RESPONSE_COMPLETED],
"idle",
["running", "idle"],
id="failure-superseded-by-completion",
),
],
)
async def test_runner_publishes_terminal_failed_when_harness_stream_fails(
harness: str,
frames: list[str],
until: str,
expected_statuses: list[str],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""A harness stream that ends after ``response.failed`` publishes ``failed``.
Regression for the stuck working indicator in codex-native sessions:
when a turn failed inside the harness, the scaffold emitted
``response.failed`` on the SSE stream, but the runner's proxy relay
called ``_on_proxy_stream_end`` with no error, so the turn ended with
``session.status: idle``. For codex-native sessions ``idle`` is
suppressed (the Codex app-server forwarder owns that edge — and posts
nothing when Codex never started a turn), so NO terminal status was
published at all: the web UI showed the error block yet spun on
"working" forever. For subprocess harnesses the terminal edge was
published with the wrong value (``idle`` instead of ``failed``).
A ``response.completed`` after an earlier in-stream ``response.failed``
supersedes the failure: the turn ended successfully, so the terminal
edge must be ``idle``.
:param harness: Harness name baked into the resolved spec, driving
the runner's native-vs-subprocess status-edge policy.
:param frames: Scripted harness SSE frames for the turn stream.
:param until: Terminal ``session.status`` value the drain waits for.
:param expected_statuses: Exact ordered ``session.status`` values the
runner must publish for the turn.
:param monkeypatch: Pytest fixture, used to isolate the codex-native
bridge directory.
:param tmp_path: Pytest temp dir receiving the isolated bridge files.
"""
conv = f"conv_stream_failed_{until}_{harness.replace('-', '_')}"
# Keep the codex-native pre-turn bridge writes (write_mcp_bridge_config)
# out of the real ``~/.omnigent/codex-native`` tree. The module documents
# this monkeypatch as the supported test isolation point.
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path)
async def _spec_resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""
Return a spec pinned to the parametrized harness.
:param agent_id: Agent id requested by the runner (unused).
:param session_id: Session id (unused).
:returns: A minimal spec for the parametrized harness.
"""
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="stream-fail-agent",
executor=ExecutorSpec(type="omnigent", config={"harness": harness}),
)
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_FakeProcessManager(_FakeHarnessClient(frames)),
),
spec_resolver=_spec_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_test_client(app) as http:
response = await http.post(
# No ``?stream=true`` → background turn (the production path the
# Omnigent server uses to forward session messages).
f"/v1/sessions/{conv}/events",
json={
"type": "message",
"role": "user",
"agent_id": "ag_stream_fail",
"model": "x",
"content": [{"role": "user", "content": "hi"}],
},
)
assert response.status_code == 202
# Await the background turn task directly so we know it has completed
# (and published its terminal status) before draining — the same race
# guard the sibling failed-status tests use.
await _await_bg_turn_task(conv)
events = await _drain_status_events(conv, until=until, timeout=2.0)
statuses = [event.get("status") for event in events]
# The turn must reach the parametrized terminal state. Without the fix,
# the failed-stream cases regress in two distinct ways: codex-native
# publishes only ["running"] (idle is suppressed, so the drain times out
# and the working indicator hangs), and the subprocess harness publishes
# ["running", "idle"] (the wrong terminal edge).
assert statuses == expected_statuses, (
f"Expected session.status sequence {expected_statuses}, got {statuses}. "
f"A missing terminal value means the turn never cleared (stuck working "
f"indicator); 'idle' in place of 'failed' means the in-stream "
f"response.failed was dropped at stream end."
)
if until == "failed":
error = events[-1].get("error")
# The terminal failed edge must carry the harness's real error so
# clients can render it — a bare ``failed`` with no payload would
# clear the spinner but tell the user nothing.
assert isinstance(error, dict)
assert _STREAM_FAILURE_MESSAGE in error["message"]
# ── Runner-local OS env dispatch ────────────────────────
@pytest.mark.asyncio
async def test_runner_os_env_tools_use_agent_spec_cwd() -> None:
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.runner.tool_dispatch import _execute_os_env_tool
from omnigent.spec.types import AgentSpec
with tempfile.TemporaryDirectory() as td:
root = Path(td)
spec = AgentSpec(
spec_version=1,
os_env=OSEnvSpec(
type="caller_process",
cwd=str(root),
sandbox=OSEnvSandboxSpec(type="none"),
),
)
write = await _execute_os_env_tool(
"sys_os_write",
{"path": "note.txt", "content": "hello\nplanet\n"},
agent_spec=spec,
conversation_id="conv_runner_os_env_test",
)
assert json.loads(write)["created"] is True
assert root.joinpath("note.txt").read_text() == "hello\nplanet\n"
edit = await _execute_os_env_tool(
"sys_os_edit",
{"path": "note.txt", "oldText": "planet", "newText": "world"},
agent_spec=spec,
conversation_id="conv_runner_os_env_test",
)
assert json.loads(edit)["replacements"] == 1
read = await _execute_os_env_tool(
"sys_os_read",
{"path": "note.txt"},
agent_spec=spec,
conversation_id="conv_runner_os_env_test",
)
assert json.loads(read)["content"] == "hello\nworld\n"
shell = await _execute_os_env_tool(
"sys_os_shell",
{"command": "pwd"},
agent_spec=spec,
conversation_id="conv_runner_os_env_test",
)
shell_result = json.loads(shell)
assert shell_result["exit_code"] == 0
assert Path(shell_result["stdout"].strip()).resolve() == root.resolve()
@pytest.mark.asyncio
async def test_runner_os_env_placeholder_cwd_uses_cli_workspace(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Runner-local OS tools map ``cwd: .`` to the CLI workspace.
Remote ``run --server`` uploads the spec to an app server,
but the local runner still owns filesystem access. This pins
the contract that a placeholder cwd resolves to the local
project root the CLI passed into the runner, not to the
runner-owned temp fallback.
:param monkeypatch: Pytest environment patch fixture.
:param tmp_path: Per-test temp root for workspace and fallback paths.
:returns: None.
"""
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.runner.tool_dispatch import _execute_os_env_tool
workspace = tmp_path / "project"
workspace.mkdir()
monkeypatch.setenv("OMNIGENT_RUNNER_OS_ENV_ROOT", str(tmp_path / "fallback"))
spec = AgentSpec(
spec_version=1,
os_env=OSEnvSpec(
type="caller_process",
cwd=".",
sandbox=OSEnvSandboxSpec(type="none"),
),
)
out = await _execute_os_env_tool(
"sys_os_write",
{"path": "created.txt", "content": "from workspace"},
agent_spec=spec,
conversation_id="conv_runner_workspace",
runner_workspace=workspace.resolve(),
)
assert json.loads(out)["created"] is True
assert workspace.joinpath("created.txt").read_text() == "from workspace"
assert not (tmp_path / "fallback").exists()
@pytest.mark.asyncio
async def test_runner_os_env_tools_default_to_conversation_workspace(monkeypatch) -> None:
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.runner.tool_dispatch import _execute_os_env_tool
from omnigent.spec.types import AgentSpec
with tempfile.TemporaryDirectory() as td:
monkeypatch.setenv("OMNIGENT_RUNNER_OS_ENV_ROOT", td)
spec = AgentSpec(
spec_version=1,
os_env=OSEnvSpec(
type="caller_process",
sandbox=OSEnvSandboxSpec(type="none"),
),
)
out = await _execute_os_env_tool(
"sys_os_write",
{"path": "created.txt", "content": "hi"},
agent_spec=spec,
conversation_id="conv/default workspace",
)
assert json.loads(out)["created"] is True
assert Path(td, "conv_default_workspace", "workspace", "created.txt").read_text() == "hi"
def test_clone_os_env_spec_preserves_all_sandbox_fields() -> None:
"""Cloning an OSEnvSpec must preserve every sandbox field.
Regression guard for the same class of bug previously fixed in
:func:`omnigent.inner.terminal._clone_sandbox_spec`: hand-enumerated
field copies silently drop security-critical fields (egress_rules,
egress_allow_private_destinations, env_passthrough, etc.) when new
fields are added to :class:`OSEnvSandboxSpec`. This test asserts the
runner-side clone is field-complete by comparing the dataclass dicts
and verifying list-typed fields are not aliased with the original.
:returns: None.
"""
import dataclasses
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.runner.tool_dispatch import _clone_os_env_spec
sandbox = OSEnvSandboxSpec(
type="darwin_seatbelt",
read_paths=["~/.databrickscfg"],
write_paths=["."],
write_files=["~/.ssh/known_hosts"],
allow_network=True,
cwd_allow_hidden=[".git", ".venv"],
cwd_hidden_scan_max_entries=12345,
cwd_hidden_scan_overflow="warn",
env_passthrough=["DATABRICKS_HOST", "DATABRICKS_TOKEN"],
egress_rules=["GET api.github.com/repos/databricks/*/**"],
egress_allow_private_destinations=True,
)
spec = OSEnvSpec(
type="caller_process",
cwd="/tmp/work",
sandbox=sandbox,
fork=True,
start_in_scratch=True,
)
clone = _clone_os_env_spec(spec)
# Every OSEnvSpec field round-trips.
assert dataclasses.asdict(clone) == dataclasses.asdict(spec), (
"clone must preserve every OSEnvSpec / OSEnvSandboxSpec field; "
"hand-enumerated copies silently drop newly-added fields."
)
# Mutable list fields are copied, not aliased.
assert clone.sandbox is not sandbox
for name in (
"read_paths",
"write_paths",
"write_files",
"cwd_allow_hidden",
"env_passthrough",
"egress_rules",
):
original_list = getattr(sandbox, name)
cloned_list = getattr(clone.sandbox, name)
assert cloned_list == original_list
assert cloned_list is not original_list, (
f"{name} must be a new list so later mutation of the clone "
"does not leak into the original spec."
)
def test_effective_runner_os_env_defaults_when_spec_has_no_os_env(monkeypatch) -> None:
"""Agent specs without ``os_env`` get a runner-owned workspace cwd.
:param monkeypatch: Pytest environment patch fixture.
:returns: None.
"""
from omnigent.runner.tool_dispatch import _effective_runner_os_env_spec
from omnigent.spec.types import AgentSpec
with tempfile.TemporaryDirectory() as td:
monkeypatch.setenv("OMNIGENT_RUNNER_OS_ENV_ROOT", td)
spec = AgentSpec(spec_version=1)
os_env = _effective_runner_os_env_spec(spec, "conv/no os env")
assert os_env.type == "caller_process"
assert Path(os_env.cwd) == Path(td, "conv_no_os_env", "workspace")
assert Path(os_env.cwd).is_dir()
def test_effective_runner_os_env_uses_cli_workspace_when_spec_has_no_os_env(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Agent specs without ``os_env`` use the CLI workspace when available.
:param monkeypatch: Pytest environment patch fixture.
:param tmp_path: Per-test temp root for workspace and fallback paths.
:returns: None.
"""
from omnigent.runner.tool_dispatch import _effective_runner_os_env_spec
from omnigent.spec.types import AgentSpec
workspace = tmp_path / "project"
workspace.mkdir()
monkeypatch.setenv("OMNIGENT_RUNNER_OS_ENV_ROOT", str(tmp_path / "fallback"))
spec = AgentSpec(spec_version=1)
os_env = _effective_runner_os_env_spec(
spec,
"conv/no os env",
runner_workspace=workspace.resolve(),
)
assert os_env.type == "caller_process"
assert Path(os_env.cwd) == workspace.resolve()
assert not (tmp_path / "fallback").exists()
def test_effective_runner_os_env_runner_workspace_overrides_absolute_spec_cwd(
tmp_path: Path,
) -> None:
"""
runner_workspace wins over an absolute ``os_env.cwd`` in the spec.
Per designs/SESSION_WORKSPACE_SELECTION.md "How this maps onto
runtime": absolute spec cwds are session-create-time
boundaries, not runtime overrides. When the runner is launched
with ``OMNIGENT_RUNNER_WORKSPACE`` set, it always wins —
otherwise picking ``~/universe/src/foo`` for an agent
declaring ``cwd: ~/universe`` would silently relocate up to
``~/universe`` at runtime.
"""
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.runner.tool_dispatch import _effective_runner_os_env_spec
from omnigent.spec.types import AgentSpec
workspace = tmp_path / "picked-subdir"
workspace.mkdir()
spec_cwd = tmp_path / "agent-spec-cwd"
spec_cwd.mkdir()
spec = AgentSpec(
spec_version=1,
os_env=OSEnvSpec(
type="caller_process",
cwd=str(spec_cwd),
sandbox=OSEnvSandboxSpec(type="none"),
),
)
os_env = _effective_runner_os_env_spec(
spec,
"conv_abs_override",
runner_workspace=workspace.resolve(),
)
# Workspace wins over the absolute spec cwd.
assert Path(os_env.cwd) == workspace.resolve()
assert Path(os_env.cwd) != spec_cwd.resolve()
def test_effective_runner_os_env_absolute_spec_cwd_used_without_runner_workspace(
tmp_path: Path,
) -> None:
"""
Without runner_workspace, an absolute ``os_env.cwd`` in the
spec is used as-is.
Pins the no-env-var fallback path so unit tests / pure local
runs that construct an agent spec directly without the env
var continue to honor whatever the spec declared.
"""
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.runner.tool_dispatch import _effective_runner_os_env_spec
from omnigent.spec.types import AgentSpec
spec_cwd = tmp_path / "agent-spec-cwd"
spec_cwd.mkdir()
spec = AgentSpec(
spec_version=1,
os_env=OSEnvSpec(
type="caller_process",
cwd=str(spec_cwd),
sandbox=OSEnvSandboxSpec(type="none"),
),
)
os_env = _effective_runner_os_env_spec(
spec,
"conv_no_workspace_abs",
runner_workspace=None,
)
assert Path(os_env.cwd) == spec_cwd
@pytest.mark.asyncio
async def test_runner_terminal_dispatch_passes_cli_workspace(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Runner terminal tools receive the CLI workspace in ``ToolContext``.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Workspace path exported to the runner.
:returns: None.
"""
from omnigent.inner.datamodel import TerminalEnvSpec
from omnigent.runner.tool_dispatch import _execute_terminal_tool
from omnigent.terminals import TerminalRegistry
from omnigent.tools.base import ToolContext
from omnigent.tools.builtins.sys_terminal import SysTerminalLaunchTool
workspace = tmp_path / "project"
workspace.mkdir()
spec = AgentSpec(
spec_version=1,
terminals={"zsh": TerminalEnvSpec(command="zsh")},
)
captured: dict[str, object] = {}
def _fake_invoke(
self: SysTerminalLaunchTool,
arguments: str,
ctx: ToolContext,
) -> str:
"""
Capture the runner-built context without launching tmux.
:param self: Bound launch tool instance.
:param arguments: JSON arguments forwarded by dispatch.
:param ctx: Tool context created by runner dispatch.
:returns: JSON status payload.
"""
del self
captured["arguments"] = arguments
captured["workspace"] = ctx.workspace
return json.dumps({"status": "captured"})
monkeypatch.setattr(SysTerminalLaunchTool, "invoke", _fake_invoke)
out = await _execute_terminal_tool(
"sys_terminal_launch",
{"terminal": "zsh", "session": "s1"},
terminal_registry=TerminalRegistry(),
agent_spec=spec,
conversation_id="conv_terminal_dispatch",
task_id="task_terminal_dispatch",
agent_id="ag_terminal_dispatch",
runner_workspace=workspace.resolve(),
)
assert json.loads(out)["status"] == "captured"
assert json.loads(captured["arguments"]) == {"terminal": "zsh", "session": "s1"}
assert captured["workspace"] == workspace.resolve()
class _StubTerminalInstance:
"""Minimal stand-in for a launched ``TerminalInstance``.
``terminal_resource_view`` reads only stable ``TerminalInstance``
fields; the launch/close tool's ``invoke`` is monkeypatched in
these tests so no real tmux instance is created.
:param running: Value surfaced as ``metadata.running`` in the
resource view.
"""
def __init__(self, running: bool = True) -> None:
self.running = running
# ``None`` => the view uses the default environment id, so we
# don't need to fabricate an OSEnvironment.
self.os_env = None
self.socket_path = Path("/tmp/omnigent-test-tmux.sock")
self.tmux_target = "main"
# ``terminal_resource_view`` reads this to project the effective
# web-attach transport into metadata; ``None`` => the global default.
self.terminal_transport = None
# Records on_activity callbacks the dispatch wires up so a fresh
# launch's pane-activity watcher start is observable (and so the
# call doesn't AttributeError against this stub).
self.activity_watchers: list[Callable[[], None]] = []
def start_idle_watcher_thread(
self,
on_idle: Callable[[], None] | None = None,
*,
on_activity: Callable[[], None] | None = None,
) -> None:
"""Record the activity callback instead of polling real tmux."""
if on_activity is not None:
self.activity_watchers.append(on_activity)
class _StubTerminalRegistry:
"""Registry stub whose ``get`` returns a fixed instance.
The launch/close tool's ``invoke`` is monkeypatched, so the tool
never touches the registry; only ``_emit_terminal_resource_event``
calls ``get`` (on the fresh-launch branch) to build the resource
view. A real stub class (not ``MagicMock``) so an unexpected extra
call surfaces as a recorded entry rather than silently passing.
:param instance: Instance returned for every ``get``, or ``None``
to simulate a registry miss.
"""
def __init__(self, instance: _StubTerminalInstance | None) -> None:
self._instance = instance
self.get_calls: list[tuple[str, str, str]] = []
def get(
self,
conversation_id: str,
terminal_name: str,
session_key: str,
) -> _StubTerminalInstance | None:
"""Record the lookup and return the configured instance."""
self.get_calls.append((conversation_id, terminal_name, session_key))
return self._instance
def _capturing_publish_event(
captured: list[dict[str, Any]],
) -> Any:
"""Build a ``publish_event`` stub that records published events.
:param captured: List the returned callable appends each event
dict to (the session id is discarded — every event in these
tests targets the same conversation).
:returns: A ``(session_id, event) -> None`` callable.
"""
def _publish(session_id: str, event: dict[str, Any]) -> None:
del session_id
captured.append(event)
return _publish
@pytest.mark.asyncio
async def test_terminal_launch_dispatch_emits_resource_created(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A fresh ``sys_terminal_launch`` publishes ``session.resource.created``.
Verifies the runner dispatcher surfaces a tool-launched terminal
on the live SSE stream mid-turn (the whole point of this change),
with the same resource shape the REST path emits.
:param monkeypatch: Pytest monkeypatch fixture.
"""
from omnigent.runner.tool_dispatch import _execute_terminal_tool
from omnigent.tools.base import ToolContext
from omnigent.tools.builtins.sys_terminal import SysTerminalLaunchTool
spec = AgentSpec(spec_version=1)
def _fake_invoke(self: SysTerminalLaunchTool, arguments: str, ctx: ToolContext) -> str:
del self, arguments, ctx
return json.dumps({"terminal": "zsh", "session": "s1", "status": "launched"})
monkeypatch.setattr(SysTerminalLaunchTool, "invoke", _fake_invoke)
registry = _StubTerminalRegistry(_StubTerminalInstance(running=True))
published: list[dict[str, Any]] = []
out = await _execute_terminal_tool(
"sys_terminal_launch",
{"terminal": "zsh", "session": "s1"},
terminal_registry=registry,
agent_spec=spec,
conversation_id="conv_emit",
task_id="task_emit",
agent_id="ag_emit",
publish_event=_capturing_publish_event(published),
)
# Tool output is returned unchanged to the harness.
assert json.loads(out)["status"] == "launched"
# Exactly one live event — the fresh-launch resource.created. A
# second event would mean the close branch or a duplicate fired.
assert len(published) == 1, f"expected 1 published event, got {published}"
event = published[0]
assert event["type"] == "session.resource.created"
# Resource shape matches the REST path: deterministic id +
# terminal type so the web rail / relay handle both identically.
assert event["resource"]["id"] == "terminal_zsh_s1"
assert event["resource"]["type"] == "terminal"
assert event["resource"]["metadata"]["terminal_name"] == "zsh"
# The instance was looked up from the registry to build the view.
assert registry.get_calls == [("conv_emit", "zsh", "s1")]
@pytest.mark.asyncio
async def test_terminal_launch_idempotent_does_not_emit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An ``already_running`` launch publishes nothing.
Re-launching an existing ``(terminal, session)`` returns
``status: already_running``; emitting ``session.resource.created``
again would double-create the same id in the rail.
:param monkeypatch: Pytest monkeypatch fixture.
"""
from omnigent.runner.tool_dispatch import _execute_terminal_tool
from omnigent.tools.base import ToolContext
from omnigent.tools.builtins.sys_terminal import SysTerminalLaunchTool
spec = AgentSpec(spec_version=1)
def _fake_invoke(self: SysTerminalLaunchTool, arguments: str, ctx: ToolContext) -> str:
del self, arguments, ctx
return json.dumps({"terminal": "zsh", "session": "s1", "status": "already_running"})
monkeypatch.setattr(SysTerminalLaunchTool, "invoke", _fake_invoke)
registry = _StubTerminalRegistry(_StubTerminalInstance(running=True))
published: list[dict[str, Any]] = []
await _execute_terminal_tool(
"sys_terminal_launch",
{"terminal": "zsh", "session": "s1"},
terminal_registry=registry,
agent_spec=spec,
conversation_id="conv_emit",
task_id="task_emit",
agent_id="ag_emit",
publish_event=_capturing_publish_event(published),
)
# No event: the resource already existed before this launch.
assert published == []
# And we didn't even bother looking up the instance to build a view.
assert registry.get_calls == []
@pytest.mark.asyncio
async def test_terminal_close_dispatch_emits_resource_deleted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A successful ``sys_terminal_close`` publishes ``session.resource.deleted``.
Verifies the symmetric teardown path: closing a terminal removes
it from the rail live, with the deleted-event shape the REST path
and web UI already handle.
:param monkeypatch: Pytest monkeypatch fixture.
"""
from omnigent.runner.tool_dispatch import _execute_terminal_tool
from omnigent.tools.base import ToolContext
from omnigent.tools.builtins.sys_terminal import SysTerminalCloseTool
spec = AgentSpec(spec_version=1)
def _fake_invoke(self: SysTerminalCloseTool, arguments: str, ctx: ToolContext) -> str:
del self, arguments, ctx
return json.dumps({"terminal": "zsh", "session": "s1", "status": "closed"})
monkeypatch.setattr(SysTerminalCloseTool, "invoke", _fake_invoke)
# Close doesn't look up the instance (no resource view to build),
# so a miss-returning registry is fine here.
registry = _StubTerminalRegistry(None)
published: list[dict[str, Any]] = []
await _execute_terminal_tool(
"sys_terminal_close",
{"terminal": "zsh", "session": "s1"},
terminal_registry=registry,
agent_spec=spec,
conversation_id="conv_emit",
task_id="task_emit",
agent_id="ag_emit",
publish_event=_capturing_publish_event(published),
)
assert len(published) == 1, f"expected 1 published event, got {published}"
event = published[0]
assert event["type"] == "session.resource.deleted"
assert event["resource_id"] == "terminal_zsh_s1"
assert event["resource_type"] == "terminal"
assert event["session_id"] == "conv_emit"
# Deleted carries only the id; no registry lookup needed.
assert registry.get_calls == []
@pytest.mark.asyncio
async def test_runner_read_inbox_continues_after_malformed_terminal_idle_item() -> None:
"""
Malformed terminal idle items must not abort the inbox drain.
:returns: None.
"""
from omnigent.runner.tool_dispatch import execute_tool
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
session_inbox.put_nowait(
{
"handle_id": "handle_before",
"tool_name": "sys_os_shell",
"status": "completed",
"output": "before",
}
)
session_inbox.put_nowait({"type": "terminal_idle", "source": "zsh"})
session_inbox.put_nowait(
{
"handle_id": "handle_after",
"tool_name": "sys_os_shell",
"status": "completed",
"output": "after",
}
)
inbox_output = await execute_tool(
tool_name="sys_read_inbox",
arguments="{}",
session_inbox=session_inbox,
)
assert "task handle_before completed" in inbox_output
assert "sys_os_shell returned: before" in inbox_output
assert "malformed terminal_idle inbox item ignored" in inbox_output
assert "terminal-idle inbox payload requires non-empty string session" in inbox_output
assert "task handle_after completed" in inbox_output
assert "sys_os_shell returned: after" in inbox_output
assert session_inbox.empty()
# ── End-to-end with real harness subprocess + real LLM ──
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set; runner→harness→LLM e2e skipped",
)
@pytest.mark.asyncio
async def test_runner_dispatches_to_spawned_harness_with_real_llm(
started_manager: HarnessProcessManager,
) -> None:
"""The flagship architectural test.
Server-side httpx → runner FastAPI's
``POST /v1/sessions/{conversation_id}/events?stream=true`` →
runner asks ``HarnessProcessManager`` to spawn / fetch the
per-conversation harness → uvicorn subprocess on a UDS →
test harness's ``run_turn`` → real OpenAI gpt-4o-mini → SSE
chunks stream back through harness UDS → runner's proxy_stream
→ test httpx client.
Successful streaming with the expected event sequence proves:
1. The runner package uses the existing HarnessProcessManager
(no parallel impl).
2. A real harness subprocess is spawned per (conversation, harness).
3. The runner correctly relays the harness's SSE bytes.
4. The runner is exercising the architectural shape from
designs/RUNNER.md §4 — not just calling an LLM directly.
"""
app = create_runner_app(
process_manager=started_manager,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_test_client(app) as http:
async with http.stream(
"POST",
"/v1/sessions/conv_runner_e2e_test/events?stream=true",
json={
"type": "message",
"role": "user",
"harness": _TEST_HARNESS_NAME,
"content": [{"role": "user", "content": "Reply with the single word 'pong'."}],
"model": "openai/gpt-4o-mini",
"instructions": "You echo single words.",
"connection_params": {"api_key": os.environ["OPENAI_API_KEY"]},
},
timeout=120.0,
) as response:
assert response.status_code == 200, (
f"runner dispatch failed: {response.status_code} "
f"{(await response.aread()).decode('utf-8', errors='replace')}"
)
assert response.headers["content-type"].startswith("text/event-stream")
body = b"".join([chunk async for chunk in response.aiter_bytes()])
# Parse the SSE bytes back into events.
events = _parse_sse(body)
types = [t for t, _ in events]
# Must START with response.created and END with response.completed —
# proves both ends of the harness's emitted stream made it through
# the runner's proxy. A real LLM produces at least one delta in
# between.
assert types[0] == "response.created", (
f"first event must be response.created; got types={types}"
)
assert types[-1] == "response.completed", (
f"last event must be response.completed; got types={types}"
)
assert any(t == "response.output_text.delta" for t in types), (
f"expected text deltas from real LLM; got types={types}"
)
# The subprocess actually got spawned — verify by checking the
# manager's internal state. (Direct attribute access; the manager
# is a test-fixture instance so this is fine.)
assert "conv_runner_e2e_test" in started_manager._entries, (
"manager should have a spawned entry for the test conversation; "
"if missing, the runner didn't actually dispatch through "
"HarnessProcessManager.get_client"
)
def _parse_sse(raw: bytes) -> list[tuple[str, dict]]:
"""Decode SSE bytes into ``[(event_type, payload), ...]``."""
text = raw.decode("utf-8", errors="replace")
events: list[tuple[str, dict]] = []
current_type: str | None = None
current_data: list[str] = []
for line in text.split("\n"):
if line.startswith("event: "):
current_type = line[len("event: ") :]
elif line.startswith("data: "):
current_data.append(line[len("data: ") :])
elif line == "" and current_type is not None and current_data:
try:
payload = json.loads("\n".join(current_data))
except json.JSONDecodeError:
payload = {"_raw": "\n".join(current_data)}
events.append((current_type, payload))
current_type = None
current_data = []
return events
def test_maybe_signal_changed_files_throttles_within_window() -> None:
"""
``_maybe_signal_changed_files`` emits at most one
``session.changed_files.invalidated`` per throttle window per
session, then re-emits after the window elapses.
The monotonic ``now`` is injected so this is deterministic with no
real sleep. Regression target: a multi-file turn must collapse to
one refetch trigger (leading-edge throttle), not fire per write.
"""
from omnigent.runner.tool_dispatch import (
_CHANGED_FILES_SIGNAL_THROTTLE_S,
_maybe_signal_changed_files,
)
published: list[dict[str, object]] = []
def _pub(_sid: str, event: dict[str, Any]) -> None:
published.append(event)
sid = "conv_changed_files_throttle_unique"
# First call emits immediately (leading edge).
_maybe_signal_changed_files(sid, _pub, now=100.0)
# Within the window — suppressed.
_maybe_signal_changed_files(sid, _pub, now=100.0 + _CHANGED_FILES_SIGNAL_THROTTLE_S / 2)
# After the window — emits again.
_maybe_signal_changed_files(sid, _pub, now=100.0 + _CHANGED_FILES_SIGNAL_THROTTLE_S + 0.01)
# 2 = first (leading) + post-window; the middle call was throttled.
# If 3, the throttle window is not being honored; if 1, the
# post-window re-emit is broken.
assert len(published) == 2, f"expected 2 signals (leading + post-window), got {len(published)}"
for event in published:
assert event["type"] == "session.changed_files.invalidated"
assert event["session_id"] == sid
assert event["environment_id"] == "default"
# A missing publisher or session id is a no-op (no crash, no emit).
_maybe_signal_changed_files(None, _pub, now=200.0)
_maybe_signal_changed_files(sid, None, now=200.0)
assert len(published) == 2
def test_subagent_read_tools_are_runner_local() -> None:
"""
``sys_session_list`` and ``sys_session_get_history`` dispatch locally in the runner.
If this regresses, native harnesses that call these tools fall through to
spec-callable resolution and the user sees "not in local dispatch table"
instead of sub-agent recovery data.
"""
from omnigent.runner.tool_dispatch import should_dispatch_locally
assert should_dispatch_locally("sys_session_list") is True
assert should_dispatch_locally("sys_session_get_history") is True
# get_info is a runner-local read like list/get_history; if it falls out of
# the local dispatch table the orchestrator's status checks break.
assert should_dispatch_locally("sys_session_get_info") is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
"subagent_args",
[
pytest.param("continue", id="plain-string-contract"),
pytest.param({"input": "continue"}, id="object-input-contract"),
],
)
async def test_sys_session_send_reuses_existing_child_session(
monkeypatch: pytest.MonkeyPatch,
subagent_args: str | dict[str, str],
) -> None:
"""
Re-sending to the same ``(agent, title)`` continues the existing child.
This catches the duplicate-create regression behind unreliable
continuation: if the runner POSTs ``/v1/sessions`` despite the existing
child row, the test fails and the user would see a duplicate-title server
error instead of a continuation. The parameterized ``args`` values also
prove the runner preserves the public plain-string contract while accepting
Nessie's object form.
:param monkeypatch: Pytest monkeypatch fixture.
:param subagent_args: ``sys_session_send`` ``args`` payload.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
create_posts = 0
event_posts: list[dict[str, Any]] = []
published: list[dict[str, Any]] = []
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
nonlocal create_posts
if (
request.method == "GET"
and request.url.path == "/v1/sessions/conv_parent/child_sessions"
):
return httpx.Response(
200,
json={
"data": [
{
"id": "conv_existing",
"tool": "claude",
"session_name": "issue-1756",
"busy": False,
}
]
},
)
if request.method == "POST" and request.url.path == "/v1/sessions":
create_posts += 1
return httpx.Response(500, json={"error": "duplicate"})
if request.method == "POST" and request.url.path == "/v1/sessions/conv_existing/events":
event_posts.append(json.loads(request.content))
return httpx.Response(200, json={"ok": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "claude",
"title": "issue-1756",
"args": subagent_args,
}
),
server_client=server_client,
conversation_id="conv_parent",
agent_spec=SimpleNamespace(sub_agents=[SimpleNamespace(name="claude")]),
session_inbox=session_inbox,
publish_event=_capturing_publish_event(published),
)
finally:
runner_app.unregister_subagent_work("conv_existing")
runner_app._session_inboxes_ref.pop("conv_parent", None)
payload = json.loads(output)
assert create_posts == 0, "continuation must not create a duplicate child session"
assert payload["conversation_id"] == "conv_existing"
assert payload["status"] == "launching"
assert "continued ok" not in payload["message"]
assert event_posts[0]["data"]["content"][0]["text"] == "continue"
assert published[-1]["type"] == "session.child_session.updated"
assert published[-1]["child"]["current_task_status"] == "launching"
assert published[-1]["child"]["busy"] is False
def _spec_with_subagent_harness(harness: str) -> SimpleNamespace:
"""
Build a parent-spec stub declaring one ``worker`` sub-agent.
Mirrors the AP-style ``sub_agents`` shape ``_subagent_harness``
walks: ``executor.config["harness"]`` falling back to
``executor.type``.
:param harness: The sub-agent's declared harness, e.g.
``"codex-native"``.
:returns: A structural parent-spec stub for ``execute_tool``.
"""
return SimpleNamespace(
sub_agents=[
SimpleNamespace(
name="worker",
executor=SimpleNamespace(type="omnigent", config={"harness": harness}),
)
]
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("harness", "model"),
[
pytest.param("claude-native", "databricks-claude-sonnet-4-6", id="claude-native"),
pytest.param("codex-native", "databricks-gpt-5-4", id="codex-native"),
pytest.param("claude-sdk", "databricks-claude-sonnet-4-6", id="claude-sdk"),
],
)
async def test_sys_session_send_model_lands_in_child_create_body(
monkeypatch: pytest.MonkeyPatch,
harness: str,
model: str,
) -> None:
"""
A per-dispatch ``model`` reaches the child create as ``model_override``.
The server persists ``model_override`` on the child row, where the
native launch paths read it as ``--model`` and the SDK harness path
as ``HARNESS_<H>_MODEL``. If the create body drops the field, the
child silently runs on the harness default — the exact silent-drop
failure this feature forbids.
:param monkeypatch: Pytest monkeypatch fixture.
:param harness: Declared sub-agent harness under test.
:param model: Family-appropriate model id for *harness*.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
create_bodies: list[dict[str, Any]] = []
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Serve fresh-create child lookup, create, and message POSTs."""
if (
request.method == "GET"
and request.url.path == "/v1/sessions/conv_parent_model/child_sessions"
):
return httpx.Response(200, json={"data": []})
if request.method == "POST" and request.url.path == "/v1/sessions":
create_bodies.append(json.loads(request.content))
return httpx.Response(201, json={"id": "conv_child_model"})
if request.method == "POST" and request.url.path == "/v1/sessions/conv_child_model/events":
return httpx.Response(202, json={"queued": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "fix-auth",
"args": {
"input": "fix the auth bug",
"model": model,
},
}
),
server_client=server_client,
conversation_id="conv_parent_model",
agent_spec=_spec_with_subagent_harness(harness),
session_inbox=session_inbox,
)
finally:
runner_app.unregister_subagent_work("conv_child_model")
runner_app._session_inboxes_ref.pop("conv_parent_model", None)
payload = json.loads(output)
assert payload["status"] == "launching"
# Exactly one create, carrying the override verbatim — the value the
# server persists and the harness launch consumes.
assert len(create_bodies) == 1, "fresh named send must create exactly one child"
assert create_bodies[0]["model_override"] == model
assert create_bodies[0]["sub_agent_name"] == "worker"
@pytest.mark.asyncio
async def test_sys_session_send_blocks_fresh_dispatch_when_harness_cli_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A fresh dispatch whose harness CLI is absent fails loud, creates nothing.
Without this preflight a missing CLI surfaces only as a lazy first-turn
boot failure (the pi harness raises ImportError, which the parent sees as
a generic "turn failed" inbox item), and the orchestrator may re-dispatch
into the same wall. The tool must instead return an actionable error
naming the missing binary and install command, BEFORE creating any child
session.
:param monkeypatch: Pytest monkeypatch fixture.
"""
from omnigent.onboarding.harness_install import HarnessInstallSpec
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
# Override the autouse "all CLIs present" stub: pi's CLI is absent here.
monkeypatch.setattr(
"omnigent.onboarding.harness_install.missing_harness_cli",
lambda harness: HarnessInstallSpec("Pi", "pi", "@earendil-works/pi-coding-agent"),
)
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
create_posts = 0
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Serve the fresh-create child lookup; count (and reject) any create."""
nonlocal create_posts
if (
request.method == "GET"
and request.url.path == "/v1/sessions/conv_parent_nopi/child_sessions"
):
return httpx.Response(200, json={"data": []})
if request.method == "POST" and request.url.path == "/v1/sessions":
create_posts += 1
return httpx.Response(201, json={"id": "conv_should_not_exist"})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "review-auth",
"args": {"input": "review the diff"},
}
),
server_client=server_client,
conversation_id="conv_parent_nopi",
agent_spec=_spec_with_subagent_harness("pi"),
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop("conv_parent_nopi", None)
# The output is a plain error string (not a JSON status payload) naming
# the missing binary and how to install it — what the orchestrator/human
# needs to unblock. If this regresses, the dispatch would instead create a
# child that can never boot.
assert output.startswith("Error:")
assert "'pi' CLI" in output
assert "npm install -g @earendil-works/pi-coding-agent" in output
# No child was created — the guard returned before the create POST. A
# nonzero count would mean we spawned a worker doomed to fail at boot.
assert create_posts == 0
@pytest.mark.asyncio
async def test_sys_session_send_model_rejected_for_existing_child(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Passing ``model`` on a continuation send fails loud, sends nothing.
A native child bakes ``--model`` in at terminal launch, so applying
a new model to an existing session would be silently ignored. The
tool must return an actionable error (continue without ``model`` or
close and respawn) instead of continuing on the wrong model.
:param monkeypatch: Pytest monkeypatch fixture.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
create_posts = 0
event_posts = 0
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Serve the existing-child lookup; count any writes."""
nonlocal create_posts, event_posts
if (
request.method == "GET"
and request.url.path == "/v1/sessions/conv_parent_model_cont/child_sessions"
):
return httpx.Response(
200,
json={
"data": [
{
"id": "conv_existing_model",
"tool": "worker",
"session_name": "fix-auth",
"busy": False,
}
]
},
)
if request.method == "POST" and request.url.path == "/v1/sessions":
create_posts += 1
return httpx.Response(201, json={"id": "conv_dup"})
if request.method == "POST" and request.url.path.endswith("/events"):
event_posts += 1
return httpx.Response(202, json={"queued": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "fix-auth",
"args": {"input": "continue", "model": "claude-opus-4-8"},
}
),
server_client=server_client,
conversation_id="conv_parent_model_cont",
agent_spec=_spec_with_subagent_harness("claude-native"),
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop("conv_parent_model_cont", None)
assert output.startswith("Error:"), output
# The error must name the existing session and the recovery paths.
assert "conv_existing_model" in output
assert "sys_session_close" in output
# No write happened: the wrong-model continuation never started.
assert create_posts == 0
assert event_posts == 0
@pytest.mark.asyncio
async def test_sys_session_send_model_rejected_in_by_id_mode() -> None:
"""
``model`` plus ``session_id`` fails loud before any server call.
By-id mode always targets an existing session, where a model
override cannot take effect — the tool must reject it instead of
silently dropping the field.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
requests_seen = 0
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Count every request — none is expected."""
nonlocal requests_seen
requests_seen += 1
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"session_id": "conv_some_child",
"args": {"input": "continue", "model": "claude-opus-4-8"},
}
),
server_client=server_client,
conversation_id="conv_parent_by_id_model",
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop("conv_parent_by_id_model", None)
assert output.startswith("Error:"), output
assert "model" in output
# Rejected before lookup: a misaddressed override must not even read
# the target session.
assert requests_seen == 0
@pytest.mark.asyncio
async def test_sys_session_send_model_rejected_for_unplumbed_harness(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A ``model`` for a harness without override plumbing fails loud.
Unknown harnesses have no runner-side model-override path, so
the persisted value would be silently ignored. The error must name
the harness so the orchestrator understands why the dispatch failed.
:param monkeypatch: Pytest monkeypatch fixture.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
create_posts = 0
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Serve the empty child lookup; count creates."""
nonlocal create_posts
if (
request.method == "GET"
and request.url.path == "/v1/sessions/conv_parent_unplumbed/child_sessions"
):
return httpx.Response(200, json={"data": []})
if request.method == "POST" and request.url.path == "/v1/sessions":
create_posts += 1
return httpx.Response(201, json={"id": "conv_never"})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "summarize",
"args": {"input": "summarize this", "model": "some-model"},
}
),
server_client=server_client,
conversation_id="conv_parent_unplumbed",
agent_spec=_spec_with_subagent_harness("unknown-harness"),
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop("conv_parent_unplumbed", None)
assert output.startswith("Error:"), output
assert "unknown-harness" in output
# The unsupported dispatch must not create a child that would then
# silently run on the harness default.
assert create_posts == 0
@pytest.mark.asyncio
@pytest.mark.parametrize(
("harness", "model", "expected_rule"),
[
pytest.param(
"claude-native", "databricks-gpt-5-4", "only runs Claude models", id="gpt-on-claude"
),
pytest.param(
"codex-native",
"databricks-claude-sonnet-4-6",
"only runs GPT models",
id="claude-on-codex",
),
pytest.param(
"claude-native",
"databricks-meta-llama-3.3-70b-instruct",
"only runs Claude models",
id="unknown-family-on-claude",
),
],
)
async def test_sys_session_send_model_rejected_for_wrong_family(
monkeypatch: pytest.MonkeyPatch,
harness: str,
model: str,
expected_rule: str,
) -> None:
"""
A cross-family ``model`` fails loud at dispatch, before any create.
The single-vendor workers can only run their own vendor's models; a
wrong-family id would otherwise spawn a child that errors opaquely
at the harness/gateway. The error names the rule so the orchestrator
can re-dispatch with a compatible model or the pi worker.
:param monkeypatch: Pytest monkeypatch fixture.
:param harness: Sub-agent harness under test.
:param model: Cross-family or undeterminable model id.
:param expected_rule: Rule text the error must contain.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
create_posts = 0
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Serve the empty child lookup; count creates."""
nonlocal create_posts
if (
request.method == "GET"
and request.url.path == "/v1/sessions/conv_parent_family/child_sessions"
):
return httpx.Response(200, json={"data": []})
if request.method == "POST" and request.url.path == "/v1/sessions":
create_posts += 1
return httpx.Response(201, json={"id": "conv_never"})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "task",
"args": {"input": "do the task", "model": model},
}
),
server_client=server_client,
conversation_id="conv_parent_family",
agent_spec=_spec_with_subagent_harness(harness),
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop("conv_parent_family", None)
assert output.startswith("Error:"), output
assert expected_rule in output
assert model in output
# The rejected dispatch must not create a child that would then fail
# opaquely on its first turn.
assert create_posts == 0
@pytest.mark.asyncio
@pytest.mark.parametrize(
"bad_model",
[
pytest.param("claude; rm -rf /", id="shell-metacharacters"),
pytest.param(" ", id="whitespace-only"),
pytest.param(42, id="non-string"),
],
)
async def test_sys_session_send_model_invalid_rejected_before_any_server_call(
bad_model: object,
) -> None:
"""
Malformed ``model`` values fail loud before any server traffic.
The override eventually lands on a command line (``--model``), so
shell-shaped or non-string values must be rejected at the tool
boundary — not persisted and not silently dropped.
:param bad_model: The invalid ``model`` payload under test.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
requests_seen = 0
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Count every request — none is expected."""
nonlocal requests_seen
requests_seen += 1
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "fix-auth",
"args": {"input": "fix it", "model": bad_model},
}
),
server_client=server_client,
conversation_id="conv_parent_bad_model",
agent_spec=_spec_with_subagent_harness("claude-native"),
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop("conv_parent_bad_model", None)
assert output.startswith("Error:"), output
assert "model" in output
# Validation precedes every lookup/create — nothing reached the server.
assert requests_seen == 0
def _spec_with_real_subagent(harness: str) -> AgentSpec:
"""
Build a real parent :class:`AgentSpec` with one ``worker`` sub-agent.
Unlike :func:`_spec_with_subagent_harness` (a structural stub), this
is a fully-typed spec the model-provider resolution can walk — the
normalization gate resolves ``executor.auth`` / ``profile`` / config
providers on the sub-spec.
:param harness: The sub-agent's declared harness, e.g.
``"claude-native"``.
:returns: The parent spec.
"""
return AgentSpec(
spec_version=1,
name="parent",
sub_agents=[
AgentSpec(
spec_version=1,
name="worker",
executor=ExecutorSpec(type="omnigent", config={"harness": harness}),
)
],
)
def _isolate_model_providers(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, yaml_text: str
) -> None:
"""
Point provider resolution at an isolated config, no ambient creds.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp dir holding ``config.yaml``.
:param yaml_text: The config contents, e.g. a ``providers:`` block.
"""
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("OMNIGENT_DISABLE_KEYRING", "1")
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
monkeypatch.setattr("omnigent.onboarding.detected.detect_providers", list)
(tmp_path / "config.yaml").write_text(yaml_text)
@dataclass
class _ModelSendResult:
"""
Outcome of one fresh-create ``sys_session_send`` model dispatch.
:param output: The tool output string (JSON handle or ``Error:``).
:param create_bodies: The ``POST /v1/sessions`` bodies the mock
server captured — the persisted ``model_override`` lives here.
"""
output: str
create_bodies: list[dict[str, Any]]
async def _dispatch_model_send(
monkeypatch: pytest.MonkeyPatch,
*,
agent_spec: Any,
model: str,
conv_id: str,
) -> _ModelSendResult:
"""
Drive one fresh-create ``sys_session_send`` carrying ``args.model``.
:param monkeypatch: Pytest monkeypatch fixture.
:param agent_spec: The parent spec under test.
:param model: The requested per-dispatch model id.
:param conv_id: A unique parent conversation id per test.
:returns: The tool output and the captured create bodies.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
create_bodies: list[dict[str, Any]] = []
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Serve fresh-create child lookup, create, and message POSTs."""
if (
request.method == "GET"
and request.url.path == f"/v1/sessions/{conv_id}/child_sessions"
):
return httpx.Response(200, json={"data": []})
if request.method == "POST" and request.url.path == "/v1/sessions":
create_bodies.append(json.loads(request.content))
return httpx.Response(201, json={"id": "conv_child_norm"})
if request.method == "POST" and request.url.path == "/v1/sessions/conv_child_norm/events":
return httpx.Response(202, json={"queued": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "task",
"args": {"input": "do the task", "model": model},
}
),
server_client=server_client,
conversation_id=conv_id,
agent_spec=agent_spec,
session_inbox=session_inbox,
)
finally:
runner_app.unregister_subagent_work("conv_child_norm")
runner_app._session_inboxes_ref.pop(conv_id, None)
return _ModelSendResult(output=output, create_bodies=create_bodies)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("harness", "model", "expected"),
[
pytest.param(
"claude-native",
"claude-sonnet-4-6",
"databricks-claude-sonnet-4-6",
id="canonical-claude-localized",
),
pytest.param(
"codex-native", "gpt-5-4", "databricks-gpt-5-4", id="canonical-gpt-localized"
),
pytest.param(
"claude-native",
"databricks-claude-sonnet-4-6",
"databricks-claude-sonnet-4-6",
id="already-local-unchanged",
),
pytest.param(
"claude-native",
"us.anthropic.claude-sonnet-4-6",
"us.anthropic.claude-sonnet-4-6",
id="non-mechanical-passthrough",
),
],
)
async def test_sys_session_send_localizes_canonical_model_for_gateway_child(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
harness: str,
model: str,
expected: str,
) -> None:
"""
A gateway-routed child persists the gateway-local spelling.
With a Databricks default provider, a bare canonical vendor id
(``claude-sonnet-4-6``) would die at the gateway ("model not
found"); the gate must persist the ``databricks-``-prefixed
spelling as ``model_override`` — and ONLY for mechanical ids:
already-local and vendor-prefixed shapes pass through verbatim.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp dir for the isolated provider config.
:param harness: The sub-agent harness under test.
:param model: The requested per-dispatch model id.
:param expected: The id the create body must persist.
"""
_isolate_model_providers(
monkeypatch,
tmp_path,
"providers:\n workspace:\n kind: databricks\n profile: prof-a\n default: true\n",
)
result = await _dispatch_model_send(
monkeypatch,
agent_spec=_spec_with_real_subagent(harness),
model=model,
conv_id="conv_parent_norm_gateway",
)
payload = json.loads(result.output)
assert payload["status"] == "launching"
# The persisted override is the localized id — this is the value the
# server stores and the harness launch consumes.
assert len(result.create_bodies) == 1
assert result.create_bodies[0]["model_override"] == expected
@pytest.mark.asyncio
async def test_sys_session_send_strips_gateway_prefix_for_vendor_direct_child(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""
A vendor-direct child persists the bare canonical spelling.
With an Anthropic API-key default provider, a ``databricks-``
prefixed id would be rejected by the vendor API; the gate must
strip the prefix before persisting.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp dir for the isolated provider config.
"""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
_isolate_model_providers(
monkeypatch,
tmp_path,
"providers:\n"
" anthropic:\n"
" kind: key\n"
" default: true\n"
" anthropic:\n"
" base_url: https://api.anthropic.com\n"
" api_key: $ANTHROPIC_API_KEY\n",
)
result = await _dispatch_model_send(
monkeypatch,
agent_spec=_spec_with_real_subagent("claude-native"),
model="databricks-claude-opus-4-8",
conv_id="conv_parent_norm_direct",
)
payload = json.loads(result.output)
assert payload["status"] == "launching"
assert len(result.create_bodies) == 1
# Stripped: the vendor API only routes the bare canonical id.
assert result.create_bodies[0]["model_override"] == "claude-opus-4-8"
@pytest.mark.asyncio
async def test_sys_session_send_passes_model_through_when_provider_undeterminable(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""
An undeterminable child provider leaves the requested id untouched.
The structural sub-spec stub has no auth/profile attributes, so
provider resolution degrades to "none" — the gate must neither
crash the dispatch nor guess a transform.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp dir for the isolated provider config.
"""
_isolate_model_providers(monkeypatch, tmp_path, "")
result = await _dispatch_model_send(
monkeypatch,
agent_spec=_spec_with_subagent_harness("claude-native"),
model="claude-sonnet-4-6",
conv_id="conv_parent_norm_unknown",
)
payload = json.loads(result.output)
assert payload["status"] == "launching"
assert len(result.create_bodies) == 1
# Pass-through: no provider kind, no transform — fail-loud at the
# harness remains the safety net.
assert result.create_bodies[0]["model_override"] == "claude-sonnet-4-6"
@pytest.mark.asyncio
async def test_sys_session_send_family_guard_runs_before_normalization(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""
The family guard fires on the RAW requested id, before any localize.
A GPT id on a claude worker must be rejected quoting exactly what
the caller sent (``gpt-5-4``, not ``databricks-gpt-5-4``) and no
child may be created — even though the gateway provider would have
localized the id had the guard passed.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp dir for the isolated provider config.
"""
_isolate_model_providers(
monkeypatch,
tmp_path,
"providers:\n workspace:\n kind: databricks\n profile: prof-a\n default: true\n",
)
result = await _dispatch_model_send(
monkeypatch,
agent_spec=_spec_with_real_subagent("claude-native"),
model="gpt-5-4",
conv_id="conv_parent_norm_family",
)
assert result.output.startswith("Error:"), result.output
assert "only runs Claude models" in result.output
# The error quotes the caller's raw id, proving the guard ran first.
assert "'gpt-5-4'" in result.output
assert result.create_bodies == []
@pytest.mark.asyncio
async def test_sys_list_models_dispatches_locally_with_static_provider(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""
``execute_tool`` routes ``sys_list_models`` to the catalog enumerator.
With a subscription default (static — no HTTP), the payload must
carry one row per declared sub-agent plus ``self``, each in the
documented ``{source, verified, models, note}`` shape with the
curated claude ids surviving the claude-family filter.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp dir for the isolated provider config.
"""
from omnigent.runner.tool_dispatch import execute_tool
_isolate_model_providers(
monkeypatch,
tmp_path,
"providers:\n claude:\n kind: subscription\n cli: claude\n default: true\n",
)
output = await execute_tool(
tool_name="sys_list_models",
arguments="{}",
agent_spec=_spec_with_real_subagent("claude-native"),
conversation_id="conv_list_models",
)
payload = json.loads(output)
assert set(payload) == {"worker", "self"}
worker = payload["worker"]
assert worker["source"] == "static"
assert worker["verified"] is False
# The curated claude aliases survive the claude-family filter — the
# exact ids an orchestrator may pass back as args.model.
assert [m["id"] for m in worker["models"]] == [
"claude-fable-5",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-haiku-4-5",
]
assert worker["note"]
@pytest.mark.asyncio
async def test_sys_list_models_requires_agent_spec() -> None:
"""
``sys_list_models`` with no resolvable spec fails loud, not empty.
A silent ``{}`` would read as "no workers exist" — the error string
tells the orchestrator the runner couldn't resolve its spec.
"""
from omnigent.runner.tool_dispatch import execute_tool
output = await execute_tool(
tool_name="sys_list_models",
arguments="{}",
agent_spec=None,
conversation_id="conv_list_models_nospec",
)
assert output.startswith("Error:")
assert "agent spec" in output
@pytest.mark.asyncio
async def test_sys_session_send_by_id_rejects_closed_child(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
By-id ``sys_session_send`` refuses closed direct children.
The close tool hands orchestrators a durable ``conversation_id``.
Without checking ``omnigent.closed=true`` in by-id mode, an
orchestrator could keep chatting with the exact child it had just
closed, bypassing the named lookup that skips closed rows.
:param monkeypatch: Pytest monkeypatch fixture.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
event_posts = 0
registrations: list[str] = []
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
monkeypatch.setattr(
runner_app,
"register_child_session",
lambda child_id, **_kwargs: registrations.append(child_id),
)
async def _server_handler(request: httpx.Request) -> httpx.Response:
nonlocal event_posts
if request.method == "GET" and request.url.path == "/v1/sessions/conv_closed":
return httpx.Response(
200,
json={
"id": "conv_closed",
"title": "researcher:auth",
"parent_session_id": "conv_parent",
"labels": {CLOSED_LABEL_KEY: CLOSED_LABEL_VALUE},
"busy": False,
},
)
if request.method == "POST" and request.url.path == "/v1/sessions/conv_closed/events":
event_posts += 1
return httpx.Response(202, json={"queued": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"session_id": "conv_closed",
"args": "please continue",
}
),
server_client=server_client,
conversation_id="conv_parent",
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop("conv_parent", None)
payload = json.loads(output)
assert payload["error"] == "session_closed"
assert payload["conversation_id"] == "conv_closed"
assert event_posts == 0
assert registrations == []
@pytest.mark.asyncio
async def test_sys_session_send_completion_drains_from_parent_inbox(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A completed async sub-agent turn arrives through ``sys_read_inbox``.
This proves ``sys_session_send`` no longer inlines the child result in the
tool result. If completion delivery is not wired, the drain returns the
empty-inbox sentinel instead of the child marker.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Serve child-session create, lookup, and message POST requests."""
if (
request.method == "GET"
and request.url.path == "/v1/sessions/conv_parent_inbox/child_sessions"
):
return httpx.Response(200, json={"data": []})
if request.method == "POST" and request.url.path == "/v1/sessions":
return httpx.Response(201, json={"id": "conv_child_inbox"})
if request.method == "POST" and request.url.path == "/v1/sessions/conv_child_inbox/events":
return httpx.Response(202, json={"queued": True})
if (
request.method == "POST"
and request.url.path == "/v1/sessions/conv_parent_inbox/policies/evaluate"
):
return httpx.Response(200, json={"result": "POLICY_ACTION_UNSPECIFIED"})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "phase-a",
"args": "run phase a",
}
),
server_client=server_client,
conversation_id="conv_parent_inbox",
agent_spec=SimpleNamespace(sub_agents=[SimpleNamespace(name="worker")]),
session_inbox=session_inbox,
)
payload = json.loads(output)
assert payload["status"] == "launching"
assert "CHILD_MARKER" not in payload["message"]
runner_app.mark_subagent_work_terminal(
"conv_child_inbox",
status="completed",
output="CHILD_MARKER",
)
inbox_output = await execute_tool(
tool_name="sys_read_inbox",
arguments="{}",
server_client=server_client,
conversation_id="conv_parent_inbox",
session_inbox=session_inbox,
)
finally:
runner_app.unregister_subagent_work("conv_child_inbox")
runner_app._session_inboxes_ref.pop("conv_parent_inbox", None)
assert "sub-agent task conv_child_inbox completed" in inbox_output
assert "worker:phase-a returned: CHILD_MARKER" in inbox_output
@pytest.mark.asyncio
async def test_subagent_inbox_cleanup_does_not_unregister_next_turn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Draining an old child result must not delete a newer turn's work entry.
Named sub-agents reuse the same child session. A parent can start a second
turn after the first has completed but before reading the first result. The
first inbox item's cleanup is guarded by the per-dispatch work id so it
cannot unregister the second running turn.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
parent_id = "conv_parent_reused_child"
child_id = "conv_reused_child"
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
lookup_count = 0
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Serve two sends to the same child and policy checks for drains."""
nonlocal lookup_count
if (
request.method == "GET"
and request.url.path == f"/v1/sessions/{parent_id}/child_sessions"
):
lookup_count += 1
if lookup_count == 1:
return httpx.Response(200, json={"data": []})
return httpx.Response(
200,
json={
"data": [
{
"id": child_id,
"title": "worker:repeat",
"sub_agent_name": "worker",
"busy": False,
}
]
},
)
if request.method == "POST" and request.url.path == "/v1/sessions":
return httpx.Response(201, json={"id": child_id})
if request.method == "POST" and request.url.path == f"/v1/sessions/{child_id}/events":
return httpx.Response(202, json={"queued": True})
if (
request.method == "POST"
and request.url.path == f"/v1/sessions/{parent_id}/policies/evaluate"
):
return httpx.Response(200, json={"result": "POLICY_ACTION_UNSPECIFIED"})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
for prompt in ("first", "second"):
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "repeat",
"args": prompt,
}
),
server_client=server_client,
conversation_id=parent_id,
agent_spec=SimpleNamespace(sub_agents=[SimpleNamespace(name="worker")]),
session_inbox=session_inbox,
)
assert json.loads(output)["status"] == "launching"
if prompt == "first":
runner_app.mark_subagent_work_terminal(
child_id,
status="completed",
output="FIRST_RESULT",
)
first_drain = await execute_tool(
tool_name="sys_read_inbox",
arguments="{}",
server_client=server_client,
conversation_id=parent_id,
session_inbox=session_inbox,
)
current = runner_app.get_subagent_work(child_id)
assert current is not None, (
"Draining the first turn must not unregister the second turn's "
"active work entry for the reused child session."
)
assert current.status == "launching"
runner_app.mark_subagent_work_terminal(
child_id,
status="completed",
output="SECOND_RESULT",
)
second_drain = await execute_tool(
tool_name="sys_read_inbox",
arguments="{}",
server_client=server_client,
conversation_id=parent_id,
session_inbox=session_inbox,
)
assert runner_app.get_subagent_work(child_id) is None, (
"Draining the matching second turn must unregister terminal "
"work; otherwise the registry leaks completed child entries."
)
finally:
runner_app.unregister_subagent_work(child_id)
runner_app._session_inboxes_ref.pop(parent_id, None)
assert "worker:repeat returned: FIRST_RESULT" in first_drain
assert "SECOND_RESULT" not in first_drain
assert "worker:repeat returned: SECOND_RESULT" in second_drain
def _sse_text_turn(text: str) -> list[str]:
"""Script one scaffold turn's SSE frames carrying ``text`` as output.
The runner's ``proxy_stream`` accumulates ``response.output_text.delta``
chunks and, on ``response.completed``, commits an assistant message to
``_session_histories`` — which is what ``_extract_last_assistant_text``
reads for the sub-agent's delivered output. So a turn that emits
``text`` here is the turn whose result the parent should receive.
:param text: Assistant text this turn streams, e.g. ``"FINAL"``.
:returns: SSE frames for a created → one-delta → completed turn.
"""
return [
'event: response.created\ndata: {"type":"response.created",'
'"response":{"id":"resp_multiturn"}}\n\n',
"event: response.output_text.delta\ndata: "
+ json.dumps({"type": "response.output_text.delta", "delta": text})
+ "\n\n",
'event: response.completed\ndata: {"type":"response.completed",'
'"response":{"id":"resp_multiturn","status":"completed"}}\n\n',
]
class _GatedTwoTurnHarnessStream:
"""Per-turn-scripted harness stream that blocks turn 1 mid-flight.
Turn 1 yields ``response.created`` + the intermediate text delta, sets
``started`` (the sync gate so the test knows the turn is live and in
``_active_turns``), then awaits ``release`` before yielding
``response.completed``. This holds turn 1 active deterministically while
the test posts a second message (which buffers a continuation) — no
sleeps, no polling of runner internals. Turn 2 (and any later turn)
streams its scripted frames straight through.
:param turns: Per-turn SSE frame lists, indexed by call order.
:param call_index: Shared 1-based turn counter (mutated per ``stream``).
:param started: Set once turn 1 has emitted its intermediate delta.
:param release: Awaited by turn 1 before emitting ``response.completed``.
"""
def __init__(
self,
turns: list[list[str]],
call_index: list[int],
started: asyncio.Event,
release: asyncio.Event,
) -> None:
"""Store scripted turns and the turn-1 synchronization events.
:param turns: Per-turn SSE frame lists, indexed by call order.
:param call_index: Shared 1-based turn counter; this stream reads
and advances it so each turn serves the next script.
:param started: Set once turn 1 has emitted its intermediate delta.
:param release: Gates turn 1's terminal ``response.completed``.
"""
self._call_index = call_index
self._call_index[0] += 1
self._turn_number = self._call_index[0]
self._frames = turns[min(self._turn_number - 1, len(turns) - 1)]
self._started = started
self._release = release
self.status_code = 200
async def __aenter__(self) -> _GatedTwoTurnHarnessStream:
"""Enter the stream context.
:returns: This stream.
"""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
"""Exit without suppressing exceptions.
:param exc_type: Exception type from the context, if any.
:param exc: Exception value from the context, if any.
:param tb: Traceback from the context, if any.
:returns: None.
"""
del exc_type, exc, tb
async def aiter_text(self) -> AsyncIterator[str]:
"""Yield scripted frames, blocking turn 1 before it completes.
:returns: Async iterator of SSE frames.
"""
if self._turn_number == 1:
# Emit created + the intermediate delta, then hand control back to
# the test (started) and wait (release) so turn 1 stays the active
# turn while the test buffers a continuation message.
yield self._frames[0]
yield self._frames[1]
self._started.set()
await self._release.wait()
yield self._frames[2]
return
for frame in self._frames:
yield frame
class _GatedTwoTurnHarnessClient:
"""Harness client whose ``stream`` returns the gated two-turn stream.
Also implements ``post`` (202) so the runner's best-effort mid-turn
injection forward — fired when the second message buffers — succeeds
quietly instead of raising into the buffering path.
:param turns: Per-turn SSE frame lists.
:param started: Set once turn 1 has emitted its intermediate delta.
:param release: Gates turn 1's terminal ``response.completed``.
"""
def __init__(
self,
turns: list[list[str]],
started: asyncio.Event,
release: asyncio.Event,
) -> None:
"""Store the scripts and synchronization events.
:param turns: Per-turn SSE frame lists.
:param started: Set once turn 1 emits its intermediate delta.
:param release: Gates turn 1's terminal ``response.completed``.
"""
self._turns = turns
self._started = started
self._release = release
self._call_index = [0]
def stream(
self,
method: str,
url: str,
*,
json: dict[str, object],
timeout: float | None,
) -> _GatedTwoTurnHarnessStream:
"""Return the next gated turn stream.
:param method: HTTP method (ignored).
:param url: Harness endpoint path (ignored).
:param json: JSON body (ignored).
:param timeout: Request timeout (ignored).
:returns: Gated two-turn stream for the current turn.
"""
del method, url, json, timeout
return _GatedTwoTurnHarnessStream(
self._turns, self._call_index, self._started, self._release
)
async def post(
self,
url: str,
*,
json: dict[str, object],
timeout: float | None,
) -> httpx.Response:
"""Accept the runner's mid-turn injection forward (best-effort).
:param url: Harness endpoint path (ignored).
:param json: Forwarded message body (ignored — the buffered copy is
what drives the continuation; this fake never echoes an
``injection.consumed`` marker, so the buffer survives).
:param timeout: Request timeout (ignored).
:returns: A 202 so the forward is treated as accepted.
"""
del url, json, timeout
return httpx.Response(202, json={"queued": True})
@pytest.mark.asyncio
async def test_scaffold_subagent_defers_terminal_delivery_while_continuation_buffered() -> None:
"""A scaffold child running two turns delivers ONLY the final turn's text.
Reproduces the multi-turn delivery bug: ``_on_proxy_stream_end`` used to
mark a scaffold child's work entry terminal (``completed`` +
``_extract_last_assistant_text``) at EVERY successful turn end. A child
that runs a second turn without a fresh parent ``sys_session_send`` — here
a buffered continuation message drained by ``_check_and_start_next_turn``
— then had its FIRST turn's intermediate narration delivered as the
result, and its real final synthesis dropped by the already-terminal +
``delivered`` short-circuit in ``mark_subagent_work_terminal``.
Determinism: turn 1's harness stream blocks (``release``) after emitting
its intermediate delta and signals ``started`` so the test can post the
second message while turn 1 is provably the active turn (so it buffers a
continuation). Releasing turn 1 then lets the continuation run to its own
empty-buffer stream end. No sleeps, no polling of runner internals.
"""
from omnigent.runner import app as runner_app
parent_id = "conv_parent_multiturn_defer"
child_id = "conv_child_multiturn_defer"
started = asyncio.Event()
release = asyncio.Event()
turns = [_sse_text_turn("INTERMEDIATE_NARRATION"), _sse_text_turn("FINAL_SYNTHESIS")]
async def _spec_resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""Return a non-native scaffold spec so the success-delivery path runs.
The test harness name is unknown to ``_build_spawn_env_from_spec``
(no model needed) and is not ``claude-native`` / ``codex-native``, so
``_is_native_harness`` is False and the scaffold completion branch in
``_on_proxy_stream_end`` is exercised.
:param agent_id: Agent id requested by the runner (unused).
:param session_id: Session id (unused).
:returns: A minimal scaffold spec bound to the test harness.
"""
del agent_id, session_id
return AgentSpec(
spec_version=1,
name="scaffold-multiturn-agent",
executor=ExecutorSpec(type="omnigent", config={"harness": _TEST_HARNESS_NAME}),
)
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_FakeProcessManager(_GatedTwoTurnHarnessClient(turns, started, release)),
),
spec_resolver=_spec_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
# Register the child as tracked sub-agent work with a real parent inbox —
# the same module-level surface a parent ``sys_session_send`` uses. The
# inbox queue is the observable delivery surface.
parent_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
runner_app._session_inboxes_ref[parent_id] = parent_inbox
runner_app.register_subagent_work(
parent_session_id=parent_id,
child_session_id=child_id,
agent="worker",
title="multiturn",
)
try:
async with _runner_test_client(app) as http:
# Turn 1: starts a background turn that blocks before completing.
resp1 = await http.post(
f"/v1/sessions/{child_id}/events",
json={
"type": "message",
"role": "user",
"agent_id": "ag_scaffold",
"model": "x",
"content": [{"type": "input_text", "text": "do the work"}],
},
)
assert resp1.status_code == 202
# Sync gate: turn 1 is live (in _active_turns) and mid-stream.
await asyncio.wait_for(started.wait(), timeout=10.0)
# Concurrent action: a second message arrives while turn 1 is
# active, so it buffers a continuation (the fake never emits an
# injection.consumed marker, so the buffered copy survives).
resp2 = await http.post(
f"/v1/sessions/{child_id}/events",
json={
"type": "message",
"role": "user",
"agent_id": "ag_scaffold",
"model": "x",
"content": [{"type": "input_text", "text": "now refine"}],
},
)
# 202 with status "buffered" proves the message buffered against the
# active turn rather than starting its own turn — the precondition
# for the deferral path. If this were "accepted", turn 1 would not
# have a continuation pending and the bug wouldn't apply.
assert resp2.status_code == 202
assert resp2.json()["status"] == "buffered"
# End turn 1. With the fix, the non-empty buffer defers delivery;
# the continuation turn 2 then runs to its own empty-buffer end.
release.set()
# Wait for the child to fully finish: terminal work entry +
# exactly one delivered inbox item. The continuation turn is
# guaranteed to reach _on_proxy_stream_end again with an empty
# buffer, so this never hangs unless the result was stranded.
deadline = asyncio.get_running_loop().time() + 10.0
while asyncio.get_running_loop().time() < deadline:
entry = runner_app.get_subagent_work(child_id)
if entry is not None and entry.delivered:
break
await asyncio.sleep(0.02)
entry = runner_app.get_subagent_work(child_id)
delivered_items: list[dict[str, Any]] = []
while not parent_inbox.empty():
delivered_items.append(parent_inbox.get_nowait())
finally:
release.set()
runner_app.unregister_subagent_work(child_id)
runner_app._session_inboxes_ref.pop(parent_id, None)
# Exactly one terminal payload reached the parent inbox. Two items would
# mean the bug's double-delivery (intermediate + final); zero would mean
# the deferral stranded the result (a worse failure than the bug).
assert len(delivered_items) == 1, (
f"expected exactly one terminal delivery, got {len(delivered_items)}: "
f"{[i.get('output') for i in delivered_items]}"
)
item = delivered_items[0]
assert item["status"] == "completed" # success terminal status
# The delivered output is the FINAL turn's text, not turn 1's intermediate
# narration. Before the fix, turn 1's "INTERMEDIATE_NARRATION" was delivered
# as the terminal result and the final synthesis was dropped — so this
# asserts the exact content that proves the right turn won.
assert item["output"] == "FINAL_SYNTHESIS", (
f"parent must receive the final turn's synthesis, got {item['output']!r}; "
f"'INTERMEDIATE_NARRATION' here means turn 1 was delivered prematurely."
)
# The work entry settled terminal-and-delivered on the continuation turn.
assert entry is not None
assert entry.status == "completed"
assert entry.delivered is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status", "policy_response", "expected_output", "blocked_output"),
[
pytest.param(
"completed",
{"result": "POLICY_ACTION_DENY", "reason": "secret output"},
"[Result suppressed by policy: secret output]",
"SECRET_MARKER",
id="completed-deny-suppresses",
),
pytest.param(
"completed",
{"result": "POLICY_ACTION_ALLOW", "data": "<REDACTED>"},
"<REDACTED>",
"SECRET_MARKER",
id="completed-allow-data-transforms",
),
pytest.param(
"failed",
{"result": "POLICY_ACTION_DENY", "reason": "failed secret output"},
"[Result suppressed by policy: failed secret output]",
"SECRET_MARKER",
id="failed-deny-suppresses",
),
],
)
async def test_sys_read_inbox_applies_subagent_tool_result_policy(
status: str,
policy_response: dict[str, Any],
expected_output: str,
blocked_output: str,
) -> None:
"""
``sys_read_inbox`` evaluates delayed sub-agent output as TOOL_RESULT.
``sys_session_send`` returns a launching handle immediately, so the
child output arrives after the original tool call. The delayed
output must still pass through Omnigent policy evaluation before the LLM
sees it in the inbox drain.
:param status: Terminal sub-agent status being drained.
:param policy_response: Fake Omnigent policy verdict body.
:param expected_output: Output expected in the drained inbox text.
:param blocked_output: Raw child output that policy must remove.
"""
from omnigent.runner.tool_dispatch import execute_tool
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
session_inbox.put_nowait(
{
"type": "sub_agent",
"task_id": "conv_child_policy",
"handle_id": "conv_child_policy",
"conversation_id": "conv_child_policy",
"tool_name": "worker",
"agent": "worker",
"title": "phase-policy",
"status": status,
"output": "SECRET_MARKER",
}
)
policy_requests: list[dict[str, Any]] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Capture the Omnigent policy evaluation request."""
if (
request.method == "POST"
and request.url.path == "/v1/sessions/conv_parent_policy/policies/evaluate"
):
policy_requests.append(json.loads(request.content))
return httpx.Response(200, json=policy_response)
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
inbox_output = await execute_tool(
tool_name="sys_read_inbox",
arguments="{}",
server_client=server_client,
conversation_id="conv_parent_policy",
session_inbox=session_inbox,
)
assert len(policy_requests) == 1, (
"Delayed sub-agent output must be policy-checked exactly once: "
"0 means raw output bypassed TOOL_RESULT policy; >1 means duplicate evaluation."
)
event = policy_requests[0]["event"]
assert event["type"] == "PHASE_TOOL_RESULT"
assert event["data"]["result"] == "SECRET_MARKER"
assert event["request_data"]["name"] == "sys_session_send"
assert event["request_data"]["args"] == {
"agent": "worker",
"title": "phase-policy",
"conversation_id": "conv_child_policy",
}
assert expected_output in inbox_output
assert blocked_output not in inbox_output
@pytest.mark.asyncio
async def test_sys_read_inbox_requeues_subagent_output_on_transient_policy_failure() -> None:
"""
Transient policy-evaluation failures must not destroy child output.
The first drain receives a non-JSON policy response, so
``sys_read_inbox`` must fail closed and hide the raw child output.
Because no real DENY/ASK/ALLOW verdict exists yet, the original
payload must remain retryable; otherwise the second drain could not
return the child result after the policy service recovers.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
parent_id = "conv_parent_policy_retry"
child_id = "conv_child_policy_retry"
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
runner_app._session_inboxes_ref[parent_id] = session_inbox
runner_app.register_subagent_work(
parent_session_id=parent_id,
child_session_id=child_id,
agent="worker",
title="retry-policy",
)
runner_app.mark_subagent_work_terminal(
child_id,
status="completed",
output="SECRET_RETRY_MARKER",
)
policy_attempts = 0
work_after_second_drain: object = "not-drained"
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""
Fail policy evaluation once, then allow the retry.
:param request: Omnigent policy-evaluation request.
:returns: Non-JSON response on first call, allow verdict later.
"""
nonlocal policy_attempts
if (
request.method == "POST"
and request.url.path == f"/v1/sessions/{parent_id}/policies/evaluate"
):
policy_attempts += 1
if policy_attempts == 1:
return httpx.Response(200, content=b"not-json")
return httpx.Response(200, json={"result": "POLICY_ACTION_UNSPECIFIED"})
return httpx.Response(404, json={"error": str(request.url)})
try:
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
first_drain = await execute_tool(
tool_name="sys_read_inbox",
arguments="{}",
server_client=server_client,
conversation_id=parent_id,
session_inbox=session_inbox,
)
assert "[Result suppressed by policy: policy evaluation failed]" in first_drain
assert "SECRET_RETRY_MARKER" not in first_drain
assert runner_app.get_subagent_work(child_id) is not None, (
"A transient policy failure must not unregister completed "
"sub-agent work, or the real child output is lost permanently."
)
assert session_inbox.qsize() == 1, (
"The original payload must be requeued for a later policy "
"retry instead of being consumed by the failed drain."
)
second_drain = await execute_tool(
tool_name="sys_read_inbox",
arguments="{}",
server_client=server_client,
conversation_id=parent_id,
session_inbox=session_inbox,
)
work_after_second_drain = runner_app.get_subagent_work(child_id)
finally:
runner_app.unregister_subagent_work(child_id)
runner_app._session_inboxes_ref.pop(parent_id, None)
assert policy_attempts == 2
assert "worker:retry-policy returned: SECRET_RETRY_MARKER" in second_drain
assert work_after_second_drain is None
def test_list_tasks_is_not_runner_local_builtin() -> None:
"""
``list_tasks`` is no longer a framework builtin.
User/local tools may still choose that name, so the runner must not
claim it as a local lifecycle tool or relay it to native harnesses as
a framework-owned builtin.
"""
from omnigent.runner.tool_dispatch import (
_NATIVE_RELAY_BUILTIN_TOOLS,
should_dispatch_locally,
)
assert should_dispatch_locally("list_tasks") is False
assert "list_tasks" not in _NATIVE_RELAY_BUILTIN_TOOLS
@pytest.mark.asyncio
async def test_sys_cancel_task_stops_subagent_and_dedupes_late_completion(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``sys_cancel_task`` hard-stops a running claude-native child cleanly.
The created child carries the ``claude-code-native-ui`` wrapper label, so
the cancel routes to ``stop_session`` (the claude-native hard-stop). The
mock server marks the child cancelled when it receives the event, matching
the synchronous claude-native stop path (``_handle_claude_native_stop``
kills the pane and reclaims the work entry). A later completion attempt
must not enqueue a second completed inbox item.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
stops: list[dict[str, Any]] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""Serve the child create/message flow and cancellation event."""
if (
request.method == "GET"
and request.url.path == "/v1/sessions/conv_parent_cancel/child_sessions"
):
return httpx.Response(200, json={"data": []})
if request.method == "POST" and request.url.path == "/v1/sessions":
# claude-native sub-agent: the server stamps the wrapper label so
# the cancel routes to stop_session (the hard-stop path).
return httpx.Response(
201,
json={
"id": "conv_child_cancel",
"labels": {"omnigent.wrapper": "claude-code-native-ui"},
},
)
if (
request.method == "POST"
and request.url.path == "/v1/sessions/conv_child_cancel/events"
):
body = json.loads(request.content)
if body.get("type") == "stop_session":
stops.append(body)
runner_app.mark_subagent_work_terminal(
"conv_child_cancel",
status="cancelled",
output="[System: sub-agent stopped]",
)
return httpx.Response(202, json={"queued": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "runner",
"title": "phase-c",
"args": "run phase c",
}
),
server_client=server_client,
conversation_id="conv_parent_cancel",
agent_spec=SimpleNamespace(sub_agents=[SimpleNamespace(name="runner")]),
session_inbox=session_inbox,
)
cancel_output = json.loads(
await execute_tool(
tool_name="sys_cancel_task",
arguments=json.dumps({"task_id": "conv_child_cancel"}),
server_client=server_client,
conversation_id="conv_parent_cancel",
session_async_tasks={},
)
)
runner_app.mark_subagent_work_terminal(
"conv_child_cancel",
status="completed",
output="SHOULD_NOT_DELIVER",
)
inbox_output = await execute_tool(
tool_name="sys_read_inbox",
arguments="{}",
session_inbox=session_inbox,
)
finally:
runner_app.unregister_subagent_work("conv_child_cancel")
runner_app._session_inboxes_ref.pop("conv_parent_cancel", None)
# ``data`` rides along because SessionEventInput requires it on older
# servers — omitting it 422'd sub-agent cancellation in production.
assert stops == [{"type": "stop_session", "data": {}}]
assert cancel_output == {
"cancelled": True,
"task_id": "conv_child_cancel",
"status": "cancelled",
}
assert inbox_output == (
"[System: sub-agent task conv_child_cancel cancelled — runner:phase-c]"
), "Cancelled sub-agent work must produce exactly one cancelled inbox item."
@pytest.mark.asyncio
async def test_sys_cancel_task_reports_codex_native_cancel_as_best_effort() -> None:
"""
Unconfirmed codex-native cancel must not promise terminal inbox status.
Codex-native has no runner-side hard-stop path, so the cancel routes to
``interrupt`` (not ``stop_session``, which the runner 204 no-ops for every
non-claude-native harness). For codex-native that interrupt is itself a
best-effort no-op — the child can remain running after the POST returns —
so the tool result must say cancellation is best-effort instead of telling
the parent to wait forever for a terminal inbox item.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
parent_id = "conv_parent_codex_cancel"
child_id = "conv_child_codex_cancel"
runner_app.register_subagent_work(
parent_session_id=parent_id,
child_session_id=child_id,
agent="codex_impl",
title="native",
wrapper_label="codex-native-ui",
)
stops: list[dict[str, Any]] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""
Accept the interrupt without marking the child terminal (codex no-op).
:param request: Request sent to the child session events route.
:returns: Accepted response.
"""
if request.method == "POST" and request.url.path == f"/v1/sessions/{child_id}/events":
stops.append(json.loads(request.content))
return httpx.Response(204)
return httpx.Response(404, json={"error": str(request.url)})
try:
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
cancel_output = json.loads(
await execute_tool(
tool_name="sys_cancel_task",
arguments=json.dumps({"task_id": child_id}),
server_client=server_client,
conversation_id=parent_id,
session_async_tasks={},
)
)
finally:
runner_app.unregister_subagent_work(child_id)
# codex-native routes to interrupt, not stop_session (which the runner
# 204 no-ops for non-claude-native harnesses). ``data`` rides along for
# SessionEventInput compatibility with older servers.
assert stops == [{"type": "interrupt", "data": {}}]
assert cancel_output == {
"cancel_requested": True,
"cancel_confirmed": False,
"best_effort": True,
"task_id": child_id,
"status": "launching",
"message": (
"Interrupt forwarded, but a runner-side hard-stop is not wired "
"for codex-native workers yet; the child may keep running and no "
"terminal inbox status is guaranteed."
),
}
@pytest.mark.asyncio
async def test_sys_cancel_task_interrupts_non_native_subagent() -> None:
"""
A non-native (in-process) sub-agent cancel must post ``interrupt``.
In-process harnesses (e.g. ``claude-sdk``) have no wrapper label. The
runner's ``stop_session`` handler 204 no-ops for them, so posting
``stop_session`` would silently leave the child running and the parent
work entry stuck ``running``. ``interrupt`` is the path they honor —
``_interrupted_sessions`` → ``_on_proxy_stream_end`` marks the turn
cancelled and wakes the parent. This guards against regressing to an
unconditional ``stop_session`` (which dropped in-process cancellation).
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
parent_id = "conv_parent_inproc_cancel"
child_id = "conv_child_inproc_cancel"
runner_app.register_subagent_work(
parent_session_id=parent_id,
child_session_id=child_id,
agent="researcher",
title="analysis",
wrapper_label=None, # in-process child has no native wrapper label
)
posts: list[dict[str, Any]] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""
Accept the interrupt; the in-process turn is cancelled out-of-band.
:param request: Request sent to the child session events route.
:returns: Accepted response (deferred cancel, child still running).
"""
if request.method == "POST" and request.url.path == f"/v1/sessions/{child_id}/events":
posts.append(json.loads(request.content))
return httpx.Response(204)
return httpx.Response(404, json={"error": str(request.url)})
try:
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
cancel_output = json.loads(
await execute_tool(
tool_name="sys_cancel_task",
arguments=json.dumps({"task_id": child_id}),
server_client=server_client,
conversation_id=parent_id,
session_async_tasks={},
)
)
finally:
runner_app.unregister_subagent_work(child_id)
# The regression guard: a non-native child must route to interrupt, never
# the stop_session that the runner no-ops for non-claude-native harnesses.
# ``data`` rides along for SessionEventInput compatibility with older servers.
assert posts == [{"type": "interrupt", "data": {}}]
# Not codex → generic (non-best-effort) pending result; the terminal
# status will arrive on the inbox once the interrupted turn ends.
assert cancel_output == {
"cancel_requested": True,
"cancel_confirmed": False,
"task_id": child_id,
"status": "launching",
"message": (
"Cancel requested; cancellation has not been confirmed yet. "
"Use sys_read_inbox to observe terminal status."
),
}
def test_session_status_to_task_status_maps_known_values() -> None:
"""
``_session_status_to_task_status`` maps a session.status value to the
child-summary ``current_task_status`` (different vocabularies), and
returns None for unknown values so the caller omits the field.
"""
from omnigent.runner.app import _session_status_to_task_status
assert _session_status_to_task_status("launching") == "launching"
assert _session_status_to_task_status("running") == "in_progress"
assert _session_status_to_task_status("waiting") == "in_progress"
assert _session_status_to_task_status("idle") == "completed"
assert _session_status_to_task_status("failed") == "failed"
assert _session_status_to_task_status("bogus") is None
def test_truncate_child_preview_caps_with_ellipsis() -> None:
"""
``_truncate_child_preview`` returns short text unchanged and truncates
text past the cap to exactly the cap + a single ellipsis char (so the
child rail preview matches the server-side truncation).
"""
from omnigent.runner.app import _CHILD_PREVIEW_MAX_CHARS, _truncate_child_preview
assert _truncate_child_preview("hello world") == "hello world"
long_text = "x" * (_CHILD_PREVIEW_MAX_CHARS + 50)
out = _truncate_child_preview(long_text)
assert out.endswith("…")
assert len(out) == _CHILD_PREVIEW_MAX_CHARS + 1
def test_register_unregister_child_session_roundtrip() -> None:
"""
``register_child_session`` stores the parent fan-out metadata and
``unregister_child_session`` drops it (used to mirror a child's
status/preview deltas onto the parent stream).
"""
from omnigent.runner.app import (
_child_session_parents,
register_child_session,
unregister_child_session,
)
child_id = "conv_child_roundtrip_unique"
register_child_session(
child_id,
parent_session_id="conv_parent_roundtrip_unique",
title="researcher:auth",
tool="researcher",
session_name="auth",
)
meta = _child_session_parents.get(child_id)
assert meta is not None
assert meta.parent_id == "conv_parent_roundtrip_unique"
assert meta.title == "researcher:auth"
assert meta.last_busy is None
unregister_child_session(child_id)
assert _child_session_parents.get(child_id) is None
# ── sys_session_get_history / _list / _close runner dispatch ────────
#
# These verify the runner-local handler that makes get_history/list/close
# work for harness agents (claude-sdk/codex/openai-agents), whose
# Omnigent tool calls surface as action_required and route through
# the runner — NOT the in-process inner Session. Confirmed empirically:
# without this dispatch the runner returns "not in local dispatch
# table"; with it, a live harness agent reads a sibling's items. The
# handler calls the Omnigent server's existing REST endpoints, so tests use a
# real httpx.AsyncClient backed by MockTransport (not a MagicMock) — the
# code exercises the same request/response objects it sees in production.
def _session_query_client(
handler: Callable[[httpx.Request], httpx.Response],
) -> httpx.AsyncClient:
"""
Build an AsyncClient whose requests are answered by ``handler``.
:param handler: Maps an ``httpx.Request`` to a canned
``httpx.Response`` (routes by method + path).
:returns: An ``httpx.AsyncClient`` pointed at a fake Omnigent server.
"""
return httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="http://server",
)
@pytest.mark.asyncio
async def test_session_list_maps_children_and_skips_closed() -> None:
"""
``sys_session_list`` maps ``child_sessions`` rows to
``{agent, title, conversation_id}`` and drops closed and
colonless rows, matching ``SysSessionListTool``.
"""
from omnigent.runner.tool_dispatch import _execute_session_query_tool
def handler(request: httpx.Request) -> httpx.Response:
# Parent-detection snapshot: this caller is top-level (no parent),
# so there is no main/sibling enrichment — only its own children.
if request.url.path == "/v1/sessions/conv_parent":
return httpx.Response(200, json={"id": "conv_parent", "parent_session_id": None})
assert request.url.path == "/v1/sessions/conv_parent/child_sessions"
return httpx.Response(
200,
json={
"object": "list",
"data": [
{
"id": "c1",
"title": "researcher:auth",
"tool": "researcher",
"session_name": "auth",
},
{
"id": "c2",
"title": "ui:claude-native-ui:1",
"tool": "claude-native-ui",
"session_name": "1",
},
{
"id": "c3",
"title": "researcher:done",
"tool": "researcher",
"session_name": "done",
"labels": {CLOSED_LABEL_KEY: CLOSED_LABEL_VALUE},
},
{
"id": "c5",
"title": "researcher:legacy:closed:c5",
"tool": "researcher",
"session_name": "legacy",
},
{
"id": "c4",
"title": "legacy-untyped",
"tool": "legacy-untyped",
"session_name": None,
},
],
},
)
async with _session_query_client(handler) as client:
out = json.loads(
await _execute_session_query_tool(
"sys_session_list", "{}", conversation_id="conv_parent", server_client=client
)
)
# c3 (explicitly closed), c5 (legacy title tombstone), and c4
# (no colon) dropped; the ui:-added child surfaces under its bound
# agent + label.
assert out["sub_agents"] == [
{"agent": "researcher", "title": "auth", "conversation_id": "c1"},
{"agent": "claude-native-ui", "title": "1", "conversation_id": "c2"},
]
@pytest.mark.asyncio
async def test_session_list_adds_main_and_siblings_for_child_caller() -> None:
"""
When the caller is itself a child (a user-added agent), sys_session_list
also surfaces ``main`` (its parent) and its siblings — so an added agent
with no children of its own can still discover the conversation_ids to
peek. The caller is excluded from its own sibling list.
"""
from omnigent.runner.tool_dispatch import _execute_session_query_tool
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path == "/v1/sessions/conv_added/child_sessions":
# The added agent has no children of its own.
return httpx.Response(200, json={"object": "list", "data": []})
if path == "/v1/sessions/conv_added":
# It IS a child of conv_main.
return httpx.Response(200, json={"id": "conv_added", "parent_session_id": "conv_main"})
if path == "/v1/sessions/conv_main/child_sessions":
# main's children = the added agent itself + one sibling.
return httpx.Response(
200,
json={
"object": "list",
"data": [
{
"id": "conv_added",
"title": "ui:claude-native-ui:1",
"tool": "claude-native-ui",
"session_name": "1",
},
{
"id": "conv_sib",
"title": "researcher:auth",
"tool": "researcher",
"session_name": "auth",
},
],
},
)
raise AssertionError(f"unexpected path {path}")
async with _session_query_client(handler) as client:
out = json.loads(
await _execute_session_query_tool(
"sys_session_list", "{}", conversation_id="conv_added", server_client=client
)
)
# No own children; gains main (its parent) + the sibling, with itself
# excluded from the sibling list.
assert out["sub_agents"] == [
{"agent": "main", "title": None, "conversation_id": "conv_main"},
{"agent": "researcher", "title": "auth", "conversation_id": "conv_sib"},
]
@pytest.mark.asyncio
async def test_session_peek_returns_chronological_projected_items() -> None:
"""
``sys_session_get_history`` reads ``GET /items`` (newest-first), reverses to
chronological, projects each item, and labels with the target's
parsed agent/title from its snapshot — matching ``SysSessionGetHistoryTool``.
"""
from omnigent.runner.tool_dispatch import _execute_session_query_tool
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v1/sessions/conv_target/items":
assert request.url.params["order"] == "desc"
return httpx.Response(
200,
json={
"object": "list",
"data": [
{
"id": "i2",
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "found it"}],
},
{
"id": "i1",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "where is the bug"}],
},
],
},
)
if request.url.path == "/v1/sessions/conv_target":
return httpx.Response(200, json={"id": "conv_target", "title": "researcher:auth"})
raise AssertionError(f"unexpected path {request.url.path}")
async with _session_query_client(handler) as client:
out = json.loads(
await _execute_session_query_tool(
"sys_session_get_history",
json.dumps({"conversation_id": "conv_target", "tail_items": 5}),
conversation_id="conv_caller",
server_client=client,
)
)
assert out["conversation_id"] == "conv_target"
assert out["agent"] == "researcher"
assert out["title"] == "auth"
# Reversed to chronological (user ask first), and message text is
# extracted from the content blocks — proves the projection ran.
assert [(i["role"], i["text"]) for i in out["items"]] == [
("user", "where is the bug"),
("assistant", "found it"),
]
@pytest.mark.asyncio
async def test_session_peek_appends_pending_elicitation_from_snapshot() -> None:
"""
``sys_session_get_history`` appends the target's parked elicitations (read
off its snapshot) after the stored items.
A parked elicitation never lands in the conversation store, so the
``/items`` response ends at the last message. The snapshot's
``pending_elicitations`` block is the only place the prompt lives;
get_history must read it and project a ``pending_elicitation`` item so the
parent agent isn't blind to a sub-agent awaiting input.
"""
from omnigent.runner.tool_dispatch import _execute_session_query_tool
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v1/sessions/conv_target/items":
return httpx.Response(
200,
json={
"object": "list",
"data": [
{
"id": "i1",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "ask me 3 questions"}],
},
],
},
)
if request.url.path == "/v1/sessions/conv_target":
return httpx.Response(
200,
json={
"id": "conv_target",
"title": "researcher:auth",
"pending_elicitations": [
{
"type": "response.elicitation_request",
"elicitation_id": "elicit_bio",
"params": {
"mode": "form",
"message": "Answer 3 questions on human biology",
"requestedSchema": {"properties": {"q1": {}, "q2": {}}},
},
}
],
},
)
raise AssertionError(f"unexpected path {request.url.path}")
async with _session_query_client(handler) as client:
out = json.loads(
await _execute_session_query_tool(
"sys_session_get_history",
json.dumps({"conversation_id": "conv_target", "tail_items": 5}),
conversation_id="conv_caller",
server_client=client,
)
)
items = out["items"]
# 2 = 1 stored message + 1 synthesized pending elicitation. If 1,
# the snapshot's pending_elicitations weren't read/appended and the
# parent stays blind to the prompt.
assert len(items) == 2
# Stored item first (chronological), elicitation appended last.
assert items[0]["type"] == "message"
elicit = items[-1]
assert elicit["type"] == "pending_elicitation"
assert elicit["elicitation_id"] == "elicit_bio"
# Prompt + fields prove the snapshot payload reached the projector.
assert elicit["prompt"] == "Answer 3 questions on human biology"
assert elicit["fields"] == ["q1", "q2"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status,expected_error",
[(404, "session_not_found"), (403, "session_out_of_tree")],
)
async def test_session_peek_maps_access_errors(status: int, expected_error: str) -> None:
"""A 404/403 from ``GET /items`` maps to the in-process tool's typed errors."""
from omnigent.runner.tool_dispatch import _execute_session_query_tool
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status, json={"error": "x"})
async with _session_query_client(handler) as client:
out = json.loads(
await _execute_session_query_tool(
"sys_session_get_history",
json.dumps({"conversation_id": "conv_other"}),
conversation_id="conv_caller",
server_client=client,
)
)
assert out["error"] == expected_error
assert out["conversation_id"] == "conv_other"
@pytest.mark.asyncio
async def test_session_close_patches_tombstoned_title() -> None:
"""
``sys_session_close`` PATCHes a closed label and internal tombstone.
The title tombstone frees the DB unique slot so future
``sys_session_send`` of the same ``(agent, title)`` creates a
fresh child. The ``omnigent.closed=true`` label is the
behavioral marker that direct write paths and clients consume.
The caller (``conv_caller``) and target (``conv_target``) share the
same ``root_conversation_id`` and the target is a sub-agent, so the
tree-scope gate passes and the PATCH is issued.
"""
# _execute_session_query_tool is the runner's REST dispatch entry
# point for session-query tools — called directly here because these
# tests validate the REST path's tree-scoping (_session_close_via_rest)
# specifically, distinct from the in-process path covered in
# tests/tools/builtins/test_sys_session.py.
from omnigent.runner.tool_dispatch import _execute_session_query_tool
patched: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/v1/sessions/conv_target":
return httpx.Response(
200,
json={
"id": "conv_target",
"title": "researcher:auth",
"root_conversation_id": "conv_root",
"parent_session_id": "conv_caller",
},
)
if request.method == "GET" and request.url.path == "/v1/sessions/conv_caller":
return httpx.Response(
200,
json={"id": "conv_caller", "root_conversation_id": "conv_root"},
)
if request.method == "PATCH" and request.url.path == "/v1/sessions/conv_target":
patched.update(json.loads(request.content))
return httpx.Response(200, json={"id": "conv_target"})
raise AssertionError(f"unexpected {request.method} {request.url.path}")
async with _session_query_client(handler) as client:
out = json.loads(
await _execute_session_query_tool(
"sys_session_close",
json.dumps({"conversation_id": "conv_target"}),
conversation_id="conv_caller",
server_client=client,
)
)
# Tombstone embeds the conv id so repeated closes stay unique, and
# the explicit label makes the closed state observable without
# exposing the suffix as UI text.
assert patched["title"] == "researcher:auth:closed:conv_target"
assert patched["labels"] == {CLOSED_LABEL_KEY: CLOSED_LABEL_VALUE}
assert out == {
"closed": True,
"conversation_id": "conv_target",
"agent": "researcher",
"title": "auth",
}
@pytest.mark.asyncio
async def test_session_close_rejects_out_of_tree_target_without_patch() -> None:
"""
``sys_session_close`` refuses a target in a different spawn tree and
issues NO PATCH.
Close is a write: the REST path must enforce the same tree-scoping as
the in-process path. Here the target's ``root_conversation_id``
(``conv_other_root``) differs from the caller's (``conv_root``), so
the tool returns ``session_out_of_tree`` and the tombstone PATCH is
never sent — proving the target's title is left intact. Without the
gate, edit access alone would let an agent close a sub-agent in one
of its other, unrelated trees.
"""
from omnigent.runner.tool_dispatch import _execute_session_query_tool
patched = False
def handler(request: httpx.Request) -> httpx.Response:
nonlocal patched
if request.method == "GET" and request.url.path == "/v1/sessions/conv_target":
return httpx.Response(
200,
json={
"id": "conv_target",
"title": "researcher:auth",
"root_conversation_id": "conv_other_root",
"parent_session_id": "conv_other_parent",
},
)
if request.method == "GET" and request.url.path == "/v1/sessions/conv_caller":
return httpx.Response(
200,
json={"id": "conv_caller", "root_conversation_id": "conv_root"},
)
if request.method == "PATCH":
patched = True
return httpx.Response(200, json={"id": "conv_target"})
raise AssertionError(f"unexpected {request.method} {request.url.path}")
async with _session_query_client(handler) as client:
out = json.loads(
await _execute_session_query_tool(
"sys_session_close",
json.dumps({"conversation_id": "conv_target"}),
conversation_id="conv_caller",
server_client=client,
)
)
assert out == {"error": "session_out_of_tree", "conversation_id": "conv_target"}
# The tombstone write must never have been issued.
assert patched is False
@pytest.mark.asyncio
async def test_session_close_rejects_top_level_target() -> None:
"""
``sys_session_close`` refuses a top-level session (no parent) even
when it shares the caller's root, and issues no PATCH.
A top-level session in the caller's own tree (its root) has no
``parent_session_id``; close only operates on sub-agents, so the
tool returns ``session_not_a_sub_agent``.
"""
from omnigent.runner.tool_dispatch import _execute_session_query_tool
patched = False
def handler(request: httpx.Request) -> httpx.Response:
nonlocal patched
if request.method == "GET" and request.url.path == "/v1/sessions/conv_root":
return httpx.Response(
200,
json={
"id": "conv_root",
"title": "some top-level title",
"root_conversation_id": "conv_root",
"parent_session_id": None,
},
)
if request.method == "PATCH":
patched = True
return httpx.Response(200, json={"id": "conv_root"})
raise AssertionError(f"unexpected {request.method} {request.url.path}")
async with _session_query_client(handler) as client:
out = json.loads(
await _execute_session_query_tool(
"sys_session_close",
json.dumps({"conversation_id": "conv_root"}),
# Caller IS conv_root, so its self-snapshot is the same row.
conversation_id="conv_root",
server_client=client,
)
)
assert out == {"error": "session_not_a_sub_agent", "conversation_id": "conv_root"}
assert patched is False
def test_agent_tools_are_runner_local() -> None:
"""
``sys_agent_get`` / ``sys_agent_download`` dispatch locally in the
runner. If this regresses, native harnesses calling them fall
through to spec-callable resolution and the orchestrator can't
inspect or fork agents.
"""
from omnigent.runner.tool_dispatch import should_dispatch_locally
assert should_dispatch_locally("sys_agent_get") is True
assert should_dispatch_locally("sys_agent_download") is True
assert should_dispatch_locally("sys_agent_list") is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tool_name",
[
pytest.param("sys_agent_get", id="get"),
pytest.param("sys_agent_download", id="download"),
],
)
async def test_agent_tools_map_404_to_agent_not_found(
tool_name: str,
tmp_path: Path,
) -> None:
"""
Both agent tools map a 404 to ``agent_not_found`` — the orchestrator
gets a typed reason instead of a raw status. If the mapping
regressed, it couldn't tell "no such agent/session" from a transport
error.
:param tool_name: The agent tool under test.
:param tmp_path: Workspace dir (only the download path needs it).
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"error": "missing"})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name=tool_name,
arguments=json.dumps({"session_id": "conv_missing"}),
server_client=server_client,
conversation_id="conv_caller",
runner_workspace=tmp_path,
)
info = json.loads(output)
assert info["error"] == "agent_not_found"
assert info["session_id"] == "conv_missing"
@pytest.mark.parametrize(
"opt_in, expected_writes",
[
pytest.param("none", set(), id="no-opt-in"),
pytest.param(
"agents",
{"sys_session_send", "sys_session_close"},
id="declared-agents",
),
pytest.param(
"spawn",
{"sys_session_send", "sys_session_close", "sys_session_create"},
id="spawn-flag",
),
],
)
def test_native_relay_builtin_set_matches_toolmanager_gating(
opt_in: str,
expected_writes: set[str],
) -> None:
"""
The native relay advertises exactly ``ToolManager``'s builtin schemas
intersected with ``_NATIVE_RELAY_BUILTIN_TOOLS``.
claude-native / codex-native ignore the harness ``tools`` list, so the
relay is their only tool surface; ``_ensure_comment_relay_started``
applies this same ``ToolManager(spec).get_tool_schemas()`` ∩
``_NATIVE_RELAY_BUILTIN_TOOLS`` filter. This locks two invariants:
- **Parity**: the always-on orchestrator/discovery surface (agent
reads, session reads, async inbox reads/cancels, comment tools)
reaches native harnesses.
- **Gating fidelity**: the spawn writes are relayed per the two
distinct grants, matching what non-native harnesses get via
``request.tools``. ``tools.agents`` permits only the declared
sub-agent list (send + close, NO create); ``spawn: true`` (set
by the native wrapper specs) additionally grants create —
launching arbitrary agents or custom bundles. A regressed gate
either strands an opted-in native agent or hands an un-opted
agent the spawn surface.
:param opt_in: Which opt-in arm the spec uses — ``"none"``,
``"agents"`` (declared ``tools.agents``), or ``"spawn"``
(top-level ``spawn: true``).
:param expected_writes: Exact spawn-write tool names expected in
the relayed set for this opt-in arm.
"""
from omnigent.runner.tool_dispatch import _NATIVE_RELAY_BUILTIN_TOOLS
from omnigent.spec.types import ToolsConfig
from omnigent.tools.manager import ToolManager
if opt_in == "agents":
spec = AgentSpec(
spec_version=1,
tools=ToolsConfig(agents=["researcher"]),
sub_agents=[AgentSpec(spec_version=1, name="researcher")],
)
elif opt_in == "spawn":
spec = AgentSpec(spec_version=1, spawn=True)
else:
spec = AgentSpec(spec_version=1)
schema_names = {s["function"]["name"] for s in ToolManager(spec).get_tool_schemas()}
relayed = schema_names & _NATIVE_RELAY_BUILTIN_TOOLS
# Always-on reads/discovery reach every native agent — if any is
# missing, the orchestrator running under claude-native can't list or
# inspect agents/sessions.
assert {"sys_agent_get", "sys_agent_download", "sys_agent_list"} <= relayed
assert {"sys_session_list", "sys_session_get_history", "sys_session_get_info"} <= relayed
assert {"sys_call_async", "sys_read_inbox", "sys_cancel_async", "sys_cancel_task"} <= relayed
assert {"list_comments", "update_comment"} <= relayed
# Exact-set check on the writes: an extra name means a grant leaked
# beyond its arm (e.g. create from tools.agents alone — letting a
# whitelisted-sub-agents spec launch arbitrary bundles); a missing
# name strands an opted-in agent.
spawn_writes = {"sys_session_send", "sys_session_close", "sys_session_create"}
assert relayed & spawn_writes == expected_writes
# Model awareness rides the dispatch grant: relayed iff send is.
assert ("sys_list_models" in relayed) == ("sys_session_send" in expected_writes)
# OS tools ride a separate unconditional relay path (overriding the
# bridge's static versions), so they must never be in the builtin set —
# otherwise they'd be double-advertised and bypass that override.
os_tools = {"sys_os_read", "sys_os_write", "sys_os_edit", "sys_os_shell"}
assert not (os_tools & _NATIVE_RELAY_BUILTIN_TOOLS)
@pytest.mark.parametrize(
"declares_terminals, expected_terminal_tools",
[
pytest.param(
True,
{
"sys_terminal_launch",
"sys_terminal_send",
"sys_terminal_read",
"sys_terminal_list",
"sys_terminal_close",
},
id="terminals-declared",
),
pytest.param(False, set(), id="no-terminals"),
],
)
def test_native_relay_advertises_terminal_tools_per_spec_gate(
declares_terminals: bool,
expected_terminal_tools: set[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The native relay advertises ``sys_terminal_*`` iff the spec declares
``terminals``.
claude-native / codex-native ignore the harness ``tools`` list, so
the relay (``ToolManager(spec).get_tool_schemas()`` ∩
``_NATIVE_RELAY_BUILTIN_TOOLS``, applied by
``_ensure_comment_relay_started``) is the ONLY way the five
terminal tools reach the real CLI. This locks both directions of
the gate:
- **Advertised when granted**: a spec with a ``terminals:`` block
must relay all five tools — a missing name strands a native
agent that was granted terminals.
- **Withheld when not granted**: a spec without ``terminals:``
must relay none — a leaked name hands tmux access to an
un-opted agent.
:param declares_terminals: Whether the spec carries a
``terminals:`` block.
:param expected_terminal_tools: Exact ``sys_terminal_*`` names
expected in the relayed set for this arm.
:param monkeypatch: Pytest monkeypatch fixture, used to install a
fresh :class:`TerminalRegistry` singleton so ToolManager's
terminal-tool registration (which looks it up via
``get_terminal_registry()``) works without runtime ``init()``.
"""
from omnigent.inner.datamodel import TerminalEnvSpec
from omnigent.runner.tool_dispatch import _NATIVE_RELAY_BUILTIN_TOOLS
from omnigent.runtime import _globals as rt_globals
from omnigent.terminals.registry import TerminalRegistry
from omnigent.tools.manager import ToolManager
monkeypatch.setattr(rt_globals, "_terminal_registry", TerminalRegistry())
terminals = {"bash": TerminalEnvSpec(command="bash")} if declares_terminals else None
spec = AgentSpec(spec_version=1, terminals=terminals)
schema_names = {s["function"]["name"] for s in ToolManager(spec).get_tool_schemas()}
relayed = schema_names & _NATIVE_RELAY_BUILTIN_TOOLS
all_terminal_tools = {
"sys_terminal_launch",
"sys_terminal_send",
"sys_terminal_read",
"sys_terminal_list",
"sys_terminal_close",
}
# Exact-set check on the terminal family: a missing name means the
# relay filter dropped a granted tool (set regression in
# _NATIVE_RELAY_BUILTIN_TOOLS); an extra name on the no-terminals
# arm means ToolManager registered terminal tools without the spec
# gate (registration regression).
assert relayed & all_terminal_tools == expected_terminal_tools
def test_session_create_is_runner_local() -> None:
"""
``sys_session_create`` dispatches locally in the runner. If it
regresses out of the local table, a native harness calling it falls
through to spec-callable resolution and the orchestrator can't spawn
child sessions.
"""
from omnigent.runner.tool_dispatch import should_dispatch_locally
assert should_dispatch_locally("sys_session_create") is True
@pytest.mark.asyncio
async def test_session_list_global_sessions_filter_and_connectivity() -> None:
"""
The global ``sessions`` view fetches GET /v1/sessions (forwarding the
``agent_name`` filter), projects each row, and annotates
``runner_online`` by checking each UNIQUE runner once. Proves: the
agent_name filter reaches the server; sessions are projected with
status + parentage; and connectivity is folded in without a
per-session status fan-out (two sessions share runner r1 → exactly
one /v1/runners/r1/status call).
"""
from omnigent.runner.tool_dispatch import _execute_session_query_tool
runner_status_calls: list[str] = []
sessions_params: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path == "/v1/sessions/conv_x/child_sessions":
return httpx.Response(200, json={"object": "list", "data": []})
if path == "/v1/sessions/conv_x":
return httpx.Response(200, json={"id": "conv_x", "parent_session_id": None})
if path == "/v1/sessions":
sessions_params.update(dict(request.url.params))
return httpx.Response(
200,
json={
"object": "list",
"data": [
{
"id": "s1",
"agent_name": "researcher",
"title": "auth",
"status": "running",
"runner_id": "r1",
"parent_session_id": None,
},
{
"id": "s2",
"agent_name": "researcher",
"title": "payments",
"status": "idle",
"runner_id": "r1",
"parent_session_id": None,
},
],
},
)
if path == "/v1/runners/r1/status":
runner_status_calls.append("r1")
return httpx.Response(200, json={"runner_id": "r1", "online": True})
raise AssertionError(f"unexpected path {path}")
async with _session_query_client(handler) as client:
out = json.loads(
await _execute_session_query_tool(
"sys_session_list",
json.dumps({"agent_name": "researcher"}),
conversation_id="conv_x",
server_client=client,
)
)
# agent_name forwarded to the server-side filter.
assert sessions_params.get("agent_name") == "researcher"
# Both sessions projected with status + connectivity from the single
# shared-runner status lookup.
assert out["sessions"] == [
{
"session_id": "s1",
"agent_name": "researcher",
"title": "auth",
"status": "running",
"runner_id": "r1",
"runner_online": True,
"parent_session_id": None,
},
{
"session_id": "s2",
"agent_name": "researcher",
"title": "payments",
"status": "idle",
"runner_id": "r1",
"runner_online": True,
"parent_session_id": None,
},
]
# Connectivity resolved once per UNIQUE runner — two sessions share
# r1, so exactly one status call (not one per session). A count of 2
# would mean the dedup regressed into a per-session fan-out.
assert runner_status_calls == ["r1"]
@pytest.mark.asyncio
async def test_sys_agent_download_rejects_path_in_dest_filename(tmp_path: Path) -> None:
"""
``sys_agent_download`` rejects a ``dest_filename`` containing a path
separator (a traversal attempt) and writes nothing. If the guard
regressed, a bundle could be written outside the working directory.
:param tmp_path: Pytest temp dir used as the runner workspace.
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
content=b"data",
headers={"X-Agent-Name": "a", "X-Agent-Version": "1"},
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_agent_download",
arguments=json.dumps({"session_id": "conv_x", "dest_filename": "../escape.tar.gz"}),
server_client=server_client,
conversation_id="conv_caller",
runner_workspace=tmp_path,
)
info = json.loads(output)
assert "error" in info
# Nothing was written anywhere under the workspace.
assert list(tmp_path.iterdir()) == []
@pytest.mark.asyncio
async def test_sys_agent_download_rejects_symlink_escape_from_cwd(tmp_path: Path) -> None:
"""
``sys_agent_download`` refuses to follow a symlink that redirects the
bundle write outside the os_env cwd. ``dest_filename`` is a bare name,
but if the cwd already holds a symlink of that name pointing elsewhere,
a naive ``write_bytes`` would clobber the symlink target outside the
sandbox. The realpath-containment guard must catch it and
write nothing to the outside target.
:param tmp_path: Pytest temp dir; holds both the workspace and an
outside directory the symlink points at.
"""
from omnigent.runner.tool_dispatch import execute_tool
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
outside_target = outside / "stolen.tar.gz"
# A symlink inside the workspace whose name matches the caller's
# dest_filename but whose target escapes the workspace.
(workspace / "escape.tar.gz").symlink_to(outside_target)
async def _server_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
content=b"payload",
headers={"X-Agent-Name": "a", "X-Agent-Version": "1"},
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_agent_download",
arguments=json.dumps({"session_id": "conv_x", "dest_filename": "escape.tar.gz"}),
server_client=server_client,
conversation_id="conv_caller",
runner_workspace=workspace,
)
info = json.loads(output)
assert "error" in info
# The outside target was never created — the guard blocked the write.
assert not outside_target.exists()
@pytest.mark.asyncio
async def test_sys_agent_download_writes_bundle_to_workspace(tmp_path: Path) -> None:
"""
``sys_agent_download`` writes the fetched ``.tar.gz`` bytes into the
agent's os_env cwd (here ``runner_workspace`` = tmp_path) and returns
the path. Proves the full path: fetch bytes, derive the default
filename from the X-Agent-* headers, and persist to the agent-visible
disk. If the write regressed, the file wouldn't exist or the bytes
wouldn't match.
:param tmp_path: Pytest temp dir used as the runner workspace, so the
resolved os_env cwd is a real local directory.
"""
from omnigent.runner.tool_dispatch import execute_tool
bundle_bytes = b"\x1f\x8b\x08fake-tar-gz-bytes"
async def _server_handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v1/sessions/conv_x/agent/contents":
return httpx.Response(
200,
content=bundle_bytes,
headers={"X-Agent-Name": "my agent", "X-Agent-Version": "5"},
)
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_agent_download",
arguments=json.dumps({"session_id": "conv_x"}),
server_client=server_client,
conversation_id="conv_caller",
runner_workspace=tmp_path,
)
info = json.loads(output)
# Default filename: agent name sanitized (space → "_") + version.
expected = tmp_path / "my_agent-v5.tar.gz"
assert info["path"] == str(expected)
assert info["bytes_written"] == len(bundle_bytes)
# The bundle actually landed on disk with the exact bytes — a
# mismatch means the write path or byte handling broke.
assert expected.read_bytes() == bundle_bytes
@pytest.mark.asyncio
async def test_sys_agent_get_projects_agent_metadata() -> None:
"""
``sys_agent_get`` projects ``GET /v1/sessions/{id}/agent`` into the
orchestrator-facing fields: agent_id, name, version, description,
harness, MCP server summaries, and policy summaries. If the
projection dropped a field or used the wrong key, the asserted
values would differ.
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/v1/sessions/conv_x/agent":
return httpx.Response(
200,
json={
"id": "ag_777",
"object": "agent",
"name": "researcher",
"version": 4,
"description": "Finds things",
"created_at": 1,
"harness": "claude-sdk",
"mcp_servers": [{"name": "fs", "transport": "stdio", "args": []}],
"policies": [{"name": "guard", "type": "label", "on": ["input"]}],
},
)
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_agent_get",
arguments=json.dumps({"session_id": "conv_x"}),
server_client=server_client,
conversation_id="conv_caller",
)
info = json.loads(output)
# agent_id comes from AgentObject.id (not a top-level "agent_id").
assert info["agent_id"] == "ag_777"
assert info["name"] == "researcher"
assert info["version"] == 4
assert info["harness"] == "claude-sdk"
# MCP/policy summaries pass through as-is so the orchestrator sees
# the agent's tool/guardrail surface.
assert info["mcp_servers"] == [{"name": "fs", "transport": "stdio", "args": []}]
assert info["policies"] == [{"name": "guard", "type": "label", "on": ["input"]}]
@pytest.mark.asyncio
async def test_sys_agent_list_degrades_when_sources_fail(tmp_path: Path) -> None:
"""
A failing source degrades to an empty section rather than failing the
whole call. Here the server 500s both list endpoints and no local
config dir exists, so all three sections come back empty — but the
tool still returns a well-formed result. If a source error
propagated, the tool would return an ``error`` instead.
:param tmp_path: Workspace dir with no agent-config subdir.
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={"error": "boom"})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_agent_list",
arguments="{}",
server_client=server_client,
conversation_id="conv_caller",
runner_workspace=tmp_path,
)
info = json.loads(output)
assert info == {"builtins": [], "session_agents": [], "local_configs": []}
@pytest.mark.asyncio
async def test_sys_agent_list_merges_three_sources(tmp_path: Path) -> None:
"""
``sys_agent_list`` merges built-ins (GET /v1/agents), session-bound
agents (GET /v1/sessions), and locally-authored config YAMLs (a scan
of the os_env cwd's agent-config subdir). Proves all three sources
are fetched and projected: a built-in's id/name, a session's
session_id+agent binding, and a local config's name/path from the
YAML on disk. If any source were dropped or mis-projected, the
corresponding section would be empty or wrong.
:param tmp_path: Pytest temp dir used as the runner workspace, so the
local-config scan reads a real directory.
"""
from omnigent.runner.tool_dispatch import _AGENT_CONFIG_SUBDIR, execute_tool
# Author a local config on disk so the scan has something to find.
configs_dir = tmp_path / _AGENT_CONFIG_SUBDIR
configs_dir.mkdir(parents=True)
(configs_dir / "my-agent.yaml").write_text(
"name: my-agent\ndescription: a local one\nprompt: hi\n", encoding="utf-8"
)
async def _server_handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v1/agents":
return httpx.Response(
200,
json={"data": [{"id": "ag_b", "name": "claude-native-ui", "harness": "claude"}]},
)
if request.url.path == "/v1/sessions":
return httpx.Response(
200,
json={
"data": [
{
"id": "conv_1",
"agent_id": "ag_s",
"agent_name": "nessie",
"status": "idle",
}
]
},
)
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_agent_list",
arguments="{}",
server_client=server_client,
conversation_id="conv_caller",
runner_workspace=tmp_path,
)
info = json.loads(output)
# Built-ins projected from GET /v1/agents (id → agent_id).
assert info["builtins"] == [
{"agent_id": "ag_b", "name": "claude-native-ui", "description": None, "harness": "claude"}
]
# Session-bound agents carry session_id so the caller can then
# sys_agent_get / sys_agent_download them.
assert info["session_agents"] == [
{"session_id": "conv_1", "agent_id": "ag_s", "agent_name": "nessie", "status": "idle"}
]
# Local config discovered by the on-disk scan, with its parsed name.
assert len(info["local_configs"]) == 1
assert info["local_configs"][0]["name"] == "my-agent"
assert info["local_configs"][0]["description"] == "a local one"
assert info["local_configs"][0]["path"].endswith("my-agent.yaml")
@pytest.mark.asyncio
async def test_sys_session_create_maps_agent_not_found() -> None:
"""
A 404 from the create maps to ``agent_not_found`` so the LLM gets a
typed reason rather than a raw status. If the mapping regressed, the
orchestrator couldn't tell a bad agent_id from a transport failure.
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"error": "no agent"})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_create",
arguments=json.dumps({"agent_id": "ag_missing"}),
server_client=server_client,
conversation_id="conv_caller",
)
info = json.loads(output)
assert info["error"] == "agent_not_found"
assert info["agent_id"] == "ag_missing"
@pytest.mark.asyncio
async def test_sys_session_create_spawns_child_under_caller() -> None:
"""
``sys_session_create`` POSTs a JSON create with
``parent_session_id`` forced to the caller (child-only), passes the
agent_id, title, and a queued initial message, and returns a handle
carrying the new child's id. If parent_session_id weren't forced to
the caller, an orchestrator could create top-level/sibling sessions —
so the asserted request body is the security-critical check.
"""
from omnigent.runner.tool_dispatch import execute_tool
captured: dict[str, Any] = {}
async def _server_handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST" and request.url.path == "/v1/sessions":
captured.update(json.loads(request.content))
return httpx.Response(
201,
json={
"id": "conv_child",
"agent_id": "ag_x",
"agent_name": "researcher",
"status": "idle",
},
)
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_create",
arguments=json.dumps({"agent_id": "ag_x", "title": "auth", "message": "start"}),
server_client=server_client,
conversation_id="conv_caller",
)
# Child-only: parent forced to the caller; agent + title + queued
# message threaded through to the create body.
assert captured["parent_session_id"] == "conv_caller"
assert captured["agent_id"] == "ag_x"
assert captured["title"] == "auth"
assert captured["initial_items"][0]["data"]["content"][0]["text"] == "start"
handle = json.loads(output)
assert handle["conversation_id"] == "conv_child"
assert handle["agent_id"] == "ag_x"
assert handle["agent_name"] == "researcher"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"arguments",
[
# Both modes at once: the two create different agents, so the
# handler must refuse rather than silently pick one.
{"agent_id": "ag_x", "config_path": "helper.yaml"},
# Neither mode: nothing to launch.
{"title": "auth"},
],
)
async def test_sys_session_create_requires_exactly_one_mode(
arguments: dict[str, Any],
) -> None:
"""
``sys_session_create`` rejects both-or-neither of ``agent_id`` /
``config_path`` without touching the server.
If the mode split regressed to a silent preference, an orchestrator
passing both could launch the wrong agent with no signal.
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"server must not be reached on invalid mode args: {request.url}")
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_create",
arguments=json.dumps(arguments),
server_client=server_client,
conversation_id="conv_caller",
)
info = json.loads(output)
assert "exactly one of 'agent_id'" in info["error"]
def _parse_multipart_create(request: httpx.Request) -> dict[str, Any]:
"""
Decode a captured multipart ``POST /v1/sessions`` request body.
Uses the stdlib email parser (multipart/form-data is MIME) rather
than hand-rolled boundary splitting.
:param request: The captured httpx request.
:returns: Dict with ``metadata`` (parsed JSON dict) and ``bundle``
(raw bytes of the uploaded file part).
"""
import email
import email.policy
header = f"Content-Type: {request.headers['content-type']}\r\n\r\n".encode()
message = email.message_from_bytes(header + request.content, policy=email.policy.HTTP)
parts: dict[str, Any] = {}
for part in message.iter_parts(): # type: ignore[attr-defined]
disposition = part.get("Content-Disposition", "")
payload = part.get_payload(decode=True)
if 'name="metadata"' in disposition:
parts["metadata"] = json.loads(payload.decode())
elif 'name="bundle"' in disposition:
parts["bundle"] = payload
return parts
@pytest.mark.asyncio
async def test_sys_session_create_bundle_mode_uploads_child_under_caller(
tmp_path: Path,
) -> None:
"""
Bundle mode bundles a local agent config, POSTs the multipart
create with ``parent_session_id`` forced to the caller, queues the
optional message as the child's first event, and returns a handle
built from the server's created-agent identifiers.
The asserted multipart metadata is the security-critical check
(child-only, mirroring agent_id mode); the tarball content check
proves the local config actually traversed bundling — an empty
bundle would create a session the server rejects at first turn.
"""
import io
import tarfile
from omnigent.runner.tool_dispatch import execute_tool
config_text = "name: helper\nprompt: do helpful things\n"
(tmp_path / "helper.yaml").write_text(config_text)
create_requests: list[httpx.Request] = []
event_bodies: list[dict[str, Any]] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST" and request.url.path == "/v1/sessions":
create_requests.append(request)
return httpx.Response(
201,
json={
"session_id": "conv_child",
"agent_id": "ag_new",
"agent_name": "helper",
},
)
if request.method == "POST" and request.url.path == "/v1/sessions/conv_child/events":
event_bodies.append(json.loads(request.content))
return httpx.Response(200, json={})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_create",
arguments=json.dumps(
{"config_path": "helper.yaml", "title": "auth", "message": "start"}
),
server_client=server_client,
conversation_id="conv_caller",
runner_workspace=tmp_path,
)
# Exactly one multipart create; parent forced to the caller
# (child-only) and the title threaded into the metadata part.
assert len(create_requests) == 1, (
f"expected exactly one create POST, got {len(create_requests)}"
)
parts = _parse_multipart_create(create_requests[0])
assert parts["metadata"] == {"parent_session_id": "conv_caller", "title": "auth"}
# The uploaded bundle is a gzipped tar holding the authored config
# verbatim — proves the local file traversed materialize → tar.
with tarfile.open(fileobj=io.BytesIO(parts["bundle"]), mode="r:gz") as tf:
names = tf.getnames()
assert names == ["helper.yaml"]
member = tf.extractfile("helper.yaml")
assert member is not None
assert member.read().decode() == config_text
# The optional message was queued as the child's first user event —
# this is what starts the child's turn (same pattern as named send).
assert len(event_bodies) == 1
assert event_bodies[0]["data"]["content"][0]["text"] == "start"
handle = json.loads(output)
assert handle["conversation_id"] == "conv_child"
# agent_id/agent_name come from the server's CreatedSessionResponse,
# not the caller's args — the orchestrator needs the NEW agent's id.
assert handle["agent_id"] == "ag_new"
assert handle["agent_name"] == "helper"
@pytest.mark.asyncio
async def test_sys_session_create_config_path_escape_rejected(
tmp_path: Path,
) -> None:
"""
A ``config_path`` resolving outside the working directory is
refused before any disk read or server call.
This mirrors the sys_agent_download containment guard:
without it, an orchestrator could exfiltrate arbitrary host files
by bundling them into an uploaded agent.
"""
from omnigent.runner.tool_dispatch import execute_tool
workdir = tmp_path / "work"
workdir.mkdir()
(tmp_path / "outside.yaml").write_text("name: outside\n")
async def _server_handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"server must not be reached on escape: {request.url}")
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_create",
arguments=json.dumps({"config_path": "../outside.yaml"}),
server_client=server_client,
conversation_id="conv_caller",
runner_workspace=workdir,
)
info = json.loads(output)
assert "escapes the working directory" in info["error"]
@pytest.mark.asyncio
async def test_sys_session_create_config_not_found(tmp_path: Path) -> None:
"""
A missing ``config_path`` returns the typed ``config_not_found``
error so the LLM can distinguish a bad path from a transport
failure, without any server call.
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"server must not be reached: {request.url}")
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_create",
arguments=json.dumps({"config_path": "nope.yaml"}),
server_client=server_client,
conversation_id="conv_caller",
runner_workspace=tmp_path,
)
info = json.loads(output)
assert info["error"] == "config_not_found"
assert info["config_path"] == "nope.yaml"
@pytest.mark.asyncio
async def test_sys_session_get_info_defaults_to_caller_session() -> None:
"""
Omitting ``session_id`` describes the caller's own session — the
runner targets ``GET /v1/sessions/{conversation_id}``. With no
runner bound, connectivity is unknown (``None``) and no
runner-status call is made. If the default-to-caller logic
regressed, the request path would be wrong and the GET would 404.
"""
from omnigent.runner.tool_dispatch import execute_tool
requested_paths: list[str] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
requested_paths.append(request.url.path)
if request.url.path == "/v1/sessions/conv_caller":
return httpx.Response(
200,
json={
"id": "conv_caller",
"agent_id": "ag_self",
"agent_name": "main",
"status": "idle",
"created_at": 1,
"runner_id": None,
"pending_elicitations": [],
},
)
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_get_info",
arguments="{}",
server_client=server_client,
conversation_id="conv_caller",
)
info = json.loads(output)
assert info["session_id"] == "conv_caller"
# No runner bound → connectivity unknown, and the status endpoint is
# never queried (a stray /v1/runners call would mean the None-runner
# short-circuit regressed).
assert info["runner_online"] is None
assert info["pending_elicitations"] == []
assert info["pending_elicitation_count"] == 0
assert not any(p.startswith("/v1/runners") for p in requested_paths)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_code,expected_error",
[
pytest.param(404, "session_not_found", id="not-found"),
pytest.param(403, "access_denied", id="forbidden"),
pytest.param(401, "access_denied", id="unauthorized"),
],
)
async def test_sys_session_get_info_maps_error_statuses(
status_code: int,
expected_error: str,
) -> None:
"""
A 404 maps to ``session_not_found``; 401/403 map to
``access_denied`` — so the LLM gets a typed reason instead of a raw
HTTP status. If the mapping regressed, the orchestrator couldn't
distinguish "no such session" from "you can't read it".
:param status_code: HTTP status the mocked Omnigent server returns.
:param expected_error: The typed error string the tool should emit.
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, json={"error": "x"})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_get_info",
arguments=json.dumps({"session_id": "conv_missing"}),
server_client=server_client,
conversation_id="conv_caller",
)
info = json.loads(output)
assert info["error"] == expected_error
assert info["session_id"] == "conv_missing"
@pytest.mark.asyncio
async def test_sys_session_share_defaults_to_caller_and_puts_grant() -> None:
"""
Omitting ``session_id`` shares the caller's own session: the runner
PUTs to ``/v1/sessions/{conversation_id}/permissions`` with the
grantee and the numeric level mapped from the friendly name. If the
default-to-caller logic or the name->level mapping regressed, the
request path or body would be wrong (and an agent's "share this
session" would silently hit the wrong session or wrong level).
"""
from omnigent.runner.tool_dispatch import execute_tool
requests: list[tuple[str, str, dict[str, Any]]] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path, json.loads(request.content)))
return httpx.Response(
200,
json={"user_id": "alice@example.com", "conversation_id": "conv_caller", "level": 2},
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_share",
arguments=json.dumps({"user_id": "alice@example.com", "level": "edit"}),
server_client=server_client,
conversation_id="conv_caller",
# Sharing a named user only needs the non-public tier enabled.
agent_spec=AgentSpec(spec_version=1, agent_session_sharing=SharePolicy.NON_PUBLIC),
)
# Exactly one PUT to the caller's own permissions sub-resource, with
# level "edit" mapped to the server's numeric 2 (1=read/2=edit/3=manage).
assert requests == [
(
"PUT",
"/v1/sessions/conv_caller/permissions",
{"user_id": "alice@example.com", "level": 2},
)
]
result = json.loads(output)
assert result == {
"shared": True,
"session_id": "conv_caller",
"user_id": "alice@example.com",
"level": "edit",
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_code,expected_error",
[
pytest.param(404, "session_not_found", id="not-found"),
pytest.param(403, "access_denied", id="forbidden"),
pytest.param(401, "access_denied", id="unauthorized"),
],
)
async def test_sys_session_share_maps_error_statuses(
status_code: int,
expected_error: str,
) -> None:
"""
A 404 maps to ``session_not_found``; 401/403 map to ``access_denied``
— a typed reason instead of a raw status, matching the sibling
session tools so the LLM can distinguish "no such session" from
"you can't manage it".
:param status_code: HTTP status the mocked Omnigent server returns.
:param expected_error: The typed error string the tool should emit.
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, json={"detail": "x"})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_share",
arguments=json.dumps({"user_id": "alice@example.com", "session_id": "conv_x"}),
server_client=server_client,
conversation_id="conv_caller",
agent_spec=AgentSpec(spec_version=1, agent_session_sharing=SharePolicy.NON_PUBLIC),
)
result = json.loads(output)
assert result["error"] == expected_error
assert result["session_id"] == "conv_x"
@pytest.mark.asyncio
async def test_sys_session_share_rejects_bad_level_without_calling_server() -> None:
"""
An unknown ``level`` is rejected client-side before any PUT — so a
typo can't fall through to the server or silently skip the grant. A
request reaching the handler would mean the level validation
regressed.
"""
from omnigent.runner.tool_dispatch import execute_tool
called = False
async def _server_handler(request: httpx.Request) -> httpx.Response:
nonlocal called
called = True
return httpx.Response(200, json={})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_share",
arguments=json.dumps({"user_id": "alice@example.com", "level": "admin"}),
server_client=server_client,
conversation_id="conv_caller",
# Share enabled (non-public) so the call reaches level validation.
agent_spec=AgentSpec(spec_version=1, agent_session_sharing=SharePolicy.NON_PUBLIC),
)
assert called is False # validation must short-circuit before the PUT
assert "level must be one of" in json.loads(output)["error"]
@pytest.mark.asyncio
async def test_sys_session_share_surfaces_server_message_on_4xx() -> None:
"""
A 4xx the typed branches don't claim (here the server's 400 for a
``__public__`` grant above read level) surfaces the server's own
``{"error": {"message": ...}}`` text rather than a bare "returned
400". If the detail-extraction regressed, the agent would see only
the status code and couldn't tell that public is read-only — the
exact actionable reason the server gave.
"""
from omnigent.runner.tool_dispatch import execute_tool
# Mirrors the OmnigentError envelope the server's exception handler
# emits (omnigent/server/app.py) for the public + level>read guard.
server_message = "Public access is limited to read-only (level 1)"
async def _server_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
400, json={"error": {"code": "INVALID_INPUT", "message": server_message}}
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_share",
arguments=json.dumps(
{"user_id": "__public__", "level": "edit", "session_id": "conv_x"}
),
server_client=server_client,
conversation_id="conv_caller",
# agent_session_sharing: public lets __public__ pass the runner
# gate and reach the server, which rejects level>read for public.
agent_spec=AgentSpec(spec_version=1, agent_session_sharing=SharePolicy.PUBLIC),
)
result = json.loads(output)
# The server's verbatim message is surfaced, not flattened to a status.
assert result["error"] == server_message
assert result["status_code"] == 400
assert result["session_id"] == "conv_x"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"share_policy",
[
pytest.param(None, id="no-spec"),
pytest.param(SharePolicy.NONE, id="share-none"),
],
)
async def test_sys_session_share_disabled_without_share_flag(
share_policy: SharePolicy | None,
) -> None:
"""
With no spec (``None``) or ``agent_session_sharing: none``, the
runner refuses the grant client-side and never PUTs — the
``agent_session_sharing`` flag is the real gate, not just tool
advertisement, so a prompt-injected call naming the tool can't
escalate. A PUT reaching the handler would mean the runner-side
policy gate regressed.
:param share_policy: The spec's ``agent_session_sharing`` policy
under test (or ``None`` for a missing spec).
"""
from omnigent.runner.tool_dispatch import execute_tool
called = False
async def _server_handler(request: httpx.Request) -> httpx.Response:
nonlocal called
called = True
return httpx.Response(200, json={})
spec = (
None
if share_policy is None
else AgentSpec(spec_version=1, agent_session_sharing=share_policy)
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_share",
arguments=json.dumps({"user_id": "alice@example.com", "session_id": "conv_x"}),
server_client=server_client,
conversation_id="conv_caller",
agent_spec=spec,
)
assert called is False # the gate must short-circuit before the PUT
assert "not enabled" in json.loads(output)["error"]
@pytest.mark.asyncio
async def test_sys_session_share_non_public_rejects_public_grant() -> None:
"""
Under ``agent_session_sharing: non-public`` a grant to a named user
is allowed, but a ``__public__`` grant is refused client-side before
any PUT — the
non-public tier must not be able to expose the transcript anonymously
even if the model (or an injection) asks for it. A PUT here would
mean the public sub-gate regressed.
"""
from omnigent.runner.tool_dispatch import execute_tool
called = False
async def _server_handler(request: httpx.Request) -> httpx.Response:
nonlocal called
called = True
return httpx.Response(200, json={})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_share",
arguments=json.dumps({"user_id": "__public__", "session_id": "conv_x"}),
server_client=server_client,
conversation_id="conv_caller",
agent_spec=AgentSpec(spec_version=1, agent_session_sharing=SharePolicy.NON_PUBLIC),
)
assert called is False # public sub-gate must short-circuit before the PUT
assert "public" in json.loads(output)["error"]
@pytest.mark.asyncio
async def test_sys_session_share_public_allows_public_grant() -> None:
"""
Under ``agent_session_sharing: public`` a ``__public__`` read grant
passes the runner gate and PUTs to the permissions endpoint — the
positive case the
non-public/none gates exclude. If the gate wrongly blocked it, public
sharing would be impossible even when the spec explicitly opts in.
"""
from omnigent.runner.tool_dispatch import execute_tool
requests: list[tuple[str, str, dict[str, Any]]] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path, json.loads(request.content)))
return httpx.Response(
200,
json={"user_id": "__public__", "conversation_id": "conv_caller", "level": 1},
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_share",
arguments=json.dumps({"user_id": "__public__"}),
server_client=server_client,
conversation_id="conv_caller",
agent_spec=AgentSpec(spec_version=1, agent_session_sharing=SharePolicy.PUBLIC),
)
# __public__ reached the server as a level-1 (read) grant on the caller.
assert requests == [
("PUT", "/v1/sessions/conv_caller/permissions", {"user_id": "__public__", "level": 1})
]
result = json.loads(output)
assert result == {
"shared": True,
"session_id": "conv_caller",
"user_id": "__public__",
"level": "read",
}
@pytest.mark.asyncio
async def test_sys_session_get_info_projects_metadata_and_runner_connectivity() -> None:
"""
``sys_session_get_info`` projects ``GET /v1/sessions/{id}`` metadata
and folds in live runner connectivity from ``GET
/v1/runners/{id}/status``.
Proves the full runner-dispatch path: read the session snapshot,
derive the effective model (a per-session ``model_override`` wins
over the spec's ``llm_model``), count pending approval prompts, and
attach ``runner_online``. If the projection regressed (dropped a
field, skipped the runner-status call, or picked the wrong model),
the asserted values would differ. The transcript is intentionally
absent — get_info is metadata-only (``sys_session_get_history`` returns
items).
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/v1/sessions/conv_target":
return httpx.Response(
200,
json={
"id": "conv_target",
"agent_id": "ag_xyz",
"agent_name": "researcher",
"status": "running",
"created_at": 1,
"title": "auth flow",
"runner_id": "runner_1",
"host_id": None,
"reasoning_effort": "high",
"parent_session_id": "conv_parent",
"sub_agent_name": "researcher",
"llm_model": "anthropic/claude-sonnet-4-6",
"model_override": "claude-opus-4-8",
"workspace": "/repo",
"git_branch": "feature/x",
"pending_elicitations": [{"id": "el_1"}, {"id": "el_2"}],
},
)
if request.method == "GET" and request.url.path == "/v1/runners/runner_1/status":
return httpx.Response(200, json={"runner_id": "runner_1", "online": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_get_info",
arguments=json.dumps({"session_id": "conv_target"}),
server_client=server_client,
conversation_id="conv_caller",
)
info = json.loads(output)
assert info["session_id"] == "conv_target"
assert info["status"] == "running"
assert info["title"] == "auth flow"
assert info["agent_id"] == "ag_xyz"
assert info["agent_name"] == "researcher"
assert info["runner_id"] == "runner_1"
# Live connectivity folded in from the runners status endpoint —
# None here would mean the best-effort status call was skipped.
assert info["runner_online"] is True
assert info["parent_session_id"] == "conv_parent"
# Effective model: the per-session override wins over the spec
# default. "anthropic/claude-sonnet-4-6" here would mean the
# override was ignored.
assert info["model"] == "claude-opus-4-8"
# Two outstanding approval prompts surfaced from the snapshot — both
# the prompts themselves and a count. If the projection dropped the
# prompts (count-only), the orchestrator couldn't tell what the
# blocked session is waiting on.
assert info["pending_elicitation_count"] == 2
assert info["pending_elicitations"] == [{"id": "el_1"}, {"id": "el_2"}]
# Metadata-only: the full transcript is never embedded.
assert "items" not in info
@pytest.mark.asyncio
async def test_sys_session_get_info_hides_native_ui_wrapper_agent_name() -> None:
"""A native-UI session describes itself with its clean public name.
Regression for the leak where ``sys_session_get_info`` returned the raw
bound ``agent_name`` ``"pi-native-ui"``, which the Pi agent then repeated to
the user ("I'm pi (agent name: pi-native-ui)"). The projection must map the
internal ``-native-ui`` wrapper name to its display name (``"Pi"``) so the
implementation detail never reaches the model.
"""
from omnigent.runner.tool_dispatch import execute_tool
async def _server_handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/v1/sessions/conv_pi":
return httpx.Response(
200,
json={
"id": "conv_pi",
"agent_id": "ag_pi",
"agent_name": "pi-native-ui",
"status": "running",
"title": "hi what agent are you?",
"runner_id": "runner_1",
},
)
if request.method == "GET" and request.url.path == "/v1/runners/runner_1/status":
return httpx.Response(200, json={"runner_id": "runner_1", "online": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
output = await execute_tool(
tool_name="sys_session_get_info",
arguments=json.dumps({"session_id": "conv_pi"}),
server_client=server_client,
conversation_id="conv_pi",
)
info = json.loads(output)
# The internal wrapper name is rewritten to the public display name; the
# raw ``pi-native-ui`` must not appear anywhere in the tool output.
assert info["agent_name"] == "Pi"
assert "pi-native-ui" not in output
@pytest.mark.asyncio
async def test_sys_session_send_session_id_posts_to_direct_child(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``sys_session_send`` in by-session-id mode verifies the target is a
direct child of the caller, posts the message, and returns a running
handle. Proves the unified session_id mode: the message reaches the
existing child and the handle carries its id with ``status:running``
(the result is delivered asynchronously via ``sys_read_inbox``).
:param monkeypatch: Stubs the runner-local child registration so the
dispatch runs without a live runner.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
event_posts: list[dict[str, Any]] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/v1/sessions/conv_child":
# Target IS a direct child of the caller.
return httpx.Response(
200,
json={
"id": "conv_child",
"parent_session_id": "conv_caller",
"title": "researcher:auth",
},
)
if request.method == "POST" and request.url.path == "/v1/sessions/conv_child/events":
event_posts.append(json.loads(request.content))
return httpx.Response(200, json={"ok": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps({"session_id": "conv_child", "args": "continue please"}),
server_client=server_client,
conversation_id="conv_caller",
agent_spec=SimpleNamespace(sub_agents=[SimpleNamespace(name="researcher")]),
session_inbox=session_inbox,
)
finally:
runner_app.unregister_subagent_work("conv_child")
runner_app._session_inboxes_ref.pop("conv_caller", None)
# Message reached the existing child; handle carries its id + running status.
assert event_posts[0]["data"]["content"][0]["text"] == "continue please"
handle = json.loads(output)
assert handle["conversation_id"] == "conv_child"
assert handle["status"] == "launching"
@pytest.mark.asyncio
async def test_sys_session_send_session_id_rejects_non_child() -> None:
"""
By-session-id send refuses a target that is NOT a direct child of the
caller (``parent_session_id`` mismatch) — returning
``session_out_of_tree`` and posting NO message. This is the
child-only safety guarantee: an orchestrator can't drive a sibling or
an unrelated session it merely has read access to. If the parentage
check regressed, the message would be posted and the assertion on
``event_posts`` would fail.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
event_posts: list[dict[str, Any]] = []
async def _server_handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/v1/sessions/conv_other":
# Target's parent is someone ELSE, not the caller.
return httpx.Response(
200,
json={
"id": "conv_other",
"parent_session_id": "conv_someone_else",
"title": "x:y",
},
)
if request.url.path == "/v1/sessions/conv_other/events":
event_posts.append(json.loads(request.content))
return httpx.Response(200, json={"ok": True})
return httpx.Response(404, json={"error": str(request.url)})
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps({"session_id": "conv_other", "args": "hi"}),
server_client=server_client,
conversation_id="conv_caller",
agent_spec=SimpleNamespace(sub_agents=[SimpleNamespace(name="researcher")]),
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop("conv_caller", None)
info = json.loads(output)
assert info["error"] == "session_out_of_tree"
# No message was posted to the non-child session.
assert event_posts == []
@pytest.mark.parametrize("empty_output", ["", " ", "\n\t "])
def test_format_async_task_item_empty_subagent_completion_reads_as_no_output(
empty_output: str,
) -> None:
"""
An empty sub-agent completion renders "produced no output", not "returned:".
A native child that idles with no assistant text is delivered as ``""`` —
the runner deliberately avoids fabricating output from stale runner
history (see
``test_external_status_idle_without_output_omits_stale_history_preview``).
The parent LLM must then see an explicit "produced no output" line rather
than a dangling ``"…returned: "`` that reads as a truncated/garbled handoff.
:param empty_output: An empty or whitespace-only child output.
"""
from omnigent.runner.tool_dispatch import _format_async_task_item
line = _format_async_task_item(
{
"type": "sub_agent",
"handle_id": "conv_child_empty",
"agent": "worker",
"title": "phase-a",
"status": "completed",
"output": empty_output,
}
)
# Reads as no-output; the dangling content-free "returned:" must not appear.
# With the old formatter, an empty output rendered "…returned: ]" and this
# would fail on the missing "produced no output" / present "returned:".
assert "produced no output" in line
assert "returned:" not in line
def test_format_async_task_item_nonempty_subagent_completion_shows_output() -> None:
"""
A non-empty sub-agent completion still renders its returned text.
Guards against the empty-output branch swallowing a real result.
"""
from omnigent.runner.tool_dispatch import _format_async_task_item
line = _format_async_task_item(
{
"type": "sub_agent",
"handle_id": "conv_child_real",
"agent": "worker",
"title": "phase-a",
"status": "completed",
"output": "review done: LGTM",
}
)
assert "returned: review done: LGTM" in line
assert "produced no output" not in line
@pytest.mark.asyncio
async def test_sys_session_send_rejects_both_session_id_and_named_target() -> None:
"""
Supplying both ``session_id`` and ``agent``/``title`` fails loud.
The by-session-id and named ``(agent, title)`` modes can point at different
children, so silently letting ``session_id`` win would misroute the message
with no signal to the caller. The dispatch must reject the ambiguity before
making any server call.
"""
from omnigent.runner import app as runner_app
from omnigent.runner.tool_dispatch import execute_tool
server_called = False
async def _server_handler(request: httpx.Request) -> httpx.Response:
"""
Fail the test if any server call is made.
:param request: Any Omnigent request — none should occur on the reject path.
:returns: A 404 (also records the unexpected call).
"""
nonlocal server_called
server_called = True
return httpx.Response(404, json={"error": str(request.url)})
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async with httpx.AsyncClient(
transport=httpx.MockTransport(_server_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"session_id": "conv_direct_child",
"agent": "claude",
"title": "issue-1",
"args": "do the thing",
}
),
server_client=server_client,
conversation_id="conv_parent_ambiguous",
agent_spec=SimpleNamespace(sub_agents=[SimpleNamespace(name="claude")]),
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop("conv_parent_ambiguous", None)
# The ambiguity is rejected with a fail-loud error naming both modes.
# Without the guard, session_id silently wins and routing proceeds into
# _send_to_existing_session (which would hit the server handler below).
assert "both 'session_id' and 'agent'/'title'" in output
assert server_called is False
@pytest.mark.asyncio
async def test_create_session_reinit_preserves_existing_inbox() -> None:
"""
A reconnect re-POST of ``/v1/sessions`` must not wipe the session inbox.
The Omnigent server re-POSTs ``/v1/sessions`` for every bound conversation
on each runner WebSocket (re)connect — including in-process reconnects of a
still-alive runner after a transient blip. A sub-agent completion that lands
while the socket is down delivers its result into the parent's
``_session_inboxes`` queue and latches the work entry ``delivered``, which
makes redelivery short-circuit. If the re-init blindly replaced the queue,
that already-delivered payload would be orphaned and the parent would drain
an empty inbox and hang forever. This test drives the real
``POST /v1/sessions`` route twice for the same session id and asserts the
inbox (and a sentinel sitting in it) survives the second call.
:returns: None.
"""
from omnigent.runner import app as runner_app
session_id = "conv_reinit_inbox_guard"
agent_id = "ag_reinit_inbox_guard"
# Real stub manager: get_client must succeed so create_session reaches the
# inbox-init lines. ``_FakeHarnessClient([])`` is never streamed on this path.
app = create_runner_app(
process_manager=cast(
HarnessProcessManager,
_FakeProcessManager(_FakeHarnessClient([])),
),
server_client=NullServerClient(), # type: ignore[arg-type]
)
# Fresh-process hygiene: the inbox ref is a module-wide singleton, so clear
# any leftover from a prior test before exercising the route.
runner_app._session_inboxes_ref.pop(session_id, None)
sentinel = {"type": "subagent_result", "marker": "SURVIVE_REINIT"}
try:
async with _runner_test_client(app) as http:
first = await http.post(
"/v1/sessions",
json={"session_id": session_id, "agent_id": agent_id},
)
# 201 proves create_session ran end-to-end (past the inbox init)
# rather than short-circuiting (400 missing fields / 501 scaffold /
# 503 spawn failure), so the inbox we inspect next is route-created.
assert first.status_code == 201, (
f"First POST /v1/sessions should create the session (201); got "
f"{first.status_code} with body {first.text!r}."
)
assert first.json()["id"] == session_id
inbox_after_first = runner_app._session_inboxes_ref.get(session_id)
# The route must have installed a real inbox queue. If None, the
# init path didn't run and the rest of the test would be vacuous.
assert inbox_after_first is not None, (
"POST /v1/sessions did not create a session inbox; the "
"inbox-init line in create_session did not run."
)
# Stand in for a delivered sub-agent payload waiting to be drained.
inbox_after_first.put_nowait(sentinel)
second = await http.post(
"/v1/sessions",
json={"session_id": session_id, "agent_id": agent_id},
)
# The reconnect re-init must also succeed; a non-201 here would mean
# the simulated reconnect couldn't re-run the handler at all.
assert second.status_code == 201, (
f"Reconnect re-POST /v1/sessions should succeed (201); got "
f"{second.status_code} with body {second.text!r}."
)
inbox_after_second = runner_app._session_inboxes_ref.get(session_id)
# The load-bearing assertion: the inbox object is preserved across
# re-init. Without the ``if session_id not in _session_inboxes``
# guard, line ~4093 would assign a brand-new asyncio.Queue() here,
# so this would be a different object and the sentinel would vanish.
assert inbox_after_second is inbox_after_first, (
"Re-init replaced the session inbox with a new queue. The "
"guard in create_session must skip re-creating an existing "
"inbox or a delivered sub-agent payload is orphaned and the "
"parent hangs on an empty inbox."
)
# Content survives: the delivered payload is still drainable. A
# fresh empty queue would make this drain raise QueueEmpty.
assert inbox_after_second.get_nowait() == sentinel, (
"Sentinel payload did not survive the re-init; the parent's "
"delivered sub-agent result was lost when the inbox was wiped."
)
finally:
runner_app._session_inboxes_ref.pop(session_id, None)
# ── approval-event flattening (elicitation-approval hang regression) ──────
@pytest.mark.asyncio
async def test_approval_event_flattened_for_harness_scaffold() -> None:
"""A nested approval envelope is flattened to the scaffold's ApprovalEvent.
Regression for the elicitation-approval hang: the server forwards the
verdict as ``{"type": "approval", "data": {...}}``, but the harness
scaffold's ``ApprovalEvent`` requires ``elicitation_id`` / ``action`` /
``content`` at the TOP level. If the runner forwards the envelope verbatim
the harness 422s and the parked ``ctx.elicit`` Future never resolves (the
turn hangs after a human approves). The runner must translate the envelope
into the flat event the scaffold validates — for every scaffold harness.
"""
from omnigent.runtime.harnesses._scaffold import ApprovalEvent
captured: dict[str, Any] = {}
class _CapturingHarnessClient:
async def post(
self, url: str, *, json: dict[str, Any], timeout: float | None = None
) -> httpx.Response:
captured["url"] = url
captured["body"] = json
return httpx.Response(204)
mgr = _FakeProcessManager(harness_client=cast(Any, _CapturingHarnessClient()))
app = create_runner_app(
process_manager=cast(HarnessProcessManager, mgr),
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_test_client(app) as http:
resp = await http.post(
"/v1/sessions/conv_x/events",
json={
"type": "approval",
"data": {
"elicitation_id": "elicit_x",
"action": "accept",
"content": {"note": "ok"},
},
},
)
assert resp.status_code == 204
# Forwarded body is FLAT — no ``data`` envelope.
assert captured["body"] == {
"type": "approval",
"elicitation_id": "elicit_x",
"action": "accept",
"content": {"note": "ok"},
}
# And it validates as the scaffold's ApprovalEvent (i.e. no 422).
ApprovalEvent.model_validate(captured["body"])
@pytest.mark.asyncio
async def test_approval_event_without_content_flattened() -> None:
"""A decline verdict with no form content flattens without a ``content`` key."""
from omnigent.runtime.harnesses._scaffold import ApprovalEvent
captured: dict[str, Any] = {}
class _CapturingHarnessClient:
async def post(
self, url: str, *, json: dict[str, Any], timeout: float | None = None
) -> httpx.Response:
captured["body"] = json
return httpx.Response(204)
mgr = _FakeProcessManager(harness_client=cast(Any, _CapturingHarnessClient()))
app = create_runner_app(
process_manager=cast(HarnessProcessManager, mgr),
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_test_client(app) as http:
resp = await http.post(
"/v1/sessions/conv_y/events",
json={"type": "approval", "data": {"elicitation_id": "e2", "action": "decline"}},
)
assert resp.status_code == 204
assert captured["body"] == {"type": "approval", "elicitation_id": "e2", "action": "decline"}
ApprovalEvent.model_validate(captured["body"])
@pytest.mark.asyncio
async def test_spawn_async_tool_cancels_losing_future_no_leak(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``_spawn_async_tool`` races the tool coro against ``cancel_event.wait()``
via ``asyncio.wait(FIRST_COMPLETED)``. The losing future must be cancelled
in BOTH branches, otherwise every ``sys_call_async`` permanently leaks one
pending task (the orphaned ``cancel_event.wait()`` on success, or the
orphaned tool coro on cancel).
"""
from omnigent.runner import tool_dispatch
spawn_kw: dict[str, Any] = {
"server_client": None,
"terminal_registry": None,
"resource_registry": None,
"agent_spec": None,
"conversation_id": "conv_leak",
"task_id": None,
"agent_id": None,
"agent_name": None,
"runner_workspace": None,
"mcp_manager": None,
"filesystem_registry": None,
}
def _leaked(before: set[asyncio.Task[Any]]) -> list[str]:
cur = asyncio.current_task()
return [
t.get_name()
for t in asyncio.all_tasks()
if t not in before and t is not cur and not t.done()
]
# Success path: tool finishes first, so cancel_event.wait() is the loser.
async def _fast(**_kw: Any) -> str:
return "ok"
monkeypatch.setattr(tool_dispatch, "execute_tool", _fast)
inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
tasks: dict[str, tuple[asyncio.Task[str], asyncio.Event]] = {}
before = set(asyncio.all_tasks())
tool_dispatch._spawn_async_tool(
{"tool": "noop", "args": "{}"},
session_inbox=inbox,
session_async_tasks=tasks,
**spawn_kw,
)
bg_task, _evt = next(iter(tasks.values()))
await bg_task
await asyncio.sleep(0) # let the .cancel() propagate to the losing future
assert inbox.get_nowait()["status"] == "completed"
assert _leaked(before) == []
# Cancel path: cancel_event fires first, so the tool coro is the loser.
async def _slow(**_kw: Any) -> str:
await asyncio.sleep(30)
return "late"
monkeypatch.setattr(tool_dispatch, "execute_tool", _slow)
inbox = asyncio.Queue()
tasks = {}
before = set(asyncio.all_tasks())
tool_dispatch._spawn_async_tool(
{"tool": "slow", "args": "{}"},
session_inbox=inbox,
session_async_tasks=tasks,
**spawn_kw,
)
bg_task, evt = next(iter(tasks.values()))
await asyncio.sleep(0) # let _bg reach asyncio.wait
evt.set()
await bg_task
await asyncio.sleep(0)
assert inbox.get_nowait()["status"] == "cancelled"
assert _leaked(before) == []