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

4856 lines
178 KiB
Python

"""Tests for server session resource endpoints (Phase 1a + 1b + 1c)."""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import AsyncIterator, Callable, Iterator
from typing import Any
import httpx
import pytest
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from omnigent.entities import DEFAULT_ENVIRONMENT_ID, Conversation, ConversationItem, PagedList
from omnigent.errors import ErrorCode, OmnigentError
from omnigent.runtime import _globals, session_stream, set_runner_client, set_runner_router
from omnigent.server.routes.sessions import _ancestor_session_ids, create_sessions_router
from omnigent.server.schemas import SessionEventInput
class _ConversationStore:
"""Minimal in-memory conversation store for tests.
:param conversations: Map of conversation_id to Conversation.
"""
def __init__(self) -> None:
"""
Initialize the canned conversations used by route tests.
:returns: None.
"""
self._conversations = {
"conv_proxy": Conversation(
id="conv_proxy",
created_at=1,
updated_at=1,
root_conversation_id="conv_proxy",
agent_id="ag_test",
),
"conv_local": Conversation(
id="conv_local",
created_at=1,
updated_at=1,
root_conversation_id="conv_local",
agent_id="ag_test",
),
"conv_claude": Conversation(
id="conv_claude",
created_at=1,
updated_at=1,
root_conversation_id="conv_claude",
agent_id="ag_test",
labels={
"omnigent.ui": "terminal",
"omnigent.wrapper": "claude-code-native-ui",
},
),
"conv_kiro": Conversation(
id="conv_kiro",
created_at=1,
updated_at=1,
root_conversation_id="conv_kiro",
agent_id="ag_kiro",
labels={
"omnigent.ui": "terminal",
"omnigent.wrapper": "kiro-native-ui",
},
),
# A spec-driven native sub-agent child (e.g. a nessie
# claude_code reviewer): kind="sub_agent" with a parent
# ref and the regular native wrapper label (NOT the
# internal "-subagent" label), so the native message
# bypass runs on its first message.
"conv_child_native": Conversation(
id="conv_child_native",
created_at=1,
updated_at=1,
root_conversation_id="conv_parent",
kind="sub_agent",
parent_conversation_id="conv_parent",
agent_id="ag_test",
labels={
"omnigent.ui": "terminal",
"omnigent.wrapper": "claude-code-native-ui",
},
),
# A three-level spawn lineage for the file-copy tests:
# conv_gp (root) -> conv_p (child) -> conv_c (grandchild).
"conv_gp": Conversation(
id="conv_gp",
created_at=1,
updated_at=1,
root_conversation_id="conv_gp",
agent_id="ag_test",
),
"conv_p": Conversation(
id="conv_p",
created_at=1,
updated_at=1,
root_conversation_id="conv_gp",
kind="sub_agent",
parent_conversation_id="conv_gp",
agent_id="ag_test",
),
"conv_c": Conversation(
id="conv_c",
created_at=1,
updated_at=1,
root_conversation_id="conv_gp",
kind="sub_agent",
parent_conversation_id="conv_p",
agent_id="ag_test",
),
}
self.appended_items: list[Any] = []
def get_conversation(self, conversation_id: str) -> Conversation | None:
"""Return the conversation or None."""
return self._conversations.get(conversation_id)
def list_conversations(
self,
*,
limit: int = 20,
after: str | None = None,
kind: str | None = None,
root_conversation_id: str | None = None,
**_kwargs: Any,
) -> PagedList[Conversation]:
"""Minimal list for policy-builder subtree walk.
:param limit: Max items per page.
:param after: Cursor for pagination.
:param kind: Conversation kind filter (ignored when ``None``).
:param root_conversation_id: Filter by root tree id.
:returns: A :class:`PagedList` of matching conversations.
"""
convs = list(self._conversations.values())
if root_conversation_id is not None:
convs = [c for c in convs if c.root_conversation_id == root_conversation_id]
if kind is not None:
convs = [c for c in convs if c.kind == kind]
return PagedList(data=convs, has_more=False)
def update_conversation(
self,
conversation_id: str,
*,
title: str | None = None,
**kwargs: Any,
) -> Conversation | None:
"""Update the stored conversation title.
:param conversation_id: Conversation id to update.
:param title: Optional title to set.
:param kwargs: Extra store fields ignored by this test stub.
:returns: Updated conversation, or ``None`` if absent.
"""
del kwargs
conv = self._conversations.get(conversation_id)
if conv is None:
return None
conv.title = title
return conv
def set_labels(
self,
conversation_id: str,
updates: dict[str, str],
updated_at: int | None = None,
) -> None:
"""Merge label updates into an in-memory conversation."""
del updated_at
conv = self._conversations[conversation_id]
conv.labels.update(updates)
def append(
self,
conversation_id: str,
items: list[Any],
) -> list[Any]:
"""Record appended items and return them with fake ids.
:param conversation_id: The conversation id.
:param items: Items to append.
:returns: Items with fake ids assigned.
"""
import time
from omnigent.entities import ConversationItem
result = []
for item in items:
persisted = ConversationItem(
id=f"item_{len(self.appended_items)}",
type=item.type,
status="completed",
response_id=item.response_id,
created_at=int(time.time()),
data=item.data,
created_by=getattr(item, "created_by", None),
)
self.appended_items.append(persisted)
result.append(persisted)
return result
def list_items(
self,
conversation_id: str,
limit: int = 100,
after: str | None = None,
before: str | None = None,
order: str = "asc",
type: str | None = None,
) -> PagedList[ConversationItem]:
"""Return appended items with the store interface shape.
:param conversation_id: Conversation id to read.
:param limit: Maximum number of items to return.
:param after: Cursor after which to read (ignored).
:param before: Cursor before which to read (ignored).
:param order: Sort order, ``"asc"`` or ``"desc"``.
:param type: Optional item type filter.
:returns: A page of matching in-memory items.
"""
del conversation_id, after, before
items = [
item
for item in self.appended_items
if type is None or getattr(item, "type", None) == type
]
if order == "desc":
items = list(reversed(items))
items = items[:limit]
return PagedList(
data=items,
first_id=items[0].id if items else None,
last_id=items[-1].id if items else None,
has_more=False,
)
class _FakeRunnerClient:
"""Fake httpx.AsyncClient that records calls and returns canned responses.
Supports per-path response overrides via ``responses`` dict.
:param payload: Default JSON payload for all requests.
:param status_code: Default HTTP status code.
:param exc: If set, raise this on every call.
:param responses: Optional map of URL path → (status, payload)
for per-path overrides.
:param text_responses: Optional map of URL path → (status,
body text, headers) for non-JSON overrides.
"""
def __init__(
self,
*,
payload: dict[str, object] | list[object] | None = None,
status_code: int = 200,
exc: Exception | None = None,
exc_paths: dict[str, Exception] | None = None,
responses: dict[str, tuple[int, dict[str, Any]]] | None = None,
text_responses: dict[str, tuple[int, str, dict[str, str]]] | None = None,
) -> None:
"""
Initialize a fake runner HTTP client.
:param payload: Default JSON payload for every response.
:param status_code: Default HTTP status code.
:param exc: Exception to raise on every request.
:param exc_paths: Per-URL exception overrides — raise only when
the request targets that exact path, e.g.
``{"/v1/sessions/conv_x/events": ConnectionError("boom")}``.
Lets a test pass one stage (terminal ensure) and fail the
next (message forward) on the same client.
:param responses: Per-URL JSON response overrides.
:param text_responses: Per-URL text response overrides.
:returns: None.
"""
self._payload = payload
self._status_code = status_code
self._exc = exc
self._exc_paths = exc_paths or {}
self._responses = responses or {}
self._text_responses = text_responses or {}
self.calls: list[tuple[str, str]] = []
self.post_json_calls: list[tuple[str, Any]] = []
# Query params passed to each GET, in call order. ``None`` when
# the caller sent no params — lets tests assert that a proxy
# forwarded (or deliberately dropped) the incoming query string.
self.get_params: list[dict[str, str] | None] = []
def _make_response(
self,
method: str,
url: str,
) -> httpx.Response:
"""Build a canned response for the given method + url.
:param method: HTTP method, e.g. ``"GET"``.
:param url: Request URL path.
:returns: The canned httpx.Response.
"""
self.calls.append((method, url))
if self._exc is not None:
raise self._exc
if url in self._exc_paths:
raise self._exc_paths[url]
if url in self._text_responses:
status, text, headers = self._text_responses[url]
return httpx.Response(
status_code=status,
text=text,
headers=headers,
request=httpx.Request(method, url),
)
if url in self._responses:
status, body = self._responses[url]
return httpx.Response(
status_code=status,
json=body,
request=httpx.Request(method, url),
)
return httpx.Response(
status_code=self._status_code,
json=self._payload,
request=httpx.Request(method, url),
)
async def get(
self,
url: str,
*,
params: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
"""
Record and return a GET response.
:param url: Request URL path.
:param params: Query params forwarded by the proxy (e.g. the
resources route's ``{"type": "environment"}``); recorded in
``get_params`` for forwarding assertions, ignored by the
canned-response lookup.
:param timeout: Request timeout (ignored).
:returns: The canned response.
"""
del timeout
self.get_params.append(params)
return self._make_response("GET", url)
async def post(
self,
url: str,
*,
json: Any = None,
timeout: float | None = None,
) -> httpx.Response:
"""
Record and return a POST response.
:param url: Request URL path.
:param json: JSON body passed to the fake client.
:param timeout: Request timeout (ignored).
:returns: The canned response.
"""
del timeout
self.post_json_calls.append((url, json))
return self._make_response("POST", url)
async def put(
self,
url: str,
*,
json: Any = None,
timeout: float | None = None,
) -> httpx.Response:
"""Record and return a PUT response."""
del json, timeout
return self._make_response("PUT", url)
async def patch(
self,
url: str,
*,
json: Any = None,
timeout: float | None = None,
) -> httpx.Response:
"""Record and return a PATCH response."""
del json, timeout
return self._make_response("PATCH", url)
async def delete(self, url: str, *, timeout: float | None = None) -> httpx.Response:
"""Record and return a DELETE response."""
del timeout
return self._make_response("DELETE", url)
class _RoutedRunner:
def __init__(self, client: _FakeRunnerClient) -> None:
self.runner_id = "runner_one"
self.client = client
class _FakeRunnerRouter:
def __init__(self, client: _FakeRunnerClient) -> None:
self.client = client
self.resource_calls: list[str] = []
def client_for_session_resources(self, session_id: str) -> _RoutedRunner:
self.resource_calls.append(session_id)
return _RoutedRunner(self.client)
@pytest.fixture
def runner_globals_reset() -> Iterator[None]:
prior_client = _globals._runner_client
prior_router = _globals._runner_router
set_runner_client(None)
set_runner_router(None)
yield
set_runner_client(prior_client)
set_runner_router(prior_router)
@pytest.fixture
def app(runner_globals_reset: None) -> FastAPI:
del runner_globals_reset
app = FastAPI()
@app.exception_handler(OmnigentError)
async def _handle_omnigent_error(
request: Request,
exc: OmnigentError,
) -> JSONResponse:
del request
return JSONResponse(
status_code=exc.http_status,
content={"error": {"code": exc.code, "message": exc.message}},
)
class _StubAgentStore:
"""Minimal agent store stub for tests that don't exercise agents."""
def get(self, agent_id):
"""
:param agent_id: Agent id.
:returns: None (no agents in stub).
"""
return
app.include_router(
create_sessions_router(
_ConversationStore(), # type: ignore[arg-type]
_StubAgentStore(), # type: ignore[arg-type]
),
prefix="/v1",
)
return app
@pytest.fixture
async def client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://server") as c:
yield c
def _runner_payload() -> dict[str, object]:
return {
"object": "list",
"data": [
{
"id": DEFAULT_ENVIRONMENT_ID,
"object": "session.resource",
"type": "environment",
"session_id": "conv_proxy",
"name": "Primary environment",
"metadata": {
"environment_type": "caller_process",
"role": "primary",
},
},
{
"id": "terminal_runner_s1",
"object": "session.resource",
"type": "terminal",
"session_id": "conv_proxy",
"name": "runner:s1",
"environment": DEFAULT_ENVIRONMENT_ID,
"metadata": {
"terminal_name": "runner",
"session_key": "s1",
"running": True,
},
},
],
"first_id": DEFAULT_ENVIRONMENT_ID,
"last_id": "terminal_runner_s1",
"has_more": False,
}
@pytest.mark.asyncio
async def test_get_session_labels_uses_labels_only_path(
client: httpx.AsyncClient,
) -> None:
"""
Labels endpoint must not build the full session snapshot.
The test app's conversation store intentionally has no ``list_items``
method. If this endpoint regresses to the full snapshot builder, the
request fails before returning labels. A runner client is also seeded
to prove the route does not query runner status or skills.
:param client: HTTP client for the test app.
:returns: None.
"""
fake_runner = _FakeRunnerClient(payload={"status": "running"})
set_runner_client(fake_runner) # type: ignore[arg-type]
resp = await client.get("/v1/sessions/conv_claude/labels")
assert resp.status_code == 200
assert resp.headers["cache-control"] == "no-store"
assert resp.json() == {
"id": "conv_claude",
"labels": {
"omnigent.ui": "terminal",
"omnigent.wrapper": "claude-code-native-ui",
},
}
assert fake_runner.calls == []
@pytest.mark.asyncio
async def test_list_session_resources_proxies_to_bound_runner(
client: httpx.AsyncClient,
) -> None:
"""Resource listing delegates runner selection to the runner router."""
fake_runner = _FakeRunnerClient(payload=_runner_payload())
fake_router = _FakeRunnerRouter(fake_runner)
set_runner_router(fake_router) # type: ignore[arg-type]
resp = await client.get("/v1/sessions/conv_proxy/resources")
assert resp.status_code == 200
assert fake_router.resource_calls == ["conv_proxy"]
assert fake_runner.calls == [("GET", "/v1/sessions/conv_proxy/resources")]
body = resp.json()
ids = [resource["id"] for resource in body["data"]]
assert ids == [DEFAULT_ENVIRONMENT_ID, "terminal_runner_s1"]
@pytest.mark.asyncio
async def test_list_session_resources_validates_session_before_proxy(
client: httpx.AsyncClient,
) -> None:
fake_runner = _FakeRunnerClient(payload=_runner_payload())
fake_router = _FakeRunnerRouter(fake_runner)
set_runner_router(fake_router) # type: ignore[arg-type]
resp = await client.get("/v1/sessions/conv_missing/resources")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "not_found"
assert not fake_router.resource_calls
assert not fake_runner.calls
@pytest.mark.asyncio
async def test_list_session_resources_rejects_malformed_runner_response(
client: httpx.AsyncClient,
) -> None:
fake_runner = _FakeRunnerClient(
payload={"object": "list", "data": [], "has_more": False},
)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get("/v1/sessions/conv_proxy/resources")
assert resp.status_code == 502
assert resp.json()["detail"] == "runner session-resources endpoint returned malformed response"
@pytest.mark.asyncio
async def test_list_session_resources_local_fallback_lists_default(
client: httpx.AsyncClient,
) -> None:
resp = await client.get("/v1/sessions/conv_local/resources")
assert resp.status_code == 200
body = resp.json()
assert body["data"] == [
{
"id": DEFAULT_ENVIRONMENT_ID,
"object": "session.resource",
"type": "environment",
"session_id": "conv_local",
"name": "Primary environment",
"metadata": {
"environment_type": "caller_process",
"role": "primary",
},
}
]
@pytest.mark.asyncio
async def test_claude_native_message_forwards_to_runner_without_persisting(
client: httpx.AsyncClient,
) -> None:
"""
Claude-native web-chat input is runner injection, not Omnigent persistence.
This fails if the route regresses to the legacy create-or-steer
path, which would either start a duplicate Omnigent agent task or make
the web UI render a non-terminal-originated user message.
"""
fake_runner = _FakeRunnerClient(payload={})
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
set_runner_client(fake_runner) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_claude/events",
json={
"type": "message",
"data": {
"role": "user",
"content": [{"type": "input_text", "text": "hello claude"}],
},
},
)
assert resp.status_code == 202, resp.text
# Native message bypass returns queued=True plus a pending-input id: the
# message isn't persisted AP-side (the transcript forwarder is the single
# writer), so the server records a pending-input entry for the optimistic
# bubble and hands back its id (see pending_inputs.record).
body = resp.json()
assert body["queued"] is True
assert body["pending_id"].startswith("pending_")
assert fake_runner.calls == [
("POST", "/v1/sessions/conv_claude/resources/terminals"),
("POST", "/v1/sessions/conv_claude/events"),
]
assert fake_runner.post_json_calls == [
(
"/v1/sessions/conv_claude/resources/terminals",
{
"terminal": "claude",
"session_key": "main",
"ensure_native_terminal": True,
},
),
(
"/v1/sessions/conv_claude/events",
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello claude"}],
"model": "claude-native-ui",
"harness": "claude-native",
# Forwarded so the runner resolves the harness spec on the
# first message (before POST /v1/sessions caches it).
"agent_id": "ag_test",
},
),
]
@pytest.mark.asyncio
async def test_claude_native_assistant_message_rejected_not_forwarded(
client: httpx.AsyncClient,
) -> None:
"""Only user messages are injectable into a claude-native terminal;
an assistant-role message is rejected (400) before any runner call."""
fake_runner = _FakeRunnerClient(payload={})
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
set_runner_client(fake_runner) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_claude/events",
json={
"type": "message",
"data": {
"role": "assistant",
"content": [{"type": "output_text", "text": "hi"}],
},
},
)
assert resp.status_code == 400, resp.text
assert fake_runner.calls == [], f"nothing should reach runner; saw {fake_runner.calls!r}"
@pytest.mark.asyncio
async def test_claude_native_message_surfaces_runner_sse_failure(
client: httpx.AsyncClient,
) -> None:
"""
Runner SSE ``response.failed`` means terminal injection failed.
This fails if the Omnigent route treats the runner's streaming HTTP 200
as success while the harness reports that ``tmux send-keys`` did
not deliver the message.
"""
sse = (
"event: response.failed\n"
'data: {"type":"response.failed","error":{"message":"tmux target missing"}}\n\n'
)
fake_runner = _FakeRunnerClient(
text_responses={
"/v1/sessions/conv_claude/events": (
200,
sse,
{"content-type": "text/event-stream"},
)
}
)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
set_runner_client(fake_runner) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_claude/events",
json={
"type": "message",
"data": {
"role": "user",
"content": [{"type": "input_text", "text": "hello claude"}],
},
},
)
assert resp.status_code == 502, resp.text
assert resp.json()["detail"] == (
"Claude terminal message delivery failed: tmux target missing"
)
assert fake_runner.calls == [
("POST", "/v1/sessions/conv_claude/resources/terminals"),
("POST", "/v1/sessions/conv_claude/events"),
]
@pytest.mark.asyncio
async def test_claude_native_message_tunnel_close_mid_forward_returns_502(
client: httpx.AsyncClient,
) -> None:
"""
A WS-tunnel drop between terminal ensure and message forward is a 502.
WSTunnelTransport raises bare ``ConnectionError`` — not an
``httpx.HTTPError`` — when the runner tunnel closes mid-request.
Before this fix the exception escaped the forward's
``except httpx.HTTPError`` clause to the global catch-all and the
client saw an opaque 500 ``internal_error``; it must map to the
same 502 as any other transport failure on the forward leg.
"""
fake_runner = _FakeRunnerClient(
payload={},
exc_paths={
# Ensure (terminals POST) succeeds via the default payload;
# only the message-forward leg drops the tunnel.
"/v1/sessions/conv_claude/events": ConnectionError(
"tunnel closed before request completed"
),
},
)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
set_runner_client(fake_runner) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_claude/events",
json={
"type": "message",
"data": {
"role": "user",
"content": [{"type": "input_text", "text": "hello claude"}],
},
},
)
# 502 is the designed transport-failure mapping; a 500 here means
# the bare ConnectionError escaped to the catch-all again.
assert resp.status_code == 502, resp.text
assert resp.json()["detail"] == "Claude terminal message delivery failed"
# Ensure ran (and passed) before the forward leg hit the drop.
assert fake_runner.calls == [
("POST", "/v1/sessions/conv_claude/resources/terminals"),
("POST", "/v1/sessions/conv_claude/events"),
]
@pytest.mark.asyncio
async def test_native_subagent_terminal_boot_failure_wakes_parent(
client: httpx.AsyncClient,
) -> None:
"""
A native sub-agent's failed terminal boot must wake its parent.
The parent's runner spawned this child and posted its first
message; the native bypass returns HTTP 202 even when the
terminal cannot boot, so the parent's ``spawn`` call sees success
and parks a ``running`` work entry. No harness boots, so the Stop
hook that drives the normal terminal-completion edge never fires.
The route must therefore forward ``external_session_status: failed``
(carrying the boot error as ``output``) to the child's runner,
which maps it to ``mark_subagent_work_terminal(status="failed")``
and wakes the parent. Without that forward the parent hangs
forever and a retry of the same (agent, title) hits the busy guard.
This fails (RED) if the failure path stops at
``_publish_status("failed")`` — which writes only the SSE / status
cache — and omits the runner forward the normal completion path
performs.
"""
# The runner accepts the terminal-ensure POST with a definitive
# 503 + structured error (e.g. the native CLI is missing), so
# ``_ensure_native_terminal_ready`` returns ErrorData and the
# bypass takes the terminal-failure branch.
fake_runner = _FakeRunnerClient(
responses={
"/v1/sessions/conv_child_native/resources/terminals": (
503,
{
"error": {
"code": "native_terminal_start_failed",
"message": "Native Claude requires the 'claude' CLI on PATH.",
}
},
),
}
)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
set_runner_client(fake_runner) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_child_native/events",
json={
"type": "message",
"data": {
"role": "user",
"content": [{"type": "input_text", "text": "review the PR"}],
},
},
)
# The route still acknowledges the message (202) — the failure is
# reported to the parent out-of-band, not by failing this request.
assert resp.status_code == 202, resp.text
# Exactly two runner POSTs: the failed terminal-ensure, then the
# parent-wake status forward. A third call, or the forward missing,
# both indicate a regression.
assert fake_runner.calls == [
("POST", "/v1/sessions/conv_child_native/resources/terminals"),
("POST", "/v1/sessions/conv_child_native/events"),
], f"expected ensure + status-forward; saw {fake_runner.calls!r}"
# The second POST is the parent-wake edge. It must be a failed
# external_session_status carrying the boot error as ``output`` so
# the runner delivers the real cause into the parent's inbox
# (runner: ``output or "...turn failed"``). Asserting the exact
# payload proves the parent is notified — not left running forever.
forward_url, forward_body = fake_runner.post_json_calls[-1]
assert forward_url == "/v1/sessions/conv_child_native/events"
assert forward_body == {
"type": "external_session_status",
"data": {
"status": "failed",
"output": "Native Claude requires the 'claude' CLI on PATH.",
},
}
@pytest.mark.asyncio
async def test_native_subagent_terminal_boot_failure_surfaces_unreachable_runner(
client: httpx.AsyncClient,
) -> None:
"""
The parent-wake forward must fail loud if the runner is unreachable.
The forward reuses ``_require_external_status_forward``: dropping
the failed edge silently would strand the parent waiting forever,
so a forward that does not land surfaces a 503 to the caller
instead of a misleading 202. Here the terminal-ensure POST fails
definitively (so the failure branch runs), and the subsequent
status-forward POST raises a transport error (no runner reachable).
This fails if the forward is best-effort (swallowed) rather than
required: the route would return 202 with the parent never woken.
"""
fake_runner = _FakeRunnerClient(
responses={
"/v1/sessions/conv_child_native/resources/terminals": (
503,
{
"error": {
"code": "native_terminal_start_failed",
"message": "Native Claude requires the 'claude' CLI on PATH.",
}
},
),
},
)
# Make only the status-forward POST fail at the transport layer.
# The terminal-ensure POST must still return its canned 503 so the
# failure branch is reached before the unreachable forward.
original_post = fake_runner.post
async def _post(url: str, *, json: Any = None, timeout: float | None = None) -> httpx.Response:
"""
Forward to the canned client, but raise on the status forward.
:param url: Request URL path.
:param json: JSON body (an ``external_session_status`` edge on
the forward POST).
:param timeout: Request timeout (ignored).
:returns: The canned response for non-forward POSTs.
:raises httpx.ConnectError: On the parent-wake status forward.
"""
if isinstance(json, dict) and json.get("type") == "external_session_status":
fake_runner.post_json_calls.append((url, json))
raise httpx.ConnectError("runner gone", request=httpx.Request("POST", url))
return await original_post(url, json=json, timeout=timeout)
fake_runner.post = _post # type: ignore[method-assign]
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
set_runner_client(fake_runner) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_child_native/events",
json={
"type": "message",
"data": {
"role": "user",
"content": [{"type": "input_text", "text": "review the PR"}],
},
},
)
# 503 RUNNER_UNAVAILABLE: the required parent-wake forward could not
# be delivered, so the route must not pretend the message landed.
assert resp.status_code == 503, resp.text
# The forward was attempted on the child's events endpoint.
assert fake_runner.post_json_calls[-1][0] == "/v1/sessions/conv_child_native/events"
assert fake_runner.post_json_calls[-1][1]["type"] == "external_session_status"
assert fake_runner.post_json_calls[-1][1]["data"]["status"] == "failed"
# ── Phase 1b: typed collections & single-resource proxy tests ────
def _env_only_payload() -> dict[str, object]:
"""Runner response for GET /resources/environments."""
return {
"object": "list",
"data": [
{
"id": DEFAULT_ENVIRONMENT_ID,
"object": "session.resource",
"type": "environment",
"session_id": "conv_proxy",
"name": "Primary environment",
"metadata": {"environment_type": "caller_process", "role": "primary"},
},
],
"first_id": DEFAULT_ENVIRONMENT_ID,
"last_id": DEFAULT_ENVIRONMENT_ID,
"has_more": False,
}
def _single_resource_payload() -> dict[str, object]:
"""Runner response for GET /resources/{id}."""
return {
"id": DEFAULT_ENVIRONMENT_ID,
"object": "session.resource",
"type": "environment",
"session_id": "conv_proxy",
"name": "Primary environment",
"metadata": {"environment_type": "caller_process", "role": "primary"},
}
@pytest.mark.asyncio
async def test_list_environments_proxies_to_runner(
client: httpx.AsyncClient,
) -> None:
"""GET /resources/environments validates session then proxies."""
fake_runner = _FakeRunnerClient(payload=_env_only_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get("/v1/sessions/conv_proxy/resources/environments")
assert resp.status_code == 200
assert resp.json()["object"] == "list"
assert fake_runner.calls == [
("GET", "/v1/sessions/conv_proxy/resources/environments"),
]
@pytest.mark.asyncio
async def test_list_terminals_forwards_pagination_params_to_runner(
client: httpx.AsyncClient,
) -> None:
"""GET /resources/terminals forwards order/limit to the runner.
The web terminal tabs request ``order=asc`` so the session's own
terminal (created first, e.g. claude-native's ``claude/main``)
stays in the first tab slot; a proxy that drops the query string
silently re-applies the runner's ``desc`` default and flips the tab
order on every page refresh.
"""
fake_runner = _FakeRunnerClient(payload={"object": "list", "data": []})
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_proxy/resources/terminals?order=asc&limit=1000&bogus=1",
)
assert resp.status_code == 200
assert fake_runner.calls == [
("GET", "/v1/sessions/conv_proxy/resources/terminals"),
]
# Exactly the runner endpoint's supported pagination params are
# forwarded; an unknown param (``bogus``) must be dropped so the
# proxy can't be used to smuggle arbitrary query strings. A ``None``
# here means the proxy dropped the whole query string — the
# refresh-flips-tab-order regression.
assert fake_runner.get_params == [{"order": "asc", "limit": "1000"}]
@pytest.mark.asyncio
async def test_list_environments_rejects_unknown_session(
client: httpx.AsyncClient,
) -> None:
"""GET /resources/environments 404s for nonexistent session."""
fake_runner = _FakeRunnerClient(payload=_env_only_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get("/v1/sessions/conv_missing/resources/environments")
assert resp.status_code == 404
assert not fake_runner.calls
@pytest.mark.asyncio
async def test_get_resource_by_id_proxies_to_runner(
client: httpx.AsyncClient,
) -> None:
"""GET /resources/{id} validates session then proxies."""
fake_runner = _FakeRunnerClient(payload=_single_resource_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(f"/v1/sessions/conv_proxy/resources/{DEFAULT_ENVIRONMENT_ID}")
assert resp.status_code == 200
assert resp.json()["id"] == DEFAULT_ENVIRONMENT_ID
assert fake_runner.calls == [
("GET", f"/v1/sessions/conv_proxy/resources/{DEFAULT_ENVIRONMENT_ID}"),
]
@pytest.mark.asyncio
async def test_get_resource_by_id_404_from_runner(
client: httpx.AsyncClient,
) -> None:
"""GET /resources/{id} surfaces runner 404."""
fake_runner = _FakeRunnerClient(
responses={
"/v1/sessions/conv_proxy/resources/nonexistent": (
404,
{"error": {"code": "not_found", "message": "Resource 'nonexistent' not found"}},
),
},
)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get("/v1/sessions/conv_proxy/resources/nonexistent")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "not_found"
@pytest.fixture
def bash_terminal_spec(monkeypatch: pytest.MonkeyPatch) -> None:
"""Resolve the session's agent spec to one declaring a ``bash`` terminal.
The terminal-create route gates user-initiated creates on the
names in the spec's ``terminals:`` block. These route tests run
with a stub agent store (no real bundle to load), so the module's
spec loader is patched to return a minimal spec declaring
``bash`` — the name the create tests request.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: None.
"""
from omnigent.inner.datamodel import TerminalEnvSpec
from omnigent.server.routes import sessions as sessions_module
from omnigent.spec.types import AgentSpec
spec = AgentSpec(spec_version=1, terminals={"bash": TerminalEnvSpec(command="bash")})
monkeypatch.setattr(
sessions_module,
"_load_agent_spec_for_session",
lambda conv, agent_store: spec,
)
@pytest.mark.asyncio
async def test_create_terminal_proxies_to_runner(
client: httpx.AsyncClient,
bash_terminal_spec: None,
) -> None:
"""POST /resources/terminals validates session then proxies."""
terminal_resource = {
"id": "terminal_bash_s1",
"object": "session.resource",
"type": "terminal",
"session_id": "conv_proxy",
"name": "bash:s1",
"environment": DEFAULT_ENVIRONMENT_ID,
"metadata": {
"terminal_name": "bash",
"session_key": "s1",
"running": True,
},
}
fake_runner = _FakeRunnerClient(payload=terminal_resource)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_proxy/resources/terminals",
json={"terminal": "bash", "session_key": "s1"},
)
assert resp.status_code == 200
assert resp.json()["id"] == "terminal_bash_s1"
assert fake_runner.calls == [
("POST", "/v1/sessions/conv_proxy/resources/terminals"),
]
@pytest.mark.asyncio
async def test_create_terminal_rejected_without_agent_terminal_access(
client: httpx.AsyncClient,
) -> None:
"""User creates are rejected when the agent declares no terminals.
The stub agent store resolves no agent, so the session has no
spec and therefore no ``terminals:`` block — the iff gate must
refuse the create instead of letting the runner synthesize an
arbitrary terminal the agent can't see or manage.
"""
fake_runner = _FakeRunnerClient(payload={})
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_proxy/resources/terminals",
json={"terminal": "bash", "session_key": "s1"},
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "invalid_input"
# The gate fired BEFORE the proxy — a recorded call here means an
# unauthorized launch reached the runner despite the 400.
assert fake_runner.calls == []
@pytest.mark.asyncio
async def test_create_terminal_rejected_for_undeclared_name(
client: httpx.AsyncClient,
bash_terminal_spec: None,
) -> None:
"""User creates must request a terminal name declared by the spec.
The agent declares only ``bash``; requesting ``zsh`` would hit the
runner's synthesize-from-body path, producing a terminal outside
the operator-declared set — the gate must refuse it.
"""
fake_runner = _FakeRunnerClient(payload={})
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_proxy/resources/terminals",
json={"terminal": "zsh", "session_key": "s1"},
)
assert resp.status_code == 400
body = resp.json()
assert body["error"]["code"] == "invalid_input"
# The message names the declared set so a UI/user can self-correct.
assert "bash" in body["error"]["message"]
assert fake_runner.calls == []
@pytest.mark.asyncio
async def test_create_terminal_native_bootstrap_exempt_from_gate(
client: httpx.AsyncClient,
) -> None:
"""``ensure_native_terminal`` requests bypass the declared-name gate.
The ``omnigent claude`` / ``codex`` wrappers launch the session's
own CLI terminal under undeclared names (``"claude"`` /
``"codex"``); gating them would break every native session boot.
No spec resolves here (stub agent store), so a recorded proxy call
proves the exemption — without it this request would 400 like the
ungated test above.
"""
terminal_resource = {
"id": "terminal_claude_main",
"object": "session.resource",
"type": "terminal",
"session_id": "conv_proxy",
"name": "claude:main",
"metadata": {"terminal_name": "claude", "session_key": "main", "running": True},
}
fake_runner = _FakeRunnerClient(payload=terminal_resource)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_proxy/resources/terminals",
json={"terminal": "claude", "session_key": "main", "ensure_native_terminal": True},
)
assert resp.status_code == 200
assert resp.json()["id"] == "terminal_claude_main"
assert fake_runner.calls == [
("POST", "/v1/sessions/conv_proxy/resources/terminals"),
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body",
[
# Marker with an arbitrary terminal name: the bootstrap shape
# is claude/codex only.
pytest.param(
{"terminal": "zsh", "session_key": "main", "bridge_inject_dir": True},
id="bridge-inject-arbitrary-name",
),
# Marker with the right name but a non-main session key: the
# wrappers only ever bootstrap the session's own main terminal.
pytest.param(
{"terminal": "claude", "session_key": "s2", "ensure_native_terminal": True},
id="ensure-non-main-key",
),
],
)
async def test_create_terminal_bootstrap_markers_do_not_bypass_gate_for_other_shapes(
client: httpx.AsyncClient,
body: dict[str, Any],
) -> None:
"""Client-controlled markers can't skip the gate for arbitrary launches.
The exemption markers ride the JSON body, so any LEVEL_EDIT caller
can set them. Without the claude/codex + main narrowing, a caller
could launch ANY terminal name (with bridge injection) on a
no-terminal-access agent just by adding the flag — the W5
operator-restriction bypass flagged in review. No spec resolves
here (stub agent store), so a 400 with no recorded runner call
proves the marker alone no longer opens the gate.
"""
fake_runner = _FakeRunnerClient(payload={})
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_proxy/resources/terminals",
json=body,
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "invalid_input"
# Gate fired before the proxy — a recorded call means the marker
# bypassed the gate and the unauthorized launch reached the runner.
assert fake_runner.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"runner_status,runner_payload,expected_status,expected_code,expected_message",
[
# Runner returns its own error body (the common case: tunnel
# dropped mid-launch, runner reports offline). The runner's code
# drives the HTTP status, and its message is surfaced verbatim.
(
503,
{
"error": {
"code": ErrorCode.RUNNER_UNAVAILABLE,
"message": "tunnel closed before request completed",
}
},
503,
ErrorCode.RUNNER_UNAVAILABLE,
"tunnel closed before request completed",
),
# Runner returns an error with no body — fall back to
# INTERNAL_ERROR (→500) and a message carrying the raw status.
(
500,
{},
500,
ErrorCode.INTERNAL_ERROR,
"Terminal launch failed (runner returned HTTP 500)",
),
],
)
async def test_create_terminal_surfaces_runner_error_without_crashing(
client: httpx.AsyncClient,
bash_terminal_spec: None,
runner_status: int,
runner_payload: dict[str, Any],
expected_status: int,
expected_code: str,
expected_message: str,
) -> None:
"""A runner ``>=400`` on terminal launch yields a clean error, not a 500 crash.
Regression for the masking bug at ``create_session_terminal``: it built
``OmnigentError(..., http_status=status)``, but ``OmnigentError``
has no ``http_status`` arg (it is a derived property), so any runner
error turned into an unhandled ``TypeError`` instead of a legible error.
With the bug present, ``client.post`` below raises ``TypeError`` rather
than returning a response, so this test errors out — the failure signal.
:param runner_status: HTTP status the fake runner returns for the
terminal-launch proxy POST, e.g. ``503``.
:param runner_payload: JSON body the fake runner returns, e.g.
``{"error": {"code": "runner_unavailable", "message": "..."}}``.
:param expected_status: HTTP status the route should surface (derived
from the error code), e.g. ``503``.
:param expected_code: Machine-readable error code the route should
surface, e.g. ``"runner_unavailable"``.
:param expected_message: The human-readable message the route should
surface.
:returns: None.
"""
path = "/v1/sessions/conv_proxy/resources/terminals"
fake_runner = _FakeRunnerClient(responses={path: (runner_status, runner_payload)})
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.post(path, json={"terminal": "bash", "session_key": "s1"})
# http_status is derived from the surfaced code; 503 here proves the
# runner's ``runner_unavailable`` propagated (a 500 would mean it was
# masked by the old TypeError path or a generic internal error).
assert resp.status_code == expected_status
body = resp.json()
# The runner's own code/message reach the client unchanged — proves the
# error was surfaced, not swallowed or replaced by a generic 500.
assert body["error"]["code"] == expected_code
assert body["error"]["message"] == expected_message
# The launch failed, so no resource-created event/resource is returned.
assert "id" not in body
# The proxy POST was actually attempted before the error was raised.
assert fake_runner.calls == [("POST", path)]
@pytest.mark.asyncio
async def test_delete_terminal_proxies_to_runner(
client: httpx.AsyncClient,
) -> None:
"""DELETE /resources/terminals/{id} validates session then proxies."""
deletion = {
"id": "terminal_bash_s1",
"object": "session.resource.deleted",
"deleted": True,
}
fake_runner = _FakeRunnerClient(payload=deletion)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.delete(
"/v1/sessions/conv_proxy/resources/terminals/terminal_bash_s1",
)
assert resp.status_code == 200
assert resp.json()["deleted"] is True
assert fake_runner.calls == [
("DELETE", "/v1/sessions/conv_proxy/resources/terminals/terminal_bash_s1"),
]
@pytest.mark.asyncio
async def test_transfer_terminal_authorizes_sessions_and_proxies_to_runner(
client: httpx.AsyncClient,
) -> None:
"""POST terminal transfer validates source and target then proxies."""
terminal_resource = {
"id": "terminal_bash_s1",
"object": "session.resource",
"type": "terminal",
"session_id": "conv_local",
"name": "bash:s1",
"environment": DEFAULT_ENVIRONMENT_ID,
"metadata": {
"terminal_name": "bash",
"session_key": "s1",
"running": True,
},
}
fake_runner = _FakeRunnerClient(payload=terminal_resource)
router = _FakeRunnerRouter(fake_runner)
set_runner_router(router) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_proxy/resources/terminals/terminal_bash_s1/transfer",
json={"target_session_id": "conv_local"},
)
assert resp.status_code == 200
assert resp.json()["session_id"] == "conv_local"
assert fake_runner.calls == [
("POST", "/v1/sessions/conv_proxy/resources/terminals/terminal_bash_s1/transfer"),
]
assert fake_runner.post_json_calls == [
(
"/v1/sessions/conv_proxy/resources/terminals/terminal_bash_s1/transfer",
{"target_session_id": "conv_local"},
),
]
assert router.resource_calls == ["conv_proxy"]
@pytest.mark.asyncio
async def test_delete_terminal_surfaces_runner_404(
client: httpx.AsyncClient,
) -> None:
"""DELETE /resources/terminals/{id} surfaces runner 404."""
fake_runner = _FakeRunnerClient(
responses={
"/v1/sessions/conv_proxy/resources/terminals/terminal_nope_s1": (
404,
{"error": {"code": "not_found", "message": "Terminal not found"}},
),
},
)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.delete(
"/v1/sessions/conv_proxy/resources/terminals/terminal_nope_s1",
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "not_found"
# ── Phase 1c: session-scoped file endpoint tests ────────────────
class _InMemoryArtifactStore:
"""Minimal artifact store backed by a dict for tests."""
def __init__(self) -> None:
self._blobs: dict[str, bytes] = {}
def put(self, key: str, data: bytes) -> None:
"""Store bytes."""
self._blobs[key] = data
def get(self, key: str) -> bytes:
"""Retrieve bytes."""
return self._blobs[key]
def delete(self, key: str) -> None:
"""Remove bytes."""
self._blobs.pop(key, None)
def exists(self, key: str) -> bool:
"""Whether a blob is stored for ``key`` (cheap metadata probe)."""
return key in self._blobs
@pytest.fixture
def file_conv_store() -> _ConversationStore:
"""Shared conversation store for file tests."""
return _ConversationStore()
@pytest.fixture
def file_store(db_uri: str) -> Any:
"""Real SqlAlchemy file store for file endpoint tests."""
from omnigent.stores.file_store.sqlalchemy_store import (
SqlAlchemyFileStore,
)
return SqlAlchemyFileStore(db_uri)
@pytest.fixture
def artifact_store() -> _InMemoryArtifactStore:
"""In-memory artifact store for file endpoint tests."""
return _InMemoryArtifactStore()
@pytest.fixture
def file_app(
runner_globals_reset: None,
file_conv_store: _ConversationStore,
file_store: Any,
artifact_store: _InMemoryArtifactStore,
) -> FastAPI:
"""FastAPI app with real file + artifact stores for file tests."""
del runner_globals_reset
test_app = FastAPI()
@test_app.exception_handler(OmnigentError)
async def _handle(
request: Request,
exc: OmnigentError,
) -> JSONResponse:
del request
return JSONResponse(
status_code=exc.http_status,
content={
"error": {"code": exc.code, "message": exc.message},
},
)
test_app.include_router(
create_sessions_router(
file_conv_store, # type: ignore[arg-type]
object(), # type: ignore[arg-type] — stub agent store
file_store=file_store, # type: ignore[arg-type]
artifact_store=artifact_store, # type: ignore[arg-type]
),
prefix="/v1",
)
return test_app
@pytest.fixture
async def file_client(
file_app: FastAPI,
) -> AsyncIterator[httpx.AsyncClient]:
"""httpx client pointed at the file-capable test app."""
transport = httpx.ASGITransport(app=file_app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://server",
) as c:
yield c
@pytest.mark.asyncio
async def test_upload_and_list_session_files(
file_client: httpx.AsyncClient,
) -> None:
"""POST + GET /resources/files round-trips through server."""
resp = await file_client.post(
"/v1/sessions/conv_proxy/resources/files",
files={"file": ("report.txt", b"hello world", "text/plain")},
)
assert resp.status_code == 201
body = resp.json()
assert body["object"] == "session.resource"
assert body["type"] == "file"
assert body["session_id"] == "conv_proxy"
assert body["name"] == "report.txt"
file_id = body["id"]
list_resp = await file_client.get(
"/v1/sessions/conv_proxy/resources/files",
)
assert list_resp.status_code == 200
ids = [f["id"] for f in list_resp.json()["data"]]
assert file_id in ids
@pytest.mark.asyncio
async def test_get_session_file_validates_ownership(
file_client: httpx.AsyncClient,
) -> None:
"""GET /resources/files/{id} 404s for wrong session."""
upload = await file_client.post(
"/v1/sessions/conv_proxy/resources/files",
files={"file": ("owned.txt", b"data", "text/plain")},
)
file_id = upload.json()["id"]
resp = await file_client.get(
f"/v1/sessions/conv_local/resources/files/{file_id}",
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_download_session_file_content(
file_client: httpx.AsyncClient,
) -> None:
"""GET /resources/files/{id}/content returns raw bytes."""
upload = await file_client.post(
"/v1/sessions/conv_proxy/resources/files",
files={"file": ("hello.txt", b"hello world", "text/plain")},
)
file_id = upload.json()["id"]
resp = await file_client.get(
f"/v1/sessions/conv_proxy/resources/files/{file_id}/content",
)
assert resp.status_code == 200
assert resp.content == b"hello world"
# The content route must force a download and forbid MIME sniffing
# so a browser never renders user-uploaded bytes as an active type
# in the server's origin. A failure here means the stored-XSS
# hardening was dropped and the response is renderable inline again.
assert resp.headers["content-disposition"] == (
"attachment; filename=\"hello.txt\"; filename*=UTF-8''hello.txt"
)
assert resp.headers["x-content-type-options"] == "nosniff"
@pytest.mark.asyncio
async def test_download_session_file_html_is_attachment_not_inline(
file_client: httpx.AsyncClient,
) -> None:
"""An uploaded .html must be served as a download, not rendered inline.
Reproduces the stored-XSS vector: a user uploads ``evil.html``
with a ``<script>`` body. Without the hardening the route would
return ``Content-Type: text/html`` with no disposition, letting a
browser execute the script in the server origin. The disposition +
nosniff headers neutralize that.
"""
upload = await file_client.post(
"/v1/sessions/conv_proxy/resources/files",
files={
"file": (
"evil.html",
b"<script>alert(document.domain)</script>",
"text/html",
),
},
)
file_id = upload.json()["id"]
resp = await file_client.get(
f"/v1/sessions/conv_proxy/resources/files/{file_id}/content",
)
assert resp.status_code == 200
# The bytes are still served verbatim — we don't mangle content,
# we only change how the browser is told to handle them.
assert resp.content == b"<script>alert(document.domain)</script>"
# attachment => browser downloads instead of rendering the script.
assert resp.headers["content-disposition"].startswith("attachment;")
assert 'filename="evil.html"' in resp.headers["content-disposition"]
# nosniff => the browser won't second-guess the declared type.
assert resp.headers["x-content-type-options"] == "nosniff"
@pytest.mark.asyncio
async def test_delete_session_file(
file_client: httpx.AsyncClient,
) -> None:
"""DELETE /resources/files/{id} removes the file."""
upload = await file_client.post(
"/v1/sessions/conv_proxy/resources/files",
files={"file": ("temp.txt", b"gone", "text/plain")},
)
file_id = upload.json()["id"]
resp = await file_client.delete(
f"/v1/sessions/conv_proxy/resources/files/{file_id}",
)
assert resp.status_code == 200
assert resp.json()["deleted"] is True
get_resp = await file_client.get(
f"/v1/sessions/conv_proxy/resources/files/{file_id}",
)
assert get_resp.status_code == 404
# ── files:copy — lineage-scoped file copy tests ─────────────────
async def _upload_file(
client: httpx.AsyncClient,
session_id: str,
name: str,
content: bytes,
) -> str:
"""Upload a file to a session and return its id.
:param client: The file-capable test client.
:param session_id: Owning session id.
:param name: Filename to upload.
:param content: Raw file bytes.
:returns: The new file id.
"""
resp = await client.post(
f"/v1/sessions/{session_id}/resources/files",
files={"file": (name, content, "text/plain")},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def test_ancestor_session_ids_stops_on_parent_cycle(
file_conv_store: _ConversationStore,
) -> None:
"""A malformed parent cycle terminates at the first repeated session."""
file_conv_store._conversations["conv_cycle_a"] = Conversation(
id="conv_cycle_a",
created_at=1,
updated_at=1,
root_conversation_id="conv_cycle_a",
kind="sub_agent",
parent_conversation_id="conv_cycle_b",
agent_id="ag_test",
)
file_conv_store._conversations["conv_cycle_b"] = Conversation(
id="conv_cycle_b",
created_at=1,
updated_at=1,
root_conversation_id="conv_cycle_a",
kind="sub_agent",
parent_conversation_id="conv_cycle_a",
agent_id="ag_test",
)
assert _ancestor_session_ids(file_conv_store, "conv_cycle_a") == ["conv_cycle_b"]
@pytest.mark.asyncio
async def test_copy_files_from_direct_parent(
file_client: httpx.AsyncClient,
file_store: Any,
) -> None:
"""A child copies one parent-owned file into its own namespace."""
parent_file = await _upload_file(file_client, "conv_p", "doc.txt", b"parent bytes")
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [parent_file]},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["object"] == "session.files.copied"
assert body["session_id"] == "conv_c"
entry = body["mapping"][parent_file]
new_id = entry["new_id"]
# A copy, not an alias: the new row is distinct from the source.
assert new_id != parent_file
# The enriched mapping carries the preserved filename + content type so
# the caller can attach the copy without a follow-up metadata fetch.
assert entry["filename"] == "doc.txt"
assert entry["content_type"] == "text/plain"
# The new row is child-scoped and readable by the child only.
copied = file_store.get(new_id, session_id="conv_c")
assert copied is not None
assert copied.filename == "doc.txt"
# The source row is unchanged and still owned by the parent.
assert file_store.get(parent_file, session_id="conv_p") is not None
# The bytes match the source.
content = await file_client.get(
f"/v1/sessions/conv_c/resources/files/{new_id}/content",
)
assert content.status_code == 200
assert content.content == b"parent bytes"
@pytest.mark.asyncio
async def test_copy_files_rejects_empty_file_ids(
file_client: httpx.AsyncClient,
) -> None:
"""The request body requires at least one file id."""
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": []},
)
assert resp.status_code == 422, resp.text
@pytest.mark.asyncio
async def test_copy_files_rejects_blank_file_id(
file_client: httpx.AsyncClient,
) -> None:
"""Each requested file id must be non-empty."""
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [""]},
)
assert resp.status_code == 422, resp.text
@pytest.mark.asyncio
async def test_copy_files_rejects_duplicate_file_ids(
file_client: httpx.AsyncClient,
) -> None:
"""Duplicate source ids are rejected instead of copied twice."""
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": ["file_same", "file_same"]},
)
assert resp.status_code == 400, resp.text
assert resp.json()["error"]["code"] == "invalid_input"
@pytest.mark.asyncio
async def test_copy_files_multi_file_mapping(
file_client: httpx.AsyncClient,
file_store: Any,
) -> None:
"""A multi-file copy returns a complete source→new mapping."""
f1 = await _upload_file(file_client, "conv_p", "a.txt", b"aaa")
f2 = await _upload_file(file_client, "conv_p", "b.txt", b"bbb")
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [f1, f2]},
)
assert resp.status_code == 200, resp.text
mapping = resp.json()["mapping"]
assert set(mapping.keys()) == {f1, f2}
new_ids = {entry["new_id"] for entry in mapping.values()}
assert len(new_ids) == 2
for new_id in new_ids:
assert file_store.get(new_id, session_id="conv_c") is not None
@pytest.mark.asyncio
async def test_copy_files_from_grandparent_in_chain(
file_client: httpx.AsyncClient,
file_store: Any,
) -> None:
"""Copying from a grandparent (two levels up) is authorized."""
gp_file = await _upload_file(file_client, "conv_gp", "root.txt", b"grandparent")
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_gp", "file_ids": [gp_file]},
)
assert resp.status_code == 200, resp.text
new_id = resp.json()["mapping"][gp_file]["new_id"]
assert file_store.get(new_id, session_id="conv_c") is not None
@pytest.mark.asyncio
async def test_copy_files_rejects_source_outside_lineage(
file_client: httpx.AsyncClient,
file_store: Any,
) -> None:
"""A source session not in the destination's lineage is forbidden."""
# conv_proxy is a top-level session with no link to conv_c's chain.
foreign_file = await _upload_file(file_client, "conv_proxy", "x.txt", b"foreign")
before = file_store.list(session_id="conv_c").data
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_proxy", "file_ids": [foreign_file]},
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "forbidden"
# Nothing was copied into the destination.
after = file_store.list(session_id="conv_c").data
assert len(after) == len(before)
@pytest.mark.asyncio
async def test_copy_files_missing_source_file_is_all_or_nothing(
file_client: httpx.AsyncClient,
file_store: Any,
) -> None:
"""A missing source file fails the whole request with no partial copy."""
good = await _upload_file(file_client, "conv_p", "good.txt", b"ok")
before = file_store.list(session_id="conv_c").data
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [good, "file_does_not_exist"]},
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "not_found"
# The valid file in the batch must NOT have been committed: validation
# is all-or-nothing, so the destination is unchanged.
after = file_store.list(session_id="conv_c").data
assert len(after) == len(before)
@pytest.mark.asyncio
async def test_copy_files_missing_blob_surfaces_before_any_write(
file_client: httpx.AsyncClient,
file_store: Any,
artifact_store: _InMemoryArtifactStore,
) -> None:
"""A source row whose blob is gone fails the request before any child write.
The metadata-only validation probes ``artifact_store.exists`` (a cheap
metadata check, not a blob read), so a dangling row — present metadata,
absent blob — is caught during validation and copies nothing, preserving
the "missing blob surfaces before any child row is created" guarantee.
"""
good = await _upload_file(file_client, "conv_p", "good.txt", b"ok")
dangling = await _upload_file(file_client, "conv_p", "gone.txt", b"bye")
# Drop the blob but leave the metadata row — a dangling source.
artifact_store.delete(dangling)
before = file_store.list(session_id="conv_c").data
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [good, dangling]},
)
assert resp.status_code == 404, resp.text
assert resp.json()["error"]["code"] == "not_found"
# The valid file in the batch was NOT committed — validation is all-or-nothing.
after = file_store.list(session_id="conv_c").data
assert len(after) == len(before)
@pytest.mark.asyncio
async def test_copy_files_midbatch_write_failure_persists_no_resource_events(
file_client: httpx.AsyncClient,
file_store: Any,
artifact_store: _InMemoryArtifactStore,
file_conv_store: _ConversationStore,
) -> None:
"""A write that fails mid-batch leaves no rows AND no resource events.
Resource events fire only after every write lands, so a second-file
failure rolls back the first file's row/blob and never persists a
``session.resource.created`` for it — clients must not see a phantom
file that was rolled back.
"""
f1 = await _upload_file(file_client, "conv_p", "a.txt", b"aa")
f2 = await _upload_file(file_client, "conv_p", "b.txt", b"bb")
file_conv_store.appended_items.clear()
before = file_store.list(session_id="conv_c").data
real_put = artifact_store.put
calls = {"n": 0}
def _put_then_fail(key: str, data: bytes) -> None:
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("blob backend down")
real_put(key, data)
artifact_store.put = _put_then_fail # type: ignore[assignment]
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [f1, f2]},
)
assert resp.status_code == 500, resp.text
assert resp.json()["error"]["code"] == "internal_error"
assert "Failed to copy files" in resp.json()["error"]["message"]
# First file's row + blob rolled back: destination unchanged.
after = file_store.list(session_id="conv_c").data
assert len(after) == len(before)
# No phantom resource event persisted for the rolled-back first file.
events = [i for i in file_conv_store.appended_items if i.type == "resource_event"]
assert events == [], f"rolled-back copy must persist no resource events, got {events}"
@pytest.mark.asyncio
async def test_copy_files_rollback_deletes_blobs_when_row_delete_fails(
file_client: httpx.AsyncClient,
file_store: Any,
artifact_store: _InMemoryArtifactStore,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Rollback still attempts blob cleanup if row cleanup fails."""
f1 = await _upload_file(file_client, "conv_p", "a.txt", b"aa")
f2 = await _upload_file(file_client, "conv_p", "b.txt", b"bb")
real_put = artifact_store.put
calls = {"n": 0}
def _put_then_fail(key: str, data: bytes) -> None:
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("blob backend down")
real_put(key, data)
deleted_blobs: list[str] = []
real_artifact_delete = artifact_store.delete
def _record_blob_delete(key: str) -> None:
deleted_blobs.append(key)
real_artifact_delete(key)
def _row_delete_fails(file_id: str, *, session_id: str) -> bool:
del file_id, session_id
raise RuntimeError("row delete down")
artifact_store.put = _put_then_fail # type: ignore[assignment]
artifact_store.delete = _record_blob_delete # type: ignore[assignment]
monkeypatch.setattr(file_store, "delete", _row_delete_fails)
caplog.set_level(logging.WARNING, logger="omnigent.server.routes.sessions")
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [f1, f2]},
)
assert resp.status_code == 500, resp.text
assert len(deleted_blobs) == 2
assert "Failed to delete copied file row during rollback" in caplog.text
@pytest.mark.asyncio
async def test_copy_files_rollback_logs_blob_delete_failures(
file_client: httpx.AsyncClient,
file_store: Any,
artifact_store: _InMemoryArtifactStore,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Rollback logs blob cleanup failures after deleting rows."""
f1 = await _upload_file(file_client, "conv_p", "a.txt", b"aa")
f2 = await _upload_file(file_client, "conv_p", "b.txt", b"bb")
real_put = artifact_store.put
calls = {"n": 0}
def _put_then_fail(key: str, data: bytes) -> None:
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("blob backend down")
real_put(key, data)
deleted_rows: list[str] = []
real_file_delete = file_store.delete
def _record_row_delete(file_id: str, *, session_id: str) -> bool:
deleted_rows.append(file_id)
return real_file_delete(file_id, session_id=session_id)
def _blob_delete_fails(key: str) -> None:
del key
raise RuntimeError("blob delete down")
artifact_store.put = _put_then_fail # type: ignore[assignment]
artifact_store.delete = _blob_delete_fails # type: ignore[assignment]
monkeypatch.setattr(file_store, "delete", _record_row_delete)
caplog.set_level(logging.WARNING, logger="omnigent.server.routes.sessions")
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [f1, f2]},
)
assert resp.status_code == 500, resp.text
assert len(deleted_rows) == 2
assert "Failed to delete copied file blob during rollback" in caplog.text
@pytest.mark.asyncio
async def test_copy_files_self_source_is_rejected(
file_client: httpx.AsyncClient,
file_store: Any,
) -> None:
"""A session may not name ITSELF as the source — lineage is ancestors only."""
own = await _upload_file(file_client, "conv_c", "self.txt", b"mine")
before = file_store.list(session_id="conv_c").data
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_c", "file_ids": [own]},
)
assert resp.status_code == 403, resp.text
assert resp.json()["error"]["code"] == "forbidden"
# Nothing copied — the destination is unchanged.
after = file_store.list(session_id="conv_c").data
assert len(after) == len(before)
@pytest.mark.asyncio
async def test_copy_files_rejects_over_count_before_any_blob_read(
file_client: httpx.AsyncClient,
file_store: Any,
artifact_store: _InMemoryArtifactStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Over the file-count limit → 400, with NO blob reads and no copies.
The cap is enforced from metadata during validation, so a rejected
request must not call ``artifact_store.get`` even once — that is the
whole point of the bound (a rejected request never buffers a blob).
"""
import omnigent.server.server_config as server_config
monkeypatch.setattr(server_config, "copy_file_count_limit", lambda: 2)
f1 = await _upload_file(file_client, "conv_p", "a.txt", b"a")
f2 = await _upload_file(file_client, "conv_p", "b.txt", b"b")
f3 = await _upload_file(file_client, "conv_p", "c.txt", b"c")
before = file_store.list(session_id="conv_c").data
get_calls = {"n": 0}
real_get = artifact_store.get
def _counting_get(key: str) -> bytes:
get_calls["n"] += 1
return real_get(key)
artifact_store.get = _counting_get # type: ignore[assignment]
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [f1, f2, f3]},
)
assert resp.status_code == 400, resp.text
assert resp.json()["error"]["code"] == "invalid_input"
# No blob was read and nothing was copied.
assert get_calls["n"] == 0
after = file_store.list(session_id="conv_c").data
assert len(after) == len(before)
@pytest.mark.asyncio
async def test_copy_files_rejects_over_total_bytes_before_any_blob_read(
file_client: httpx.AsyncClient,
file_store: Any,
artifact_store: _InMemoryArtifactStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Over the total-bytes limit → 400, with NO blob reads and no copies."""
import omnigent.server.server_config as server_config
monkeypatch.setattr(server_config, "copy_total_bytes_limit", lambda: 5)
f1 = await _upload_file(file_client, "conv_p", "a.txt", b"aaa")
f2 = await _upload_file(file_client, "conv_p", "b.txt", b"bbb")
before = file_store.list(session_id="conv_c").data
get_calls = {"n": 0}
real_get = artifact_store.get
def _counting_get(key: str) -> bytes:
get_calls["n"] += 1
return real_get(key)
artifact_store.get = _counting_get # type: ignore[assignment]
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [f1, f2]},
)
assert resp.status_code == 400, resp.text
assert resp.json()["error"]["code"] == "invalid_input"
assert get_calls["n"] == 0
after = file_store.list(session_id="conv_c").data
assert len(after) == len(before)
@pytest.mark.asyncio
async def test_copy_files_at_limit_boundary_succeeds(
file_client: httpx.AsyncClient,
file_store: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Exactly at the count and total-bytes limits → succeeds."""
import omnigent.server.server_config as server_config
monkeypatch.setattr(server_config, "copy_file_count_limit", lambda: 2)
monkeypatch.setattr(server_config, "copy_total_bytes_limit", lambda: 6)
f1 = await _upload_file(file_client, "conv_p", "a.txt", b"aaa")
f2 = await _upload_file(file_client, "conv_p", "b.txt", b"bbb")
resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [f1, f2]},
)
assert resp.status_code == 200, resp.text
mapping = resp.json()["mapping"]
assert set(mapping.keys()) == {f1, f2}
@pytest.mark.asyncio
async def test_copy_files_then_download_returns_bytes(
file_client: httpx.AsyncClient,
) -> None:
"""After copy, the child can download the copied content."""
parent_file = await _upload_file(file_client, "conv_p", "payload.bin", b"\x00\x01\x02data")
copy_resp = await file_client.post(
"/v1/sessions/conv_c/resources/files:copy",
json={"source_session_id": "conv_p", "file_ids": [parent_file]},
)
new_id = copy_resp.json()["mapping"][parent_file]["new_id"]
resp = await file_client.get(
f"/v1/sessions/conv_c/resources/files/{new_id}/content",
)
assert resp.status_code == 200
assert resp.content == b"\x00\x01\x02data"
# ── Phase 1d: integration hardening tests ────────────────────────
@pytest.mark.asyncio
async def test_files_route_not_captured_as_resource_id(
file_client: httpx.AsyncClient,
) -> None:
"""'files' is a typed collection route, not a resource id."""
resp = await file_client.get(
"/v1/sessions/conv_proxy/resources/files",
)
assert resp.status_code == 200
assert resp.json()["object"] == "list"
@pytest.mark.asyncio
async def test_files_appear_in_unified_inventory(
file_client: httpx.AsyncClient,
) -> None:
"""Uploaded files appear in GET /resources with type 'file'."""
upload = await file_client.post(
"/v1/sessions/conv_local/resources/files",
files={"file": ("report.txt", b"data", "text/plain")},
)
file_id = upload.json()["id"]
resp = await file_client.get("/v1/sessions/conv_local/resources")
assert resp.status_code == 200
body = resp.json()
ids = [r["id"] for r in body["data"]]
assert file_id in ids
file_resource = next(r for r in body["data"] if r["id"] == file_id)
assert file_resource["type"] == "file"
assert file_resource["object"] == "session.resource"
assert file_resource["session_id"] == "conv_local"
@pytest.mark.asyncio
async def test_delete_for_session_cleans_up_all_files(
file_client: httpx.AsyncClient,
file_store: Any,
) -> None:
"""delete_all_for_session removes all session files."""
for name in ("a.txt", "b.txt", "c.txt"):
await file_client.post(
"/v1/sessions/conv_proxy/resources/files",
files={"file": (name, b"data", "text/plain")},
)
list_resp = await file_client.get(
"/v1/sessions/conv_proxy/resources/files",
)
assert len(list_resp.json()["data"]) == 3
# Use the injected file_store fixture directly — no closure
# introspection needed.
deleted_ids = file_store.delete_all_for_session("conv_proxy")
assert len(deleted_ids) == 3
list_after = await file_client.get(
"/v1/sessions/conv_proxy/resources/files",
)
assert len(list_after.json()["data"]) == 0
def test_resource_lifecycle_event_schemas_in_union() -> None:
"""Session resource events are part of the ServerStreamEvent union."""
from pydantic import TypeAdapter
from omnigent.server.schemas import (
ServerStreamEvent,
SessionResourceCreatedEvent,
SessionResourceDeletedEvent,
)
adapter = TypeAdapter(ServerStreamEvent)
created = adapter.validate_python(
{
"type": "session.resource.created",
"resource": {
"id": "file_abc",
"object": "session.resource",
"type": "file",
"session_id": "conv_1",
"name": "test.txt",
"metadata": {},
},
}
)
assert isinstance(created, SessionResourceCreatedEvent)
assert created.resource["id"] == "file_abc"
deleted = adapter.validate_python(
{
"type": "session.resource.deleted",
"resource_id": "terminal_bash_s1",
"resource_type": "terminal",
"session_id": "conv_1",
}
)
assert isinstance(deleted, SessionResourceDeletedEvent)
assert deleted.resource_id == "terminal_bash_s1"
# ── Server filesystem proxy tests ────────────────────────────────
def _fs_list_payload() -> dict[str, object]:
"""Canned runner response for filesystem directory listing."""
return {
"object": "list",
"data": [
{
"id": "README.md",
"object": "session.environment.filesystem.entry",
"name": "README.md",
"path": "README.md",
"type": "file",
"bytes": 100,
"modified_at": 1700000000,
},
],
"first_id": "README.md",
"last_id": "README.md",
"has_more": False,
}
def _fs_write_payload() -> dict[str, object]:
"""Canned runner response for filesystem write."""
return {
"object": "session.environment.filesystem.write_result",
"operation": "write",
"path": "new.txt",
"created": True,
"bytes_written": 5,
"entry": None,
}
def _fs_edit_payload() -> dict[str, object]:
"""Canned runner response for filesystem edit."""
return {
"object": "session.environment.filesystem.edit_result",
"operation": "edit",
"path": "hello.txt",
"replacements": 1,
"bytes_before": 11,
"bytes_after": 13,
"entry": None,
}
def _fs_delete_payload() -> dict[str, object]:
"""Canned runner response for filesystem delete."""
return {
"object": "session.environment.filesystem.delete_result",
"operation": "delete",
"path": "old.txt",
"deleted": True,
"type": "file",
"bytes_deleted": 42,
"entries_deleted": None,
}
@pytest.mark.asyncio
async def test_filesystem_list_proxies_to_runner(
client: httpx.AsyncClient,
) -> None:
"""GET /environments/{id}/filesystem proxies to runner with default pagination params."""
fake_runner = _FakeRunnerClient(payload=_fs_list_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem",
)
assert resp.status_code == 200
assert resp.json()["object"] == "list"
# Default limit=20 and order=desc are forwarded; no cursor params when absent.
assert fake_runner.calls == [
(
"GET",
"/v1/sessions/conv_proxy/resources/environments/default/filesystem?limit=20&order=desc",
),
]
@pytest.mark.asyncio
async def test_filesystem_list_forwards_custom_limit_and_order(
client: httpx.AsyncClient,
) -> None:
"""GET /environments/{id}/filesystem forwards limit and order to runner."""
fake_runner = _FakeRunnerClient(payload=_fs_list_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem?limit=1000&order=asc",
)
assert resp.status_code == 200
# limit=1000 and order=asc must reach the runner — they are not silently capped or ignored.
assert fake_runner.calls == [
(
"GET",
"/v1/sessions/conv_proxy/resources/environments/default/filesystem?limit=1000&order=asc",
),
]
@pytest.mark.asyncio
async def test_filesystem_list_forwards_after_cursor(
client: httpx.AsyncClient,
) -> None:
"""GET /environments/{id}/filesystem forwards `after` cursor to runner."""
fake_runner = _FakeRunnerClient(payload=_fs_list_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem"
"?limit=100&order=asc&after=README.md",
)
assert resp.status_code == 200
forwarded_url = fake_runner.calls[0][1]
# after=README.md must be included — without it the runner would return the
# first page again instead of the next page.
assert "after=README.md" in forwarded_url
assert "limit=100" in forwarded_url
assert "order=asc" in forwarded_url
@pytest.mark.asyncio
async def test_filesystem_list_forwards_before_cursor(
client: httpx.AsyncClient,
) -> None:
"""GET /environments/{id}/filesystem forwards `before` cursor to runner."""
fake_runner = _FakeRunnerClient(payload=_fs_list_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem"
"?limit=50&order=asc&before=src",
)
assert resp.status_code == 200
forwarded_url = fake_runner.calls[0][1]
# before=src must be included — without it the runner would not constrain
# the upper bound of the page window.
assert "before=src" in forwarded_url
@pytest.mark.asyncio
async def test_filesystem_list_omits_absent_cursors(
client: httpx.AsyncClient,
) -> None:
"""GET /environments/{id}/filesystem does not forward after/before when absent."""
fake_runner = _FakeRunnerClient(payload=_fs_list_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem?limit=50",
)
assert resp.status_code == 200
forwarded_url = fake_runner.calls[0][1]
# after and before must be absent — including them as empty strings or
# "None" would corrupt the runner's cursor parsing.
assert "after" not in forwarded_url
assert "before" not in forwarded_url
@pytest.mark.asyncio
async def test_filesystem_path_forwards_pagination_params(
client: httpx.AsyncClient,
) -> None:
"""GET /environments/{id}/filesystem/{path} forwards pagination params
for directory listing."""
fake_runner = _FakeRunnerClient(payload=_fs_list_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem/src"
"?limit=1000&order=asc",
)
assert resp.status_code == 200
forwarded_url = fake_runner.calls[0][1]
# limit=1000 and order=asc must reach the runner so subdirectory listings
# are not silently capped at the default of 20.
assert "limit=1000" in forwarded_url
assert "order=asc" in forwarded_url
@pytest.mark.asyncio
async def test_filesystem_path_omits_absent_cursors(
client: httpx.AsyncClient,
) -> None:
"""GET /environments/{id}/filesystem/{path} omits after/before when not provided."""
fake_runner = _FakeRunnerClient(payload=_fs_list_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem/src"
"?limit=100&order=asc",
)
assert resp.status_code == 200
forwarded_url = fake_runner.calls[0][1]
# Cursor params absent from the client request must not appear in the
# proxied URL — "after=None" or "before=None" would break the runner.
assert "after" not in forwarded_url
assert "before" not in forwarded_url
@pytest.mark.asyncio
async def test_filesystem_read_proxies_to_runner(
client: httpx.AsyncClient,
) -> None:
"""GET /environments/{id}/filesystem/{path} proxies to runner."""
payload = {
"object": "session.environment.filesystem.file_content",
"path": "hello.txt",
"encoding": "utf-8",
"content": "hello world",
"bytes": 11,
"truncated": False,
}
fake_runner = _FakeRunnerClient(payload=payload)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem/hello.txt",
)
assert resp.status_code == 200
assert resp.json()["content"] == "hello world"
@pytest.mark.asyncio
async def test_filesystem_write_proxies_to_runner(
client: httpx.AsyncClient,
) -> None:
"""PUT /environments/{id}/filesystem/{path} proxies to runner."""
fake_runner = _FakeRunnerClient(payload=_fs_write_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.put(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem/new.txt",
json={"content": "hello", "encoding": "utf-8"},
)
assert resp.status_code == 200
assert resp.json()["created"] is True
assert fake_runner.calls == [
("PUT", "/v1/sessions/conv_proxy/resources/environments/default/filesystem/new.txt"),
]
@pytest.mark.asyncio
async def test_filesystem_write_publishes_changed_files_invalidation(
client: httpx.AsyncClient,
) -> None:
"""Successful filesystem writes publish a session filesystem invalidation."""
fake_runner = _FakeRunnerClient(payload=_fs_write_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
stream = session_stream.subscribe("conv_proxy", ready_event={"type": "test.ready"})
try:
assert await anext(stream) == {"type": "test.ready"}
resp = await client.put(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem/new.txt",
json={"content": "hello", "encoding": "utf-8"},
)
event = await asyncio.wait_for(anext(stream), timeout=1)
finally:
await stream.aclose()
assert resp.status_code == 200
assert event == {
"type": "session.changed_files.invalidated",
"session_id": "conv_proxy",
"environment_id": "default",
}
@pytest.mark.asyncio
async def test_filesystem_edit_proxies_to_runner(
client: httpx.AsyncClient,
) -> None:
"""PATCH /environments/{id}/filesystem/{path} proxies to runner."""
fake_runner = _FakeRunnerClient(payload=_fs_edit_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.patch(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem/hello.txt",
json={"old_text": "hello", "new_text": "goodbye"},
)
assert resp.status_code == 200
assert resp.json()["replacements"] == 1
assert fake_runner.calls == [
("PATCH", "/v1/sessions/conv_proxy/resources/environments/default/filesystem/hello.txt"),
]
@pytest.mark.asyncio
async def test_filesystem_delete_proxies_to_runner(
client: httpx.AsyncClient,
) -> None:
"""DELETE /environments/{id}/filesystem/{path} proxies to runner."""
fake_runner = _FakeRunnerClient(payload=_fs_delete_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.delete(
"/v1/sessions/conv_proxy/resources/environments/default/filesystem/old.txt",
)
assert resp.status_code == 200
assert resp.json()["deleted"] is True
@pytest.mark.asyncio
async def test_filesystem_proxy_validates_session(
client: httpx.AsyncClient,
) -> None:
"""Filesystem proxy rejects unknown sessions."""
fake_runner = _FakeRunnerClient(payload=_fs_list_payload())
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.get(
"/v1/sessions/conv_missing/resources/environments/default/filesystem",
)
assert resp.status_code == 404
assert not fake_runner.calls
# ── Server shell proxy test ──────────────────────────────────────
@pytest.mark.asyncio
async def test_shell_proxies_to_runner(
client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""POST /environments/{id}/shell validates session then proxies."""
published: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
session_stream,
"publish",
lambda conversation_id, event: published.append((conversation_id, event)),
)
shell_payload = {
"object": "session.environment.shell_result",
"stdout": "hello\n",
"stderr": "",
"exit_code": 0,
"timed_out": False,
"cwd": "/workspace",
}
fake_runner = _FakeRunnerClient(payload=shell_payload)
set_runner_router(_FakeRunnerRouter(fake_runner)) # type: ignore[arg-type]
resp = await client.post(
"/v1/sessions/conv_proxy/resources/environments/default/shell",
json={"command": "echo hello"},
)
assert resp.status_code == 200
body = resp.json()
assert body["object"] == "session.environment.shell_result"
assert body["stdout"] == "hello\n"
assert body["exit_code"] == 0
assert body["timed_out"] is False
assert fake_runner.calls == [
("POST", "/v1/sessions/conv_proxy/resources/environments/default/shell"),
]
assert published == []
# ── Conversation deletion cleanup test ───────────────────────────
@pytest.mark.asyncio
async def test_session_file_cleanup_on_delete(
file_client: httpx.AsyncClient,
file_store: Any,
artifact_store: _InMemoryArtifactStore,
) -> None:
"""Session file cleanup removes metadata and artifact bytes."""
upload = await file_client.post(
"/v1/sessions/conv_proxy/resources/files",
files={"file": ("cleanup.txt", b"session data", "text/plain")},
)
file_id = upload.json()["id"]
assert artifact_store.get(file_id) is not None
deleted_ids = file_store.delete_all_for_session("conv_proxy")
assert file_id in deleted_ids
for fid in deleted_ids:
artifact_store.delete(fid)
assert file_store.get(file_id, session_id="conv_proxy") is None
assert file_id not in artifact_store._blobs
# ── Resource event persistence tests ─────────────────────────────
@pytest.mark.asyncio
async def test_file_upload_persists_resource_event(
file_client: httpx.AsyncClient,
file_conv_store: _ConversationStore,
) -> None:
"""Uploading a file persists a resource_event conversation item."""
await file_client.post(
"/v1/sessions/conv_proxy/resources/files",
files={"file": ("persist.txt", b"data", "text/plain")},
)
events = [i for i in file_conv_store.appended_items if i.type == "resource_event"]
assert len(events) == 1
assert events[0].data.event_type == "session.resource.created"
assert events[0].data.resource_type == "file"
assert events[0].data.resource is not None
@pytest.mark.asyncio
async def test_file_delete_persists_resource_event(
file_client: httpx.AsyncClient,
file_conv_store: _ConversationStore,
) -> None:
"""Deleting a file persists a resource_event conversation item."""
upload = await file_client.post(
"/v1/sessions/conv_proxy/resources/files",
files={"file": ("del.txt", b"gone", "text/plain")},
)
file_id = upload.json()["id"]
file_conv_store.appended_items.clear()
await file_client.delete(
f"/v1/sessions/conv_proxy/resources/files/{file_id}",
)
events = [i for i in file_conv_store.appended_items if i.type == "resource_event"]
assert len(events) == 1
assert events[0].data.event_type == "session.resource.deleted"
assert events[0].data.resource_id == file_id
assert events[0].data.resource_type == "file"
class _FakeStreamCtx:
"""Async-context-manager body for ``_FakeStreamingRunnerClient.stream``.
Yields the configured SSE frame strings in order, then ends — the
relay loop returns when it reads the terminal ``[DONE]`` frame.
:param frames: SSE frame strings (each already terminated by a
blank line) to yield from ``aiter_text``.
"""
def __init__(self, frames: list[str]) -> None:
self._frames = frames
async def __aenter__(self) -> _FakeStreamCtx:
"""Enter the context, returning self as the response object."""
return self
async def __aexit__(self, *exc: object) -> bool:
"""Exit the context without suppressing exceptions."""
return False
async def aiter_text(self) -> AsyncIterator[str]:
"""Yield each configured SSE frame string in order."""
for frame in self._frames:
yield frame
class _FakeStreamingRunnerClient:
"""Runner-client stub whose ``stream`` yields fixed SSE frames.
Real stub class (not ``MagicMock``) so an unexpected method call
raises ``AttributeError`` loudly instead of silently passing.
:param frames: SSE frame strings the relay will consume from the
runner's ``GET /v1/sessions/{id}/stream``.
"""
def __init__(self, frames: list[str]) -> None:
self._frames = frames
self.stream_urls: list[str] = []
def stream(self, method: str, url: str, timeout: object = None) -> _FakeStreamCtx:
"""Record the URL and return the canned streaming context."""
del method, timeout
self.stream_urls.append(url)
return _FakeStreamCtx(self._frames)
def _sse_frame(payload: dict[str, Any]) -> str:
"""Encode one SSE ``data:`` frame from a JSON payload.
:param payload: The event dict to JSON-encode.
:returns: A complete SSE frame string ending in a blank line.
"""
import json
return "data: " + json.dumps(payload) + "\n\n"
class _ScriptedStreamCtx:
"""Stream context that runs side-effect hooks between SSE frames.
Each step is either an SSE frame string (yielded to the relay) or a
zero-arg callable (run between frames). Because the relay fully
processes one frame before pulling the next chunk, a callable step
runs at a deterministic point — after every prior frame's processing
and before any later frame's — which is how these tests install the
stop fence "mid-turn" exactly the way the ``POST /events`` route does.
:param steps: Ordered frames and hooks to execute.
"""
def __init__(self, steps: list[str | Callable[[], None]]) -> None:
self._steps = steps
async def __aenter__(self) -> _ScriptedStreamCtx:
"""Enter the context, returning self as the response object."""
return self
async def __aexit__(self, *exc: object) -> bool:
"""Exit the context without suppressing exceptions."""
return False
async def aiter_text(self) -> AsyncIterator[str]:
"""Yield frame steps in order, running callable steps in between."""
for step in self._steps:
if callable(step):
step()
else:
yield step
class _ScriptedStreamingRunnerClient:
"""Runner-client stub whose ``stream`` interleaves frames and hooks.
Real stub class (not ``MagicMock``) so an unexpected method call
raises ``AttributeError`` loudly instead of silently passing.
:param steps: SSE frame strings and zero-arg hooks, in order — see
:class:`_ScriptedStreamCtx`.
"""
def __init__(self, steps: list[str | Callable[[], None]]) -> None:
self._steps = steps
def stream(self, method: str, url: str, timeout: object = None) -> _ScriptedStreamCtx:
"""Return the scripted streaming context."""
del method, url, timeout
return _ScriptedStreamCtx(self._steps)
@pytest.mark.asyncio
async def test_relay_persists_terminal_resource_created_from_runner() -> None:
"""The relay persists a ``resource_event`` for a runner-emitted create.
An agent ``sys_terminal_launch`` makes the runner emit
``session.resource.created`` on its SSE stream. The Omnigent relay must
persist a durable ``resource_event`` item so a client reconnecting
mid-turn rediscovers the terminal in the snapshot — matching the
REST resource path.
"""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "session.resource.created",
"resource": {
"id": "terminal_zsh_s1",
"type": "terminal",
"name": "zsh:s1",
"metadata": {"terminal_name": "zsh", "session_key": "s1"},
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
events = [i for i in store.appended_items if i.type == "resource_event"]
# Exactly one durable item — the created event. Zero would mean the
# relay dropped it (the bug this change fixes); more than one would
# mean a non-resource frame leaked into the resource-event path.
assert len(events) == 1, f"expected 1 resource_event, got {store.appended_items}"
assert events[0].data.event_type == "session.resource.created"
assert events[0].data.resource_id == "terminal_zsh_s1"
assert events[0].data.resource_type == "terminal"
# Persisted item carries the full resource dict for snapshot replay.
assert events[0].data.resource is not None
assert events[0].data.resource["id"] == "terminal_zsh_s1"
# Resource events thread on the session id (matches the REST path).
assert events[0].response_id == "conv_proxy"
@pytest.mark.asyncio
async def test_relay_persists_terminal_resource_deleted_from_runner() -> None:
"""The relay persists a ``resource_event`` for a runner-emitted delete.
The symmetric teardown: an agent ``sys_terminal_close`` makes the
runner emit ``session.resource.deleted``; the relay persists the
durable delete item.
"""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "session.resource.deleted",
"resource_id": "terminal_zsh_s1",
"resource_type": "terminal",
"session_id": "conv_proxy",
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
events = [i for i in store.appended_items if i.type == "resource_event"]
assert len(events) == 1, f"expected 1 resource_event, got {store.appended_items}"
assert events[0].data.event_type == "session.resource.deleted"
assert events[0].data.resource_id == "terminal_zsh_s1"
assert events[0].data.resource_type == "terminal"
# Delete carries no resource body.
assert events[0].data.resource is None
@pytest.mark.asyncio
async def test_relay_persists_failed_status_error_labels_from_runner() -> None:
"""Runner ``session.status: failed`` error details survive reload."""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "session.status",
"status": "failed",
"error": {
"code": "required_terminal_exited",
"message": "Required terminal exited unexpectedly",
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
assert (
store._conversations["conv_proxy"].labels["omnigent.last_task_error_code"]
== "required_terminal_exited"
)
assert (
store._conversations["conv_proxy"].labels["omnigent.last_task_error_message"]
== "Required terminal exited unexpectedly"
)
@pytest.mark.asyncio
async def test_relay_truncates_long_error_message_before_persisting() -> None:
"""A >256-char error message is truncated before the label write.
Long messages (tracebacks, 5xx bodies) overflow the String(256)
``conversation_labels.value`` column and previously caused a
``DataError`` that silently dropped the failure reason — the session
rendered as bare ``"failed"`` with no explanation on reload.
Truncation must happen inside the relay (the real call site) so no
long message ever reaches the store.
"""
from omnigent.server.routes.sessions import _LABEL_VALUE_MAX_LEN, _relay_runner_stream
store = _ConversationStore()
long_message = "Runner MCP execute failed: " + "x" * 300 # 327 chars, well over 256
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "session.status",
"status": "failed",
"error": {
"code": "mcp_error",
"message": long_message,
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
stored = store._conversations["conv_proxy"].labels["omnigent.last_task_error_message"]
assert len(stored) <= _LABEL_VALUE_MAX_LEN
@pytest.mark.asyncio
async def test_relay_persists_routing_decision_before_assistant_output() -> None:
"""The relay persists a turn-start ``routing_decision`` item BEFORE the
turn's assistant output.
The runner's cost advisor emits the router's verdict as a
``response.output_item.done`` (item type ``routing_decision``) at turn
start, before ``response.in_progress`` and any assistant message. The
relay must persist it as a durable, display-only item at that position
so a reload renders the chip before the answer it sized — not after.
"""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.output_item.done",
"item": {
"type": "routing_decision",
"model": "databricks-claude-opus-4-8",
"tier": "expensive",
"applied": True,
"rationale": "multi-file refactor needs deep reasoning",
},
}
),
_sse_frame({"type": "response.in_progress", "response": {"id": "resp_turn"}}),
_sse_frame(
{
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Done refactoring."}],
# Field name the relay's parse_item_data expects for an
# assistant message (serialized as ``model`` on the wire).
"agent": "databricks-claude-opus-4-8",
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
# Arrival order is preserved, and the router item lands BEFORE the
# assistant message. If the order flipped (or the routing item were
# dropped), the chip would render after the answer or not at all.
types_in_order = [i.type for i in store.appended_items]
assert types_in_order == ["routing_decision", "message"], (
f"expected [routing_decision, message], got {types_in_order}"
)
routing = store.appended_items[0]
# Every render field round-tripped through RoutingDecisionData on
# persist — a parse failure would have dropped the item entirely.
assert routing.data.model == "databricks-claude-opus-4-8"
assert routing.data.applied is True
assert routing.data.rationale == "multi-file refactor needs deep reasoning"
@pytest.mark.asyncio
async def test_relay_routing_decision_live_event_carries_persisted_id() -> None:
"""The relay re-publishes the routing decision live with the store id.
The raw runner event has no item id, so a turn-start snapshot refetch
(which fetches the just-persisted item with its store id) would render
a SECOND chip alongside the live one. The relay must publish the live
``response.output_item.done`` carrying the persisted item id so the
web UI dedups both copies by ``ctx.itemId`` — exactly one chip.
"""
from omnigent.runtime import session_stream
from omnigent.server.routes.sessions import _relay_runner_stream
published: list[dict[str, Any]] = []
routing_seen = asyncio.Event()
async def _consume() -> None:
async for event in session_stream.subscribe("conv_proxy"):
published.append(event)
item = event.get("item")
if isinstance(item, dict) and item.get("type") == "routing_decision":
routing_seen.set()
consumer = asyncio.create_task(_consume())
await asyncio.sleep(0)
try:
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.output_item.done",
"item": {
"type": "routing_decision",
"model": "databricks-claude-haiku-4-5",
"tier": "cheap",
"applied": False,
"rationale": "trivial question",
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
await asyncio.wait_for(routing_seen.wait(), timeout=1)
finally:
consumer.cancel()
with contextlib.suppress(asyncio.CancelledError):
await consumer
routing_items = [i for i in store.appended_items if i.type == "routing_decision"]
# Persisted exactly once (the dedicated relay branch handles it and
# ``continue``s, so the general persist path doesn't also store it).
assert len(routing_items) == 1, f"expected 1 persisted item, got {store.appended_items}"
persisted_id = routing_items[0].id
routing_live = [
e
for e in published
if isinstance(e.get("item"), dict) and e["item"].get("type") == "routing_decision"
]
# Exactly one live frame, carrying the SAME id as the persisted item —
# a null/missing id (the raw runner event) would not dedup against a
# snapshot-merged copy and would double-render the chip.
assert len(routing_live) == 1, f"expected 1 live routing frame, got {published}"
assert routing_live[0]["item"]["id"] == persisted_id
# Verdict fields survive the live re-publish too.
assert routing_live[0]["item"]["applied"] is False
assert routing_live[0]["item"]["model"] == "databricks-claude-haiku-4-5"
@pytest.mark.asyncio
async def test_relay_drops_malformed_routing_decision() -> None:
"""A malformed routing item (empty model) is dropped, not persisted.
The runner should never emit one, but a bad frame must not poison the
relay or persist a chip that can't render. The frame is parsed by
``RoutingDecisionData`` (which rejects an empty model); on failure the
relay drops it.
"""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.output_item.done",
"item": {
"type": "routing_decision",
"model": "",
"tier": "expensive",
"applied": True,
"rationale": "x",
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
routing = [i for i in store.appended_items if i.type == "routing_decision"]
# Empty-model frame dropped — zero persisted. A persisted item would
# mean the relay stored a chip with no model to render.
assert routing == []
@pytest.mark.asyncio
async def test_relay_does_not_persist_session_level_response_error() -> None:
"""The relay does not persist a startup ``response.error`` orphan.
A runner can emit ``response.error`` before any response lifecycle
frame while auto-creating a native terminal. That is a session
status signal, not a transcript turn. Persisting it would create a
top-of-transcript error that is duplicated when the next user
message fast-fails and records its own ordered ``message,error``
pair.
"""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.error",
"source": "execution",
"error": {
"code": "native_terminal_start_failed",
"message": "Native Codex requires the 'codex' CLI on PATH.",
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
errors = [i for i in store.appended_items if i.type == "error"]
assert errors == []
@pytest.mark.asyncio
async def test_relay_persists_in_turn_response_error_once_from_runner() -> None:
"""The relay durably stores an in-turn runner error banner once.
Once a ``response.in_progress`` frame establishes the active turn,
a following ``response.error`` is transcript-scoped. The relay
persists that visible error payload so refresh shows the banner, but
still dedupes identical frames so history is not spammed.
"""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.in_progress",
"response": {"id": "resp_turn"},
}
),
_sse_frame(
{
"type": "response.error",
"source": "execution",
"error": {
"code": "native_terminal_start_failed",
"message": "Native Codex requires the 'codex' CLI on PATH.",
},
}
),
_sse_frame(
{
"type": "response.error",
"source": "execution",
"error": {
"code": "native_terminal_start_failed",
"message": "Native Codex requires the 'codex' CLI on PATH.",
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
errors = [i for i in store.appended_items if i.type == "error"]
# One durable error proves the first in-turn frame was persisted;
# zero would mean refresh loses the banner, while two would mean
# reconnect/retry frames can spam the transcript.
assert len(errors) == 1, f"expected 1 durable error item, got {store.appended_items}"
assert errors[0].response_id == "resp_turn"
assert errors[0].data.source == "execution"
assert errors[0].data.code == "native_terminal_start_failed"
assert errors[0].data.message == "Native Codex requires the 'codex' CLI on PATH."
@pytest.mark.asyncio
async def test_relay_dedupes_duplicate_error_persistence_but_forwards_live_frames() -> None:
"""Duplicate runner errors are deduped only in durable history.
Reconnect can make a runner re-emit the same startup failure. The
relay still forwards the runner's live stream exactly, but it
persists only the first matching banner until another user message
starts a new transcript turn.
"""
from omnigent.runtime import session_stream
from omnigent.server.routes.sessions import _relay_runner_stream
published: list[dict[str, Any]] = []
live_errors_seen = asyncio.Event()
async def _consume() -> None:
async for event in session_stream.subscribe("conv_proxy"):
published.append(event)
if len([item for item in published if item.get("type") == "response.error"]) == 2:
live_errors_seen.set()
consumer = asyncio.create_task(_consume())
await asyncio.sleep(0)
try:
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.in_progress",
"response": {"id": "resp_turn"},
}
),
_sse_frame(
{
"type": "response.error",
"source": "execution",
"error": {
"code": "native_terminal_start_failed",
"message": "Native Codex requires the 'codex' CLI on PATH.",
},
}
),
_sse_frame(
{
"type": "response.error",
"source": "execution",
"error": {
"code": "native_terminal_start_failed",
"message": "Native Codex requires the 'codex' CLI on PATH.",
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
await asyncio.wait_for(live_errors_seen.wait(), timeout=1)
finally:
consumer.cancel()
with contextlib.suppress(asyncio.CancelledError):
await consumer
error_events = [event for event in published if event.get("type") == "response.error"]
# Two live frames proves persistence dedupe does not alter the
# producer stream; one would mean reconnect frames are being
# suppressed before clients see them.
assert len(error_events) == 2
errors = [i for i in store.appended_items if i.type == "error"]
# One durable item is the transcript dedupe invariant: zero loses
# the refresh banner, while two duplicates the same runner failure.
assert len(errors) == 1
@pytest.mark.asyncio
async def test_native_dispatch_fast_fails_and_consumes_message_on_terminal_error() -> None:
"""Definitive native terminal failure consumes the user message quickly.
The Omnigent server first asks the runner to ensure the native terminal.
When the runner returns a structured startup failure, Omnigent must not
create a pending input or forward into the dead terminal. It should
persist the user message plus a durable error item instead.
"""
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
store = _ConversationStore()
conv = store.get_conversation("conv_claude")
assert conv is not None
client = _FakeRunnerClient(
responses={
"/v1/sessions/conv_claude/resources/terminals": (
500,
{
"error": {
"code": "native_terminal_start_failed",
"message": "Native Claude requires the 'claude' CLI on PATH.",
}
},
)
}
)
body = SessionEventInput(
type="message",
data={"role": "user", "content": [{"type": "input_text", "text": "try again"}]},
)
result = await _dispatch_session_event_to_runner(
"conv_claude",
conv,
body,
store, # type: ignore[arg-type]
client, # type: ignore[arg-type]
agent_name="claude-native-ui",
file_store=None,
artifact_store=None,
created_by="alice@example.com",
)
assert result.pending_id is None
assert result.item_id is not None
assert [call[0] for call in client.post_json_calls] == [
"/v1/sessions/conv_claude/resources/terminals"
]
messages = [i for i in store.appended_items if i.type == "message"]
errors = [i for i in store.appended_items if i.type == "error"]
assert [i.type for i in store.appended_items] == ["message", "error"]
# One message means the failed turn was consumed. Zero would leave
# the input pending forever; two would mean the Omnigent fast-fail path
# duplicated the user's message.
assert len(messages) == 1
assert messages[0].created_by == "alice@example.com"
assert messages[0].data.content == [{"type": "input_text", "text": "try again"}]
# One error means the consumed message has exactly one visible
# failure sibling. Zero would be silent after refresh; two would
# duplicate the same terminal-start failure.
assert len(errors) == 1
assert errors[0].data.code == "native_terminal_start_failed"
assert errors[0].data.message == "Native Claude requires the 'claude' CLI on PATH."
@pytest.mark.asyncio
async def test_kiro_native_dispatch_forwards_without_persisting() -> None:
"""Kiro web-chat input is mirrored by Kiro's session forwarder."""
from omnigent.runtime import pending_inputs
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
pending_inputs.reset_for_tests()
store = _ConversationStore()
conv = store.get_conversation("conv_kiro")
assert conv is not None
client = _FakeRunnerClient()
body = SessionEventInput(
type="message",
data={"role": "user", "content": [{"type": "input_text", "text": "hello"}]},
)
try:
result = await _dispatch_session_event_to_runner(
"conv_kiro",
conv,
body,
store, # type: ignore[arg-type]
client, # type: ignore[arg-type]
agent_name="kiro-native-ui",
file_store=None,
artifact_store=None,
created_by="alice@example.com",
)
assert result.item_id is None
assert result.pending_id is not None
assert result.pending_id.startswith("pending_")
assert [call[0] for call in client.post_json_calls] == [
"/v1/sessions/conv_kiro/resources/terminals",
"/v1/sessions/conv_kiro/events",
]
pending = pending_inputs.snapshot_for("conv_kiro")
assert len(pending) == 1
assert pending[0]["content"] == [{"type": "input_text", "text": "hello"}]
assert store.appended_items == []
forwarded = client.post_json_calls[1][1]
assert forwarded["agent_id"] == "ag_kiro"
assert forwarded["model"] == "kiro-native-ui"
finally:
pending_inputs.reset_for_tests()
@pytest.mark.asyncio
async def test_kiro_native_dispatch_clears_pending_when_injection_fails() -> None:
"""A failed Kiro tmux injection must not leave a ghost pending input."""
from omnigent.runtime import pending_inputs
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
pending_inputs.reset_for_tests()
store = _ConversationStore()
conv = store.get_conversation("conv_kiro")
assert conv is not None
client = _FakeRunnerClient(
responses={"/v1/sessions/conv_kiro/events": (500, {"error": "tmux failed"})}
)
body = SessionEventInput(
type="message",
data={"role": "user", "content": [{"type": "input_text", "text": "hello"}]},
)
try:
with pytest.raises(HTTPException):
await _dispatch_session_event_to_runner(
"conv_kiro",
conv,
body,
store, # type: ignore[arg-type]
client, # type: ignore[arg-type]
agent_name="kiro-native-ui",
file_store=None,
artifact_store=None,
created_by="alice@example.com",
)
assert [call[0] for call in client.post_json_calls] == [
"/v1/sessions/conv_kiro/resources/terminals",
"/v1/sessions/conv_kiro/events",
]
assert store.appended_items == []
assert pending_inputs.snapshot_for("conv_kiro") == []
finally:
pending_inputs.reset_for_tests()
@pytest.mark.asyncio
async def test_kiro_external_prompt_matches_pending_and_reports_skipped_input() -> None:
"""A failed Kiro prompt must not make the next prompt clear the wrong pending input."""
from omnigent.runtime import pending_inputs
from omnigent.server.routes.sessions import _persist_external_conversation_item
pending_inputs.reset_for_tests()
store = _ConversationStore()
conv = store.get_conversation("conv_kiro")
assert conv is not None
first = pending_inputs.record(
"conv_kiro",
[{"type": "input_text", "text": "!!!! XOXOX !!!!"}],
created_by="alice@example.com",
)
second = pending_inputs.record(
"conv_kiro",
[{"type": "input_text", "text": "tell me a joke"}],
created_by="alice@example.com",
)
body = SessionEventInput(
type="external_conversation_item",
data={
"item_type": "message",
"item_data": {
"role": "user",
"content": [{"type": "input_text", "text": "tell me a joke"}],
},
"response_id": "kiro:prompt-joke",
},
)
try:
item_id = await _persist_external_conversation_item(
"conv_kiro",
conv,
body,
store, # type: ignore[arg-type]
)
assert item_id == "item_2"
assert pending_inputs.snapshot_for("conv_kiro") == []
assert [item.type for item in store.appended_items] == ["message", "error", "message"]
skipped_user, skipped_error, matched_user = store.appended_items
assert skipped_user.data.role == "user"
assert skipped_user.data.content == [{"type": "input_text", "text": "!!!! XOXOX !!!!"}]
assert skipped_user.created_by == "alice@example.com"
assert skipped_error.data.code == "kiro_native_prompt_not_recorded"
assert matched_user.data.role == "user"
assert matched_user.data.content == [{"type": "input_text", "text": "tell me a joke"}]
assert matched_user.created_by == "alice@example.com"
assert first != second
finally:
pending_inputs.reset_for_tests()
@pytest.mark.asyncio
async def test_native_dispatch_reports_malformed_runner_error_body() -> None:
"""Opaque framework 500 bodies become explicit ensure errors.
If the runner/tunnel returns a plain ``Internal Server Error`` body
instead of the structured native startup error JSON, Omnigent still fast
fails the message. The transcript should not invent a missing
native CLI cause or persist the raw HTTP body; it should say the
runner returned a malformed ensure response.
"""
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
store = _ConversationStore()
conv = store.get_conversation("conv_claude")
assert conv is not None
client = _FakeRunnerClient(
text_responses={
"/v1/sessions/conv_claude/resources/terminals": (
500,
"Internal Server Error",
{"content-type": "text/plain"},
)
}
)
body = SessionEventInput(
type="message",
data={"role": "user", "content": [{"type": "input_text", "text": "retry"}]},
)
result = await _dispatch_session_event_to_runner(
"conv_claude",
conv,
body,
store, # type: ignore[arg-type]
client, # type: ignore[arg-type]
agent_name="claude-native-ui",
file_store=None,
artifact_store=None,
created_by=None,
)
assert result.pending_id is None
errors = [i for i in store.appended_items if i.type == "error"]
# One durable error proves the failed user message is consumed with
# a visible banner. Zero would leave the turn silent after refresh;
# two would duplicate the AP-side fast-fail record.
assert len(errors) == 1
assert errors[0].data.code == "native_terminal_ensure_failed"
assert (
errors[0].data.message
== "Native Claude terminal ensure failed with malformed runner response (HTTP 500)."
)
@pytest.mark.asyncio
async def test_native_dispatch_transport_error_does_not_fallback_to_forwarding() -> None:
"""Runner transport failure is a definitive AP-side ensure error.
If Omnigent cannot reach the runner's terminal ensure endpoint, it must
consume the user message and record an explicit ensure failure. It
should not fall back to forwarding the message and waiting on the
old boot grace path, because that reintroduces the slow hang.
"""
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
store = _ConversationStore()
conv = store.get_conversation("conv_claude")
assert conv is not None
client = _FakeRunnerClient(
exc=httpx.ConnectError(
"connection refused",
request=httpx.Request(
"POST",
"/v1/sessions/conv_claude/resources/terminals",
),
)
)
body = SessionEventInput(
type="message",
data={"role": "user", "content": [{"type": "input_text", "text": "retry"}]},
)
result = await _dispatch_session_event_to_runner(
"conv_claude",
conv,
body,
store, # type: ignore[arg-type]
client, # type: ignore[arg-type]
agent_name="claude-native-ui",
file_store=None,
artifact_store=None,
created_by=None,
)
assert result.pending_id is None
assert [call[0] for call in client.post_json_calls] == [
"/v1/sessions/conv_claude/resources/terminals"
]
assert [i.type for i in store.appended_items] == ["message", "error"]
errors = [i for i in store.appended_items if i.type == "error"]
# One error proves the transport failure was surfaced durably. Zero
# would be silent after refresh; two would mean the fast-fail path
# duplicated its own banner.
assert len(errors) == 1
assert errors[0].data.code == "native_terminal_ensure_failed"
assert (
errors[0].data.message
== "Native Claude terminal ensure request failed. connection refused"
)
@pytest.mark.asyncio
async def test_native_dispatch_tunnel_close_is_definitive_ensure_error() -> None:
"""A WS-tunnel drop during terminal ensure fails the turn durably.
WSTunnelTransport raises bare ``ConnectionError`` — not an
``httpx.HTTPError`` — when the runner's tunnel closes mid-request
("tunnel closed before request completed"). Before this fix the
exception escaped the ensure probe's ``except httpx.HTTPError``
clause to the global catch-all, so the web client saw an opaque
500 ``internal_error`` instead of the durable ensure-failure turn
error. The dispatch must treat it exactly like an httpx transport
failure: consume the user message and persist an explicit error.
"""
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
store = _ConversationStore()
conv = store.get_conversation("conv_claude")
assert conv is not None
client = _FakeRunnerClient(exc=ConnectionError("tunnel closed before request completed"))
body = SessionEventInput(
type="message",
data={"role": "user", "content": [{"type": "input_text", "text": "retry"}]},
)
result = await _dispatch_session_event_to_runner(
"conv_claude",
conv,
body,
store, # type: ignore[arg-type]
client, # type: ignore[arg-type]
agent_name="claude-native-ui",
file_store=None,
artifact_store=None,
created_by=None,
)
assert result.pending_id is None
# message + error proves the fast-fail path consumed the input and
# recorded a visible failure; an escaped ConnectionError would have
# raised out of the dispatch instead of returning a result at all.
assert [i.type for i in store.appended_items] == ["message", "error"]
errors = [i for i in store.appended_items if i.type == "error"]
assert len(errors) == 1
assert errors[0].data.code == "native_terminal_ensure_failed"
assert (
errors[0].data.message
== "Native Claude terminal ensure request failed. tunnel closed before request completed"
)
@pytest.mark.asyncio
async def test_native_dispatch_persists_error_for_each_user_retry() -> None:
"""A fresh user retry gets its own durable terminal error.
Reconnect spam should dedupe, but a user sending a new message to a
still-broken native terminal is a new transcript turn. The error
must be written after that user message so refresh still shows why
the retry failed.
"""
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
store = _ConversationStore()
conv = store.get_conversation("conv_claude")
assert conv is not None
client = _FakeRunnerClient(
responses={
"/v1/sessions/conv_claude/resources/terminals": (
500,
{
"error": {
"code": "native_terminal_start_failed",
"message": "Native Claude requires the 'claude' CLI on PATH.",
}
},
)
}
)
for text in ("first retry", "second retry"):
body = SessionEventInput(
type="message",
data={"role": "user", "content": [{"type": "input_text", "text": text}]},
)
result = await _dispatch_session_event_to_runner(
"conv_claude",
conv,
body,
store, # type: ignore[arg-type]
client, # type: ignore[arg-type]
agent_name="claude-native-ui",
file_store=None,
artifact_store=None,
created_by=None,
)
assert result.pending_id is None
messages = [i for i in store.appended_items if i.type == "message"]
errors = [i for i in store.appended_items if i.type == "error"]
assert [m.data.content[0]["text"] for m in messages] == ["first retry", "second retry"]
assert [i.type for i in store.appended_items] == ["message", "error", "message", "error"]
# Two errors proves dedupe resets after each new user message. One
# would incorrectly treat a user retry as reconnect spam; three or
# more would mean one retry wrote duplicate banners.
assert len(errors) == 2
assert [call[0] for call in client.post_json_calls] == [
"/v1/sessions/conv_claude/resources/terminals",
"/v1/sessions/conv_claude/resources/terminals",
]
assert all(
e.data.message == "Native Claude requires the 'claude' CLI on PATH." for e in errors
)
@pytest.mark.asyncio
async def test_native_dispatch_records_same_error_after_recovery_boundary() -> None:
"""A retry after intervening activity gets its own error.
If a native terminal recovers enough to produce assistant output
and then later hits the same startup failure again, the user's new
retry should record a new visible error instead of being suppressed
by the previous failure.
"""
from omnigent.entities import MessageData, NewConversationItem
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
store = _ConversationStore()
conv = store.get_conversation("conv_claude")
assert conv is not None
client = _FakeRunnerClient(
responses={
"/v1/sessions/conv_claude/resources/terminals": (
500,
{
"error": {
"code": "native_terminal_start_failed",
"message": "Native Claude requires the 'claude' CLI on PATH.",
}
},
)
}
)
first = SessionEventInput(
type="message",
data={"role": "user", "content": [{"type": "input_text", "text": "first retry"}]},
)
await _dispatch_session_event_to_runner(
"conv_claude",
conv,
first,
store, # type: ignore[arg-type]
client, # type: ignore[arg-type]
agent_name="claude-native-ui",
file_store=None,
artifact_store=None,
created_by=None,
)
store.append(
"conv_claude",
[
NewConversationItem(
type="message",
response_id="resp_recovered",
data=MessageData(
role="assistant",
agent="claude-native-ui",
content=[{"type": "output_text", "text": "recovered"}],
),
)
],
)
second = SessionEventInput(
type="message",
data={"role": "user", "content": [{"type": "input_text", "text": "second retry"}]},
)
await _dispatch_session_event_to_runner(
"conv_claude",
conv,
second,
store, # type: ignore[arg-type]
client, # type: ignore[arg-type]
agent_name="claude-native-ui",
file_store=None,
artifact_store=None,
created_by=None,
)
errors = [i for i in store.appended_items if i.type == "error"]
# Two errors proves a later user retry after intervening activity is
# not suppressed by session-global dedupe. One would hide the second
# failure; three or more would indicate duplicate persistence.
assert len(errors) == 2
assert all(
e.data.message == "Native Claude requires the 'claude' CLI on PATH." for e in errors
)
@pytest.mark.asyncio
async def test_relay_skips_malformed_resource_created_from_runner() -> None:
"""A ``session.resource.created`` missing its ``resource`` persists nothing.
A malformed frame must not poison the relay or persist a partial
item — the snapshot endpoint stays the source of truth.
"""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame({"type": "session.resource.created"}),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
events = [i for i in store.appended_items if i.type == "resource_event"]
assert events == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"frame_payload",
[
# created: empty-string id / type are malformed — isinstance(str)
# admits "" but it can't resolve to a real resource.
{"type": "session.resource.created", "resource": {"id": "", "type": "terminal"}},
{"type": "session.resource.created", "resource": {"id": "terminal_zsh_s1", "type": ""}},
# deleted: same for the flat id / type fields.
{
"type": "session.resource.deleted",
"resource_id": "",
"resource_type": "terminal",
"session_id": "conv_proxy",
},
{
"type": "session.resource.deleted",
"resource_id": "terminal_zsh_s1",
"resource_type": "",
"session_id": "conv_proxy",
},
],
)
async def test_relay_skips_empty_resource_id_or_type_from_runner(
frame_payload: dict[str, Any],
) -> None:
"""Empty-string resource id/type frames persist nothing.
A frame whose id or type is ``""`` is malformed — persisting it
would leave a ``resource_event`` item the snapshot can't map back
to a real resource. The relay must drop it (regression guard for
the ``isinstance(x, str)``-only check that accepted ``""``).
"""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient([_sse_frame(frame_payload), "data: [DONE]\n\n"])
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
events = [i for i in store.appended_items if i.type == "resource_event"]
assert events == []
@pytest.mark.asyncio
async def test_relay_pairs_function_call_output_with_call_response_id() -> None:
"""
A ``function_call_output`` persists with its call's ``response_id``.
When the harness starts a new response before the tool result
arrives, the relay's ``current_response_id`` has already advanced.
Without the call_id → response_id tracking, the output gets the
new id and the web UI can't pair it with the spinner — the tool
card shows "Waiting for output" forever.
"""
from omnigent.server.routes.sessions import _relay_runner_stream
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.in_progress",
"response": {"id": "resp_first"},
}
),
# function_call: the relay records call_id → response_id
# from the raw event even though parse_item_data fails
# (serialization alias mismatch for the ``model`` field).
_sse_frame(
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"id": "item_fc1",
"call_id": "call_abc",
"name": "github_write_api_call",
"arguments": '{"comment_body": "/merge"}',
"status": "completed",
"model": "nessie",
"response_id": "resp_first",
},
}
),
# New response starts BEFORE tool result arrives.
_sse_frame(
{
"type": "response.in_progress",
"response": {"id": "resp_second"},
}
),
_sse_frame(
{
"type": "response.output_item.done",
"item": {
"type": "function_call_output",
"id": "item_fco1",
"call_id": "call_abc",
"output": "Comment posted.",
"response_id": "resp_second",
},
}
),
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
outputs = [i for i in store.appended_items if i.type == "function_call_output"]
# Exactly one output — the single tool result in the stream. Zero
# would mean the relay dropped it; more than one means a duplicate.
assert len(outputs) == 1
# The output must share the CALL's response_id, not the second
# response's id that current_response_id would have contained.
assert outputs[0].response_id == "resp_first", (
f"function_call_output got response_id={outputs[0].response_id!r}; "
f"expected 'resp_first' (the matching function_call's response_id)"
)
@pytest.mark.asyncio
async def test_relay_publishes_inflight_frames_and_discards_on_exit() -> None:
"""
The relay feeds the in-flight index, then discards it on exit.
Drives the REAL relay with the runner's actual SSE frame shapes —
``response.created`` + ``response.output_text.delta`` — and ends the
stream with NO terminal ``response.*`` (a runner death / Stop /
tunnel drop mid-turn). Two properties:
* **Producer:** the relay republishes the lifecycle + text frames
through ``session_stream.publish`` (captured via a concurrent
subscriber). Those are exactly what ``record_publish`` accumulates
into the in-flight index for snapshot replay — so the populate
path is real, not just asserted in isolation.
* **Leak fix:** when the relay task exits without a terminal event,
its ``finally`` calls ``inflight_text.discard`` so the entry is
gone. Without that discard, a turn ended by Stop / runner death
would strand the entry forever and replay stale text on the next
reload (and grow the index unbounded).
"""
from omnigent.runtime import inflight_text, session_stream
from omnigent.server.routes.sessions import _relay_runner_stream
inflight_text.reset_for_tests()
published: list[dict[str, Any]] = []
async def _consume() -> None:
async for event in session_stream.subscribe("conv_proxy"):
published.append(event)
try:
consumer = asyncio.create_task(_consume())
# One loop turn lets the subscriber register its slot before the
# relay starts publishing, so nothing is missed.
await asyncio.sleep(0)
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.created",
"response": {
"id": "resp_mid",
"model": "nessie",
"status": "queued",
"created_at": 1,
},
}
),
_sse_frame({"type": "response.output_text.delta", "delta": "Planning "}),
_sse_frame({"type": "response.output_text.delta", "delta": "the work."}),
# No response.completed / cancelled — the turn is still
# streaming when the stream ends (runner death / Stop).
"data: [DONE]\n\n",
]
)
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
session_stream.close("conv_proxy")
await consumer
# Producer: the relay published the lifecycle + text frames that
# feed the in-flight index. Missing either would mean the relay
# stopped republishing them and the index could never populate.
published_types = [e.get("type") for e in published]
assert "response.created" in published_types, published_types
assert published_types.count("response.output_text.delta") == 2, published_types
# The turn never completed → no assistant message persisted.
assert [i for i in store.appended_items if i.type == "message"] == [], (
"an unfinished turn must not persist an assistant message"
)
# Leak fix: the relay's teardown discarded the entry. A non-empty
# result means the finally discard was dropped and a Stop / runner
# death would strand stale text (replayed on the next reload).
assert inflight_text.snapshot_for("conv_proxy") == [], (
"relay exit must discard the in-flight entry"
)
finally:
inflight_text.reset_for_tests()
session_stream._subscribers.clear()
@pytest.mark.asyncio
async def test_relay_fences_cancelled_turn_and_resumes_on_next_turn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A Stop fences the turn: its trailing deltas aren't persisted or forwarded.
Repro of the stop-mid-stream bug: after the user Stops, the cancelled
turn's remaining deltas must be DROPPED — not persisted to the transcript
and not forwarded to the live stream. Dropped deltas never enter the text
buffer, so the turn's ``response.completed`` (which now lifts the fence
and is processed normally) flushes nothing for the abandoned tail. The
follow-up turn persists + forwards normally so it has a real effect.
"""
from omnigent.runtime import session_stream
from omnigent.server.routes import sessions as sessions_module
from omnigent.server.routes.sessions import _relay_runner_stream
sid = "conv_fenced"
published: list[dict[str, Any]] = []
def _capture(session_id: str, event: dict[str, Any]) -> None:
"""Record events the relay forwards to the live stream for *sid*."""
if session_id == sid:
published.append(event)
monkeypatch.setattr(session_stream, "publish", _capture)
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
# Cancelled turn's trailing content — the session is already fenced.
_sse_frame({"type": "response.output_text.delta", "delta": "ABANDONED"}),
_sse_frame({"type": "response.completed", "response": {"id": "r1", "model": "m"}}),
# Follow-up turn: running lifts the fence, then a real reply.
_sse_frame({"type": "session.status", "status": "running"}),
_sse_frame({"type": "response.output_text.delta", "delta": "REAL REPLY"}),
_sse_frame({"type": "response.completed", "response": {"id": "r2", "model": "m"}}),
"data: [DONE]\n\n",
]
)
# The Stop fenced the session before this relay run (set by the route).
sessions_module._interrupt_fenced_sessions.add(sid)
try:
await _relay_runner_stream(sid, client, store) # type: ignore[arg-type]
finally:
sessions_module._interrupt_fenced_sessions.discard(sid)
# Exactly one assistant message persisted — the follow-up reply. The
# cancelled turn's delta+completed were dropped. If 2 (or "ABANDONED"),
# the fence leaked and the abandoned reply reached the transcript.
messages = [
i for i in store.appended_items if i.type == "message" and i.data.role == "assistant"
]
assert len(messages) == 1, f"expected 1 assistant message, got {[m.data for m in messages]}"
assert messages[0].data.content[0]["text"] == "REAL REPLY"
# The follow-up turn's running status lifted the fence.
assert sid not in sessions_module._interrupt_fenced_sessions
# The cancelled turn's content was never forwarded to the live stream;
# only the follow-up's reply was.
published_deltas = [
e.get("delta") for e in published if e.get("type") == "response.output_text.delta"
]
assert "ABANDONED" not in published_deltas, (
f"cancelled-turn delta must not be forwarded; got {published_deltas}"
)
assert "REAL REPLY" in published_deltas
@pytest.mark.asyncio
async def test_relay_flushes_partial_text_on_failed_turn_before_error_item() -> None:
"""A failed turn persists its streamed narration, ordered before the error.
Repro of the lost-narration bug: scaffold text only flushed at tool
boundaries and on ``response.completed``, so a turn that FAILED dropped
everything streamed since the last boundary — after reload only the error
remained. The relay must flush the buffered text on ``response.failed``
(before the durable error item, matching what the user watched stream),
and the terminal's publish must clear the in-flight replay entry.
"""
from omnigent.runtime import inflight_text
from omnigent.server.routes.sessions import _relay_runner_stream
inflight_text.reset_for_tests()
sid = "conv_proxy"
store = _ConversationStore()
# In-flight replay snapshots probed at deterministic points: after the
# deltas (entry populated) and after the failed terminal (entry cleared).
inflight_after_deltas: list[list[dict[str, Any]]] = []
inflight_after_failed: list[list[dict[str, Any]]] = []
client = _ScriptedStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.in_progress",
"response": {"id": "resp_fail", "model": "nessie"},
}
),
_sse_frame({"type": "response.output_text.delta", "delta": "Drafting the plan. "}),
_sse_frame({"type": "response.output_text.delta", "delta": "Now running checks."}),
lambda: inflight_after_deltas.append(inflight_text.snapshot_for(sid)),
_sse_frame(
{
"type": "response.failed",
"response": {
"id": "resp_fail",
"model": "nessie",
"error": {"code": "llm_error", "message": "LLM exploded"},
},
}
),
lambda: inflight_after_failed.append(inflight_text.snapshot_for(sid)),
"data: [DONE]\n\n",
]
)
try:
await _relay_runner_stream(sid, client, store) # type: ignore[arg-type]
# Store order: narration message FIRST, then the error item. Reversed
# (or missing the message) means the failed-turn flush regressed and
# reload shows the error without the text the user watched stream.
types = [i.type for i in store.appended_items]
assert types == ["message", "error"], types
message, error = store.appended_items
assert "".join(b["text"] for b in message.data.content) == (
"Drafting the plan. Now running checks."
)
# Both items share the failed turn's id so they group in one bubble.
assert message.response_id == "resp_fail"
assert error.response_id == "resp_fail"
assert error.data.code == "llm_error"
assert error.data.message == "LLM exploded"
# Populated before the terminal: proves the clear below is a real
# transition, not "the index was never fed".
assert inflight_after_deltas == [
[
{
"type": "response.created",
"response": {"id": "resp_fail", "model": "nessie"},
},
{
"type": "response.output_text.delta",
"delta": "Drafting the plan. Now running checks.",
},
]
], inflight_after_deltas
# Cleared by the terminal's publish AFTER the flush persisted the
# text — a non-empty snapshot here would double-render on reconnect.
assert inflight_after_failed == [[]], inflight_after_failed
finally:
inflight_text.reset_for_tests()
@pytest.mark.asyncio
async def test_relay_flushes_final_text_on_fenced_response_completed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A ``response.completed`` consumed while fenced still persists the answer.
Repro of the stop-vs-completion race: the user hits Stop just as a
no-tool turn finishes naturally. The fence used to drop the fenced
``response.completed`` (skipping the final flush), losing the ENTIRE
answer from history even though the live tab rendered it. A fenced
completed proves the turn was NOT cancelled: it must flush + publish
normally and lift the fence.
"""
from omnigent.runtime import inflight_text, session_stream
from omnigent.server.routes import sessions as sessions_module
from omnigent.server.routes.sessions import _relay_runner_stream
inflight_text.reset_for_tests()
sid = "conv_fence_completed"
store = _ConversationStore()
published: list[dict[str, Any]] = []
real_publish = session_stream.publish
def _capture_publish(conversation_id: str, event: dict[str, Any]) -> None:
"""Record events for *sid* while keeping the real publish path."""
if conversation_id == sid:
published.append(event)
real_publish(conversation_id, event)
monkeypatch.setattr(session_stream, "publish", _capture_publish)
def _install_fence() -> None:
"""Install the stop fence mid-turn, as POST /events does on Stop."""
sessions_module._interrupt_fenced_sessions.add(sid)
client = _ScriptedStreamingRunnerClient(
[
_sse_frame(
{"type": "response.in_progress", "response": {"id": "resp_done", "model": "m"}}
),
_sse_frame({"type": "response.output_text.delta", "delta": "The answer is 42."}),
_install_fence,
_sse_frame(
{"type": "response.completed", "response": {"id": "resp_done", "model": "m"}}
),
"data: [DONE]\n\n",
]
)
try:
await _relay_runner_stream(sid, client, store) # type: ignore[arg-type]
# The full answer persisted despite the fence. Empty means the fenced
# completed was dropped again and the turn vanishes from history.
messages = [i for i in store.appended_items if i.type == "message"]
assert len(messages) == 1, f"expected 1 assistant message, got {store.appended_items}"
assert "".join(b["text"] for b in messages[0].data.content) == "The answer is 42."
assert messages[0].response_id == "resp_done"
# The terminal lifted the fence — without this, every later turn's
# output would be dropped until the next "running" status.
assert sid not in sessions_module._interrupt_fenced_sessions
# The completed event reached the live stream so clients close the
# turn; absence means the fence still swallows terminal events.
assert any(e.get("type") == "response.completed" for e in published), published
finally:
sessions_module._interrupt_fenced_sessions.discard(sid)
inflight_text.reset_for_tests()
@pytest.mark.asyncio
async def test_relay_persists_pre_stop_narration_on_fenced_incomplete(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An interrupted turn keeps its pre-Stop narration; the trailing tail drops.
The live tab keeps showing the text streamed before the Stop, so reload
must too: the fenced ``response.incomplete`` (the runtime cancel
handler's terminal) flushes the buffered pre-Stop text. Deltas that
arrive AFTER the Stop are still suppressed — they never reach the live
stream, so persisting them would make reload diverge from what was
watched.
"""
from omnigent.runtime import inflight_text, session_stream
from omnigent.server.routes import sessions as sessions_module
from omnigent.server.routes.sessions import _relay_runner_stream
inflight_text.reset_for_tests()
sid = "conv_fence_incomplete"
store = _ConversationStore()
published: list[dict[str, Any]] = []
real_publish = session_stream.publish
def _capture_publish(conversation_id: str, event: dict[str, Any]) -> None:
"""Record events for *sid* while keeping the real publish path."""
if conversation_id == sid:
published.append(event)
real_publish(conversation_id, event)
monkeypatch.setattr(session_stream, "publish", _capture_publish)
def _install_fence() -> None:
"""Install the stop fence mid-turn, as POST /events does on Stop."""
sessions_module._interrupt_fenced_sessions.add(sid)
client = _ScriptedStreamingRunnerClient(
[
_sse_frame(
{"type": "response.in_progress", "response": {"id": "resp_int", "model": "m"}}
),
_sse_frame({"type": "response.output_text.delta", "delta": "Partial thought."}),
_install_fence,
_sse_frame({"type": "response.output_text.delta", "delta": " DOOMED TAIL"}),
_sse_frame(
{"type": "response.incomplete", "response": {"id": "resp_int", "model": "m"}}
),
_sse_frame({"type": "session.status", "status": "idle"}),
"data: [DONE]\n\n",
]
)
try:
await _relay_runner_stream(sid, client, store) # type: ignore[arg-type]
# Only the pre-Stop narration persisted. A message containing the
# tail means fenced deltas leaked into the buffer; no message means
# the fenced terminal still drops the text the user watched.
messages = [i for i in store.appended_items if i.type == "message"]
assert len(messages) == 1, f"expected 1 assistant message, got {store.appended_items}"
assert "".join(b["text"] for b in messages[0].data.content) == "Partial thought."
assert messages[0].response_id == "resp_int"
# The post-Stop delta never reached the live stream.
published_deltas = [
e.get("delta") for e in published if e.get("type") == "response.output_text.delta"
]
assert " DOOMED TAIL" not in published_deltas, published_deltas
# The terminal lifted the fence without waiting for the next turn.
assert sid not in sessions_module._interrupt_fenced_sessions
finally:
sessions_module._interrupt_fenced_sessions.discard(sid)
inflight_text.reset_for_tests()
@pytest.mark.asyncio
async def test_relay_lets_elicitation_resolved_pass_the_fence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``response.elicitation_resolved`` passes the fence and clears the index.
Repro of the ghost-approval-card leak: on Stop, the runner's parked-ASK
cleanup publishes ``response.elicitation_resolved`` exactly inside the
fenced window. The pending-elicitations index decrements ONLY via that
event, so swallowing it leaks the entry — every later snapshot replays a
dead approval card and the "Needs input" badge never clears.
"""
from omnigent.runtime import inflight_text, pending_elicitations, session_stream
from omnigent.server.routes import sessions as sessions_module
from omnigent.server.routes.sessions import _relay_runner_stream
inflight_text.reset_for_tests()
pending_elicitations.reset_for_tests()
sid = "conv_fence_elicit"
store = _ConversationStore()
published: list[dict[str, Any]] = []
real_publish = session_stream.publish
def _capture_publish(conversation_id: str, event: dict[str, Any]) -> None:
"""Record events for *sid* while keeping the real publish path."""
if conversation_id == sid:
published.append(event)
real_publish(conversation_id, event)
monkeypatch.setattr(session_stream, "publish", _capture_publish)
# Pending count probed between frames: after the request (tracked) and
# after the fenced resolved (must be drained).
counts: list[int] = []
def _record_count_and_install_fence() -> None:
"""Capture the tracked count, then fence the session (Stop pressed)."""
counts.append(pending_elicitations.count_for(sid))
sessions_module._interrupt_fenced_sessions.add(sid)
client = _ScriptedStreamingRunnerClient(
[
_sse_frame(
{
"type": "response.elicitation_request",
"elicitation_id": "elicit_1",
"params": {"message": "Approve push?", "mode": "question"},
}
),
_record_count_and_install_fence,
_sse_frame({"type": "response.elicitation_resolved", "elicitation_id": "elicit_1"}),
lambda: counts.append(pending_elicitations.count_for(sid)),
"data: [DONE]\n\n",
]
)
try:
await _relay_runner_stream(sid, client, store) # type: ignore[arg-type]
# [1, 0]: the request was tracked, and the FENCED resolved drained it.
# A trailing 1 means the fence swallowed the resolved event — the
# ghost-card leak this change fixes.
assert counts == [1, 0], counts
# The resolved event also reached live subscribers so open tabs
# clear their approval card.
assert any(e.get("type") == "response.elicitation_resolved" for e in published), published
# Elicitation bookkeeping is exempt from the fence but must not
# LIFT it — only a terminal or the next turn's running does.
assert sid in sessions_module._interrupt_fenced_sessions
finally:
sessions_module._interrupt_fenced_sessions.discard(sid)
pending_elicitations.reset_for_tests()
inflight_text.reset_for_tests()
@pytest.mark.asyncio
async def test_relay_suppresses_fenced_deltas_until_running_when_no_terminal_arrives(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With no terminal event, the fence holds until the next turn's running.
The stop_session hard-kill path can end a turn with NO terminal
``response.*`` at all. The fence must keep suppressing the dead turn's
trailing deltas and lift only on the next turn's ``running`` status, so
the follow-up turn flows normally.
"""
from omnigent.runtime import session_stream
from omnigent.server.routes import sessions as sessions_module
from omnigent.server.routes.sessions import _relay_runner_stream
sid = "conv_fence_no_terminal"
published: list[dict[str, Any]] = []
def _capture(session_id: str, event: dict[str, Any]) -> None:
"""Record events the relay forwards to the live stream for *sid*."""
if session_id == sid:
published.append(event)
monkeypatch.setattr(session_stream, "publish", _capture)
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
# Dead turn's trailing delta — the session is already fenced.
_sse_frame({"type": "response.output_text.delta", "delta": "GHOST"}),
# Next turn: running lifts the fence, then a real reply.
_sse_frame({"type": "session.status", "status": "running"}),
_sse_frame(
{"type": "response.in_progress", "response": {"id": "resp_next", "model": "m"}}
),
_sse_frame({"type": "response.output_text.delta", "delta": "REAL"}),
_sse_frame(
{"type": "response.completed", "response": {"id": "resp_next", "model": "m"}}
),
"data: [DONE]\n\n",
]
)
sessions_module._interrupt_fenced_sessions.add(sid)
try:
await _relay_runner_stream(sid, client, store) # type: ignore[arg-type]
finally:
sessions_module._interrupt_fenced_sessions.discard(sid)
# Only the follow-up turn's text persisted; "GHOST" persisting means the
# kept-buffer change let a fenced delta leak into the transcript.
messages = [i for i in store.appended_items if i.type == "message"]
assert len(messages) == 1, f"expected 1 assistant message, got {store.appended_items}"
assert "".join(b["text"] for b in messages[0].data.content) == "REAL"
# The dead turn's delta was never forwarded live; the real one was.
published_deltas = [
e.get("delta") for e in published if e.get("type") == "response.output_text.delta"
]
assert published_deltas == ["REAL"], published_deltas
@pytest.mark.asyncio
async def test_relay_interleaves_text_segments_with_tool_calls() -> None:
"""
Scaffold narration persists interleaved with the tool calls it preceded.
A scaffold turn streams text1 → tool1 → tool2 → text2 → tool3 → text3.
The relay must persist three SEPARATE assistant messages, each BEFORE
the tool call that followed it — not one concatenated message after all
the tools (which renders tools-above-text + run-on text on reload).
"""
from omnigent.runtime import inflight_text
from omnigent.server.routes.sessions import _relay_runner_stream
inflight_text.reset_for_tests()
store = _ConversationStore()
def _fc(call_id: str, name: str) -> str:
return _sse_frame(
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"id": f"item_{call_id}",
"call_id": call_id,
"name": name,
"arguments": "{}",
"status": "completed",
"agent": "nessie",
},
}
)
def _fco(call_id: str) -> str:
return _sse_frame(
{
"type": "response.output_item.done",
"item": {
"type": "function_call_output",
"id": f"out_{call_id}",
"call_id": call_id,
"output": "ok",
},
}
)
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{"type": "response.in_progress", "response": {"id": "resp_1", "model": "nessie"}}
),
_sse_frame({"type": "response.output_text.delta", "delta": "I'll dispatch "}),
_sse_frame({"type": "response.output_text.delta", "delta": "both agents."}),
_fc("call_1", "sys_session_send"),
_fco("call_1"),
_fc("call_2", "sys_session_send"),
_fco("call_2"),
_sse_frame({"type": "response.output_text.delta", "delta": "Both are running. "}),
_sse_frame({"type": "response.output_text.delta", "delta": "Checking inbox."}),
_fc("call_3", "sys_read_inbox"),
_fco("call_3"),
_sse_frame({"type": "response.output_text.delta", "delta": "Here are the jokes."}),
_sse_frame(
{"type": "response.completed", "response": {"id": "resp_1", "model": "nessie"}}
),
"data: [DONE]\n\n",
]
)
try:
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
# Persisted order interleaves narration with its tool calls — the
# first text lands BEFORE call_1, not pooled after every tool.
types = [i.type for i in store.appended_items]
assert types == [
"message",
"function_call",
"function_call_output",
"function_call",
"function_call_output",
"message",
"function_call",
"function_call_output",
"message",
], types
# Three SEPARATE messages, each its own segment — not one run-on.
msgs = [i for i in store.appended_items if i.type == "message"]
texts = ["".join(b["text"] for b in m.data.content) for m in msgs]
assert texts == [
"I'll dispatch both agents.",
"Both are running. Checking inbox.",
"Here are the jokes.",
], texts
finally:
inflight_text.reset_for_tests()
@pytest.mark.asyncio
async def test_relay_flush_drops_committed_text_from_inflight_replay(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
After a text→tool flush, a mid-turn reconnect must NOT replay the
just-committed narration — it would double-render beside the persisted
copy. Pins that the relay calls ``inflight_text.reset_text`` at the
flush: feed a turn that ENDS right after the first flush (no terminal
event), neutralize the relay's teardown ``discard``, and assert the
text both committed AND no longer replays. Without the reset call this
fails (the in-flight buffer would still replay the committed text).
"""
from omnigent.runtime import inflight_text
from omnigent.server.routes.sessions import _relay_runner_stream
inflight_text.reset_for_tests()
# The relay's finally discards the in-flight entry (the leak fix);
# neutralize it so we observe the state the flush itself left behind.
monkeypatch.setattr(inflight_text, "discard", lambda *_a, **_k: None)
store = _ConversationStore()
client = _FakeStreamingRunnerClient(
[
_sse_frame(
{"type": "response.in_progress", "response": {"id": "resp_1", "model": "nessie"}}
),
_sse_frame(
{"type": "response.output_text.delta", "delta": "Narration before the tool."}
),
_sse_frame(
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"id": "item_c1",
"call_id": "c1",
"name": "sys_session_send",
"arguments": "{}",
"status": "completed",
"agent": "nessie",
},
}
),
# Ends mid-turn (no terminal response.* / session.status) — only
# the flush's reset_text could have cleared the in-flight buffer.
"data: [DONE]\n\n",
]
)
try:
await _relay_runner_stream("conv_proxy", client, store) # type: ignore[arg-type]
# The narration committed as its own message...
msgs = [i for i in store.appended_items if i.type == "message"]
assert [b["text"] for m in msgs for b in m.data.content] == [
"Narration before the tool."
], [m.data.content for m in msgs]
# ...and the flush dropped it from the replay, so a reconnect here
# gets nothing. If reset_text were removed, snapshot_for would
# replay the committed text and double-render it.
assert inflight_text.snapshot_for("conv_proxy") == [], (
"flushed (committed) text must not replay on mid-turn reconnect"
)
finally:
inflight_text.reset_for_tests()
class _StreamAndCaptureRunnerClient(_FakeStreamingRunnerClient):
"""``_FakeStreamingRunnerClient`` that also captures forwarded POSTs.
The relay forwards the sub-agent terminal ``external_session_status`` back
to the bound runner via ``post``; capturing those lets a test assert the
wake-triggering delivery. ``post_failures`` makes the first N posts return
503 so the retry path can be exercised.
:param frames: SSE frame strings yielded from the runner stream.
:param post_failures: Number of initial posts to fail with 503.
"""
def __init__(self, frames: list[str], post_failures: int = 0) -> None:
"""Initialize the stream frames, post capture, and failure count."""
super().__init__(frames)
self.posts: list[dict[str, Any]] = []
self._post_failures = post_failures
async def post(
self,
url: str,
*,
json: dict[str, Any] | None = None,
timeout: object = None,
) -> httpx.Response:
"""Capture a forwarded event; 503 for the first ``post_failures``, else 204."""
del timeout
self.posts.append({"url": url, "body": json})
if len(self.posts) <= self._post_failures:
return httpx.Response(503)
return httpx.Response(204)
class _SubagentTerminalStore:
"""Conversation store stub for claude-native sub-agent delivery tests.
Returns one configurable :class:`Conversation` from
``get_conversation`` and one assistant message from ``list_items``,
using real entity types so the relay's enrichment
(``_latest_assistant_text_from_store``) runs for real.
:param conv: The conversation row the relay inspects to decide
whether a terminal edge needs sub-agent delivery.
:param assistant_text: Latest assistant text surfaced via
``list_items``, or ``None`` for no persisted assistant message.
"""
def __init__(self, conv: Conversation, assistant_text: str | None) -> None:
"""Store the canned conversation and assistant text."""
self._conv = conv
self._assistant_text = assistant_text
def get_conversation(self, conversation_id: str) -> Conversation | None:
"""Return the configured conversation when the id matches."""
return self._conv if conversation_id == self._conv.id else None
def list_items(
self,
conversation_id: str,
*,
limit: int,
order: str,
type: str,
) -> Any:
"""Return a one-item assistant page (or empty) as a real PagedList."""
from omnigent.entities import ConversationItem, MessageData, PagedList
del conversation_id, limit, order, type
if self._assistant_text is None:
return PagedList(data=[], first_id=None, last_id=None, has_more=False)
item = ConversationItem(
id="item_assistant",
type="message",
status="completed",
response_id="resp_done",
created_at=1,
data=MessageData(
role="assistant",
agent="claude-native-ui",
content=[{"type": "output_text", "text": self._assistant_text}],
),
)
return PagedList(data=[item], first_id=item.id, last_id=item.id, has_more=False)
def _make_subagent_conv(child_id: str, *, wrapper: str, kind: str = "sub_agent") -> Conversation:
"""Build a sub-agent conversation row for terminal-delivery relay tests.
:param child_id: Child session id, e.g. ``"conv_cc_child"``.
:param wrapper: ``omnigent.wrapper`` label, e.g.
``"claude-code-native-ui"`` or ``"codex-native-ui"``.
:param kind: Conversation kind, ``"sub_agent"`` or ``"default"``.
:returns: A real :class:`Conversation` carrying the wrapper label.
"""
return Conversation(
id=child_id,
created_at=1,
updated_at=1,
root_conversation_id="conv_parent",
parent_conversation_id="conv_parent",
agent_id="ag_test",
kind=kind,
labels={"omnigent.ui": "terminal", "omnigent.wrapper": wrapper},
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"wrapper, kind, status",
[
("claude-code-native-ui", "sub_agent", "idle"), # the reported bug
("claude-code-native-ui", "sub_agent", "failed"),
("codex-native-ui", "sub_agent", "idle"),
("claude-code-native-ui", "default", "idle"), # top-level
],
)
async def test_relay_never_delivers_terminal_on_pty_status(
wrapper: str,
kind: str,
status: str,
) -> None:
"""The PTY-activity ``session.status`` edge never triggers sub-agent delivery.
Regression guard for the premature-completion bug. claude-native's PTY
``idle`` is a ~1s-quiescence heuristic that oscillates on every mid-turn
lull (thinking, an API round-trip, a long Bash). Bridging it to terminal
delivery (the old relay forward) reported ``finished (completed)`` to the
orchestrator mid-turn and idempotently locked out the child's real
completion. Terminal delivery now rides the ``Stop`` / ``StopFailure``
hook (``external_session_status``, the codex-shared path), so the relay
must forward nothing on a PTY status edge — it only republishes the UI
status. Were the relay to deliver here, ``posts`` would be non-empty.
"""
from omnigent.server.routes.sessions import _relay_runner_stream
child_id = "conv_relay_nodeliver"
store = _SubagentTerminalStore(
_make_subagent_conv(child_id, wrapper=wrapper, kind=kind),
assistant_text="work in progress",
)
client = _StreamAndCaptureRunnerClient(
[_sse_frame({"type": "session.status", "status": status}), "data: [DONE]\n\n"]
)
await _relay_runner_stream(child_id, client, store) # type: ignore[arg-type]
assert client.posts == []