1329 lines
51 KiB
Python
1329 lines
51 KiB
Python
"""
|
||
Tunnel three-layer integration test: Omnigent → WS tunnel → runner → harness.
|
||
|
||
Production-shaped variant of ``test_sessions_three_layer.py``. The
|
||
prior test wired the AP's ``_runner_client`` directly to the runner
|
||
app via ``httpx.ASGITransport``, which short-circuits the WebSocket
|
||
tunnel that ships in production. This test instead drives the real
|
||
``/v1/runners/{runner_id}/tunnel`` route with an
|
||
``asgiref.testing.ApplicationCommunicator`` and pumps
|
||
``RequestFrame`` traffic through ``dispatch_via_asgi`` into a real
|
||
``create_runner_app``. The AP's ``RunnerRouter`` ends up serving a
|
||
``WSTunnelTransport``-backed client (the same code path the real
|
||
server uses), so every native-sessions HTTP hop crosses the live
|
||
tunnel registry.
|
||
|
||
Catches bugs that the ASGI-only three-layer test misses: frame
|
||
encode/decode regressions, tunnel registry leak/registration races,
|
||
``WSTunnelTransport`` error mapping, and any AP-side code that
|
||
reaches for ``app.state.tunnel_registry`` / ``get_runner_router()``.
|
||
|
||
Layers:
|
||
Omnigent server ──HTTP──> RunnerRouter ──WSTunnelTransport──>
|
||
TunnelRegistry ──tunnel WS route──> ApplicationCommunicator ──>
|
||
forwarder task ──> create_runner_app ──> FakeProcessManager ──>
|
||
EchoHarness ASGI app
|
||
|
||
The TODO at the end of the module flags ``FakeProcessManager`` and
|
||
``_build_harness_agent_bundle`` as candidates to factor out of
|
||
``test_sessions_three_layer.py`` once a second consumer (this file)
|
||
lands.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import io
|
||
import json
|
||
import tarfile
|
||
from collections.abc import AsyncIterator, Iterator
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import pytest
|
||
import pytest_asyncio
|
||
import yaml
|
||
from asgiref.testing import ApplicationCommunicator
|
||
from fastapi import FastAPI
|
||
from omnigent_client._events import ResponseCompleted, ResponseFailed
|
||
from omnigent_client._files import FilesNamespace
|
||
from omnigent_client._sessions import SessionsNamespace
|
||
|
||
from omnigent.repl._repl import _SessionsChatReplAdapter
|
||
from omnigent.runner.app import create_runner_app
|
||
from omnigent.runner.transports.ws_tunnel.frames import (
|
||
HelloFrame,
|
||
RequestFrame,
|
||
decode_frame,
|
||
encode_frame,
|
||
)
|
||
from omnigent.runner.transports.ws_tunnel.serve import dispatch_via_asgi
|
||
from omnigent.runner.transports.ws_tunnel.transport import WSTunnelTransport
|
||
from omnigent.runtime import (
|
||
init as init_runtime,
|
||
)
|
||
from omnigent.runtime import (
|
||
set_harness_process_manager,
|
||
set_runner_id,
|
||
set_runner_router,
|
||
)
|
||
from omnigent.runtime.agent_cache import AgentCache
|
||
from omnigent.runtime.harnesses import _HARNESS_MODULES
|
||
from omnigent.server.app import create_app
|
||
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
||
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||
SqlAlchemyConversationStore,
|
||
)
|
||
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
||
from tests.runner.helpers import NullServerClient
|
||
from tests.runtime.harnesses._test_scaffold_harnesses import _EchoHarness
|
||
|
||
# ``claude-sdk`` is one of the harness names the agent spec validator
|
||
# accepts. The fixture rewrites its registry entry to point at the
|
||
# echo-harness scaffold module for the duration of the test (then
|
||
# restores) so ``_create_executor`` picks the runner client path
|
||
# without trying to import the real claude-sdk module.
|
||
_TEST_HARNESS_NAME = "claude-sdk"
|
||
_RUNNER_ID = "runner-tunnel-three-layer-test"
|
||
|
||
|
||
class FakeProcessManager:
|
||
"""In-process replacement for :class:`HarnessProcessManager`.
|
||
|
||
Returns a per-conversation ``httpx.AsyncClient`` whose transport
|
||
is an ``ASGITransport`` to a freshly-built :class:`_EchoHarness`
|
||
FastAPI app with ``app.state.conversation_id`` pinned to that
|
||
conversation. Mirrors the parts of the production process
|
||
manager that the runner and ``the harness HTTP client`` actually
|
||
call.
|
||
|
||
Duplicates :class:`tests.server.integration.test_sessions_three_layer.FakeProcessManager`;
|
||
factor into a shared helper module once a third consumer needs it.
|
||
"""
|
||
|
||
handles_tool_dispatch = False
|
||
|
||
def __init__(self) -> None:
|
||
self._apps: dict[str, FastAPI] = {}
|
||
self._clients: dict[str, httpx.AsyncClient] = {}
|
||
self._in_flight: dict[str, str] = {}
|
||
|
||
async def start(self) -> None: # pragma: no cover — protocol shim
|
||
pass
|
||
|
||
async def shutdown(self) -> None:
|
||
for client in self._clients.values():
|
||
await client.aclose()
|
||
self._apps.clear()
|
||
self._clients.clear()
|
||
|
||
async def get_client(
|
||
self,
|
||
conversation_id: str,
|
||
harness: str,
|
||
env: dict[str, str] | None = None,
|
||
) -> httpx.AsyncClient:
|
||
if conversation_id not in self._clients:
|
||
app = _EchoHarness().build()
|
||
app.state.conversation_id = conversation_id
|
||
self._apps[conversation_id] = app
|
||
self._clients[conversation_id] = httpx.AsyncClient(
|
||
transport=httpx.ASGITransport(app=app),
|
||
base_url="http://harness",
|
||
timeout=httpx.Timeout(5.0, read=None),
|
||
)
|
||
return self._clients[conversation_id]
|
||
|
||
def has_session(self, conversation_id: str) -> bool:
|
||
return conversation_id in self._clients
|
||
|
||
def has_active_turn(self, conversation_id: str) -> bool:
|
||
return conversation_id in self._in_flight
|
||
|
||
def mark_in_flight(self, conversation_id: str, response_id: str) -> None:
|
||
# Mirror the real manager: the runner registers the live turn on
|
||
# response.created so the idle reaper spares it.
|
||
self._in_flight[conversation_id] = response_id
|
||
|
||
def clear_in_flight(self, conversation_id: str) -> None:
|
||
self._in_flight.pop(conversation_id, None)
|
||
|
||
async def release(self, conversation_id: str) -> None:
|
||
client = self._clients.pop(conversation_id, None)
|
||
if client is not None:
|
||
await client.aclose()
|
||
self._apps.pop(conversation_id, None)
|
||
self._in_flight.pop(conversation_id, None)
|
||
|
||
async def forward_cancel(self, conversation_id: str) -> None:
|
||
# EchoHarness completes synchronously; cancellation is not
|
||
# exercised by the happy-path test. A future tunneled
|
||
# cancellation test would extend EchoHarness to block on
|
||
# an event the test can release after the cancel frame.
|
||
del conversation_id
|
||
|
||
|
||
def _build_harness_agent_bundle() -> bytes:
|
||
"""Build an agent bundle that routes through the harness path.
|
||
|
||
Uses ``executor.type='omnigent'`` with
|
||
``executor.harness=<TEST_HARNESS>`` so ``_create_executor`` in
|
||
``runtime/workflow.py`` routes to ``the harness HTTP client`` and
|
||
thus through the runner → harness HTTP chain.
|
||
|
||
:returns: tar.gz bytes with a single ``config.yaml``.
|
||
"""
|
||
config: dict[str, Any] = {
|
||
"spec_version": 1,
|
||
"name": "echo-tunnel-test",
|
||
"executor": {
|
||
"type": "omnigent",
|
||
"config": {"harness": _TEST_HARNESS_NAME},
|
||
"model": "test-model",
|
||
},
|
||
}
|
||
buf = io.BytesIO()
|
||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||
data = yaml.dump(config).encode()
|
||
info = tarfile.TarInfo(name="config.yaml")
|
||
info.size = len(data)
|
||
tf.addfile(info, io.BytesIO(data))
|
||
return buf.getvalue()
|
||
|
||
|
||
# ── Tunnel handshake / forwarder helpers ──────────────────
|
||
|
||
|
||
def _websocket_scope(
|
||
path: str,
|
||
*,
|
||
client_host: str = "127.0.0.1",
|
||
) -> dict[str, object]:
|
||
"""Build a minimal ASGI WebSocket scope for the tunnel route."""
|
||
return {
|
||
"type": "websocket",
|
||
"asgi": {"version": "3.0"},
|
||
"scheme": "ws",
|
||
"path": path,
|
||
"raw_path": path.encode("ascii"),
|
||
"query_string": b"",
|
||
"headers": [],
|
||
"client": (client_host, 50000),
|
||
"server": ("testserver", 80),
|
||
"subprotocols": [],
|
||
}
|
||
|
||
|
||
async def _connect_runner_tunnel(
|
||
app: FastAPI,
|
||
runner_id: str,
|
||
) -> ApplicationCommunicator:
|
||
"""Open an ASGI WebSocket against the runner tunnel route.
|
||
|
||
:returns: A communicator already past ``websocket.accept``.
|
||
"""
|
||
communicator = ApplicationCommunicator(
|
||
app,
|
||
_websocket_scope(f"/v1/runners/{runner_id}/tunnel"),
|
||
)
|
||
await communicator.send_input({"type": "websocket.connect"})
|
||
accepted = await communicator.receive_output(timeout=2.0)
|
||
assert accepted["type"] == "websocket.accept", (
|
||
f"Tunnel route did not accept the WS handshake; got {accepted!r}"
|
||
)
|
||
return communicator
|
||
|
||
|
||
async def _send_hello_and_wait(
|
||
communicator: ApplicationCommunicator,
|
||
app: FastAPI,
|
||
runner_id: str,
|
||
*,
|
||
harnesses: list[str],
|
||
) -> None:
|
||
"""Send a HelloFrame and wait until the registry lists the runner."""
|
||
hello = HelloFrame(
|
||
runner_version="0.1.0-test",
|
||
frame_protocol_version=1,
|
||
harnesses=list(harnesses),
|
||
envs=["os_sandbox"],
|
||
)
|
||
await communicator.send_input(
|
||
{"type": "websocket.receive", "text": encode_frame(hello)},
|
||
)
|
||
registry = app.state.tunnel_registry
|
||
|
||
async def _registered() -> None:
|
||
while registry.get(runner_id) is None:
|
||
await asyncio.sleep(0.01)
|
||
|
||
await asyncio.wait_for(_registered(), timeout=2.0)
|
||
|
||
|
||
async def _forward_requests_to_runner(
|
||
communicator: ApplicationCommunicator,
|
||
runner_app: FastAPI,
|
||
) -> None:
|
||
"""Pump frames from the tunnel into the runner ASGI app.
|
||
|
||
Reads ``websocket.send`` outputs from the server side and decodes
|
||
each ``RequestFrame``. Each request is dispatched in its own
|
||
asyncio task so streaming responses (SSE relays) don't block
|
||
concurrent requests — the production runner WS loop has the same
|
||
shape. Frame writes back to the communicator are serialised
|
||
behind ``_write_lock`` because ``ApplicationCommunicator.send_input``
|
||
is not safe to call from multiple concurrent tasks.
|
||
"""
|
||
pending: set[asyncio.Task[None]] = set()
|
||
write_lock = asyncio.Lock()
|
||
|
||
async def _send_text(payload: str) -> None:
|
||
async with write_lock:
|
||
await communicator.send_input(
|
||
{"type": "websocket.receive", "text": payload},
|
||
)
|
||
|
||
try:
|
||
while True:
|
||
# ApplicationCommunicator.receive_output() defaults to a
|
||
# 1-second timeout. Under xdist scheduling pressure in CI
|
||
# the >=1s gap between WS frames is common (one xdist
|
||
# worker holds the event loop while EchoHarness runs), so
|
||
# the default triggers asyncio.TimeoutError which kills
|
||
# the forwarder and tears down the tunnel mid-turn. That
|
||
# severs the AP-side SSE relay before it has persisted the
|
||
# assistant message, and the test fails with "no assistant
|
||
# text in session snapshot". 60s is well above any
|
||
# plausible per-frame interval but still bounded so a
|
||
# genuinely stuck test fails rather than hangs.
|
||
output = await communicator.receive_output(timeout=60.0)
|
||
if output["type"] == "websocket.close":
|
||
return
|
||
if output["type"] != "websocket.send":
|
||
continue
|
||
|
||
text = output.get("text")
|
||
if not isinstance(text, str):
|
||
continue
|
||
|
||
frame = decode_frame(text)
|
||
if not isinstance(frame, RequestFrame):
|
||
continue
|
||
|
||
task = asyncio.create_task(
|
||
dispatch_via_asgi(runner_app, frame, _send_text),
|
||
name=f"tunnel-dispatch-{frame.id}",
|
||
)
|
||
pending.add(task)
|
||
task.add_done_callback(pending.discard)
|
||
finally:
|
||
for task in list(pending):
|
||
task.cancel()
|
||
for task in list(pending):
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await task
|
||
|
||
|
||
# ── Fixture: Omnigent app + tunnel WS + runner app + EchoHarness ────
|
||
|
||
|
||
@dataclass
|
||
class _TunnelStack:
|
||
ap_client: httpx.AsyncClient
|
||
ap_app: FastAPI
|
||
fake_pm: FakeProcessManager
|
||
|
||
|
||
@pytest_asyncio.fixture()
|
||
async def tunnel_three_layer_stack(tmp_path: Path) -> AsyncIterator[_TunnelStack]:
|
||
"""Wire Omnigent server + WS-tunneled runner + EchoHarness in-process.
|
||
|
||
Lifecycle: build stores, init runtime, override the test harness
|
||
module entry, build Omnigent app + runner app, open the WS tunnel via
|
||
``ApplicationCommunicator``, send hello, start the forwarder
|
||
task, register the runner in the AP-side ``set_runner_router``
|
||
so resource paths can resolve it, then yield an
|
||
``httpx.AsyncClient`` to the AP.
|
||
|
||
Teardown shuts down in reverse order (httpx → forwarder →
|
||
communicator → fake PM → runtime globals → DBOS) so DBOS
|
||
background threads observe a clean exit before pytest-asyncio
|
||
closes the loop.
|
||
"""
|
||
db_uri = f"sqlite:///{tmp_path / 'test.db'}"
|
||
|
||
agent_store = SqlAlchemyAgentStore(db_uri)
|
||
conv_store = SqlAlchemyConversationStore(db_uri)
|
||
file_store = SqlAlchemyFileStore(db_uri)
|
||
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
||
agent_cache = AgentCache(
|
||
artifact_store=artifact_store,
|
||
cache_dir=tmp_path / "cache",
|
||
)
|
||
|
||
init_runtime(
|
||
conversation_store=conv_store,
|
||
agent_store=agent_store,
|
||
agent_cache=agent_cache,
|
||
file_store=file_store,
|
||
artifact_store=artifact_store,
|
||
)
|
||
|
||
# Override the harness module entry so ``_create_executor`` routes
|
||
# ``claude-sdk`` to the EchoHarness scaffold for this test only.
|
||
_saved_harness_module = _HARNESS_MODULES.get(_TEST_HARNESS_NAME)
|
||
_HARNESS_MODULES[_TEST_HARNESS_NAME] = "tests.runtime.harnesses._test_scaffold_harnesses"
|
||
|
||
fake_pm = FakeProcessManager()
|
||
set_harness_process_manager(fake_pm)
|
||
set_runner_id(_RUNNER_ID)
|
||
|
||
async def _resolve_spec(agent_id: str, session_id: str | None = None) -> Any:
|
||
record = agent_store.get(agent_id)
|
||
if record is None:
|
||
return None
|
||
return agent_cache.load(agent_id, record.bundle_location).spec
|
||
|
||
runner_app = create_runner_app(
|
||
process_manager=fake_pm,
|
||
spec_resolver=_resolve_spec,
|
||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||
)
|
||
|
||
# Build the Omnigent server. ``create_app`` constructs the
|
||
# ``TunnelRegistry`` + ``RunnerRouter`` synchronously (before
|
||
# lifespan) and threads ``runner_router`` into every router as a
|
||
# closure, so the WS route + the sessions route share the same
|
||
# registry without us needing to drive the lifespan context
|
||
# manager. We still publish the router into the runtime globals
|
||
# so paths like ``_get_runner_client_for_resource_access`` (which
|
||
# reads ``get_runner_router()``) see the same instance.
|
||
ap_app = create_app(
|
||
agent_store=agent_store,
|
||
file_store=file_store,
|
||
conversation_store=conv_store,
|
||
artifact_store=artifact_store,
|
||
agent_cache=agent_cache,
|
||
)
|
||
set_runner_router(ap_app.state.runner_router)
|
||
|
||
communicator = await _connect_runner_tunnel(ap_app, _RUNNER_ID)
|
||
await _send_hello_and_wait(
|
||
communicator,
|
||
ap_app,
|
||
_RUNNER_ID,
|
||
harnesses=[_TEST_HARNESS_NAME],
|
||
)
|
||
|
||
forwarder_task = asyncio.create_task(
|
||
_forward_requests_to_runner(communicator, runner_app),
|
||
name="tunnel-three-layer-forwarder",
|
||
)
|
||
|
||
ap_client = httpx.AsyncClient(
|
||
transport=httpx.ASGITransport(app=ap_app),
|
||
base_url="http://ap",
|
||
timeout=httpx.Timeout(10.0, read=None),
|
||
)
|
||
|
||
try:
|
||
yield _TunnelStack(ap_client=ap_client, ap_app=ap_app, fake_pm=fake_pm)
|
||
finally:
|
||
# Cancel any AP-side background tasks (SSE relays, etc.) the
|
||
# production lifespan would have owned. ``create_app`` does not
|
||
# track them itself, so we sweep the loop for tasks whose
|
||
# ``get_name()`` advertises a runner-relay coroutine and cancel
|
||
# them before tearing down the tunnel. Without this, an
|
||
# in-flight ``runner-relay-*`` task races the WS disconnect and
|
||
# surfaces ``ConnectionError: tunnel closed`` as an uncaught
|
||
# task exception during teardown.
|
||
loop_tasks = []
|
||
for task in asyncio.all_tasks():
|
||
if task is asyncio.current_task():
|
||
continue
|
||
coro_qualname = getattr(task.get_coro(), "__qualname__", "")
|
||
if (
|
||
task.get_name().startswith("runner-relay-")
|
||
or "_relay_runner_stream" in coro_qualname
|
||
):
|
||
loop_tasks.append(task)
|
||
for t in loop_tasks:
|
||
t.cancel()
|
||
for t in loop_tasks:
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await t
|
||
|
||
await ap_client.aclose()
|
||
|
||
forwarder_task.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await forwarder_task
|
||
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await communicator.send_input(
|
||
{"type": "websocket.disconnect", "code": 1000},
|
||
)
|
||
with contextlib.suppress(asyncio.TimeoutError, Exception):
|
||
await communicator.wait(timeout=2.0)
|
||
|
||
# Close any cached WSTunnelTransport-backed clients before
|
||
# we tear the registry down so they can flush in-flight
|
||
# response futures cleanly.
|
||
with contextlib.suppress(Exception):
|
||
await ap_app.state.runner_router.aclose()
|
||
|
||
await fake_pm.shutdown()
|
||
set_runner_router(None)
|
||
set_runner_id(None)
|
||
set_harness_process_manager(None)
|
||
if _saved_harness_module is None:
|
||
_HARNESS_MODULES.pop(_TEST_HARNESS_NAME, None)
|
||
else:
|
||
_HARNESS_MODULES[_TEST_HARNESS_NAME] = _saved_harness_module
|
||
|
||
|
||
def _fake_client(ap_client: httpx.AsyncClient) -> object:
|
||
"""Build the SDK namespace shim expected by the REPL adapter.
|
||
|
||
The sessions adapter only uses these namespace attributes; using
|
||
the real namespace classes keeps request/response parsing on the
|
||
production client path while avoiding an actual network client.
|
||
|
||
:param ap_client: ASGI-backed client pointed at the Omnigent app.
|
||
:returns: Duck-typed client with the namespaces the adapter uses.
|
||
"""
|
||
base = str(ap_client.base_url).rstrip("/")
|
||
|
||
class _FakeClient:
|
||
"""Duck-typed Omnigent client backed by the tunnel-stack httpx client."""
|
||
|
||
def __init__(self) -> None:
|
||
self.sessions = SessionsNamespace(ap_client, base)
|
||
self.files = FilesNamespace(ap_client, base)
|
||
|
||
return _FakeClient()
|
||
|
||
|
||
def _new_repl_adapter(
|
||
ap_client: httpx.AsyncClient,
|
||
*,
|
||
agent_name: str,
|
||
session_id: str | None = None,
|
||
runner_id: str = _RUNNER_ID,
|
||
runner_recover: Any | None = None,
|
||
) -> _SessionsChatReplAdapter:
|
||
"""Create a sessions REPL adapter over the tunneled Omnigent stack.
|
||
|
||
:param ap_client: ASGI-backed client pointed at the Omnigent app.
|
||
:param agent_name: Human-readable agent display name.
|
||
:param session_id: Optional existing session to resume.
|
||
:param runner_id: Runner id the adapter should bind before send.
|
||
:param runner_recover: Optional callback returning replacement runner id.
|
||
:returns: Configured :class:`_SessionsChatReplAdapter`.
|
||
"""
|
||
return _SessionsChatReplAdapter(
|
||
client=_fake_client(ap_client), # type: ignore[arg-type]
|
||
agent_name=agent_name,
|
||
session_id=session_id,
|
||
session_bundle=None if session_id is not None else _build_harness_agent_bundle(),
|
||
runner_id=runner_id,
|
||
runner_recover=runner_recover,
|
||
)
|
||
|
||
|
||
async def _collect_adapter_turn(
|
||
adapter: _SessionsChatReplAdapter,
|
||
text: str,
|
||
) -> list[object]:
|
||
"""Send one adapter turn and collect terminal events.
|
||
|
||
``_SessionsChatReplAdapter.send`` intentionally yields only the
|
||
terminal SDK-shaped event; streaming deltas render through the
|
||
background callback path and are covered by the smoke test in this
|
||
file plus the legacy adapter file.
|
||
|
||
:param adapter: Adapter under test.
|
||
:param text: User input for :meth:`_SessionsChatReplAdapter.send`.
|
||
:returns: Events yielded by the ``send()`` generator.
|
||
"""
|
||
send_events: list[object] = []
|
||
async for ev in adapter.send(text):
|
||
send_events.append(ev)
|
||
if isinstance(ev, (ResponseCompleted, ResponseFailed)):
|
||
break
|
||
return send_events
|
||
|
||
|
||
# ── Tests ─────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_native_session_happy_path_via_ws_tunnel(
|
||
tunnel_three_layer_stack: _TunnelStack,
|
||
) -> None:
|
||
"""End-to-end native sessions flow through the live WS tunnel.
|
||
|
||
Mirrors ``test_session_happy_path_through_three_layers`` but
|
||
forces traffic across the production tunnel route. Failure here
|
||
points at either the AP↔tunnel wire (frame encoding, registry
|
||
handshake) or the runner ASGI dispatcher — both invisible to the
|
||
pure-ASGI three-layer test.
|
||
"""
|
||
ap_client = tunnel_three_layer_stack.ap_client
|
||
bundle = _build_harness_agent_bundle()
|
||
|
||
create_resp = await ap_client.post(
|
||
"/v1/sessions",
|
||
data={"metadata": json.dumps({})},
|
||
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
|
||
)
|
||
assert create_resp.status_code == 201, (
|
||
f"session create failed: {create_resp.status_code} {create_resp.text}"
|
||
)
|
||
session_id = create_resp.json()["session_id"]
|
||
|
||
bind_resp = await ap_client.patch(
|
||
f"/v1/sessions/{session_id}",
|
||
json={"runner_id": _RUNNER_ID},
|
||
)
|
||
assert bind_resp.status_code == 200, (
|
||
f"runner bind failed: {bind_resp.status_code} {bind_resp.text}"
|
||
)
|
||
|
||
event_resp = await ap_client.post(
|
||
f"/v1/sessions/{session_id}/events",
|
||
json={
|
||
"type": "message",
|
||
"data": {
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": "ping"}],
|
||
},
|
||
},
|
||
)
|
||
assert event_resp.status_code == 202, (
|
||
f"message event failed: {event_resp.status_code} {event_resp.text}"
|
||
)
|
||
|
||
# Poll until the relay has persisted the assistant message and
|
||
# the cache reflects a terminal status. Checking status alone is
|
||
# not enough: ``_get_session_snapshot`` defaults to ``"idle"`` on
|
||
# a cache miss when the legacy ``get_runner_client()`` singleton
|
||
# is unset (which is the case in any router-only deployment,
|
||
# including this fixture). On a cold-cache run the relay can
|
||
# still be in its initial transport handshake when the test
|
||
# polls, so the first GET returns the default ``"idle"`` and the
|
||
# old loop exited before the harness had emitted a single
|
||
# delta, turning into a flaky "no assistant text" failure.
|
||
# Waiting for the persisted assistant item makes us cancel-race
|
||
# resistant too: the fixture's ``failed`` to ``idle`` rewrite
|
||
# can no longer let us bail on a turn the runner never finished.
|
||
# 600 × 100 ms = 60 s budget, well above the WS-frame round-trip
|
||
# overhead but generous enough to absorb CI scheduler jitter.
|
||
def _assistant_texts(snapshot: dict[str, Any]) -> list[str]:
|
||
return [
|
||
chunk.get("text", "")
|
||
for item in snapshot["items"]
|
||
if item["type"] == "message" and item["data"]["role"] == "assistant"
|
||
for chunk in item["data"]["content"]
|
||
if chunk.get("type") == "output_text"
|
||
]
|
||
|
||
snap: dict[str, Any] | None = None
|
||
final_status: str | None = None
|
||
for _ in range(600):
|
||
snap_resp = await ap_client.get(f"/v1/sessions/{session_id}")
|
||
snap = snap_resp.json()
|
||
final_status = snap["status"]
|
||
if final_status == "failed":
|
||
break
|
||
if final_status == "idle" and _assistant_texts(snap):
|
||
break
|
||
await asyncio.sleep(0.1)
|
||
|
||
assert snap is not None
|
||
assert final_status == "idle", (
|
||
f"session did not reach idle (got {final_status!r}); snapshot={json.dumps(snap, indent=2)}"
|
||
)
|
||
|
||
assistant_texts = _assistant_texts(snap)
|
||
assert assistant_texts, (
|
||
f"no assistant text in session snapshot; items={json.dumps(snap['items'], indent=2)}"
|
||
)
|
||
joined = " ".join(assistant_texts)
|
||
assert "ping" in joined, f"echoed input missing from assistant text: {joined!r}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_runner_router_uses_ws_tunnel_for_session_resources(
|
||
tunnel_three_layer_stack: _TunnelStack,
|
||
) -> None:
|
||
"""Resource-access lookups go through the same WS-tunneled client.
|
||
|
||
Lightweight REPL-adapter-shaped smoke: after binding, the AP's
|
||
runner router must hand out a ``WSTunnelTransport``-backed client
|
||
(not a fallback ASGI one) for the session. This catches the
|
||
regression class where ``_get_runner_client_for_resource_access``
|
||
falls back to ``get_runner_client()`` because the router lookup
|
||
silently returned ``None``.
|
||
"""
|
||
ap_client = tunnel_three_layer_stack.ap_client
|
||
ap_app = tunnel_three_layer_stack.ap_app
|
||
|
||
create_resp = await ap_client.post(
|
||
"/v1/sessions",
|
||
data={"metadata": json.dumps({})},
|
||
files={
|
||
"bundle": (
|
||
"agent.tar.gz",
|
||
_build_harness_agent_bundle(),
|
||
"application/gzip",
|
||
),
|
||
},
|
||
)
|
||
assert create_resp.status_code == 201
|
||
session_id = create_resp.json()["session_id"]
|
||
|
||
bind_resp = await ap_client.patch(
|
||
f"/v1/sessions/{session_id}",
|
||
json={"runner_id": _RUNNER_ID},
|
||
)
|
||
assert bind_resp.status_code == 200
|
||
|
||
routed = ap_app.state.runner_router.client_for_session_resources(session_id)
|
||
assert routed.runner_id == _RUNNER_ID, (
|
||
f"router did not pin to {_RUNNER_ID!r}; got {routed.runner_id!r}"
|
||
)
|
||
|
||
transport = routed.client._transport
|
||
assert isinstance(transport, WSTunnelTransport), (
|
||
"router handed out a non-tunnel transport; tunnel wiring "
|
||
f"regressed. Got {type(transport).__name__}."
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_repl_adapter_smoke_via_ws_tunnel(
|
||
tunnel_three_layer_stack: _TunnelStack,
|
||
) -> None:
|
||
"""REPL adapter ``send()`` rides the same tunnel and yields terminal.
|
||
|
||
Lightweight smoke that the legacy adapter still completes its
|
||
end-to-end loop when the AP↔runner hop is the production WS
|
||
tunnel rather than ASGITransport. Builds the same client-namespace
|
||
shim ``test_sessions_repl_adapter.py`` uses (typed-duck, not the
|
||
real SDK ``Client``) so we don't pull a network dependency and
|
||
don't import a private helper across test files.
|
||
|
||
Asserts a terminal completion arrives — the EchoHarness emits a
|
||
``response.output_text.delta`` then completes. A failed terminal is
|
||
not accepted here because this test is intended to catch tunnel
|
||
regressions, not merely prove that the adapter can surface them.
|
||
"""
|
||
adapter = _new_repl_adapter(
|
||
tunnel_three_layer_stack.ap_client,
|
||
agent_name="tunnel-smoke-agent",
|
||
)
|
||
|
||
try:
|
||
send_events = await _collect_adapter_turn(adapter, "ping")
|
||
|
||
assert not any(isinstance(e, ResponseFailed) for e in send_events), (
|
||
"adapter.send failed over the tunnel; "
|
||
f"events={[type(e).__name__ for e in send_events]}"
|
||
)
|
||
assert any(isinstance(e, ResponseCompleted) for e in send_events), (
|
||
"adapter.send did not yield ResponseCompleted over the tunnel; "
|
||
f"events={[type(e).__name__ for e in send_events]}"
|
||
)
|
||
finally:
|
||
with contextlib.suppress(Exception):
|
||
await adapter.aclose()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_repl_adapter_resume_rebinds_via_ws_tunnel(
|
||
tunnel_three_layer_stack: _TunnelStack,
|
||
) -> None:
|
||
"""Resumed REPL adapter rebinding works on the native tunnel path.
|
||
|
||
Starts from an existing session bound to the live runner, then
|
||
resumes through an adapter whose local ``_bound_runner_id`` cache
|
||
is stale. The send must PATCH the session before dispatching over
|
||
the tunnel; otherwise the user message would not reach the runner
|
||
or the adapter would skip the resume binding path entirely.
|
||
"""
|
||
ap_client = tunnel_three_layer_stack.ap_client
|
||
|
||
create_resp = await ap_client.post(
|
||
"/v1/sessions",
|
||
data={"metadata": json.dumps({})},
|
||
files={
|
||
"bundle": (
|
||
"agent.tar.gz",
|
||
_build_harness_agent_bundle(),
|
||
"application/gzip",
|
||
),
|
||
},
|
||
)
|
||
assert create_resp.status_code == 201
|
||
session_id = create_resp.json()["session_id"]
|
||
|
||
bind_resp = await ap_client.patch(
|
||
f"/v1/sessions/{session_id}",
|
||
json={"runner_id": _RUNNER_ID},
|
||
)
|
||
assert bind_resp.status_code == 200
|
||
|
||
adapter = _new_repl_adapter(
|
||
ap_client,
|
||
agent_name="tunnel-resume-agent",
|
||
session_id=session_id,
|
||
runner_id=_RUNNER_ID,
|
||
)
|
||
adapter._bound_runner_id = "stale-runner-cache"
|
||
|
||
try:
|
||
send_events = await _collect_adapter_turn(adapter, "resumed turn")
|
||
assert not any(isinstance(e, ResponseFailed) for e in send_events), (
|
||
f"resumed turn failed; events={[type(e).__name__ for e in send_events]}"
|
||
)
|
||
assert any(isinstance(e, ResponseCompleted) for e in send_events), (
|
||
f"resumed turn did not complete; events={[type(e).__name__ for e in send_events]}"
|
||
)
|
||
assert adapter.session_id == session_id
|
||
assert adapter._bound_runner_id == _RUNNER_ID
|
||
|
||
snap_resp = await ap_client.get(f"/v1/sessions/{session_id}")
|
||
assert snap_resp.status_code == 200
|
||
snap = snap_resp.json()
|
||
assert snap["runner_id"] == _RUNNER_ID
|
||
user_texts = [
|
||
block["text"]
|
||
for item in snap["items"]
|
||
if item.get("type") == "message"
|
||
and isinstance(item.get("data"), dict)
|
||
and item["data"].get("role") == "user"
|
||
for block in item["data"].get("content", [])
|
||
if isinstance(block, dict) and block.get("type") == "input_text"
|
||
]
|
||
assert "resumed turn" in user_texts, (
|
||
f"resumed input missing from snapshot: {json.dumps(snap['items'], indent=2)}"
|
||
)
|
||
finally:
|
||
with contextlib.suppress(Exception):
|
||
await adapter.aclose()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_on_runner_connect_restarts_relay_via_router(
|
||
tunnel_three_layer_stack: _TunnelStack,
|
||
) -> None:
|
||
"""Reconnect hook restarts relays via the router.
|
||
|
||
Pre-fix, ``_on_runner_connect`` short-circuited on
|
||
``get_runner_client() is None`` — always true in multi-runner
|
||
deployments where only ``set_runner_router`` is wired — so every
|
||
reconnect was a silent no-op.
|
||
|
||
Drives a real WS tunnel disconnect/reconnect and asserts the hook
|
||
(a) routes through ``client_for_session_resources`` for the bound
|
||
session, (b) reaches ``_ensure_runner_relay`` with the exact
|
||
routed client, and (c) leaves a sibling session bound to a
|
||
different runner alone (per-conv ``runner_id`` filter).
|
||
"""
|
||
from omnigent.runner.routing import RoutedRunner
|
||
from omnigent.server.routes import sessions as sessions_routes
|
||
from omnigent.server.routes.sessions import _runner_relay_tasks
|
||
|
||
ap_client = tunnel_three_layer_stack.ap_client
|
||
ap_app = tunnel_three_layer_stack.ap_app
|
||
fake_pm = tunnel_three_layer_stack.fake_pm
|
||
|
||
create_resp = await ap_client.post(
|
||
"/v1/sessions",
|
||
data={"metadata": json.dumps({})},
|
||
files={
|
||
"bundle": (
|
||
"agent.tar.gz",
|
||
_build_harness_agent_bundle(),
|
||
"application/gzip",
|
||
),
|
||
},
|
||
)
|
||
assert create_resp.status_code == 201, create_resp.text
|
||
session_id = create_resp.json()["session_id"]
|
||
|
||
bind_resp = await ap_client.patch(
|
||
f"/v1/sessions/{session_id}",
|
||
json={"runner_id": _RUNNER_ID},
|
||
)
|
||
assert bind_resp.status_code == 200, bind_resp.text
|
||
|
||
# Sibling session bound to a different runner so the per-conv
|
||
# ``runner_id`` filter has something to skip. Written via the
|
||
# store directly because PATCH-bind would spawn a relay against
|
||
# a runner that has no WS pump, hanging teardown.
|
||
from omnigent.runtime import get_conversation_store
|
||
|
||
other_runner_id = "runner-other-irrelevant"
|
||
other_create_resp = await ap_client.post(
|
||
"/v1/sessions",
|
||
data={"metadata": json.dumps({})},
|
||
files={
|
||
"bundle": (
|
||
"agent.tar.gz",
|
||
_build_harness_agent_bundle(),
|
||
"application/gzip",
|
||
),
|
||
},
|
||
)
|
||
assert other_create_resp.status_code == 201, other_create_resp.text
|
||
other_session_id = other_create_resp.json()["session_id"]
|
||
get_conversation_store().replace_runner_id(other_session_id, other_runner_id)
|
||
|
||
initial_handle = _runner_relay_tasks.get(session_id)
|
||
assert initial_handle is not None, (
|
||
"PATCH-bind did not spawn a relay; the reconnect assertion "
|
||
"below would be vacuous without a baseline relay to replace."
|
||
)
|
||
initial_task = initial_handle.task
|
||
|
||
# Spies on the router resolver and ``_ensure_runner_relay``.
|
||
# The stub ``.post`` short-circuits because ``WSTunnelTransport``
|
||
# ignores httpx ``timeout=`` and would hang. The
|
||
# ensure spy does not chain through — the real helper would spawn
|
||
# an SSE task that errors immediately against the stub client.
|
||
router = ap_app.state.runner_router
|
||
real_resolver = router.client_for_session_resources
|
||
routed_calls: list[str] = []
|
||
routed_clients: dict[str, Any] = {}
|
||
|
||
class _StubResponse:
|
||
status_code = 200
|
||
|
||
def raise_for_status(self) -> None:
|
||
return None
|
||
|
||
class _StubClient:
|
||
async def post(self, *args: Any, **kwargs: Any) -> _StubResponse:
|
||
return _StubResponse()
|
||
|
||
def _spy_resolver(conv_id: str): # type: ignore[no-untyped-def]
|
||
routed_calls.append(conv_id)
|
||
real_routed = real_resolver(conv_id)
|
||
fake = RoutedRunner(runner_id=real_routed.runner_id, client=_StubClient()) # type: ignore[arg-type]
|
||
routed_clients[conv_id] = fake.client
|
||
return fake
|
||
|
||
router.client_for_session_resources = _spy_resolver # type: ignore[method-assign]
|
||
|
||
real_ensure = sessions_routes._ensure_runner_relay
|
||
ensure_calls: list[tuple[str, str | None, Any]] = []
|
||
|
||
def _spy_ensure(sid, rid, client, store=None): # type: ignore[no-untyped-def]
|
||
ensure_calls.append((sid, rid, client))
|
||
|
||
sessions_routes._ensure_runner_relay = _spy_ensure # type: ignore[assignment]
|
||
|
||
new_communicator: ApplicationCommunicator | None = None
|
||
new_forwarder_task: asyncio.Task[None] | None = None
|
||
try:
|
||
# Deregister aborts the in-flight relay with ConnectionError;
|
||
# the relay's done-callback then clears its slot.
|
||
ap_app.state.tunnel_registry.deregister(_RUNNER_ID)
|
||
|
||
async def _relay_slot_cleared() -> None:
|
||
while True:
|
||
handle = _runner_relay_tasks.get(session_id)
|
||
if handle is None or handle.task.done():
|
||
return
|
||
await asyncio.sleep(0.01)
|
||
|
||
await asyncio.wait_for(_relay_slot_cleared(), timeout=2.0)
|
||
|
||
# Fresh WS + hello re-registers and fires _on_runner_connect.
|
||
new_communicator = await _connect_runner_tunnel(ap_app, _RUNNER_ID)
|
||
await _send_hello_and_wait(
|
||
new_communicator,
|
||
ap_app,
|
||
_RUNNER_ID,
|
||
harnesses=[_TEST_HARNESS_NAME],
|
||
)
|
||
|
||
async def _resolve_spec(agent_id: str) -> Any:
|
||
return None
|
||
|
||
new_runner_app = create_runner_app(
|
||
process_manager=fake_pm,
|
||
spec_resolver=_resolve_spec,
|
||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||
)
|
||
new_forwarder_task = asyncio.create_task(
|
||
_forward_requests_to_runner(new_communicator, new_runner_app),
|
||
name="tunnel-reconnect-forwarder",
|
||
)
|
||
|
||
async def _hook_did_its_job() -> None:
|
||
while True:
|
||
if session_id in routed_calls and any(
|
||
sid == session_id for sid, _, _ in ensure_calls
|
||
):
|
||
return
|
||
await asyncio.sleep(0.02)
|
||
|
||
try:
|
||
await asyncio.wait_for(_hook_did_its_job(), timeout=5.0)
|
||
except asyncio.TimeoutError:
|
||
registry_entry = ap_app.state.tunnel_registry.get(_RUNNER_ID)
|
||
raise AssertionError(
|
||
"Reconnect did not drive the connect hook to completion within 5s.\n"
|
||
f" routed_calls = {routed_calls}\n"
|
||
f" ensure_calls = {ensure_calls}\n"
|
||
f" registry has runner = {registry_entry is not None}\n"
|
||
f" initial_task.done = {initial_task.done()}"
|
||
) from None
|
||
|
||
assert session_id in routed_calls, (
|
||
"_on_runner_connect did not call "
|
||
"runner_router.client_for_session_resources for the bound "
|
||
"session — legacy get_runner_client() path is still in use "
|
||
"(B2 regression)."
|
||
)
|
||
ensure_for_session = [
|
||
(sid, rid, client) for sid, rid, client in ensure_calls if sid == session_id
|
||
]
|
||
assert ensure_for_session, (
|
||
"_on_runner_connect resolved the router but never reached "
|
||
"_ensure_runner_relay for the bound session."
|
||
)
|
||
_last_sid, last_rid, last_client = ensure_for_session[-1]
|
||
assert last_rid == _RUNNER_ID, (
|
||
f"_ensure_runner_relay was called with runner_id "
|
||
f"{last_rid!r}, expected {_RUNNER_ID!r}."
|
||
)
|
||
assert last_client is routed_clients[session_id], (
|
||
"_ensure_runner_relay was called with a client that is not the "
|
||
"one returned by runner_router.client_for_session_resources — "
|
||
"the routed client is not threading through the hook."
|
||
)
|
||
|
||
# Sibling session bound to a different runner must not be touched
|
||
# — the per-conv runner_id filter is the only cross-runner guard.
|
||
assert other_session_id not in routed_calls, (
|
||
f"Connect hook resolved router for sibling session "
|
||
f"{other_session_id!r} bound to {other_runner_id!r} — "
|
||
f"per-conv runner_id filter regressed."
|
||
)
|
||
assert all(sid != other_session_id for sid, _, _ in ensure_calls), (
|
||
f"Connect hook called _ensure_runner_relay for sibling session "
|
||
f"{other_session_id!r} bound to {other_runner_id!r}."
|
||
)
|
||
finally:
|
||
router.client_for_session_resources = real_resolver # type: ignore[method-assign]
|
||
sessions_routes._ensure_runner_relay = real_ensure # type: ignore[assignment]
|
||
if new_forwarder_task is not None:
|
||
new_forwarder_task.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await new_forwarder_task
|
||
if new_communicator is not None:
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await new_communicator.send_input(
|
||
{"type": "websocket.disconnect", "code": 1000},
|
||
)
|
||
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError, Exception):
|
||
await new_communicator.wait(timeout=2.0)
|
||
|
||
|
||
@contextlib.asynccontextmanager
|
||
async def _reconnect_fires_connect_hook(
|
||
ap_app: FastAPI,
|
||
fake_pm: Any,
|
||
*,
|
||
wait_for_recover: str,
|
||
) -> AsyncIterator[list[str]]:
|
||
"""Drive a real tunnel disconnect/reconnect so ``_on_runner_connect`` fires.
|
||
|
||
Deregisters ``_RUNNER_ID`` then opens a fresh WS + hello, which is
|
||
the production trigger for the connect hook. Stubs the router
|
||
resolver + ``_ensure_runner_relay`` so the hook does not spawn a real
|
||
SSE relay against a stub client, and wraps the REAL
|
||
``_publish_runner_recovered_status`` with a spy that records
|
||
completion (the closure re-imports it from the module on each call,
|
||
so the patch is picked up). Waits until the recovery helper has run
|
||
to completion for ``wait_for_recover`` before yielding, so callers
|
||
can assert on the post-recovery state.
|
||
|
||
:yields: The list of session ids the recovery helper ran for.
|
||
"""
|
||
from omnigent.runner.routing import RoutedRunner
|
||
from omnigent.server.routes import sessions as sessions_routes
|
||
|
||
router = ap_app.state.runner_router
|
||
real_resolver = router.client_for_session_resources
|
||
|
||
class _StubResponse:
|
||
status_code = 200
|
||
|
||
def raise_for_status(self) -> None:
|
||
return None
|
||
|
||
class _StubClient:
|
||
async def post(self, *args: Any, **kwargs: Any) -> _StubResponse:
|
||
return _StubResponse()
|
||
|
||
def _spy_resolver(conv_id: str): # type: ignore[no-untyped-def]
|
||
real_routed = real_resolver(conv_id)
|
||
return RoutedRunner(runner_id=real_routed.runner_id, client=_StubClient()) # type: ignore[arg-type]
|
||
|
||
router.client_for_session_resources = _spy_resolver # type: ignore[method-assign]
|
||
|
||
real_ensure = sessions_routes._ensure_runner_relay
|
||
|
||
def _stub_ensure(sid, rid, client, store=None): # type: ignore[no-untyped-def]
|
||
return None
|
||
|
||
sessions_routes._ensure_runner_relay = _stub_ensure # type: ignore[assignment]
|
||
|
||
# Wrap (not replace) the real recovery helper so the narrowed
|
||
# disconnect-vs-failure guard is exercised, and record completion so
|
||
# the test can wait for a deterministic signal rather than sleeping.
|
||
real_recover = sessions_routes._publish_runner_recovered_status
|
||
recovered_calls: list[str] = []
|
||
|
||
async def _spy_recover(sid, store, **kwargs): # type: ignore[no-untyped-def]
|
||
await real_recover(sid, store, **kwargs)
|
||
recovered_calls.append(sid)
|
||
|
||
sessions_routes._publish_runner_recovered_status = _spy_recover # type: ignore[assignment]
|
||
|
||
communicator: ApplicationCommunicator | None = None
|
||
forwarder_task: asyncio.Task[None] | None = None
|
||
try:
|
||
ap_app.state.tunnel_registry.deregister(_RUNNER_ID)
|
||
|
||
communicator = await _connect_runner_tunnel(ap_app, _RUNNER_ID)
|
||
await _send_hello_and_wait(
|
||
communicator,
|
||
ap_app,
|
||
_RUNNER_ID,
|
||
harnesses=[_TEST_HARNESS_NAME],
|
||
)
|
||
|
||
async def _resolve_spec(agent_id: str) -> Any:
|
||
return None
|
||
|
||
new_runner_app = create_runner_app(
|
||
process_manager=fake_pm,
|
||
spec_resolver=_resolve_spec,
|
||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||
)
|
||
forwarder_task = asyncio.create_task(
|
||
_forward_requests_to_runner(communicator, new_runner_app),
|
||
name="tunnel-recover-reconnect-forwarder",
|
||
)
|
||
|
||
async def _recovered() -> None:
|
||
while wait_for_recover not in recovered_calls:
|
||
await asyncio.sleep(0.02)
|
||
|
||
try:
|
||
await asyncio.wait_for(_recovered(), timeout=5.0)
|
||
except asyncio.TimeoutError:
|
||
raise AssertionError(
|
||
"Reconnect did not drive _on_runner_connect to call "
|
||
f"_publish_runner_recovered_status for {wait_for_recover!r} "
|
||
f"within 5s. recovered_calls={recovered_calls}"
|
||
) from None
|
||
|
||
yield recovered_calls
|
||
finally:
|
||
router.client_for_session_resources = real_resolver # type: ignore[method-assign]
|
||
sessions_routes._ensure_runner_relay = real_ensure # type: ignore[assignment]
|
||
sessions_routes._publish_runner_recovered_status = real_recover # type: ignore[assignment]
|
||
if forwarder_task is not None:
|
||
forwarder_task.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await forwarder_task
|
||
if communicator is not None:
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await communicator.send_input(
|
||
{"type": "websocket.disconnect", "code": 1000},
|
||
)
|
||
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError, Exception):
|
||
await communicator.wait(timeout=2.0)
|
||
|
||
|
||
async def _bind_failed_session(
|
||
ap_client: httpx.AsyncClient,
|
||
*,
|
||
error_code: str,
|
||
error_message: str,
|
||
) -> str:
|
||
"""Create a session, bind it to ``_RUNNER_ID`` (no relay), mark it failed.
|
||
|
||
Binds via the store directly (like the sibling session in
|
||
``test_on_runner_connect_restarts_relay_via_router``) because a
|
||
PATCH-bind would spawn a relay whose teardown races the reconnect.
|
||
Then stamps the ``failed`` cache status + persisted
|
||
``last_task_error`` labels a real disconnect / task failure leaves
|
||
behind, so the reconnect hook sees an authentic pre-recovery state.
|
||
"""
|
||
from omnigent.runtime import get_conversation_store
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
from omnigent.server.schemas import ErrorDetail
|
||
|
||
create_resp = await ap_client.post(
|
||
"/v1/sessions",
|
||
data={"metadata": json.dumps({})},
|
||
files={
|
||
"bundle": (
|
||
"agent.tar.gz",
|
||
_build_harness_agent_bundle(),
|
||
"application/gzip",
|
||
),
|
||
},
|
||
)
|
||
assert create_resp.status_code == 201, create_resp.text
|
||
session_id = create_resp.json()["session_id"]
|
||
|
||
store = get_conversation_store()
|
||
store.replace_runner_id(session_id, _RUNNER_ID)
|
||
sessions_module._session_status_cache[session_id] = "failed"
|
||
await sessions_module._persist_session_status_error_labels(
|
||
session_id,
|
||
ErrorDetail(code=error_code, message=error_message),
|
||
store,
|
||
)
|
||
return session_id
|
||
|
||
|
||
@pytest.fixture()
|
||
def _isolated_session_status_cache() -> Iterator[None]:
|
||
"""Snapshot + clear + restore the module-global session-status cache.
|
||
|
||
``_session_status_cache`` lives at module scope in the sessions
|
||
route, so it survives across tests while each test rebuilds its own
|
||
conversation store on a fresh ``tmp_path`` DB. A prior test can leave
|
||
entries behind (and per-test session ids can collide), which makes
|
||
the reconnect-recovery assertions non-deterministic when the whole
|
||
suite runs. Start each guarded test from an empty cache and restore
|
||
the pre-test contents afterward so nothing leaks in either direction.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
saved = dict(sessions_module._session_status_cache)
|
||
sessions_module._session_status_cache.clear()
|
||
try:
|
||
yield
|
||
finally:
|
||
sessions_module._session_status_cache.clear()
|
||
sessions_module._session_status_cache.update(saved)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.usefixtures("_isolated_session_status_cache")
|
||
async def test_on_runner_connect_clears_disconnect_failure_on_idle_reconnect(
|
||
tunnel_three_layer_stack: _TunnelStack,
|
||
) -> None:
|
||
"""Reconnect-to-idle clears a persisted ``runner_disconnected`` failure.
|
||
|
||
A transient WS blip drops and reconnects a runner whose process
|
||
survived, with no new turn. The disconnect left the session marked
|
||
``failed`` with persisted ``runner_disconnected`` labels; before this
|
||
wiring nothing cleared them until the next user message, so the
|
||
Subagents panel kept the grey "Disconnected" dot forever. Drives a
|
||
real tunnel reconnect (which fires ``_on_runner_connect``) and asserts
|
||
the session leaves ``failed`` and the disconnect labels are cleared —
|
||
WITHOUT sending any message.
|
||
"""
|
||
from omnigent.runtime import get_conversation_store
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
ap_client = tunnel_three_layer_stack.ap_client
|
||
ap_app = tunnel_three_layer_stack.ap_app
|
||
fake_pm = tunnel_three_layer_stack.fake_pm
|
||
|
||
session_id = await _bind_failed_session(
|
||
ap_client,
|
||
error_code="runner_disconnected",
|
||
error_message="Runner disconnected unexpectedly.",
|
||
)
|
||
store = get_conversation_store()
|
||
|
||
# Sanity: this is the exact state a real tunnel drop leaves behind.
|
||
conv = store.get_conversation(session_id)
|
||
assert conv is not None
|
||
assert sessions_module._last_task_error_from_labels(conv.labels) == {
|
||
"code": "runner_disconnected",
|
||
"message": "Runner disconnected unexpectedly.",
|
||
}
|
||
assert sessions_module._session_status_cache.get(session_id) == "failed"
|
||
|
||
try:
|
||
# Assert INSIDE the block: the context manager's teardown closes
|
||
# the tunnel, which re-fires ``_on_runner_disconnect`` and would
|
||
# re-mark the session failed. We want the reconnect-recovery state.
|
||
async with _reconnect_fires_connect_hook(ap_app, fake_pm, wait_for_recover=session_id):
|
||
# No message was sent — the reconnect alone cleared the stale
|
||
# disconnect failure.
|
||
assert sessions_module._session_status_cache.get(session_id) != "failed"
|
||
cleared = store.get_conversation(session_id)
|
||
assert cleared is not None
|
||
assert sessions_module._last_task_error_from_labels(cleared.labels) is None
|
||
finally:
|
||
sessions_module._session_status_cache.pop(session_id, None)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.usefixtures("_isolated_session_status_cache")
|
||
async def test_on_runner_connect_preserves_genuine_failure_on_reconnect(
|
||
tunnel_three_layer_stack: _TunnelStack,
|
||
) -> None:
|
||
"""Reconnect must NOT erase a genuine (non-disconnect) task failure.
|
||
|
||
A runner reconnect proves the tunnel is healthy again, which
|
||
invalidates a benign disconnect failure — but says nothing about a
|
||
real task error. A session that genuinely failed (a
|
||
``last_task_error`` code other than ``runner_disconnected``) must keep
|
||
its ``failed`` state and error labels across the reconnect. Drives the
|
||
real ``_on_runner_connect`` and asserts the failure survives.
|
||
"""
|
||
from omnigent.runtime import get_conversation_store
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
ap_client = tunnel_three_layer_stack.ap_client
|
||
ap_app = tunnel_three_layer_stack.ap_app
|
||
fake_pm = tunnel_three_layer_stack.fake_pm
|
||
|
||
session_id = await _bind_failed_session(
|
||
ap_client,
|
||
error_code="runner_error",
|
||
error_message="Turn failed: agent raised.",
|
||
)
|
||
store = get_conversation_store()
|
||
|
||
try:
|
||
async with _reconnect_fires_connect_hook(ap_app, fake_pm, wait_for_recover=session_id):
|
||
# The recovery helper ran (the hook called it) but self-guarded:
|
||
# a genuine failure is not a stale disconnect, so status + labels
|
||
# are untouched.
|
||
assert sessions_module._session_status_cache.get(session_id) == "failed"
|
||
survived = store.get_conversation(session_id)
|
||
assert survived is not None
|
||
assert sessions_module._last_task_error_from_labels(survived.labels) == {
|
||
"code": "runner_error",
|
||
"message": "Turn failed: agent raised.",
|
||
}
|
||
finally:
|
||
sessions_module._session_status_cache.pop(session_id, None)
|
||
|
||
|
||
# TODO: factor ``FakeProcessManager`` and ``_build_harness_agent_bundle``
|
||
# out of ``test_sessions_three_layer.py`` + this file into a shared
|
||
# ``_three_layer_helpers.py`` module once a third caller arrives. Kept
|
||
# duplicated for now to keep this PR scoped to fixture + tests only.
|