17979 lines
697 KiB
Python
17979 lines
697 KiB
Python
"""Tests for runner app integration with the sessions-native event path.
|
|
|
|
Verifies the `_stream_message_to_harness` shared helper covers both
|
|
``POST /v1/responses`` (legacy) and ``POST /v1/sessions/{conv}/events``
|
|
(sessions-native). Both paths must inject MCP schemas, stamp the
|
|
``omnigent_runner_dispatched`` marker on intercepted events, and
|
|
route MCP dispatch to the runner manager.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import shutil
|
|
import sys
|
|
import threading
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
|
|
from omnigent import (
|
|
claude_native_bridge,
|
|
codex_native_bridge,
|
|
cursor_native_bridge,
|
|
kiro_native_bridge,
|
|
qwen_native_bridge,
|
|
)
|
|
from omnigent.antigravity_native_bridge import (
|
|
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY,
|
|
)
|
|
from omnigent.antigravity_native_bridge import (
|
|
is_placeholder_conversation_id as bridge_mod_is_placeholder,
|
|
)
|
|
from omnigent.claude_native_bridge import (
|
|
BRIDGE_ID_LABEL_KEY,
|
|
bridge_dir_for_bridge_id,
|
|
bridge_dir_for_conversation_id,
|
|
prepare_bridge_dir,
|
|
read_permission_hook_config,
|
|
)
|
|
from omnigent.entities.session_resources import SessionResourceView, terminal_resource_id
|
|
from omnigent.inner.terminal import TerminalInstance
|
|
from omnigent.runner import create_runner_app
|
|
from omnigent.runner import tool_dispatch as _tool_dispatch
|
|
from omnigent.runner.app import (
|
|
_RUNNER_DISPATCHED_FIELD,
|
|
_WAKE_POST_MAX_ATTEMPTS,
|
|
ResolvedSpec,
|
|
_agent_os_env_from_spec,
|
|
_auto_create_antigravity_terminal,
|
|
_auto_create_claude_terminal,
|
|
_auto_create_codex_terminal,
|
|
_auto_create_cursor_terminal,
|
|
_auto_create_kiro_terminal,
|
|
_auto_create_pi_terminal,
|
|
_auto_create_repl_terminal,
|
|
_deliver_subagent_wake_post,
|
|
_KiroNativeLaunchConfig,
|
|
_log_terminal_lookup_miss,
|
|
_PiNativeLaunchConfig,
|
|
_publish_native_terminal_start_error,
|
|
_publish_terminal_pending,
|
|
_resolved_workdir_for_spec,
|
|
_session_labels_for_runner_spawn,
|
|
_terminal_lookup_miss_log_state,
|
|
_wake_post_is_retryable,
|
|
)
|
|
from omnigent.runner.mcp_manager import McpSchemasResult
|
|
from omnigent.runner.resource_registry import (
|
|
ANTIGRAVITY_NATIVE_TERMINAL_ROLE,
|
|
CLAUDE_NATIVE_TERMINAL_ROLE,
|
|
CODEX_NATIVE_TERMINAL_ROLE,
|
|
KIRO_NATIVE_TERMINAL_ROLE,
|
|
OMNIGENT_REPL_TERMINAL_ROLE,
|
|
PI_NATIVE_TERMINAL_ROLE,
|
|
SessionResourceRegistry,
|
|
)
|
|
from omnigent.spec.types import AgentSpec, ExecutorSpec, LocalToolInfo, MCPServerConfig
|
|
from omnigent.terminals import TerminalRegistry
|
|
from tests.runner.helpers import NullServerClient
|
|
|
|
# ── Fakes for the runner's collaborators ──────────────────────────────
|
|
|
|
|
|
class _FakeMcpManager:
|
|
"""Stand-in for RunnerMcpManager that returns scripted schemas/names."""
|
|
|
|
handles_tool_dispatch = True
|
|
|
|
def __init__(self, *, tool_name: str = "jira_search_issues") -> None:
|
|
"""Schema set is a single-tool jira fixture."""
|
|
self._tool_name = tool_name
|
|
self.call_tool_invocations: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
async def schemas_for(self, spec: AgentSpec) -> McpSchemasResult:
|
|
"""Return one MCP schema with the configured tool name."""
|
|
del spec
|
|
schema = {
|
|
"type": "function",
|
|
"name": self._tool_name,
|
|
"description": "fake mcp tool",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
}
|
|
return McpSchemasResult(schemas=[schema], tool_names={self._tool_name}, failures={})
|
|
|
|
async def call_tool(
|
|
self,
|
|
spec: AgentSpec,
|
|
tool_name: str,
|
|
arguments: dict[str, Any],
|
|
**_kwargs: Any,
|
|
) -> str:
|
|
"""Record the dispatch + return a fixed reply."""
|
|
del spec
|
|
self.call_tool_invocations.append((tool_name, arguments))
|
|
return f"called {tool_name}"
|
|
|
|
|
|
class _ScriptedHarnessClient:
|
|
"""Records every POST body; streams a scripted SSE response on request."""
|
|
|
|
def __init__(
|
|
self,
|
|
sse_frames: list[str],
|
|
*,
|
|
stream_finished: asyncio.Event | None = None,
|
|
) -> None:
|
|
"""
|
|
Initialize with the SSE frames to relay.
|
|
|
|
:param sse_frames: SSE frames returned by the harness stream.
|
|
:param stream_finished: Optional event set after ``aiter_text``
|
|
exhausts the scripted frames.
|
|
:returns: None.
|
|
"""
|
|
self.posted_bodies: list[dict[str, Any]] = []
|
|
self._sse_frames = sse_frames
|
|
self._stream_finished = stream_finished
|
|
self.patched_events: list[dict[str, Any]] = []
|
|
|
|
def stream(self, method: str, url: str, *, json: dict[str, Any], timeout: Any) -> Any:
|
|
"""Capture body + return a context manager streaming scripted frames."""
|
|
del method, url, timeout
|
|
self.posted_bodies.append(json)
|
|
scripted = self._sse_frames
|
|
stream_finished = self._stream_finished
|
|
|
|
class _StreamCtx:
|
|
status_code = 200
|
|
|
|
async def __aenter__(self) -> _ScriptedHarnessClient._StreamHandle:
|
|
return _ScriptedHarnessClient._StreamHandle(scripted, stream_finished)
|
|
|
|
async def __aexit__(self, *_: Any) -> None:
|
|
return None
|
|
|
|
return _StreamCtx()
|
|
|
|
class _StreamHandle:
|
|
status_code = 200
|
|
|
|
def __init__(
|
|
self,
|
|
frames: list[str],
|
|
stream_finished: asyncio.Event | None,
|
|
) -> None:
|
|
"""
|
|
Initialize a scripted stream handle.
|
|
|
|
:param frames: SSE frame strings to yield.
|
|
:param stream_finished: Optional event set after all frames are
|
|
yielded.
|
|
:returns: None.
|
|
"""
|
|
self._frames = frames
|
|
self._stream_finished = stream_finished
|
|
|
|
async def aiter_text(self) -> AsyncIterator[str]:
|
|
"""
|
|
Yield scripted SSE frame text and signal exhaustion.
|
|
|
|
:returns: Async iterator of SSE frame text chunks.
|
|
"""
|
|
try:
|
|
for frame in self._frames:
|
|
yield frame
|
|
finally:
|
|
if self._stream_finished is not None:
|
|
self._stream_finished.set()
|
|
|
|
async def post(self, url: str, *, json: dict[str, Any], timeout: Any = None) -> Any:
|
|
"""PATCH the result back to the harness — record body and return 200."""
|
|
del url, timeout
|
|
self.patched_events.append(json)
|
|
|
|
class _Response:
|
|
status_code = 200
|
|
headers: dict[str, str] = {}
|
|
content = b""
|
|
|
|
def raise_for_status(self) -> None:
|
|
pass
|
|
|
|
return _Response()
|
|
|
|
|
|
class _FakeProcessManager:
|
|
"""ProcessManager stub that returns a single ScriptedHarnessClient."""
|
|
|
|
handles_tool_dispatch = True
|
|
|
|
def __init__(self, client: _ScriptedHarnessClient) -> None:
|
|
"""Wrap *client* so :meth:`get_client` returns it."""
|
|
self._client = client
|
|
self._sessions: set[str] = set()
|
|
self._active_turns: set[str] = set()
|
|
self.released: list[str] = []
|
|
self.cancelled: list[str] = []
|
|
self.get_client_calls: list[tuple[str, str, dict[str, str] | None]] = []
|
|
# In-flight tracking the runner wires up on response.created /
|
|
# stream end. Recorded so tests can assert the
|
|
# idle reaper's guard is actually populated for a live turn.
|
|
self.marked_in_flight: list[tuple[str, str]] = []
|
|
self.cleared_in_flight: list[str] = []
|
|
|
|
async def get_client(
|
|
self, conversation_id: str, harness: str, env: Any = None
|
|
) -> _ScriptedHarnessClient:
|
|
"""Return the fixed scripted client."""
|
|
self.get_client_calls.append((conversation_id, harness, env))
|
|
self._sessions.add(conversation_id)
|
|
return self._client
|
|
|
|
def has_session(self, conversation_id: str) -> bool:
|
|
"""Check if a session was registered via :meth:`get_client`."""
|
|
return conversation_id in self._sessions
|
|
|
|
def has_active_turn(self, conversation_id: str) -> bool:
|
|
"""Check if a turn is marked active for this conversation."""
|
|
return conversation_id in self._active_turns
|
|
|
|
def mark_turn_active(self, conversation_id: str) -> None:
|
|
"""Mark a conversation as having an active turn (test helper)."""
|
|
self._active_turns.add(conversation_id)
|
|
|
|
def mark_in_flight(self, conversation_id: str, response_id: str) -> None:
|
|
"""Record a live turn, mirroring the real manager's reaper guard."""
|
|
self.marked_in_flight.append((conversation_id, response_id))
|
|
self._active_turns.add(conversation_id)
|
|
|
|
def clear_in_flight(self, conversation_id: str) -> None:
|
|
"""Clear the live-turn marker at stream end."""
|
|
self.cleared_in_flight.append(conversation_id)
|
|
self._active_turns.discard(conversation_id)
|
|
|
|
async def forward_cancel(self, conversation_id: str) -> bool:
|
|
"""Record a cancel and return ``True``."""
|
|
self.cancelled.append(conversation_id)
|
|
return True
|
|
|
|
async def release(self, conversation_id: str) -> None:
|
|
"""Record a release and remove the session."""
|
|
self.released.append(conversation_id)
|
|
self._sessions.discard(conversation_id)
|
|
|
|
|
|
class _ReadTimeoutTransport(httpx.AsyncBaseTransport):
|
|
"""Transport that raises ``ReadTimeout`` for every request."""
|
|
|
|
def __init__(self) -> None:
|
|
"""
|
|
Initialize request capture.
|
|
|
|
:returns: None.
|
|
"""
|
|
self.requests: list[httpx.Request] = []
|
|
|
|
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Record *request* and raise a read timeout.
|
|
|
|
:param request: Outbound request from ``httpx.AsyncClient``.
|
|
:returns: Never returns; raises ``httpx.ReadTimeout``.
|
|
:raises httpx.ReadTimeout: Always raised to simulate Omnigent slowness.
|
|
"""
|
|
self.requests.append(request)
|
|
raise httpx.ReadTimeout("session lookup timed out", request=request)
|
|
|
|
|
|
async def _spec_resolver_returning(spec: AgentSpec) -> Any:
|
|
"""Build an async spec_resolver that always returns *spec*."""
|
|
|
|
async def _resolve(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
return _resolve
|
|
|
|
|
|
# ── Shared helpers ────────────────────────────────────────────────────
|
|
|
|
|
|
def _sse(event: dict[str, Any]) -> str:
|
|
"""Render one SSE ``data: {json}\\n\\n`` frame from *event*."""
|
|
return f"data: {json.dumps(event)}\n\n"
|
|
|
|
|
|
class _McpToolsListServerClient(NullServerClient):
|
|
"""Server client stub that handles MCP tools/list and tools/call requests.
|
|
|
|
Returns a scripted tool schema for tools/list calls and records tools/call
|
|
invocations. All other requests are handled by :class:`NullServerClient`
|
|
(empty 200). Used in tests that exercise MCP schema injection and tool
|
|
dispatch through :class:`ProxyMcpManager`.
|
|
"""
|
|
|
|
def __init__(self, tool_name: str) -> None:
|
|
"""Configure the tool name returned by tools/list.
|
|
|
|
:param tool_name: MCP tool name to advertise, e.g. ``"jira_search_issues"``.
|
|
"""
|
|
self._tool_name = tool_name
|
|
self.call_tool_invocations: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
async def post(self, url: str, **kwargs: Any) -> NullServerClient._Response:
|
|
"""Handle MCP endpoint requests and delegate others to null parent.
|
|
|
|
:param url: Request URL. If it ends with ``/mcp``, handles tools/list
|
|
and tools/call JSON-RPC calls. Otherwise delegates to the null parent.
|
|
:param kwargs: Extra keyword arguments (forwarded for non-MCP calls).
|
|
:returns: Stub 200 response with appropriate payload.
|
|
"""
|
|
if url.endswith("/mcp"):
|
|
body = kwargs.get("json", {})
|
|
if isinstance(body, dict) and body.get("method") == "tools/list":
|
|
|
|
class _ToolsListResponse(NullServerClient._Response):
|
|
def __init__(self, tool_name: str) -> None:
|
|
self._tool_name = tool_name
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return {
|
|
"result": {
|
|
"tools": [
|
|
{
|
|
"name": self._tool_name,
|
|
"description": "fake mcp tool",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
return _ToolsListResponse(self._tool_name) # type: ignore[return-value]
|
|
|
|
if isinstance(body, dict) and body.get("method") == "tools/call":
|
|
params = body.get("params", {})
|
|
tool_name = params.get("name", "")
|
|
arguments = params.get("arguments", {})
|
|
self.call_tool_invocations.append((tool_name, arguments))
|
|
|
|
class _ToolsCallResponse(NullServerClient._Response):
|
|
def __init__(self, tn: str) -> None:
|
|
self._tn = tn
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return {
|
|
"result": {"content": [{"type": "text", "text": f"called {self._tn}"}]}
|
|
}
|
|
|
|
return _ToolsCallResponse(tool_name) # type: ignore[return-value]
|
|
|
|
return await super().post(url, **kwargs)
|
|
|
|
|
|
def _build_app_with_mcp_tool(
|
|
tool_name: str = "jira_search_issues",
|
|
) -> tuple[FastAPI, _FakeMcpManager, _ScriptedHarnessClient, _McpToolsListServerClient]:
|
|
"""Wire a runner app with the fakes and one mcp tool name.
|
|
|
|
:param tool_name: MCP tool name to advertise and dispatch, e.g.
|
|
``"jira_search_issues"``.
|
|
:returns: ``(app, mcp_manager, harness_client, server_client)`` tuple.
|
|
``mcp_manager`` is the :class:`_FakeMcpManager` used for the runner's
|
|
``/mcp/execute`` endpoint (not inline turn dispatch).
|
|
``server_client`` records tools/call invocations from inline turn dispatch
|
|
via :class:`ProxyMcpManager`.
|
|
"""
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
mcp_servers=[MCPServerConfig(name="jira", transport="http", url="http://x")],
|
|
)
|
|
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_abc"}}),
|
|
_sse(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "function_call",
|
|
"status": "action_required",
|
|
"name": tool_name,
|
|
"call_id": "call_1",
|
|
"arguments": "{}",
|
|
},
|
|
}
|
|
),
|
|
]
|
|
harness_client = _ScriptedHarnessClient(sse_frames)
|
|
mcp_manager = _FakeMcpManager(tool_name=tool_name)
|
|
server_client = _McpToolsListServerClient(tool_name)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
mcp_manager=mcp_manager,
|
|
)
|
|
return app, mcp_manager, harness_client, server_client
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _runner_client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
|
|
"""ASGI test client for the runner app."""
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://runner") as client:
|
|
yield client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_labels_for_runner_spawn_timeout_is_quiet(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
Timed-out optional label resolution returns the spawn fallback quietly.
|
|
|
|
Native harness spawn can recover by using the session id when labels
|
|
cannot be fetched. A slow Omnigent session lookup therefore must not emit a
|
|
warning with traceback; that was noisy and misleading for a best-effort
|
|
lookup.
|
|
|
|
:param caplog: Pytest log capture fixture.
|
|
:returns: None.
|
|
"""
|
|
transport = _ReadTimeoutTransport()
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
|
|
with caplog.at_level(logging.DEBUG, logger="omnigent.runner.app"):
|
|
labels = await _session_labels_for_runner_spawn(
|
|
server_client=client,
|
|
session_id="conv_slow",
|
|
)
|
|
|
|
assert labels == {}
|
|
assert [(request.method, request.url.path) for request in transport.requests] == [
|
|
("GET", "/v1/sessions/conv_slow/labels")
|
|
]
|
|
timeout = transport.requests[0].extensions.get("timeout")
|
|
assert isinstance(timeout, dict)
|
|
assert timeout["read"] == 1.0
|
|
|
|
timeout_records = [
|
|
record
|
|
for record in caplog.records
|
|
if "Timed out resolving session labels" in record.getMessage()
|
|
]
|
|
assert len(timeout_records) == 1
|
|
assert timeout_records[0].levelno == logging.DEBUG
|
|
assert timeout_records[0].exc_info is None
|
|
assert "Failed to resolve session labels" not in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_labels_for_runner_spawn_empty_200_body_recovers(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
A 200 response with an empty (non-JSON) body returns the fallback.
|
|
|
|
The Databricks Apps proxy can return HTTP 200 with an empty body
|
|
when the server event loop is starved. Parsing that with
|
|
``resp.json()`` raises ``JSONDecodeError``; left unguarded it
|
|
propagated out of ``_ensure_comment_relay_started`` and aborted
|
|
every message turn before any LLM call (observed in production:
|
|
"turn setup failed: Expecting value: line 1 column 1 (char 0)").
|
|
Labels are a best-effort spawn hint, so a bad body must degrade to
|
|
``{}`` like the timeout / non-200 paths — not raise.
|
|
|
|
:param caplog: Pytest log capture fixture.
|
|
:returns: None.
|
|
"""
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
# 200 with an empty body — the exact proxy-under-load shape.
|
|
return httpx.Response(200, content=b"")
|
|
|
|
transport = httpx.MockTransport(_handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
|
|
with caplog.at_level(logging.WARNING, logger="omnigent.runner.app"):
|
|
labels = await _session_labels_for_runner_spawn(
|
|
server_client=client,
|
|
session_id="conv_empty",
|
|
)
|
|
|
|
# Recovered to the fallback instead of raising JSONDecodeError —
|
|
# if the guard is removed, this call raises and the test errors out.
|
|
assert labels == {}
|
|
# The non-JSON 200 is logged once at WARNING with no traceback;
|
|
# absence of this record would mean the bad body was swallowed
|
|
# silently (or, worse, that the guard never ran).
|
|
json_records = [
|
|
record
|
|
for record in caplog.records
|
|
if "Session labels response was not valid JSON" in record.getMessage()
|
|
]
|
|
assert len(json_records) == 1
|
|
assert json_records[0].levelno == logging.WARNING
|
|
|
|
|
|
class _FakeFileServerClient:
|
|
"""Minimal server client for runner-side file_id resolution tests."""
|
|
|
|
def __init__(self) -> None:
|
|
self.get_calls: list[str] = []
|
|
|
|
async def get(self, url: str, **kwargs: Any) -> Any:
|
|
del kwargs
|
|
self.get_calls.append(url)
|
|
|
|
class _Response:
|
|
def __init__(
|
|
self, *, body: bytes = b"", payload: dict[str, Any] | None = None
|
|
) -> None:
|
|
self.content = body
|
|
self._payload = payload or {}
|
|
self.headers = {"content-type": self._payload.get("content_type", "image/png")}
|
|
self.status_code = 200
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return self._payload
|
|
|
|
def raise_for_status(self) -> None:
|
|
return None
|
|
|
|
if url.endswith("/content"):
|
|
return _Response(body=b"png-bytes")
|
|
return _Response(
|
|
payload={"id": "file_img", "filename": "photo.png", "content_type": "image/png"}
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sessions_native_resolves_file_id_before_harness() -> None:
|
|
"""Remote runner resolves raw web ``file_id`` blocks before harness input."""
|
|
harness_client = _ScriptedHarnessClient(
|
|
[_sse({"type": "response.completed", "response": {"id": "resp_1"}})]
|
|
)
|
|
pm = _FakeProcessManager(harness_client)
|
|
server_client = _FakeFileServerClient()
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_file/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_abc",
|
|
"model": "test-agent",
|
|
"content": [
|
|
{"type": "input_image", "file_id": "file_img", "filename": "photo.png"},
|
|
{"type": "input_text", "text": "what is this?"},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 202
|
|
assert server_client.get_calls == [
|
|
# file_id blocks are resolved first (before the harness sees them)...
|
|
"/v1/sessions/conv_file/resources/files/file_img",
|
|
"/v1/sessions/conv_file/resources/files/file_img/content",
|
|
# ...then the cold in-memory cache is rehydrated from the store
|
|
# (empty here) before the turn is dispatched.
|
|
"/v1/sessions/conv_file/items",
|
|
]
|
|
for _ in range(20):
|
|
if harness_client.posted_bodies:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
posted = harness_client.posted_bodies[0]
|
|
image_block = posted["content"][0]["content"][0]
|
|
assert image_block == {
|
|
"type": "input_image",
|
|
"filename": "photo.png",
|
|
"image_url": "data:image/png;base64,cG5nLWJ5dGVz",
|
|
}
|
|
assert "file_id" not in image_block
|
|
|
|
|
|
# ── Tests ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_session_tool_schemas_use_resolved_bundle_workdir(tmp_path: Path) -> None:
|
|
bundle_dir = tmp_path / "bundle"
|
|
tool_dir = bundle_dir / "tools" / "python"
|
|
tool_dir.mkdir(parents=True)
|
|
(tool_dir / "bundle_tool.py").write_text(
|
|
"from omnigent_client.tools import tool\n\n"
|
|
"@tool\n"
|
|
"def bundle_tool(text: str) -> str:\n"
|
|
" return text\n"
|
|
)
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="bundle-agent",
|
|
local_tools=[
|
|
LocalToolInfo(
|
|
name="bundle_tool",
|
|
path="tools/python/bundle_tool.py",
|
|
language="python",
|
|
)
|
|
],
|
|
)
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_1"}}),
|
|
_sse(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "function_call",
|
|
"status": "action_required",
|
|
"name": "bundle_tool",
|
|
"call_id": "call_bundle",
|
|
"arguments": json.dumps({"text": "from-bundle"}),
|
|
},
|
|
}
|
|
),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_1"}}),
|
|
]
|
|
harness_client = _ScriptedHarnessClient(sse_frames)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> ResolvedSpec:
|
|
del agent_id, session_id
|
|
return ResolvedSpec(spec=spec, workdir=bundle_dir)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
runner_workspace=workspace,
|
|
)
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_bundle/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_bundle",
|
|
"model": "bundle-agent",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
for _ in range(20):
|
|
if harness_client.posted_bodies:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
for _ in range(100):
|
|
if harness_client.patched_events:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
|
|
assert harness_client.posted_bodies, "harness must receive the turn"
|
|
schemas = harness_client.posted_bodies[0].get("tools") or []
|
|
assert any(s.get("function", {}).get("name") == "bundle_tool" for s in schemas), (
|
|
f"expected bundled local tool schema, got {schemas}"
|
|
)
|
|
|
|
|
|
def test_resolved_workdir_for_spec_prefers_bundle_workdir(tmp_path: Path) -> None:
|
|
"""``_resolved_workdir_for_spec`` uses ``ResolvedSpec.workdir`` over fallback.
|
|
|
|
Bundle-deployed agents carry their own workdir (where
|
|
``tools/python/*.py`` live). The dispatch path must thread that
|
|
workdir into ``dispatch_tool_locally`` so native python tools are
|
|
found at call time — not the generic ``runner_workspace``.
|
|
"""
|
|
bundle_dir = tmp_path / "bundle"
|
|
runner_workspace = tmp_path / "workspace"
|
|
spec = AgentSpec(spec_version=1, name="bundle-agent")
|
|
entry = ResolvedSpec(spec=spec, workdir=bundle_dir)
|
|
|
|
assert _resolved_workdir_for_spec(entry, runner_workspace) == bundle_dir
|
|
|
|
|
|
def test_resolved_workdir_for_spec_falls_back_without_bundle(tmp_path: Path) -> None:
|
|
"""Non-bundle specs fall back to ``runner_workspace`` (prior behavior).
|
|
|
|
A bare ``AgentSpec`` (no ResolvedSpec wrapper) or a ``ResolvedSpec``
|
|
with ``workdir=None`` carries no bundle dir, so dispatch must keep
|
|
using the CLI launch workspace exactly as base did.
|
|
"""
|
|
runner_workspace = tmp_path / "workspace"
|
|
bare_spec = AgentSpec(spec_version=1, name="plain-agent")
|
|
|
|
# Unwrapped spec → no workdir → fallback.
|
|
assert _resolved_workdir_for_spec(bare_spec, runner_workspace) == runner_workspace
|
|
# ResolvedSpec with no workdir → fallback.
|
|
wrapped_no_workdir = ResolvedSpec(spec=bare_spec, workdir=None)
|
|
assert _resolved_workdir_for_spec(wrapped_no_workdir, runner_workspace) == runner_workspace
|
|
# Missing fallback stays None (don't fabricate a path).
|
|
assert _resolved_workdir_for_spec(bare_spec, None) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sessions_native_dispatches_native_tool_with_bundle_workdir(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A bundle agent's native python tool dispatches against the bundle workdir.
|
|
|
|
End-to-end through ``POST /v1/sessions/{conv}/events`` (no live LLM):
|
|
the scripted harness emits an ``action_required`` for a spec-declared
|
|
python tool, and the runner must dispatch it locally with
|
|
``runner_workspace`` set to the resolved ``ResolvedSpec.workdir`` (the
|
|
bundle dir), not the generic CLI ``runner_workspace``. This is the
|
|
dispatch-time counterpart to
|
|
:func:`test_runner_session_tool_schemas_use_resolved_bundle_workdir`,
|
|
which only proved schema generation used the bundle workdir.
|
|
"""
|
|
bundle_dir = tmp_path / "bundle"
|
|
tool_dir = bundle_dir / "tools" / "python"
|
|
tool_dir.mkdir(parents=True)
|
|
(tool_dir / "bundle_tool.py").write_text(
|
|
"from omnigent_client.tools import tool\n\n"
|
|
"@tool\n"
|
|
"def bundle_tool(text: str) -> str:\n"
|
|
" return text\n"
|
|
)
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="bundle-agent",
|
|
local_tools=[
|
|
LocalToolInfo(
|
|
name="bundle_tool",
|
|
path="tools/python/bundle_tool.py",
|
|
language="python",
|
|
)
|
|
],
|
|
)
|
|
|
|
captured_workspaces: list[Path | None] = []
|
|
|
|
async def _fake_dispatch(*, runner_workspace: Path | None = None, **kwargs: Any) -> str:
|
|
captured_workspaces.append(runner_workspace)
|
|
return "ok"
|
|
|
|
monkeypatch.setattr(_tool_dispatch, "dispatch_tool_locally", _fake_dispatch)
|
|
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_1"}}),
|
|
_sse(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "function_call",
|
|
"status": "action_required",
|
|
"name": "bundle_tool",
|
|
"call_id": "call_bundle",
|
|
"arguments": json.dumps({"text": "from-bundle"}),
|
|
},
|
|
}
|
|
),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_1"}}),
|
|
]
|
|
harness_client = _ScriptedHarnessClient(sse_frames)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> ResolvedSpec:
|
|
del agent_id, session_id
|
|
return ResolvedSpec(spec=spec, workdir=bundle_dir)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
runner_workspace=workspace,
|
|
)
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_bundle/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_bundle",
|
|
"model": "bundle-agent",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
for _ in range(100):
|
|
if captured_workspaces:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
|
|
assert captured_workspaces, "native tool must be dispatched locally"
|
|
assert captured_workspaces[0] == bundle_dir, (
|
|
"dispatch must use the resolved bundle workdir, not runner_workspace "
|
|
f"({workspace!r}); got {captured_workspaces[0]!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sessions_native_marks_and_clears_in_flight_turn() -> None:
|
|
"""proxy_stream registers the live turn with the process manager.
|
|
|
|
Regression test for #1414. The idle reaper skips conversations present in
|
|
the manager's ``_in_flight_response_ids``, but that map had no writers, so
|
|
a turn running past the idle window was reaped mid-stream. The runner must
|
|
call ``mark_in_flight`` on ``response.created`` (so the reaper spares the
|
|
live turn) and ``clear_in_flight`` at stream end (so the now-idle entry can
|
|
later be reclaimed — not leaked, cf. #1349). Before the fix the runner
|
|
never called either, so both recorded lists stay empty.
|
|
"""
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_live"}}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_live"}}),
|
|
]
|
|
harness_client = _ScriptedHarnessClient(sse_frames)
|
|
pm = _FakeProcessManager(harness_client)
|
|
spec = AgentSpec(spec_version=1, name="plain-agent")
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_live/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_live",
|
|
"model": "plain-agent",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
# Wait for the background turn to finish (clear runs at stream end).
|
|
for _ in range(100):
|
|
if pm.cleared_in_flight:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
|
|
# Live turn was registered with the reaper's in-flight guard on
|
|
# response.created, then cleared once the stream ended.
|
|
assert pm.marked_in_flight == [("conv_live", "resp_live")], pm.marked_in_flight
|
|
assert pm.cleared_in_flight == ["conv_live"], pm.cleared_in_flight
|
|
|
|
|
|
class _StreamErrorHarnessClient(_ScriptedHarnessClient):
|
|
"""Harness whose stream yields its frames then drops mid-stream.
|
|
|
|
Mirrors the production reaper-kill failure: after ``response.created``
|
|
the per-conversation client is force-closed and ``aiter_text`` raises
|
|
``httpx.ReadError``, which proxy_stream surfaces as the
|
|
"Harness stream connection error." terminal failure.
|
|
"""
|
|
|
|
def stream(self, method: str, url: str, *, json: dict[str, Any], timeout: Any) -> Any:
|
|
"""Return a context manager whose stream errors after the frames."""
|
|
del method, url, timeout
|
|
self.posted_bodies.append(json)
|
|
frames = self._sse_frames
|
|
|
|
class _ErrCtx:
|
|
status_code = 200
|
|
|
|
async def __aenter__(self) -> _StreamErrorHarnessClient._ErrHandle:
|
|
return _StreamErrorHarnessClient._ErrHandle(frames)
|
|
|
|
async def __aexit__(self, *_: Any) -> None:
|
|
return None
|
|
|
|
return _ErrCtx()
|
|
|
|
class _ErrHandle:
|
|
"""Stream handle that raises ``ReadError`` after yielding its frames."""
|
|
|
|
status_code = 200
|
|
|
|
def __init__(self, frames: list[str]) -> None:
|
|
"""Store the frames to yield before erroring."""
|
|
self._frames = frames
|
|
|
|
async def aiter_text(self) -> AsyncIterator[str]:
|
|
"""Yield each scripted frame, then drop the stream mid-flight."""
|
|
for frame in self._frames:
|
|
yield frame
|
|
raise httpx.ReadError("harness subprocess closed mid-stream")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sessions_native_clears_in_flight_when_stream_errors() -> None:
|
|
"""clear_in_flight fires even when a turn ends abnormally.
|
|
|
|
The fix clears the reaper's in-flight marker in ``_on_proxy_stream_end``,
|
|
which is reached on every terminal path — not only on ``response.completed``.
|
|
A turn that streams ``response.created`` and then drops mid-stream (exactly
|
|
the reaper-kill failure: ``httpx.ReadError`` → "Harness stream connection
|
|
error.") must still clear the marker; a missed clear would leave the entry
|
|
permanently in-flight and therefore never reaped — the inverse of #1414
|
|
(cf. #1349).
|
|
"""
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_drop"}}),
|
|
]
|
|
harness_client = _StreamErrorHarnessClient(sse_frames)
|
|
pm = _FakeProcessManager(harness_client)
|
|
spec = AgentSpec(spec_version=1, name="plain-agent")
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_drop/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_drop",
|
|
"model": "plain-agent",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
# Wait for the background turn to error out (clear runs at stream end).
|
|
for _ in range(100):
|
|
if pm.cleared_in_flight:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
|
|
# Marked live on response.created, then cleared despite the mid-stream drop.
|
|
assert pm.marked_in_flight == [("conv_drop", "resp_drop")], pm.marked_in_flight
|
|
assert pm.cleared_in_flight == ["conv_drop"], pm.cleared_in_flight
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_session_clears_in_flight_marker() -> None:
|
|
"""A mid-stream cancel clears the reaper's in-flight marker.
|
|
|
|
Guards the in-flight-tracking contract against a #1349-class inverse leak:
|
|
because ``mark_in_flight`` (set on ``response.created``) *persists*, a
|
|
cancel must still clear it, or ``has_active_turn`` stays true and the idle
|
|
reaper skips the subprocess forever. The clear happens because cancelling
|
|
the turn task raises ``CancelledError`` into ``_run_turn_bg``'s handler,
|
|
which runs ``_on_proxy_stream_end`` (→ ``clear_in_flight``). This test locks
|
|
that path so a future change to the cancel teardown can't silently strand
|
|
the marker.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
gate = _aio.Event() # never set → harness blocks after response.created
|
|
app, pm, hc = _build_interrupt_app(gate)
|
|
conv_id = "conv_stop_clear"
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_stop",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "blocked"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
# Wait until response.created is processed (marker set), then stop.
|
|
await _aio.wait_for(hc.post_seen.wait(), timeout=5.0)
|
|
for _ in range(100):
|
|
if pm.marked_in_flight:
|
|
break
|
|
await _aio.sleep(0.05)
|
|
assert pm.marked_in_flight == [(conv_id, "resp_int")], pm.marked_in_flight
|
|
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
gate.set() # release the blocked stream so teardown completes cleanly
|
|
for _ in range(100):
|
|
if pm.cleared_in_flight:
|
|
break
|
|
await _aio.sleep(0.05)
|
|
|
|
# The cancel teardown must have cleared the marker (else the reaper would
|
|
# skip this subprocess forever and has_active_turn would stay true). Clear
|
|
# is idempotent, so it may fire on more than one teardown path — what
|
|
# matters is the end state: marked, then cleared, no longer active.
|
|
assert conv_id in pm.cleared_in_flight, pm.cleared_in_flight
|
|
assert not pm.has_active_turn(conv_id)
|
|
|
|
|
|
class _SignalOnCreatedHarnessClient(_ScriptedHarnessClient):
|
|
"""Streams its frames, firing an event the moment ``response.created`` is sent.
|
|
|
|
Lets a test distinguish the lazy turn-spec resolution (which runs only after
|
|
the harness has started streaming) from the eager setup-phase resolutions
|
|
that precede it.
|
|
"""
|
|
|
|
def __init__(self, sse_frames: list[str], created: asyncio.Event) -> None:
|
|
"""Store the frames and the event to fire on ``response.created``."""
|
|
super().__init__(sse_frames)
|
|
self._created = created
|
|
|
|
def stream(self, method: str, url: str, *, json: dict[str, Any], timeout: Any) -> Any:
|
|
"""Return a context manager that signals once ``response.created`` is sent."""
|
|
del method, url, timeout
|
|
self.posted_bodies.append(json)
|
|
frames = self._sse_frames
|
|
created = self._created
|
|
|
|
class _Ctx:
|
|
status_code = 200
|
|
|
|
async def __aenter__(self) -> _SignalOnCreatedHarnessClient._Handle:
|
|
return _SignalOnCreatedHarnessClient._Handle(frames, created)
|
|
|
|
async def __aexit__(self, *_: Any) -> None:
|
|
return None
|
|
|
|
return _Ctx()
|
|
|
|
class _Handle:
|
|
"""Stream handle that fires *created* right after the ``response.created`` frame."""
|
|
|
|
status_code = 200
|
|
|
|
def __init__(self, frames: list[str], created: asyncio.Event) -> None:
|
|
"""Store the frames and the response.created signal."""
|
|
self._frames = frames
|
|
self._created = created
|
|
|
|
async def aiter_text(self) -> AsyncIterator[str]:
|
|
"""Yield each frame, signalling once ``response.created`` has been sent."""
|
|
for frame in self._frames:
|
|
yield frame
|
|
if '"response.created"' in frame:
|
|
self._created.set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sessions_native_clears_in_flight_on_lazy_spec_error() -> None:
|
|
"""A lazy turn-spec resolution failure mid-dispatch still clears the marker.
|
|
|
|
Regression for a #1349-class inverse leak. For a non-MCP agent the turn
|
|
spec is resolved lazily at tool-dispatch time — after ``response.created``
|
|
has already set the in-flight marker. A transient resolver failure there
|
|
drives ``proxy_stream``'s lazy-spec-error early ``return``, which (unlike a
|
|
stream error or a cancel) exits the generator *cleanly* — so neither the
|
|
drain nor ``_run_turn_bg``'s ``CancelledError`` handler runs
|
|
``_on_proxy_stream_end``. Routing that early return through
|
|
``_on_proxy_stream_end`` is what clears the marker; without it the reaper
|
|
skips the subprocess forever and ``has_active_turn`` stays true.
|
|
|
|
The resolver is gated on ``response.created`` so it succeeds for the two
|
|
setup-phase resolutions (spec cache + harness pick) — letting the turn
|
|
stream — and fails only on the lazy dispatch call.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
created = _aio.Event()
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_lazy"}}),
|
|
_sse(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "function_call",
|
|
"status": "action_required",
|
|
"name": "sys_list_models",
|
|
"call_id": "call_lazy",
|
|
"arguments": "{}",
|
|
},
|
|
}
|
|
),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_lazy"}}),
|
|
]
|
|
harness_client = _SignalOnCreatedHarnessClient(sse_frames, created)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> Any:
|
|
# Before the harness streams response.created the two setup-phase
|
|
# resolutions run: return None (uncached spec → default harness) so the
|
|
# turn streams without populating _session_spec_cache. Once streaming
|
|
# has started the only caller is the lazy dispatch resolution — fail it.
|
|
del agent_id, session_id
|
|
if created.is_set():
|
|
raise RuntimeError("transient lazy spec resolution failure")
|
|
return None
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
conv_id = "conv_lazy"
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_lazy",
|
|
"model": "plain-agent",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
# Wait for the background turn to finish; the marker should be cleared.
|
|
for _ in range(100):
|
|
if pm.cleared_in_flight:
|
|
break
|
|
await _aio.sleep(0.05)
|
|
|
|
# Marked live on response.created; the lazy-spec-error early return must
|
|
# still finalize the turn and clear the marker.
|
|
assert pm.marked_in_flight == [(conv_id, "resp_lazy")], pm.marked_in_flight
|
|
assert conv_id in pm.cleared_in_flight, pm.cleared_in_flight
|
|
assert not pm.has_active_turn(conv_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sessions_native_dispatches_builtin_tool_with_runner_workspace(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A bundle agent's builtin OS-env tool dispatches in runner_workspace.
|
|
|
|
Bundle workdirs are only for spec-local native python tools. Builtins
|
|
such as ``sys_os_write`` run in the caller process and must keep the
|
|
original runner workspace even when the agent spec was resolved from an
|
|
extracted bundle directory.
|
|
"""
|
|
bundle_dir = tmp_path / "bundle"
|
|
bundle_dir.mkdir()
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
spec = AgentSpec(spec_version=1, name="bundle-agent")
|
|
|
|
captured_workspaces: list[Path | None] = []
|
|
|
|
async def _fake_dispatch(*, runner_workspace: Path | None = None, **kwargs: Any) -> str:
|
|
captured_workspaces.append(runner_workspace)
|
|
return "ok"
|
|
|
|
monkeypatch.setattr(_tool_dispatch, "dispatch_tool_locally", _fake_dispatch)
|
|
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_1"}}),
|
|
_sse(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "function_call",
|
|
"status": "action_required",
|
|
"name": "sys_os_write",
|
|
"call_id": "call_write",
|
|
"arguments": json.dumps(
|
|
{"path": "created-by-tool.txt", "content": "from workspace"}
|
|
),
|
|
},
|
|
}
|
|
),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_1"}}),
|
|
]
|
|
harness_client = _ScriptedHarnessClient(sse_frames)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> ResolvedSpec:
|
|
del agent_id, session_id
|
|
return ResolvedSpec(spec=spec, workdir=bundle_dir)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
runner_workspace=workspace,
|
|
)
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_builtin/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_bundle",
|
|
"model": "bundle-agent",
|
|
"content": [{"type": "input_text", "text": "write a file"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
for _ in range(100):
|
|
if captured_workspaces:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
|
|
assert captured_workspaces, "builtin tool must be dispatched locally"
|
|
assert captured_workspaces[0] == workspace, (
|
|
"builtin OS-env dispatch must use runner_workspace, not the bundle workdir "
|
|
f"({bundle_dir!r}); got {captured_workspaces[0]!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_execute_dispatches_builtin_tool_with_runner_workspace(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""``/mcp/execute`` also keeps builtin OS-env tools in runner_workspace."""
|
|
bundle_dir = tmp_path / "bundle"
|
|
bundle_dir.mkdir()
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
spec = AgentSpec(spec_version=1, name="bundle-agent")
|
|
|
|
captured_workspaces: list[Path | None] = []
|
|
|
|
async def _fake_execute_tool(*, runner_workspace: Path | None = None, **kwargs: Any) -> str:
|
|
captured_workspaces.append(runner_workspace)
|
|
return "ok"
|
|
|
|
monkeypatch.setattr(_tool_dispatch, "execute_tool", _fake_execute_tool)
|
|
|
|
harness_client = _ScriptedHarnessClient(
|
|
[_sse({"type": "response.completed", "response": {"id": "resp_1"}})]
|
|
)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> ResolvedSpec:
|
|
del agent_id, session_id
|
|
return ResolvedSpec(spec=spec, workdir=bundle_dir)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
runner_workspace=workspace,
|
|
)
|
|
async with _runner_client(app) as client:
|
|
seed_resp = await client.post(
|
|
"/v1/sessions/conv_execute_builtin/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_bundle",
|
|
"model": "bundle-agent",
|
|
"content": [{"type": "input_text", "text": "seed"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert seed_resp.status_code == 202
|
|
for _ in range(100):
|
|
if harness_client.posted_bodies:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
|
|
execute_resp = await client.post(
|
|
"/v1/sessions/conv_execute_builtin/mcp/execute",
|
|
json={
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "sys_os_write",
|
|
"arguments": {"path": "created-by-tool.txt", "content": "from workspace"},
|
|
},
|
|
},
|
|
)
|
|
|
|
assert execute_resp.status_code == 200
|
|
assert execute_resp.json() == {"result": {"output": "ok"}}
|
|
assert captured_workspaces == [workspace]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_execute_dispatches_full_namespaced_mcp_tool_name() -> None:
|
|
"""``/mcp/execute`` must not strip the MCP server prefix before dispatch."""
|
|
app, mcp_manager, _harness_client, _server_client = _build_app_with_mcp_tool(
|
|
tool_name="jira__search_issues"
|
|
)
|
|
async with _runner_client(app) as client:
|
|
seed_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_execute_mcp", "agent_id": "ag_abc"},
|
|
)
|
|
assert seed_resp.status_code == 201, seed_resp.text
|
|
|
|
execute_resp = await client.post(
|
|
"/v1/sessions/conv_execute_mcp/mcp/execute",
|
|
json={
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "jira__search_issues",
|
|
"arguments": {"query": "asyncio"},
|
|
},
|
|
},
|
|
)
|
|
|
|
assert execute_resp.status_code == 200
|
|
assert execute_resp.json() == {"result": {"output": "called jira__search_issues"}}
|
|
assert mcp_manager.call_tool_invocations == [("jira__search_issues", {"query": "asyncio"})]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sessions_native_path_injects_mcp_schemas() -> None:
|
|
"""``POST /v1/sessions/{conv}/events`` with a message body injects MCP schemas.
|
|
|
|
Sessions-native clients must get the same MCP injection that the
|
|
legacy ``/v1/responses`` path provides.
|
|
"""
|
|
app, _mcp_manager, harness_client, _server_client = _build_app_with_mcp_tool()
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_abc/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_abc",
|
|
"model": "test-agent",
|
|
"input": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
"has_mcp_servers": True,
|
|
},
|
|
)
|
|
# Sessions-native POST returns 202; the turn runs as a
|
|
# background task. Wait for the background turn to complete.
|
|
assert resp.status_code == 202
|
|
await asyncio.sleep(0.1)
|
|
|
|
assert harness_client.posted_bodies, "harness must receive at least one event"
|
|
body = harness_client.posted_bodies[0]
|
|
schemas = body.get("tools") or []
|
|
assert any(s.get("name") == "jira_search_issues" for s in schemas), (
|
|
f"MCP schema must be injected on sessions-native path; got {schemas}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_required_marker_round_trips_to_relayed_frame() -> None:
|
|
"""The runner stamps ``omnigent_runner_dispatched`` on action_required frames.
|
|
|
|
The Omnigent executor's ``_runner_dispatches`` predicate reads this marker
|
|
to skip server-side dispatch. Without the stamp it'd race the
|
|
runner's dispatch and return "unknown server-side tool."
|
|
"""
|
|
app, _mcp_manager, _client, server_client = _build_app_with_mcp_tool()
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_abc/events?stream=true",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_abc",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
"has_mcp_servers": True,
|
|
},
|
|
)
|
|
relayed = []
|
|
async for chunk in resp.aiter_text():
|
|
relayed.append(chunk)
|
|
stream_text = "".join(relayed)
|
|
|
|
# The relayed action_required frame must carry the marker.
|
|
assert f'"{_RUNNER_DISPATCHED_FIELD}": true' in stream_text, (
|
|
f"action_required event must be stamped with the dispatch marker; "
|
|
f"stream text was {stream_text!r}"
|
|
)
|
|
# Runner dispatched the MCP tool through the Omnigent server proxy (AP mode).
|
|
assert server_client.call_tool_invocations == [("jira_search_issues", {})], (
|
|
f"runner must dispatch the MCP tool via ProxyMcpManager (AP server); "
|
|
f"got {server_client.call_tool_invocations}"
|
|
)
|
|
|
|
|
|
# ── Session lifecycle endpoint tests (Step 3) ────────────────────────
|
|
|
|
|
|
def _build_lifecycle_app() -> tuple[FastAPI, _FakeProcessManager, _ScriptedHarnessClient]:
|
|
"""Wire a runner app for session lifecycle testing.
|
|
|
|
:returns: ``(app, process_manager, harness_client)`` tuple.
|
|
"""
|
|
spec = AgentSpec(spec_version=1, name="t")
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_1"}}),
|
|
_sse({"type": "response.output_text.delta", "delta": "hi"}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_1"}}),
|
|
]
|
|
harness_client = _ScriptedHarnessClient(sse_frames)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
return app, pm, harness_client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_threads_resolved_bundle_dir_to_codex_spawn_env(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Session pre-spawn must include bundle-dir env for Codex skills.
|
|
|
|
The real e2e flow creates the session before the first turn.
|
|
``HarnessProcessManager`` fixes env on first spawn and ignores env
|
|
on later cache hits, so dropping the resolved bundle workdir here
|
|
means the later turn cannot recover ``HARNESS_CODEX_BUNDLE_DIR``.
|
|
Codex then only sees host/default skills, not bundled fixture
|
|
skills.
|
|
"""
|
|
bundle_dir = tmp_path / "bundle"
|
|
bundle_dir.mkdir()
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="codex-bundle-agent",
|
|
skills_filter=["codex_e2e_xyz_greet_a3f9c2"],
|
|
executor=ExecutorSpec(
|
|
config={"harness": "codex", "profile": "test-profile"},
|
|
model="databricks-gpt-5-4-mini",
|
|
),
|
|
)
|
|
harness_client = _ScriptedHarnessClient([])
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> ResolvedSpec:
|
|
del agent_id, session_id
|
|
return ResolvedSpec(spec=spec, workdir=bundle_dir)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_codex", "agent_id": "ag_codex"},
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
assert pm.get_client_calls
|
|
conversation_id, harness, env = pm.get_client_calls[-1]
|
|
assert conversation_id == "conv_codex"
|
|
assert harness == "codex"
|
|
assert env is not None
|
|
assert env["HARNESS_CODEX_BUNDLE_DIR"] == str(bundle_dir)
|
|
assert env["HARNESS_CODEX_SKILLS_FILTER"] == '["codex_e2e_xyz_greet_a3f9c2"]'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_threads_cursor_bridge_dir_without_dead_guard_env(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Cursor-native session pre-spawn emits only the bridge dir env.
|
|
|
|
This locks the runner boundary, not just the bridge helper: the
|
|
session-creation route must pass a cursor-native bridge dir into the
|
|
harness process manager, while omitting the unread request-session-id
|
|
guard env. Inject/stop/interrupt paths use the bridge dir and tmux target;
|
|
none consume an active-session guard.
|
|
"""
|
|
monkeypatch.setattr(cursor_native_bridge, "_BRIDGE_ROOT", tmp_path / "cursor-bridge")
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="cursor-native-agent",
|
|
executor=ExecutorSpec(
|
|
config={"harness": "cursor-native", "model": "cursor-default"},
|
|
),
|
|
)
|
|
harness_client = _ScriptedHarnessClient([])
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_cursor", "agent_id": "ag_cursor"},
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
assert pm.get_client_calls
|
|
conversation_id, harness, env = pm.get_client_calls[-1]
|
|
assert conversation_id == "conv_cursor"
|
|
assert harness == "cursor-native"
|
|
assert env == {
|
|
cursor_native_bridge.BRIDGE_DIR_ENV_VAR: str(
|
|
cursor_native_bridge.bridge_dir_for_session_id("conv_cursor")
|
|
)
|
|
}
|
|
assert "HARNESS_CURSOR_NATIVE_REQUEST_SESSION_ID" not in env
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_threads_kiro_bridge_dir(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Kiro-native session pre-spawn emits the Kiro bridge dir env."""
|
|
monkeypatch.setattr(kiro_native_bridge, "_BRIDGE_ROOT", tmp_path / "kiro-bridge")
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="kiro-native-agent",
|
|
executor=ExecutorSpec(
|
|
config={"harness": "kiro-native", "model": "auto"},
|
|
),
|
|
)
|
|
harness_client = _ScriptedHarnessClient([])
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> ResolvedSpec:
|
|
del agent_id, session_id
|
|
return ResolvedSpec(spec=spec, workdir=tmp_path)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_kiro", "agent_id": "ag_kiro"},
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
assert pm.get_client_calls
|
|
conversation_id, harness, env = pm.get_client_calls[-1]
|
|
assert conversation_id == "conv_kiro"
|
|
assert harness == "kiro-native"
|
|
assert env == {
|
|
kiro_native_bridge.KIRO_NATIVE_BRIDGE_DIR_ENV_VAR: str(
|
|
kiro_native_bridge.bridge_dir_for_session_id("conv_kiro")
|
|
)
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_threads_workspace_to_pi_cwd(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Pi pre-spawn receives the session workspace, not the bundle dir."""
|
|
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path / "config-home"))
|
|
session_id = "conv_pi_worktree"
|
|
runner_workspace = tmp_path / "runner-workspace"
|
|
runner_workspace.mkdir()
|
|
bundle_dir = tmp_path / "runner-specs" / "ag_pi-v1"
|
|
bundle_dir.mkdir(parents=True)
|
|
worktree = tmp_path / "repo-worktrees" / "feature-x"
|
|
worktree.mkdir(parents=True)
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="pi-worktree-agent",
|
|
skills_filter="none",
|
|
executor=ExecutorSpec(config={"harness": "pi"}),
|
|
)
|
|
harness_client = _ScriptedHarnessClient([])
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> ResolvedSpec:
|
|
del agent_id, session_id
|
|
return ResolvedSpec(spec=spec, workdir=bundle_dir)
|
|
|
|
class _SessionWorkspaceClient(NullServerClient):
|
|
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
del kwargs
|
|
if url == f"/v1/sessions/{session_id}":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": session_id,
|
|
"agent_id": "ag_pi",
|
|
"created_at": 1.0,
|
|
"workspace": str(worktree),
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
return await super().get(url)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=_SessionWorkspaceClient(), # type: ignore[arg-type]
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": session_id, "agent_id": "ag_pi"},
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
assert pm.get_client_calls
|
|
conversation_id, harness, env = pm.get_client_calls[-1]
|
|
assert conversation_id == session_id
|
|
assert harness == "pi"
|
|
assert env is not None
|
|
assert env["HARNESS_PI_CWD"] == str(worktree.resolve())
|
|
assert env["HARNESS_PI_BUNDLE_DIR"] == str(bundle_dir)
|
|
|
|
|
|
@pytest.mark.parametrize("workspace_value", [None, " "])
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_threads_runner_workspace_to_pi_cwd_when_session_workspace_missing(
|
|
workspace_value: str | None,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Pi pre-spawn falls back to runner workspace when session workspace is empty."""
|
|
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path / "config-home"))
|
|
session_id = "conv_pi_runner_workspace"
|
|
runner_workspace = tmp_path / "runner-workspace"
|
|
runner_workspace.mkdir()
|
|
bundle_dir = tmp_path / "runner-specs" / "ag_pi-v1"
|
|
bundle_dir.mkdir(parents=True)
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="pi-runner-workspace-agent",
|
|
skills_filter="none",
|
|
executor=ExecutorSpec(config={"harness": "pi"}),
|
|
)
|
|
harness_client = _ScriptedHarnessClient([])
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> ResolvedSpec:
|
|
del agent_id, session_id
|
|
return ResolvedSpec(spec=spec, workdir=bundle_dir)
|
|
|
|
class _SessionWorkspaceClient(NullServerClient):
|
|
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
del kwargs
|
|
if url == f"/v1/sessions/{session_id}":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": session_id,
|
|
"agent_id": "ag_pi",
|
|
"created_at": 1.0,
|
|
"workspace": workspace_value,
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
return await super().get(url)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=_SessionWorkspaceClient(), # type: ignore[arg-type]
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": session_id, "agent_id": "ag_pi"},
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
assert pm.get_client_calls
|
|
conversation_id, harness, env = pm.get_client_calls[-1]
|
|
assert conversation_id == session_id
|
|
assert harness == "pi"
|
|
assert env is not None
|
|
assert env["HARNESS_PI_CWD"] == str(runner_workspace.resolve())
|
|
assert env["HARNESS_PI_BUNDLE_DIR"] == str(bundle_dir)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("session_json", "expected"),
|
|
[
|
|
# Host-spawned (web UI): bound to a host -> auto-create.
|
|
({"id": "conv_x", "host_id": "host_abc"}, True),
|
|
# Top-level CLI session: no host_id and no parent, but the runner
|
|
# still owns the Codex app-server and terminal.
|
|
({"id": "conv_x", "host_id": None}, True),
|
|
({"id": "conv_x"}, True),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_codex_top_level_session_needs_runner_terminal_for_all_session_shapes(
|
|
session_json: dict[str, object], expected: bool
|
|
) -> None:
|
|
"""
|
|
Codex-native terminal auto-create is runner-owned for every session.
|
|
|
|
A top-level CLI session has no ``host_id`` and no parent, but the
|
|
runner must still create the app-server and TUI terminal. If the old
|
|
host-id gate returns ``False`` here, ``omnigent codex`` falls back to
|
|
a CLI-owned app-server.
|
|
"""
|
|
from omnigent.runner.app import _codex_session_needs_runner_terminal
|
|
|
|
class _Client:
|
|
async def get(self, url: str, *, timeout: float) -> httpx.Response:
|
|
return httpx.Response(200, json=session_json, request=httpx.Request("GET", url))
|
|
|
|
assert await _codex_session_needs_runner_terminal(_Client(), "conv_x") is expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_codex_terminal_uses_persisted_resume_launch_config(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Runner-owned Codex launch consumes persisted args and thread id.
|
|
|
|
The CLI now persists launch intent and asks the runner to ensure the
|
|
terminal. This test exercises the runner helper directly: it must read
|
|
``terminal_launch_args`` and ``external_session_id`` from the Omnigent snapshot,
|
|
start the app-server itself, launch the TUI as ``codex ... resume
|
|
--remote <runner-ws> <thread>``, and run the known-thread forwarder. If
|
|
this regresses, the CLI falls back into split ownership or loses user
|
|
pass-through flags on resume.
|
|
|
|
:param tmp_path: Temporary directory for isolated bridge state.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.codex_native_app_server as codex_app_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
session_id = "conv_codex_resume"
|
|
thread_id = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(tmp_path / "workspace"))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(session_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=session_id,
|
|
socket_path="ws://127.0.0.1:1",
|
|
thread_id="019e96aa-1111-7222-8333-444455556666",
|
|
codex_home=str(tmp_path / "stale-codex-home"),
|
|
),
|
|
)
|
|
|
|
class _SnapshotServerClient:
|
|
"""Server client that returns the persisted Codex launch config."""
|
|
|
|
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
"""
|
|
Return the session snapshot consumed by the runner helper.
|
|
|
|
:param url: Request path, e.g.
|
|
``"/v1/sessions/conv_codex_resume"``.
|
|
:param kwargs: Request keyword arguments such as
|
|
``{"timeout": 10.0}`` or ``{"params": {"limit": 1000}}``.
|
|
:returns: HTTP 200 response carrying launch config.
|
|
"""
|
|
if url == f"/v1/sessions/{session_id}/items":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"data": [
|
|
{
|
|
"id": "msg_user_1",
|
|
"response_id": "codex_turn_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "remember this"}],
|
|
}
|
|
],
|
|
"has_more": False,
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
assert url == f"/v1/sessions/{session_id}", kwargs
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"terminal_launch_args": [
|
|
"--config",
|
|
"approval_policy=on-request",
|
|
],
|
|
"model_override": "gpt-5.4-mini",
|
|
"external_session_id": thread_id,
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
|
|
class _FakeCodexAppServer:
|
|
"""Minimal app-server object used by ``codex_terminal_env``."""
|
|
|
|
codex_path = "/opt/codex/bin/codex"
|
|
|
|
def __init__(self) -> None:
|
|
""":returns: None."""
|
|
self.env = {"OPENAI_API_KEY": "sk-test", "IGNORED": "nope"}
|
|
self.codex_home = tmp_path / "unconfigured-codex-home"
|
|
self.listen_url: str | None = None
|
|
self.started = False
|
|
# Provider/model -c overrides the runner forwards to the
|
|
# --remote TUI; empty here (no profile in this test).
|
|
self.config_overrides: list[str] = []
|
|
|
|
async def start(self) -> None:
|
|
""":returns: None."""
|
|
assert list(self.codex_home.glob(f"sessions/**/rollout-*-{thread_id}.jsonl")), (
|
|
"Codex resume rollout must be synthesized in app-server CODEX_HOME "
|
|
"before app-server start"
|
|
)
|
|
self.started = True
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
app_server = _FakeCodexAppServer()
|
|
build_calls: list[dict[str, Any]] = []
|
|
|
|
def _fake_build_codex_native_server(**kwargs: Any) -> _FakeCodexAppServer:
|
|
"""
|
|
Capture app-server construction.
|
|
|
|
:param kwargs: Keyword arguments passed by the runner helper.
|
|
:returns: Fake app-server.
|
|
"""
|
|
build_calls.append(kwargs)
|
|
app_server.codex_home = kwargs["codex_home"]
|
|
return app_server
|
|
|
|
class _UnexpectedDiscoveryClient:
|
|
"""
|
|
App-server client that must not connect on a known-thread resume.
|
|
|
|
Fresh sessions connect this listener to discover ``thread/started``.
|
|
Resume sessions already have ``external_session_id`` and should go
|
|
straight to the known-thread forwarder.
|
|
"""
|
|
|
|
def __init__(self, *, ws_url: str, client_name: str) -> None:
|
|
"""
|
|
:param ws_url: App-server WebSocket URL.
|
|
:param client_name: JSON-RPC client name.
|
|
"""
|
|
self.ws_url = ws_url
|
|
self.client_name = client_name
|
|
|
|
async def connect(self) -> None:
|
|
"""
|
|
Fail if the resume path tries to discover a fresh thread.
|
|
|
|
:returns: None.
|
|
"""
|
|
raise AssertionError("resume path must not connect discovery client")
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
launched_specs: list[Any] = []
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Resource registry that records the launched terminal spec."""
|
|
|
|
async def launch_auxiliary_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""
|
|
Record the terminal launch request.
|
|
|
|
:param session_id: Session id being launched.
|
|
:param terminal_name: Terminal name, e.g. ``"codex"``.
|
|
:param session_key: Terminal session key, e.g. ``"main"``.
|
|
:param spec: Terminal launch spec.
|
|
:param resource_role: Private runner resource marker.
|
|
:returns: Terminal resource view.
|
|
"""
|
|
assert session_id == "conv_codex_resume"
|
|
assert terminal_name == "codex"
|
|
assert session_key == "main"
|
|
assert resource_role == CODEX_NATIVE_TERMINAL_ROLE
|
|
launched_specs.append(spec)
|
|
return SessionResourceView(
|
|
id="terminal_codex_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Codex",
|
|
)
|
|
|
|
published_events: list[dict[str, Any]] = []
|
|
forward_calls: list[dict[str, Any]] = []
|
|
preload_calls: list[tuple[str, str]] = []
|
|
|
|
async def _fake_preload_thread(transport: str, loaded_thread_id: str) -> None:
|
|
"""
|
|
Record preloading of the known Codex thread.
|
|
|
|
:param transport: App-server transport URL.
|
|
:param loaded_thread_id: Thread id passed to ``thread/resume``.
|
|
:returns: None.
|
|
"""
|
|
assert codex_native_bridge.read_bridge_state(bridge_dir) is None, (
|
|
"stale bridge state must be cleared until the new app-server has "
|
|
"loaded the resume thread"
|
|
)
|
|
preload_calls.append((transport, loaded_thread_id))
|
|
|
|
async def _fake_forward_known_thread(**kwargs: Any) -> None:
|
|
"""
|
|
Record the known-thread forwarder invocation.
|
|
|
|
:param kwargs: Forwarder keyword arguments.
|
|
:returns: None.
|
|
"""
|
|
forward_calls.append(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
codex_app_mod,
|
|
"build_codex_native_server",
|
|
_fake_build_codex_native_server,
|
|
)
|
|
monkeypatch.setattr(codex_app_mod, "CodexAppServerClient", _UnexpectedDiscoveryClient)
|
|
monkeypatch.setattr(codex_app_mod, "preload_codex_thread_for_resume", _fake_preload_thread)
|
|
monkeypatch.setattr(runner_app_mod, "_codex_forward_known_thread", _fake_forward_known_thread)
|
|
|
|
agent_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="codex",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5-default"},
|
|
),
|
|
)
|
|
|
|
try:
|
|
terminal_view = await _auto_create_codex_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, event: published_events.append(event),
|
|
agent_spec=agent_spec,
|
|
server_client=_SnapshotServerClient(), # type: ignore[arg-type]
|
|
)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
runner_app_mod._AUTO_CODEX_APP_SERVERS.pop(session_id, None)
|
|
|
|
assert terminal_view.id == "terminal_codex_main"
|
|
assert app_server.started is True
|
|
expected_codex_home = codex_native_bridge.codex_home_for_bridge_dir(
|
|
codex_native_bridge.bridge_dir_for_bridge_id(session_id)
|
|
)
|
|
assert app_server.codex_home == expected_codex_home
|
|
assert build_calls[0]["model"] == "gpt-5.4-mini"
|
|
assert build_calls[0]["cwd"] == tmp_path / "workspace"
|
|
assert len(launched_specs) == 1
|
|
launched = launched_specs[0]
|
|
assert launched.command == "/opt/codex/bin/codex"
|
|
assert launched.args[:3] == [
|
|
"--config",
|
|
"approval_policy=on-request",
|
|
"resume",
|
|
]
|
|
assert launched.args[3] == "--remote"
|
|
assert launched.args[4].startswith("ws://127.0.0.1:")
|
|
assert launched.args[5] == thread_id
|
|
assert launched.env["OPENAI_API_KEY"] == "sk-test"
|
|
assert "IGNORED" not in launched.env
|
|
assert launched.env["CODEX_HOME"] == str(app_server.codex_home)
|
|
assert launched.tmux_start_on_attach is False
|
|
assert launched.tmux_allow_passthrough is True
|
|
assert preload_calls == [(app_server.listen_url, thread_id)]
|
|
assert published_events[0]["type"] == "session.resource.created"
|
|
assert forward_calls == [
|
|
{
|
|
"session_id": session_id,
|
|
"bridge_dir": bridge_dir,
|
|
"codex_ws_url": app_server.listen_url,
|
|
"thread_id": thread_id,
|
|
}
|
|
]
|
|
bridge_state = codex_native_bridge.read_bridge_state(bridge_dir)
|
|
assert bridge_state is not None
|
|
assert bridge_state.thread_id == thread_id
|
|
assert bridge_state.socket_path == app_server.listen_url
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_codex_terminal_fork_clones_rollout_and_resumes(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A forked codex clone clones the source rollout and resumes its copy.
|
|
|
|
When the clone has no ``external_session_id`` but carries the fork
|
|
labels, the runner must clone the SOURCE's rollout into the clone's
|
|
own ``CODEX_HOME`` under a freshly minted thread id, pre-set that id
|
|
on the Omnigent session, and launch ``codex resume <minted_id>`` (not the
|
|
source thread). A regression launches fresh (no ``resume`` subcommand)
|
|
and the clone loses the source's Codex history.
|
|
|
|
:param tmp_path: Temporary directory for isolated bridge state.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.codex_native_app_server as codex_app_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
from omnigent import codex_native
|
|
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id, codex_home_for_bridge_dir
|
|
from omnigent.stores.conversation_store import (
|
|
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY,
|
|
FORK_SOURCE_LABEL_KEY,
|
|
)
|
|
|
|
session_id = "conv_codex_clone"
|
|
source_id = "conv_codex_source"
|
|
source_thread = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(workspace))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
bridge_dir = bridge_dir_for_bridge_id(session_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=session_id,
|
|
socket_path="ws://127.0.0.1:1",
|
|
thread_id="019e96aa-1111-7222-8333-444455556666",
|
|
codex_home=str(tmp_path / "stale-codex-home"),
|
|
),
|
|
)
|
|
|
|
# Seed the SOURCE rollout in the source session's CODEX_HOME so the
|
|
# fork branch finds something to clone.
|
|
source_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id(source_id))
|
|
source_rollout_dir = source_home / "sessions" / "2026" / "06" / "05"
|
|
source_rollout_dir.mkdir(parents=True)
|
|
(source_rollout_dir / f"rollout-2026-06-05T15-23-07-{source_thread}.jsonl").write_text(
|
|
json.dumps(
|
|
{
|
|
"type": "session_meta",
|
|
"payload": {"id": source_thread, "cwd": "/old/source/dir"},
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
patched_external_ids: list[str] = []
|
|
|
|
class _ForkSnapshotClient:
|
|
"""Server client returning a forked clone snapshot (no thread id)."""
|
|
|
|
async def get(self, url: str, *, timeout: float) -> httpx.Response:
|
|
"""
|
|
Return the clone's snapshot carrying fork labels but no thread id.
|
|
|
|
:param url: Request path, e.g. ``"/v1/sessions/conv_codex_clone"``.
|
|
:param timeout: Request timeout in seconds.
|
|
:returns: HTTP 200 response with fork labels.
|
|
"""
|
|
del timeout
|
|
assert url == f"/v1/sessions/{session_id}"
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"external_session_id": None,
|
|
"labels": {
|
|
FORK_SOURCE_LABEL_KEY: source_id,
|
|
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY: source_thread,
|
|
},
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
|
|
async def patch(self, url: str, *, json: dict[str, Any], timeout: float) -> httpx.Response:
|
|
"""
|
|
Record the pre-set external_session_id PATCH.
|
|
|
|
:param url: Request path.
|
|
:param json: PATCH body, e.g. ``{"external_session_id": "..."}``.
|
|
:param timeout: Request timeout in seconds.
|
|
:returns: HTTP 200 response.
|
|
"""
|
|
del timeout
|
|
patched_external_ids.append(json["external_session_id"])
|
|
return httpx.Response(200, json={}, request=httpx.Request("PATCH", url))
|
|
|
|
class _FakeCodexAppServer:
|
|
"""Minimal app-server object used by ``codex_terminal_env``."""
|
|
|
|
codex_path = "/opt/codex/bin/codex"
|
|
|
|
def __init__(self) -> None:
|
|
""":returns: None."""
|
|
self.env = {"OPENAI_API_KEY": "sk-test"}
|
|
self.codex_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id(session_id))
|
|
self.listen_url: str | None = None
|
|
self.started = False
|
|
# Provider/model -c overrides forwarded to the --remote TUI.
|
|
self.config_overrides: list[str] = []
|
|
|
|
async def start(self) -> None:
|
|
""":returns: None."""
|
|
self.started = True
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
app_server = _FakeCodexAppServer()
|
|
|
|
def _fake_build_codex_native_server(**kwargs: Any) -> _FakeCodexAppServer:
|
|
"""
|
|
Return the fake app-server.
|
|
|
|
:param kwargs: Construction kwargs (ignored).
|
|
:returns: Fake app-server.
|
|
"""
|
|
del kwargs
|
|
return app_server
|
|
|
|
class _UnexpectedDiscoveryClient:
|
|
"""Discovery client that must not connect on the resume path."""
|
|
|
|
def __init__(self, *, ws_url: str, client_name: str) -> None:
|
|
"""
|
|
:param ws_url: App-server WebSocket URL.
|
|
:param client_name: JSON-RPC client name.
|
|
"""
|
|
self.ws_url = ws_url
|
|
self.client_name = client_name
|
|
|
|
async def connect(self) -> None:
|
|
""":raises AssertionError: Always — the fork resumes a known thread."""
|
|
raise AssertionError("fork resume path must not connect discovery client")
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
launched_specs: list[Any] = []
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Resource registry that records the launched terminal spec."""
|
|
|
|
async def launch_auxiliary_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""
|
|
Record the terminal launch request.
|
|
|
|
:param session_id: Session id being launched.
|
|
:param terminal_name: Terminal name, e.g. ``"codex"``.
|
|
:param session_key: Terminal session key, e.g. ``"main"``.
|
|
:param spec: Terminal launch spec.
|
|
:param resource_role: Private runner resource marker.
|
|
:returns: Terminal resource view.
|
|
"""
|
|
del terminal_name, session_key, resource_role
|
|
launched_specs.append(spec)
|
|
return SessionResourceView(
|
|
id="terminal_codex_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Codex",
|
|
)
|
|
|
|
forward_calls: list[dict[str, Any]] = []
|
|
preload_calls: list[tuple[str, str]] = []
|
|
|
|
async def _fake_preload_thread(transport: str, loaded_thread_id: str) -> None:
|
|
"""
|
|
Record preloading of the cloned Codex thread.
|
|
|
|
:param transport: App-server transport URL.
|
|
:param loaded_thread_id: Thread id passed to ``thread/resume``.
|
|
:returns: None.
|
|
"""
|
|
assert codex_native_bridge.read_bridge_state(bridge_dir) is None, (
|
|
"fork-resume must not expose stale bridge state before preload"
|
|
)
|
|
preload_calls.append((transport, loaded_thread_id))
|
|
|
|
async def _fake_forward_known_thread(**kwargs: Any) -> None:
|
|
"""
|
|
Record the known-thread forwarder invocation.
|
|
|
|
:param kwargs: Forwarder keyword arguments.
|
|
:returns: None.
|
|
"""
|
|
forward_calls.append(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
codex_app_mod, "build_codex_native_server", _fake_build_codex_native_server
|
|
)
|
|
monkeypatch.setattr(codex_app_mod, "CodexAppServerClient", _UnexpectedDiscoveryClient)
|
|
monkeypatch.setattr(codex_app_mod, "preload_codex_thread_for_resume", _fake_preload_thread)
|
|
monkeypatch.setattr(runner_app_mod, "_codex_forward_known_thread", _fake_forward_known_thread)
|
|
|
|
agent_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="codex",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5-default"},
|
|
),
|
|
)
|
|
|
|
try:
|
|
await _auto_create_codex_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, event: None,
|
|
agent_spec=agent_spec,
|
|
server_client=_ForkSnapshotClient(), # type: ignore[arg-type]
|
|
)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
runner_app_mod._AUTO_CODEX_APP_SERVERS.pop(session_id, None)
|
|
|
|
# A thread id was minted (uuidv7), pre-set on AP, and used for resume —
|
|
# never the source thread id.
|
|
assert len(patched_external_ids) == 1
|
|
minted = patched_external_ids[0]
|
|
assert minted != source_thread
|
|
assert codex_native._CODEX_THREAD_ID_RE.fullmatch(minted)
|
|
assert preload_calls == [(app_server.listen_url, minted)]
|
|
assert forward_calls and forward_calls[0]["thread_id"] == minted
|
|
|
|
launched = launched_specs[0]
|
|
assert "resume" in launched.args
|
|
assert launched.args[-1] == minted, (
|
|
f"resume must target the minted thread id, got {launched.args}"
|
|
)
|
|
|
|
# The cloned rollout exists in the CLONE's CODEX_HOME under the minted
|
|
# id, with session_meta.id and cwd rewritten.
|
|
clone_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id(session_id))
|
|
cloned = list(clone_home.glob(f"sessions/**/rollout-*-{minted}.jsonl"))
|
|
assert len(cloned) == 1, f"expected one cloned rollout under {clone_home}, found {cloned}"
|
|
meta = json.loads(cloned[0].read_text().splitlines()[0])["payload"]
|
|
assert meta["id"] == minted
|
|
assert meta["cwd"] == str(workspace.resolve())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_codex_terminal_fork_builds_rollout_from_items_and_resumes(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A forked codex clone from an SDK source builds its rollout from items.
|
|
|
|
When the clone carries the carry-history directive but has NO source
|
|
Codex thread to clone (an SDK source, so no rollout on disk), the runner
|
|
must build the clone's rollout from its OWN copied Omnigent items under a
|
|
freshly minted thread id, pre-set that id on the Omnigent session, and launch
|
|
``codex resume <minted_id>``. A regression launches fresh and the clone
|
|
loses the SDK source's conversation history.
|
|
|
|
:param tmp_path: Temporary directory for isolated bridge state.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.codex_native_app_server as codex_app_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
from omnigent import codex_native
|
|
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id, codex_home_for_bridge_dir
|
|
from omnigent.stores.conversation_store import (
|
|
FORK_CARRY_HISTORY_LABEL_KEY,
|
|
FORK_SOURCE_LABEL_KEY,
|
|
)
|
|
|
|
session_id = "conv_codex_sdkfork"
|
|
source_id = "conv_sdk_source"
|
|
codeword = "swordfish-7281"
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(workspace))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
|
|
patched_external_ids: list[str] = []
|
|
|
|
class _ItemsForkSnapshotClient:
|
|
"""Server client: clone snapshot (carry-history, no source thread)
|
|
plus the copied Omnigent items the rollout is built from."""
|
|
|
|
async def get(
|
|
self,
|
|
url: str,
|
|
*,
|
|
timeout: float | None = None,
|
|
params: dict[str, Any] | None = None,
|
|
) -> httpx.Response:
|
|
"""
|
|
Serve the clone snapshot and its copied items.
|
|
|
|
:param url: Request path — the session snapshot or its items.
|
|
:param timeout: Request timeout (snapshot fetch).
|
|
:param params: Query params (items fetch pagination).
|
|
:returns: HTTP 200 response.
|
|
"""
|
|
del timeout
|
|
if url == f"/v1/sessions/{session_id}":
|
|
# SDK source: carry-history set, but NO source external
|
|
# session id → the runner must build from items, not clone.
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"external_session_id": None,
|
|
"labels": {
|
|
FORK_SOURCE_LABEL_KEY: source_id,
|
|
FORK_CARRY_HISTORY_LABEL_KEY: "1",
|
|
},
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
assert url == f"/v1/sessions/{session_id}/items"
|
|
assert params is not None and params.get("order") == "asc"
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"data": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": f"codeword {codeword}"}],
|
|
}
|
|
],
|
|
"has_more": False,
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
|
|
async def patch(self, url: str, *, json: dict[str, Any], timeout: float) -> httpx.Response:
|
|
"""
|
|
Record the pre-set external_session_id PATCH.
|
|
|
|
:param url: Request path.
|
|
:param json: PATCH body, e.g. ``{"external_session_id": "..."}``.
|
|
:param timeout: Request timeout in seconds.
|
|
:returns: HTTP 200 response.
|
|
"""
|
|
del timeout
|
|
patched_external_ids.append(json["external_session_id"])
|
|
return httpx.Response(200, json={}, request=httpx.Request("PATCH", url))
|
|
|
|
class _FakeCodexAppServer:
|
|
"""Minimal app-server object used by ``codex_terminal_env``."""
|
|
|
|
codex_path = "/opt/codex/bin/codex"
|
|
|
|
def __init__(self) -> None:
|
|
""":returns: None."""
|
|
self.env = {"OPENAI_API_KEY": "sk-test"}
|
|
self.codex_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id(session_id))
|
|
self.listen_url: str | None = None
|
|
self.started = False
|
|
self.config_overrides: list[str] = []
|
|
|
|
async def start(self) -> None:
|
|
""":returns: None."""
|
|
self.started = True
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
app_server = _FakeCodexAppServer()
|
|
|
|
def _fake_build_codex_native_server(**kwargs: Any) -> _FakeCodexAppServer:
|
|
""":param kwargs: Construction kwargs (ignored). :returns: Fake server."""
|
|
del kwargs
|
|
return app_server
|
|
|
|
class _UnexpectedDiscoveryClient:
|
|
"""Discovery client that must not connect on the resume path."""
|
|
|
|
def __init__(self, *, ws_url: str, client_name: str) -> None:
|
|
""":param ws_url: App-server URL. :param client_name: RPC client name."""
|
|
self.ws_url = ws_url
|
|
self.client_name = client_name
|
|
|
|
async def connect(self) -> None:
|
|
""":raises AssertionError: Always — the fork resumes a known thread."""
|
|
raise AssertionError("fork resume path must not connect discovery client")
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
launched_specs: list[Any] = []
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Resource registry that records the launched terminal spec."""
|
|
|
|
async def launch_auxiliary_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""
|
|
Record the terminal launch request.
|
|
|
|
:param session_id: Session id being launched.
|
|
:param terminal_name: Terminal name, e.g. ``"codex"``.
|
|
:param session_key: Terminal session key.
|
|
:param spec: Terminal launch spec.
|
|
:param resource_role: Private runner resource marker.
|
|
:returns: Terminal resource view.
|
|
"""
|
|
del terminal_name, session_key, resource_role
|
|
launched_specs.append(spec)
|
|
return SessionResourceView(
|
|
id="terminal_codex_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Codex",
|
|
)
|
|
|
|
forward_calls: list[dict[str, Any]] = []
|
|
preload_calls: list[tuple[str, str]] = []
|
|
|
|
async def _fake_preload_thread(transport: str, loaded_thread_id: str) -> None:
|
|
""":param transport: App-server URL. :param loaded_thread_id: Resumed thread."""
|
|
preload_calls.append((transport, loaded_thread_id))
|
|
|
|
async def _fake_forward_known_thread(**kwargs: Any) -> None:
|
|
""":param kwargs: Forwarder keyword arguments. :returns: None."""
|
|
forward_calls.append(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
codex_app_mod, "build_codex_native_server", _fake_build_codex_native_server
|
|
)
|
|
monkeypatch.setattr(codex_app_mod, "CodexAppServerClient", _UnexpectedDiscoveryClient)
|
|
monkeypatch.setattr(codex_app_mod, "preload_codex_thread_for_resume", _fake_preload_thread)
|
|
monkeypatch.setattr(runner_app_mod, "_codex_forward_known_thread", _fake_forward_known_thread)
|
|
|
|
agent_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="codex",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5-default"},
|
|
),
|
|
)
|
|
|
|
try:
|
|
await _auto_create_codex_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, event: None,
|
|
agent_spec=agent_spec,
|
|
server_client=_ItemsForkSnapshotClient(), # type: ignore[arg-type]
|
|
)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
runner_app_mod._AUTO_CODEX_APP_SERVERS.pop(session_id, None)
|
|
|
|
# A thread id was minted, pre-set on AP, and used for resume.
|
|
assert len(patched_external_ids) == 1
|
|
minted = patched_external_ids[0]
|
|
assert codex_native._CODEX_THREAD_ID_RE.fullmatch(minted)
|
|
assert preload_calls == [(app_server.listen_url, minted)]
|
|
assert forward_calls and forward_calls[0]["thread_id"] == minted
|
|
|
|
launched = launched_specs[0]
|
|
assert "resume" in launched.args
|
|
assert launched.args[-1] == minted, (
|
|
f"resume must target the minted thread id, got {launched.args}"
|
|
)
|
|
|
|
# The rollout was BUILT (not cloned) in the clone's CODEX_HOME under the
|
|
# minted id, carrying the source conversation's codeword — proving the
|
|
# copied Omnigent items, not a source rollout, seeded the history.
|
|
clone_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id(session_id))
|
|
built = list(clone_home.glob(f"sessions/**/rollout-*-{minted}.jsonl"))
|
|
assert len(built) == 1, f"expected one built rollout under {clone_home}, found {built}"
|
|
body = built[0].read_text()
|
|
meta = json.loads(body.splitlines()[0])["payload"]
|
|
assert meta["id"] == minted
|
|
assert meta["cwd"] == str(workspace.resolve())
|
|
assert codeword in body, (
|
|
"Built rollout must carry the source conversation's text from the "
|
|
"copied Omnigent items; missing it means history was not seeded."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_codex_terminal_uses_worktree_workspace_not_bundle_dir(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Codex-native launches in the session worktree, not the bundle dir.
|
|
|
|
Regression for the worktree bug: ``_codex_workspace_from_spec_or_env``
|
|
preferred ``ResolvedSpec.workdir`` (the runner's spec-bundle
|
|
extraction dir) over the session workspace, stranding Codex in a temp
|
|
dir with no ``.git`` and ignoring the worktree entirely. The fix reads
|
|
the workspace from the session snapshot (the worktree path), matching
|
|
claude-native and the per-session filesystem registry.
|
|
|
|
This test sets up the adversarial case the bug got wrong: the snapshot
|
|
reports a worktree workspace that differs from BOTH the runner env var
|
|
and the ResolvedSpec bundle dir. The launched Codex app-server's
|
|
``cwd`` must be the worktree. If reverted, ``cwd`` would be the bundle
|
|
dir and the ``cwd == worktree`` assertion fails.
|
|
|
|
:param tmp_path: Temporary directory for isolated bridge/workspace state.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.codex_native_app_server as codex_app_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
|
|
session_id = "conv_codex_worktree"
|
|
# Three distinct dirs so the assertion can only pass for the worktree:
|
|
# runner_env — OMNIGENT_RUNNER_WORKSPACE (claude-native's source)
|
|
# bundle_dir — ResolvedSpec.workdir (what the bug used)
|
|
# worktree — the session's stored workspace (correct answer)
|
|
runner_env = tmp_path / "runner_workspace"
|
|
runner_env.mkdir()
|
|
bundle_dir = tmp_path / "runner-specs" / f"{session_id}-v1"
|
|
bundle_dir.mkdir(parents=True)
|
|
worktree = tmp_path / "repo-worktrees" / "feature-x"
|
|
worktree.mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(runner_env))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(session_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=session_id,
|
|
socket_path="ws://127.0.0.1:1",
|
|
thread_id="019e96aa-1111-7222-8333-444455556666",
|
|
codex_home=str(tmp_path / "stale-codex-home"),
|
|
),
|
|
)
|
|
|
|
class _WorktreeSnapshotClient:
|
|
"""Server client whose session snapshot carries a worktree workspace."""
|
|
|
|
async def get(self, url: str, *, timeout: float) -> httpx.Response:
|
|
"""
|
|
Return the session snapshot with a worktree ``workspace``.
|
|
|
|
:param url: Request path, e.g.
|
|
``"/v1/sessions/conv_codex_worktree"``.
|
|
:param timeout: Request timeout in seconds.
|
|
:returns: HTTP 200 response carrying the worktree workspace.
|
|
"""
|
|
del timeout
|
|
assert url == f"/v1/sessions/{session_id}"
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"workspace": str(worktree),
|
|
"terminal_launch_args": None,
|
|
"model_override": None,
|
|
"external_session_id": None,
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
|
|
class _FakeCodexAppServer:
|
|
"""Minimal app-server object used by ``codex_terminal_env``."""
|
|
|
|
codex_path = "/opt/codex/bin/codex"
|
|
|
|
def __init__(self) -> None:
|
|
""":returns: None."""
|
|
self.env = {"OPENAI_API_KEY": "sk-test"}
|
|
self.codex_home = tmp_path / "codex-home"
|
|
self.listen_url: str | None = None
|
|
self.started = False
|
|
# Provider/model -c overrides forwarded to the --remote TUI.
|
|
self.config_overrides: list[str] = []
|
|
|
|
async def start(self) -> None:
|
|
""":returns: None."""
|
|
self.started = True
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
app_server = _FakeCodexAppServer()
|
|
build_calls: list[dict[str, Any]] = []
|
|
|
|
def _fake_build_codex_native_server(**kwargs: Any) -> _FakeCodexAppServer:
|
|
"""
|
|
Capture app-server construction.
|
|
|
|
:param kwargs: Keyword arguments passed by the runner helper.
|
|
:returns: Fake app-server.
|
|
"""
|
|
build_calls.append(kwargs)
|
|
return app_server
|
|
|
|
class _FakeDiscoveryClient:
|
|
"""App-server client for the fresh-thread discovery path."""
|
|
|
|
def __init__(self, *, ws_url: str, client_name: str) -> None:
|
|
"""
|
|
:param ws_url: App-server WebSocket URL.
|
|
:param client_name: JSON-RPC client name.
|
|
"""
|
|
self.ws_url = ws_url
|
|
self.client_name = client_name
|
|
|
|
async def connect(self) -> None:
|
|
""":returns: None."""
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
async def _fake_discover_thread_and_forward(**kwargs: Any) -> None:
|
|
"""
|
|
Stand in for the fresh-session discovery forwarder.
|
|
|
|
:param kwargs: Forwarder keyword arguments.
|
|
:returns: None.
|
|
"""
|
|
assert kwargs["bridge_dir"] == bridge_dir
|
|
assert codex_native_bridge.read_bridge_state(bridge_dir) is None, (
|
|
"fresh Codex launch must clear stale bridge state before the "
|
|
"discovery forwarder publishes the new thread"
|
|
)
|
|
|
|
launch_captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Resource registry that records the launched terminal spec."""
|
|
|
|
async def launch_auxiliary_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""
|
|
Record the terminal launch request.
|
|
|
|
:param session_id: Session id being launched.
|
|
:param terminal_name: Terminal name, e.g. ``"codex"``.
|
|
:param session_key: Terminal session key, e.g. ``"main"``.
|
|
:param spec: Terminal launch spec.
|
|
:param resource_role: Private runner resource marker.
|
|
:param parent_os_env: Agent os_env threaded as the inheritance
|
|
parent so the agent's sandbox / egress / passthrough apply.
|
|
:returns: Terminal resource view.
|
|
"""
|
|
del session_key, resource_role
|
|
launch_captured["spec"] = spec
|
|
launch_captured["parent_os_env"] = parent_os_env
|
|
return SessionResourceView(
|
|
id="terminal_codex_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Codex",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
codex_app_mod,
|
|
"build_codex_native_server",
|
|
_fake_build_codex_native_server,
|
|
)
|
|
monkeypatch.setattr(codex_app_mod, "CodexAppServerClient", _FakeDiscoveryClient)
|
|
monkeypatch.setattr(
|
|
runner_app_mod,
|
|
"_codex_discover_thread_and_forward",
|
|
_fake_discover_thread_and_forward,
|
|
)
|
|
|
|
# agent_spec is a ResolvedSpec whose workdir is the bundle dir — the
|
|
# exact value the old code wrongly used as the cwd. Its os_env declares
|
|
# sandbox: none, so the launched terminal must inherit that (not the
|
|
# platform default) — see the sandbox-override regression note below.
|
|
codex_os_env = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd=".",
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
)
|
|
agent_spec = ResolvedSpec(
|
|
spec=AgentSpec(
|
|
spec_version=1,
|
|
name="codex",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5-default"},
|
|
),
|
|
os_env=codex_os_env,
|
|
),
|
|
workdir=bundle_dir,
|
|
)
|
|
|
|
try:
|
|
await _auto_create_codex_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, event: None,
|
|
agent_spec=agent_spec,
|
|
server_client=_WorktreeSnapshotClient(), # type: ignore[arg-type]
|
|
)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
runner_app_mod._AUTO_CODEX_APP_SERVERS.pop(session_id, None)
|
|
|
|
# The Codex app-server cwd must be the worktree (resolved — the launch
|
|
# config normalizes with expanduser().resolve()). A failure here means
|
|
# the workspace resolution regressed: the bundle dir means the old
|
|
# ResolvedSpec.workdir bug is back; the runner env dir means the
|
|
# snapshot workspace was ignored.
|
|
assert build_calls[0]["cwd"] == worktree.resolve(), (
|
|
f"Codex launched in {build_calls[0]['cwd']!r}; expected the worktree "
|
|
f"{worktree.resolve()!r}. bundle_dir={bundle_dir!r} would mean the "
|
|
f"ResolvedSpec.workdir bug regressed; runner_env={runner_env!r} would "
|
|
"mean the session snapshot workspace was ignored."
|
|
)
|
|
assert build_calls[0]["cwd"] != bundle_dir.resolve() # never the spec-bundle dir
|
|
|
|
# Sandbox-override regression: the launched Codex terminal must inherit
|
|
# the agent's sandbox: none rather than falling back to the platform
|
|
# default (bwrap). Without it, a codex-native agent declaring
|
|
# ``os_env.sandbox.type: none`` is wrongly forced into bwrap.
|
|
launched_sandbox = launch_captured["spec"].os_env.sandbox
|
|
assert launched_sandbox is not None and launched_sandbox.type == "none"
|
|
assert launch_captured["parent_os_env"] is codex_os_env
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_codex_terminal_starts_relay_at_session_creation(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The tool relay is started at session creation, non-blocking.
|
|
|
|
Root-cause fix for the ~30s first-turn stall: the relay (which writes
|
|
``tool_relay.json`` codex reads via serve-mcp) must be started when the
|
|
session is created, into the same bridge dir codex uses, and WITHOUT
|
|
awaiting the tools/list_changed notification. Previously the relay was
|
|
only started on the first turn with ``await_notify=True``, which blocked
|
|
on codex's MCP bridge ``server.json`` — a file that only appears once
|
|
codex runs the turn — until ``post_tools_changed``'s 30s timeout.
|
|
|
|
Asserts ``_auto_create_codex_terminal`` invokes the injected
|
|
``ensure_comment_relay`` exactly once, for this session's bridge dir,
|
|
with ``await_notify=False``. If the call regressed to the first-turn
|
|
path the spy would never fire here; if it regressed to
|
|
``await_notify=True`` the assertion on that kwarg would fail.
|
|
|
|
:param tmp_path: Temporary directory for isolated bridge state.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.codex_native_app_server as codex_app_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
session_id = "conv_codex_relay_start"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(tmp_path / "workspace"))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
|
|
class _SnapshotClient:
|
|
"""Fresh-session snapshot (no external thread → discovery path)."""
|
|
|
|
async def get(self, url: str, *, timeout: float) -> httpx.Response:
|
|
""":returns: HTTP 200 fresh-session snapshot."""
|
|
del timeout, url
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"workspace": str(tmp_path / "workspace"),
|
|
"terminal_launch_args": None,
|
|
"model_override": None,
|
|
"external_session_id": None,
|
|
},
|
|
request=httpx.Request("GET", f"/v1/sessions/{session_id}"),
|
|
)
|
|
|
|
class _FakeCodexAppServer:
|
|
"""Minimal app-server object."""
|
|
|
|
codex_path = "/opt/codex/bin/codex"
|
|
|
|
def __init__(self) -> None:
|
|
""":returns: None."""
|
|
self.env = {"OPENAI_API_KEY": "sk-test"}
|
|
self.codex_home = tmp_path / "codex-home"
|
|
self.listen_url: str | None = None
|
|
# Provider/model -c overrides forwarded to the --remote TUI.
|
|
self.config_overrides: list[str] = []
|
|
|
|
async def start(self) -> None:
|
|
""":returns: None."""
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
class _FakeDiscoveryClient:
|
|
"""No-op app-server client for the discovery path."""
|
|
|
|
def __init__(self, *, ws_url: str, client_name: str) -> None:
|
|
""":param ws_url: ws url. :param client_name: client name."""
|
|
del ws_url, client_name
|
|
|
|
async def connect(self) -> None:
|
|
""":returns: None."""
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Resource registry returning a fixed terminal view."""
|
|
|
|
async def launch_auxiliary_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
""":returns: A fixed terminal resource view."""
|
|
del terminal_name, session_key, spec, resource_role
|
|
return SessionResourceView(
|
|
id="terminal_codex_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Codex",
|
|
)
|
|
|
|
async def _fake_discover(**kwargs: Any) -> None:
|
|
""":returns: None — stands in for the discovery forwarder."""
|
|
del kwargs
|
|
|
|
monkeypatch.setattr(
|
|
codex_app_mod, "build_codex_native_server", lambda **k: _FakeCodexAppServer()
|
|
)
|
|
monkeypatch.setattr(codex_app_mod, "CodexAppServerClient", _FakeDiscoveryClient)
|
|
monkeypatch.setattr(runner_app_mod, "_codex_discover_thread_and_forward", _fake_discover)
|
|
|
|
relay_calls: list[dict[str, Any]] = []
|
|
|
|
async def _spy_ensure_relay(sid: str, **kwargs: Any) -> None:
|
|
"""Record the relay-start invocation."""
|
|
relay_calls.append({"session_id": sid, **kwargs})
|
|
|
|
agent_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="codex",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5-default"},
|
|
),
|
|
)
|
|
|
|
try:
|
|
await _auto_create_codex_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, event: None,
|
|
agent_spec=agent_spec,
|
|
server_client=_SnapshotClient(), # type: ignore[arg-type]
|
|
ensure_comment_relay=_spy_ensure_relay,
|
|
)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
runner_app_mod._AUTO_CODEX_APP_SERVERS.pop(session_id, None)
|
|
|
|
# Exactly one relay start, at session creation, for this session's bridge
|
|
# dir, and non-blocking (await_notify=False) — the crux of the fix.
|
|
assert len(relay_calls) == 1, relay_calls
|
|
assert relay_calls[0]["session_id"] == session_id
|
|
assert relay_calls[0]["explicit_bridge_dir"] == codex_native_bridge.bridge_dir_for_bridge_id(
|
|
session_id
|
|
)
|
|
assert relay_calls[0]["await_notify"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claude_native_first_turn_not_blocked_by_cold_bridge_notify(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""First claude-native turn dispatches without waiting on a cold bridge.
|
|
|
|
A UI-launched (never pre-warmed) claude-native session starts the comment
|
|
relay lazily on the first turn. The ``tools/list_changed`` delivery
|
|
(``post_tools_changed``) blocks until the bridge publishes ``server.json``
|
|
— up to ``_TOOLS_CHANGED_READY_TIMEOUT_S`` (30s) on a still-cold bridge.
|
|
The turn must NOT be gated on that: the claude-native first-turn caller
|
|
passes ``await_notify=False``, so the relay starts and the notification is
|
|
fired in a background task while the turn dispatches immediately.
|
|
|
|
This holds ``post_tools_changed`` open on a never-released event (a cold
|
|
bridge that never publishes ``server.json``) and asserts:
|
|
|
|
(a) the notification was actually attempted — the relay genuinely started
|
|
and reached the delivery step. Without this, (b) passes vacuously: a
|
|
relay that bailed early (failed socket bind, unresolved spec) never
|
|
blocks, so the turn was never at risk.
|
|
(b) the harness still received the turn while the notification is blocked.
|
|
|
|
A regression to ``await_notify=True`` would await ``post_tools_changed``
|
|
inline, parking ``_run_turn_bg`` at the relay-start step until the event
|
|
is released, so the harness would never see the turn within the poll
|
|
budget and (b) fails.
|
|
|
|
:param tmp_path: Temp dir backing the runner workspace (the bridge tree
|
|
itself must live under the real ``/tmp`` trusted parent —
|
|
``_ensure_secure_dir`` rejects a bridge dir anywhere else, so the
|
|
bridge root is NOT redirected into ``tmp_path``).
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
session_id = f"conv_{uuid.uuid4().hex[:12]}"
|
|
# claude-native pins the bridge tree under /tmp (see _ensure_secure_dir);
|
|
# use the real per-user bridge dir like tests/runner/test_comment_relay.py
|
|
# and rmtree it on teardown rather than redirecting _BRIDGE_ROOT.
|
|
bridge_dir = bridge_dir_for_bridge_id(session_id)
|
|
# start_tool_relay writes tool_relay.json INTO the bridge dir but does not
|
|
# create it — mirror the client's prepare_bridge_dir before launch.
|
|
prepare_bridge_dir(session_id, workspace=tmp_path)
|
|
|
|
notify_started = threading.Event()
|
|
notify_release = threading.Event()
|
|
|
|
def _blocking_post_tools_changed(*args: Any, **kwargs: Any) -> None:
|
|
"""Stand in for a cold bridge: signal entry, then block until released.
|
|
|
|
Runs in the default thread-pool executor (``post_tools_changed`` is
|
|
synchronous), so a threading.Event is the right primitive.
|
|
|
|
:param args: Positional args from the call site (``bridge_dir``).
|
|
:param kwargs: Keyword args (none expected).
|
|
:returns: None.
|
|
"""
|
|
del args, kwargs
|
|
notify_started.set()
|
|
notify_release.wait()
|
|
|
|
# The runner imports post_tools_changed from this module at call time, so
|
|
# patching the module attribute is picked up by _ensure_comment_relay_started.
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_bridge.post_tools_changed",
|
|
_blocking_post_tools_changed,
|
|
)
|
|
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="claude",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Resolve every request to the claude-native spec under test."""
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
hc = _ScriptedHarnessClient(
|
|
[
|
|
_sse({"type": "response.created", "response": {"id": "resp_1"}}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_1"}}),
|
|
]
|
|
)
|
|
pm = _FakeProcessManager(hc)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "claude-agent",
|
|
"agent_id": "ag_claude",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
},
|
|
)
|
|
assert resp.status_code == 202, f"{resp.status_code} {resp.text}"
|
|
|
|
# (a) The relay started and reached the notification: post_tools_changed
|
|
# is now parked on notify_release. If start_tool_relay or the spec
|
|
# resolve had bailed, this never fires — making (b) vacuous.
|
|
for _ in range(300):
|
|
if notify_started.is_set():
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
assert notify_started.is_set(), (
|
|
"post_tools_changed was never invoked: the relay did not start or "
|
|
"did not reach the notify step, so the no-block assertion below "
|
|
"would be vacuous."
|
|
)
|
|
|
|
# (b) The harness received the turn even though post_tools_changed is
|
|
# still blocked (notify_release is NOT set). An unbounded await on the
|
|
# notification would park _run_turn_bg at relay-start, leaving
|
|
# posted_bodies empty until release — that is the ~15-30s first-turn
|
|
# stall this change removes.
|
|
for _ in range(300):
|
|
if hc.posted_bodies:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
assert hc.posted_bodies, (
|
|
"claude-native first turn never reached the harness while the "
|
|
"tools/list_changed delivery was blocked — the turn is gated on a "
|
|
"cold-bridge notification."
|
|
)
|
|
# Sanity: we never unblocked delivery, so (b) proves a bounded wait,
|
|
# not that the bridge came up.
|
|
assert not notify_release.is_set()
|
|
finally:
|
|
# Unblock the parked executor thread BEFORE teardown so the loop's
|
|
# shutdown_default_executor(wait=True) does not hang joining it, then
|
|
# close the relay socket/thread by deleting the session.
|
|
notify_release.set()
|
|
with contextlib.suppress(httpx.HTTPError):
|
|
async with _runner_client(app) as cleanup_client:
|
|
await cleanup_client.delete(f"/v1/sessions/{session_id}")
|
|
shutil.rmtree(bridge_dir, ignore_errors=True)
|
|
|
|
|
|
async def _run_antigravity_auto_create(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
session_id: str,
|
|
snapshot: dict[str, Any],
|
|
candidate_ports: list[int],
|
|
pane: tuple[Path, str] | None = None,
|
|
pane_scoped_port: int | None = None,
|
|
pane_agy_found: bool = True,
|
|
) -> tuple[Any, list[tuple[int, str]], list[dict[str, Any]], list[tuple[str, dict[str, Any]]]]:
|
|
"""
|
|
Drive ``_auto_create_antigravity_terminal`` with every live collaborator faked.
|
|
|
|
No real agy is launched: ``build_agy_launch`` is stubbed to a no-op argv, the
|
|
onboarding seed is a no-op, the resource registry records the launched spec,
|
|
the forwarder is a counting stub, and the connect-RPC layer
|
|
(``_candidate_agy_rpc_ports`` / ``resolve_pane_agy_rpc_port_state`` /
|
|
``start_cascade``) is mocked so the cold-start bootstrap runs without a socket.
|
|
|
|
The cold-start exercises the REAL ``resolve_cold_start_agy_rpc_port`` dispatch:
|
|
with no ``pane`` the pane is absent (``_terminal_tmux_pane`` → ``(None, None)``)
|
|
and it falls back to the candidate scan; with a ``pane`` the pane-scoped
|
|
resolver's 3-state result (driven by ``pane_scoped_port`` + ``pane_agy_found``)
|
|
is consulted first.
|
|
|
|
:param tmp_path: Temporary directory for isolated bridge state.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param session_id: Session/conversation id under test.
|
|
:param snapshot: The Omnigent session snapshot the helper should read.
|
|
:param candidate_ports: Ports ``_candidate_agy_rpc_ports`` yields (``[]`` →
|
|
the bootstrap never finds a candidate port).
|
|
:param pane: ``(tmux_socket, tmux_target)`` ``_terminal_tmux_pane`` returns,
|
|
or ``None`` (the default) → ``(None, None)`` (no local pane).
|
|
:param pane_scoped_port: The port the pane-scoped resolver reports (``None`` →
|
|
no port; combined with ``pane_agy_found`` to pick state 1/2/3).
|
|
:param pane_agy_found: Whether the pane-scoped resolver found our agy in the
|
|
pane subtree. ``True`` + a port → scoped (state 1); ``True`` + no port →
|
|
candidate fallback (state 2); ``False`` → keep polling (state 3).
|
|
:returns: ``(bridge_state_after, start_cascade_calls, reader_calls,
|
|
external_session_id_patch_calls)``.
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
import omnigent.antigravity_native_launch as launch_mod
|
|
import omnigent.antigravity_native_reader as reader_mod
|
|
import omnigent.antigravity_native_rpc as rpc_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(tmp_path / "workspace"))
|
|
(tmp_path / "workspace").mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
|
|
# No-op the launch builder + onboarding seed so nothing tries to find agy.
|
|
monkeypatch.setattr(
|
|
launch_mod, "build_agy_launch", lambda **_kwargs: (("agy",), {"AGY_ENV": "1"})
|
|
)
|
|
monkeypatch.setattr(bridge_mod, "ensure_agy_onboarding_complete", lambda: None)
|
|
# Auto-create now spawns the RPC reader (NOT the transcript forwarder); stub
|
|
# ``supervise_reader`` at its definition module (the helper imports it lazily)
|
|
# so the test does not start a real one. The reader is wrapped in
|
|
# ``_run_antigravity_reader``, which still opens (and, on teardown, closes) a
|
|
# real Omnigent client around this stub — fine, since nothing posts here.
|
|
reader_calls: list[dict[str, Any]] = []
|
|
|
|
def _counting_reader(*args: Any, **kwargs: Any) -> Any:
|
|
reader_calls.append(kwargs)
|
|
|
|
async def _runner() -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
return _runner()
|
|
|
|
monkeypatch.setattr(reader_mod, "supervise_reader", _counting_reader)
|
|
# The pane the runner resolves for cold-start scoping + the tmux advertise.
|
|
# Default ``None`` → no local pane (``(None, None)``), so the cold-start uses
|
|
# the candidate-scan fallback. A provided pane lets the test assert the
|
|
# pane-scoped port path.
|
|
resolved_pane = (None, None) if pane is None else pane
|
|
monkeypatch.setattr(runner_app_mod, "_terminal_tmux_pane", lambda *_a, **_k: resolved_pane)
|
|
# The pane-scoped resolver the REAL ``resolve_cold_start_agy_rpc_port``
|
|
# consults first when a pane is present — returns the 3-state result.
|
|
pane_resolution = rpc_mod.PaneAgyResolution(agy_found=pane_agy_found, port=pane_scoped_port)
|
|
monkeypatch.setattr(
|
|
rpc_mod, "resolve_pane_agy_rpc_port_state", lambda _sock, _tgt: pane_resolution
|
|
)
|
|
|
|
# Mock the connect-RPC cold-start surface. Collapse the port-poll budget +
|
|
# backoff so the no-port case bails immediately instead of waiting the real
|
|
# 20s (and the success case still finds its port on the first probe).
|
|
monkeypatch.setattr(runner_app_mod, "_AGY_COLD_START_PORT_TIMEOUT_S", 0.0)
|
|
|
|
async def _no_sleep(_seconds: float) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(runner_app_mod, "_agy_cold_start_poll_sleep", _no_sleep)
|
|
monkeypatch.setattr(rpc_mod, "_candidate_agy_rpc_ports", lambda: list(candidate_ports))
|
|
start_cascade_calls: list[tuple[int, str]] = []
|
|
|
|
def _fake_start_cascade(port: int, cascade_id: str, **_kwargs: Any) -> None:
|
|
start_cascade_calls.append((port, cascade_id))
|
|
|
|
monkeypatch.setattr(rpc_mod, "start_cascade", _fake_start_cascade)
|
|
|
|
patch_calls: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
class _SnapshotServerClient:
|
|
"""Server client returning the snapshot + recording external_session_id PATCHes."""
|
|
|
|
async def get(self, url: str, **_kwargs: Any) -> httpx.Response:
|
|
assert url == f"/v1/sessions/{session_id}"
|
|
return httpx.Response(200, json=snapshot, request=httpx.Request("GET", url))
|
|
|
|
async def patch(self, url: str, *, json: dict[str, Any], **_kwargs: Any) -> httpx.Response:
|
|
patch_calls.append((url, json))
|
|
return httpx.Response(200, json={}, request=httpx.Request("PATCH", url))
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Resource registry that records the required-terminal launch."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
*,
|
|
resource_role: str | None = None,
|
|
**_kwargs: Any,
|
|
) -> SessionResourceView:
|
|
assert terminal_name == "antigravity"
|
|
assert session_key == "main"
|
|
assert resource_role == ANTIGRAVITY_NATIVE_TERMINAL_ROLE
|
|
return SessionResourceView(
|
|
id="terminal_antigravity_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Antigravity",
|
|
)
|
|
|
|
try:
|
|
await _auto_create_antigravity_terminal(
|
|
session_id,
|
|
cast(SessionResourceRegistry, _FakeResourceRegistry()),
|
|
lambda _sid, _event: None,
|
|
server_client=cast(httpx.AsyncClient, _SnapshotServerClient()),
|
|
)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
await runner_app_mod._cancel_auto_forwarder_task(session_id)
|
|
|
|
bridge_dir = bridge_mod.bridge_dir_for_bridge_id(session_id)
|
|
return (
|
|
bridge_mod.read_bridge_state(bridge_dir),
|
|
start_cascade_calls,
|
|
reader_calls,
|
|
patch_calls,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_cold_starts_real_conversation(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A fresh runner launch cold-starts the agy conversation over RPC.
|
|
|
|
The runner mints the cascade over ``StartCascade`` (no send-keys / no waiting
|
|
for the TUI to lazily create it) so the executor's turn-1 has a real cascade
|
|
id. This asserts the load-bearing integration: after the agy terminal launches
|
|
and the connect-RPC port answers, the runner calls ``start_cascade`` with a
|
|
runner-generated id, writes THAT real id into bridge state — NOT the
|
|
``agy_conv_*`` placeholder ``read_bridge_state`` would otherwise return — and
|
|
PATCHes it onto the session as ``external_session_id`` so a later ``--resume``
|
|
continues it.
|
|
"""
|
|
session_id = "conv_agy_coldstart"
|
|
state, start_cascade_calls, reader_calls, patch_calls = await _run_antigravity_auto_create(
|
|
tmp_path,
|
|
monkeypatch,
|
|
session_id=session_id,
|
|
snapshot={}, # fresh: no external_session_id
|
|
candidate_ports=[52548],
|
|
)
|
|
# start_cascade was called once, on the discovered port, with a real id.
|
|
assert len(start_cascade_calls) == 1
|
|
called_port, called_id = start_cascade_calls[0]
|
|
assert called_port == 52548
|
|
assert not bridge_mod_is_placeholder(called_id)
|
|
# The real cold-started id is what reaches bridge state (no placeholder).
|
|
assert state is not None
|
|
assert state.conversation_id == called_id
|
|
assert not bridge_mod_is_placeholder(state.conversation_id)
|
|
# The same real id is PATCHed onto the session as external_session_id so a
|
|
# later --resume continues agy's actual conversation (the read-path
|
|
# replacement for the retired forwarder's _patch_external_session_id).
|
|
assert patch_calls == [] # cold-start no longer records the phantom cascade (#2 data-loss)
|
|
# The RPC reader spawns (it replaced the transcript forwarder).
|
|
assert len(reader_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_cold_start_scopes_to_pane_agy(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
With several agy candidates, cold-start binds THIS session's pane agy.
|
|
|
|
The cross-bind fix: on a host running several agy instances (sub-agent
|
|
fan-out / shared runner), ``StartCascade`` must target the agy actually
|
|
running under this session's tmux pane — NOT the lowest Heartbeat-answering
|
|
candidate, which could be a FOREIGN agy and permanently cross-bind the
|
|
session. With a resolvable pane the cold-start uses the pane-scoped port
|
|
(61000) even though a lower foreign candidate (52548) exists.
|
|
"""
|
|
session_id = "conv_agy_paneScoped"
|
|
state, start_cascade_calls, _reader_calls, patch_calls = await _run_antigravity_auto_create(
|
|
tmp_path,
|
|
monkeypatch,
|
|
session_id=session_id,
|
|
snapshot={},
|
|
# Several agy ports on the host; 52548 is the lowest (a FOREIGN agy).
|
|
candidate_ports=[52548, 61000],
|
|
# This session's pane resolves to a DIFFERENT (higher) agy's port.
|
|
pane=(tmp_path / "agy.sock", "main"),
|
|
pane_scoped_port=61000,
|
|
)
|
|
# StartCascade fired on the PANE-SCOPED port, NOT candidates[0] (52548).
|
|
assert len(start_cascade_calls) == 1
|
|
called_port, called_id = start_cascade_calls[0]
|
|
assert called_port == 61000
|
|
assert not bridge_mod_is_placeholder(called_id)
|
|
assert state is not None
|
|
assert state.conversation_id == called_id
|
|
assert patch_calls == [] # cold-start no longer records the phantom cascade (#2 data-loss)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_cold_start_falls_back_when_no_pane(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
No local pane (remote runner) → cold-start uses the lowest candidate port.
|
|
|
|
Preserves the current behavior on single-agy hosts and remote runners: when
|
|
``_terminal_tmux_pane`` yields no socket/target the pane cannot be scoped, so
|
|
the cold-start falls back to ``_candidate_agy_rpc_ports()[0]``.
|
|
"""
|
|
session_id = "conv_agy_noPaneFallback"
|
|
state, start_cascade_calls, _reader_calls, patch_calls = await _run_antigravity_auto_create(
|
|
tmp_path,
|
|
monkeypatch,
|
|
session_id=session_id,
|
|
snapshot={},
|
|
candidate_ports=[52548, 61000],
|
|
pane=None, # remote runner / no local pane
|
|
pane_scoped_port=99999, # must be ignored — there is no pane to scope to
|
|
)
|
|
assert len(start_cascade_calls) == 1
|
|
called_port, _called_id = start_cascade_calls[0]
|
|
assert called_port == 52548 # the lowest candidate, NOT the (ignored) pane port
|
|
assert state is not None
|
|
assert patch_calls == [] # cold-start no longer records the phantom cascade (#2 data-loss)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_cold_start_waits_when_pane_agy_absent(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Pane present, our agy NOT up yet, FOREIGN candidate present → no cold-start.
|
|
|
|
The cross-bind guard: with a local pane whose agy has not appeared yet
|
|
(``agy_found=False``) and a foreign agy as the only candidate, the cold-start
|
|
must NOT bind the foreign candidate — it keeps polling until its (collapsed)
|
|
deadline, leaving the placeholder for the reader to bind later. No
|
|
``StartCascade``, no ``external_session_id`` PATCH.
|
|
"""
|
|
session_id = "conv_agy_paneEarlyPoll"
|
|
state, start_cascade_calls, _reader_calls, patch_calls = await _run_antigravity_auto_create(
|
|
tmp_path,
|
|
monkeypatch,
|
|
session_id=session_id,
|
|
snapshot={},
|
|
candidate_ports=[52548], # a FOREIGN agy is the only candidate
|
|
pane=(tmp_path / "agy.sock", "main"),
|
|
pane_agy_found=False, # our agy not exec'd into the pane yet
|
|
pane_scoped_port=None,
|
|
)
|
|
# Never cold-started onto the foreign candidate; placeholder stands.
|
|
assert start_cascade_calls == []
|
|
assert patch_calls == []
|
|
assert state is not None
|
|
assert bridge_mod_is_placeholder(state.conversation_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_cold_start_falls_back_when_port_unattributable(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Pane present, our agy found, port not lsof-attributable → candidate fallback.
|
|
|
|
The restricted-/proc one-agy-per-pod case: our agy IS up in the pane
|
|
(``agy_found=True``) but lsof cannot attribute its listener, so the scoped
|
|
port is ``None``. Since agy exists here, the lone candidate is ours and the
|
|
candidate fallback is safe.
|
|
"""
|
|
session_id = "conv_agy_paneNoPort"
|
|
state, start_cascade_calls, _reader_calls, patch_calls = await _run_antigravity_auto_create(
|
|
tmp_path,
|
|
monkeypatch,
|
|
session_id=session_id,
|
|
snapshot={},
|
|
candidate_ports=[52548], # one-agy-per-pod → the lone candidate is ours
|
|
pane=(tmp_path / "agy.sock", "main"),
|
|
pane_agy_found=True, # our agy IS up...
|
|
pane_scoped_port=None, # ...but its port is not lsof-attributable
|
|
)
|
|
assert len(start_cascade_calls) == 1
|
|
assert start_cascade_calls[0][0] == 52548 # safe candidate fallback
|
|
assert state is not None
|
|
assert patch_calls == [] # cold-start no longer records the phantom cascade (#2 data-loss)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_resume_skips_cold_start(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A resume launch does NOT cold-start — the conversation already exists.
|
|
|
|
On resume the snapshot carries agy's real ``external_session_id`` (persisted
|
|
by a prior run), so the conversation already exists and ``StartCascade`` must
|
|
not be issued (it would create a second, empty one). Bridge state keeps the
|
|
resume id verbatim, and — since no cold-start runs — no ``external_session_id``
|
|
PATCH is issued (it already holds the resume id).
|
|
"""
|
|
session_id = "conv_agy_resume"
|
|
resume_id = "68caaeac-2eaf-4e2c-9b95-721b022f4903"
|
|
state, start_cascade_calls, _reader_calls, patch_calls = await _run_antigravity_auto_create(
|
|
tmp_path,
|
|
monkeypatch,
|
|
session_id=session_id,
|
|
snapshot={"external_session_id": resume_id},
|
|
candidate_ports=[52548],
|
|
)
|
|
assert start_cascade_calls == []
|
|
assert state is not None
|
|
assert state.conversation_id == resume_id
|
|
assert patch_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cold_start_agy_conversation_returns_early_on_real_id_in_bridge_state(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The runner cold-start refuses to run when bridge state already holds a real id.
|
|
|
|
Defense-in-depth mirroring the CLI cold-start (``antigravity_native.py``): the
|
|
caller only invokes this on a fresh launch (``if not resume:``), but if bridge
|
|
state already names a NON-placeholder conversation id, cold-starting would
|
|
create a second empty conversation and clobber the real id. The guard must
|
|
early-return BEFORE probing for a port or calling ``StartCascade`` — so even a
|
|
future caller that forgets the resume gate cannot cold-start over a real id.
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
import omnigent.antigravity_native_rpc as rpc_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
session_id = "conv_agy_guard"
|
|
real_id = "68caaeac-2eaf-4e2c-9b95-721b022f4903" # NOT an agy_conv_* placeholder
|
|
assert not bridge_mod.is_placeholder_conversation_id(real_id)
|
|
|
|
bridge_dir = bridge_mod.prepare_bridge_dir(session_id)
|
|
bridge_mod.write_bridge_state(
|
|
bridge_dir,
|
|
bridge_mod.AntigravityNativeBridgeState(
|
|
session_id=session_id,
|
|
conversation_id=real_id,
|
|
),
|
|
)
|
|
|
|
# The cold-start must touch NEITHER the port-scan NOR StartCascade.
|
|
def _no_ports() -> list[int]:
|
|
raise AssertionError("cold-start must not probe for a port when the id is real")
|
|
|
|
start_cascade_calls: list[tuple[int, str]] = []
|
|
|
|
def _fake_start_cascade(port: int, cascade_id: str, **_kwargs: Any) -> None:
|
|
start_cascade_calls.append((port, cascade_id))
|
|
|
|
monkeypatch.setattr(rpc_mod, "_candidate_agy_rpc_ports", _no_ports)
|
|
monkeypatch.setattr(rpc_mod, "start_cascade", _fake_start_cascade)
|
|
|
|
patch_calls: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
class _RecordingServerClient:
|
|
async def patch(self, url: str, *, json: dict[str, Any], **_kwargs: Any) -> httpx.Response:
|
|
patch_calls.append((url, json))
|
|
return httpx.Response(200, json={}, request=httpx.Request("PATCH", url))
|
|
|
|
result = await runner_app_mod._cold_start_agy_conversation(
|
|
bridge_dir,
|
|
session_id,
|
|
server_client=cast(httpx.AsyncClient, _RecordingServerClient()),
|
|
)
|
|
|
|
# Returns the existing real id, and never cold-started or re-PATCHed.
|
|
assert result == real_id
|
|
assert start_cascade_calls == []
|
|
assert patch_calls == []
|
|
# Bridge state is untouched (still the real id, not a fresh cold-start id).
|
|
state = bridge_mod.read_bridge_state(bridge_dir)
|
|
assert state is not None
|
|
assert state.conversation_id == real_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_cold_start_port_timeout_keeps_placeholder(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
When no connect-RPC port answers, the cold-start is best-effort: the launch
|
|
still completes and leaves the placeholder id for the reader to bind.
|
|
|
|
The cold-start must NOT abort the launch (which would leave a registered
|
|
terminal with no reader, never self-healing). With no port, ``start_cascade``
|
|
is never called, bridge state retains the ``agy_conv_*`` placeholder, and no
|
|
``external_session_id`` PATCH is issued (there is no real id to record).
|
|
"""
|
|
session_id = "conv_agy_noport"
|
|
state, start_cascade_calls, reader_calls, patch_calls = await _run_antigravity_auto_create(
|
|
tmp_path,
|
|
monkeypatch,
|
|
session_id=session_id,
|
|
snapshot={},
|
|
candidate_ports=[], # port never comes up within the bounded poll
|
|
)
|
|
assert start_cascade_calls == []
|
|
assert state is not None
|
|
assert bridge_mod_is_placeholder(state.conversation_id)
|
|
assert patch_calls == []
|
|
# The RPC reader still spawns regardless of the cold-start outcome.
|
|
assert len(reader_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_wires_reader_task_and_interaction_bridge(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Auto-create spawns the RPC reader task and wires its interaction bridge.
|
|
|
|
Asserts the Task 11b integration linchpin end-to-end against fakes:
|
|
|
|
* the background task is the ``antigravity-reader-{session_id}`` reader (NOT
|
|
the transcript forwarder), registered in the single-instance task slot;
|
|
* the reader is wired with an ``on_pending_interaction`` that, when a WAITING
|
|
interaction is handed to it, POSTs the Task 9 antigravity-elicitation hook
|
|
with ``{elicitation_id, params}``, then — on the human verdict — delivers
|
|
the answer to agy via ``handle_user_interaction`` (the bridge default).
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
import omnigent.antigravity_native_interactions as interactions_mod
|
|
import omnigent.antigravity_native_launch as launch_mod
|
|
import omnigent.antigravity_native_reader as reader_mod
|
|
import omnigent.antigravity_native_rpc as rpc_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
from omnigent.antigravity_native_interactions import agy_elicitation_id
|
|
from omnigent.antigravity_native_steps import pending_interaction
|
|
|
|
session_id = "conv_agy_wiring"
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(tmp_path / "workspace"))
|
|
(tmp_path / "workspace").mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
monkeypatch.setattr(
|
|
launch_mod, "build_agy_launch", lambda **_kwargs: (("agy",), {"AGY_ENV": "1"})
|
|
)
|
|
monkeypatch.setattr(bridge_mod, "ensure_agy_onboarding_complete", lambda: None)
|
|
monkeypatch.setattr(runner_app_mod, "_terminal_tmux_pane", lambda *_a, **_k: (None, None))
|
|
# Skip the 11a cold-start network work (resume launch → no StartCascade).
|
|
resume_id = "efb134b2-d69f-43de-bb54-c9ece346d8a3"
|
|
monkeypatch.setattr(rpc_mod, "_candidate_agy_rpc_ports", list)
|
|
|
|
# Capture the reader's wiring (client + on_pending_interaction) and park so the
|
|
# owning ``_run_antigravity_reader`` keeps its client open while we drive the
|
|
# callback. ``supervise_reader`` is patched at its definition module (the
|
|
# helper imports it lazily).
|
|
captured: dict[str, Any] = {}
|
|
wired = asyncio.Event()
|
|
|
|
def _capturing_reader(*_args: Any, **kwargs: Any) -> Any:
|
|
captured.update(kwargs)
|
|
wired.set()
|
|
|
|
async def _runner() -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
return _runner()
|
|
|
|
monkeypatch.setattr(reader_mod, "supervise_reader", _capturing_reader)
|
|
|
|
# Control the reader's Omnigent client transport: record the elicitation hook
|
|
# POST and return the human's ACCEPT verdict as an ElicitationResult body.
|
|
hook_posts: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
def _handle(request: httpx.Request) -> httpx.Response:
|
|
hook_posts.append((request.url.path, json.loads(request.content)))
|
|
return httpx.Response(200, json={"action": "accept", "content": {}})
|
|
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def _mock_client(**kwargs: Any) -> httpx.AsyncClient:
|
|
kwargs.pop("transport", None)
|
|
return real_async_client(transport=httpx.MockTransport(_handle), **kwargs)
|
|
|
|
# ``_run_antigravity_reader`` builds its client via ``httpx.AsyncClient``; patch
|
|
# the httpx module itself (the reader is the only AsyncClient built on this
|
|
# auto-create path — the snapshot client is a hand-rolled fake) so its POSTs hit
|
|
# the MockTransport above instead of the network.
|
|
monkeypatch.setattr(httpx, "AsyncClient", _mock_client)
|
|
|
|
# The WAITING (permission) step the bridge re-reads at delivery time, and the
|
|
# ``handle_user_interaction`` delivery sink (the bridge's default ``deliver``).
|
|
waiting_step = json.loads(
|
|
(
|
|
Path(__file__).resolve().parents[1]
|
|
/ "fixtures"
|
|
/ "antigravity"
|
|
/ "steps"
|
|
/ "run_command_waiting.json"
|
|
).read_text()
|
|
)
|
|
# The shared ``run_reader_with_bridge`` helper's ``_get_steps`` closure binds
|
|
# ``get_trajectory_steps`` from the reader module's top-level import, so patch
|
|
# it there (matching how the reader's own poll-loop tests patch it).
|
|
monkeypatch.setattr(reader_mod, "get_trajectory_steps", lambda _port, _cid: [waiting_step])
|
|
delivered: list[dict[str, Any]] = []
|
|
|
|
def _fake_deliver(
|
|
port: int, cascade_id: str, *, trajectory_id: str, step_index: int, payload: Any
|
|
) -> None:
|
|
delivered.append(
|
|
{
|
|
"port": port,
|
|
"cascade_id": cascade_id,
|
|
"trajectory_id": trajectory_id,
|
|
"step_index": step_index,
|
|
"payload": payload,
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(interactions_mod, "handle_user_interaction", _fake_deliver)
|
|
|
|
class _SnapshotServerClient:
|
|
async def get(self, url: str, **_kwargs: Any) -> httpx.Response:
|
|
assert url == f"/v1/sessions/{session_id}"
|
|
return httpx.Response(
|
|
200,
|
|
json={"external_session_id": resume_id},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
|
|
class _FakeResourceRegistry:
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
*,
|
|
resource_role: str | None = None,
|
|
**_kwargs: Any,
|
|
) -> SessionResourceView:
|
|
return SessionResourceView(
|
|
id="terminal_antigravity_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Antigravity",
|
|
)
|
|
|
|
try:
|
|
await _auto_create_antigravity_terminal(
|
|
session_id,
|
|
cast(SessionResourceRegistry, _FakeResourceRegistry()),
|
|
lambda _sid, _event: None,
|
|
server_client=cast(httpx.AsyncClient, _SnapshotServerClient()),
|
|
)
|
|
await asyncio.wait_for(wired.wait(), timeout=5.0)
|
|
|
|
# The single-instance task slot holds the reader task, named for the reader.
|
|
task = runner_app_mod._AUTO_FORWARDER_TASKS[session_id]
|
|
assert task.get_name() == f"antigravity-reader-{session_id}"
|
|
|
|
# Drive the captured wiring with a WAITING (permission) interaction, as the
|
|
# reader would when it observes one. Use the SAME cascade id + port the
|
|
# callback contract threads through.
|
|
port = 52548
|
|
pending = pending_interaction(waiting_step)
|
|
assert pending is not None
|
|
await captured["on_pending_interaction"](resume_id, port, pending)
|
|
|
|
# 1) It POSTed the antigravity-elicitation hook with {elicitation_id, params}.
|
|
assert len(hook_posts) == 1
|
|
path, body = hook_posts[0]
|
|
assert path == f"/v1/sessions/{session_id}/hooks/antigravity-elicitation-request"
|
|
assert body["elicitation_id"] == agy_elicitation_id(
|
|
resume_id, pending["trajectory_id"], pending["step_index"]
|
|
)
|
|
assert isinstance(body["params"], dict)
|
|
|
|
# 2) On the ACCEPT verdict it delivered the answer to agy via the bridge.
|
|
assert len(delivered) == 1
|
|
assert delivered[0]["cascade_id"] == resume_id
|
|
assert delivered[0]["port"] == port
|
|
assert delivered[0]["trajectory_id"] == pending["trajectory_id"]
|
|
assert delivered[0]["step_index"] == pending["step_index"]
|
|
assert delivered[0]["payload"] == {"permission": {"allow": True}}
|
|
finally:
|
|
await runner_app_mod._cancel_auto_forwarder_task(session_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_wires_omnigent_mcp_relay(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Auto-create wires the Omnigent MCP relay so agy gets the sys_* tools (#1194).
|
|
|
|
Asserts the three wiring points end-to-end against fakes:
|
|
|
|
* the relay starter (``ensure_comment_relay``) is invoked for THIS session's
|
|
bridge dir before launch, so its ``tool_relay.json`` is on disk when agy
|
|
first scans the MCP server;
|
|
* the relay ``mcp_config.json`` is written into the per-session isolated agy
|
|
Gemini dir (``<bridge_dir>/agy-home/.gemini/config``), NOT the user's real
|
|
``~/.gemini`` — the config-scoping footgun the design avoids;
|
|
* the launch args carry ``--gemini_dir=<isolated .gemini>`` while the launch
|
|
env does not override ``HOME``, so agy keeps platform auth such as macOS
|
|
Keychain but loads the bridge-scoped config.
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
import omnigent.antigravity_native_launch as launch_mod
|
|
import omnigent.antigravity_native_reader as reader_mod
|
|
import omnigent.antigravity_native_rpc as rpc_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
session_id = "conv_agy_mcp"
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(tmp_path / "workspace"))
|
|
(tmp_path / "workspace").mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
monkeypatch.setattr(bridge_mod, "ensure_agy_onboarding_complete", lambda: None)
|
|
monkeypatch.setattr(runner_app_mod, "_terminal_tmux_pane", lambda *_a, **_k: (None, None))
|
|
monkeypatch.setattr(rpc_mod, "_candidate_agy_rpc_ports", list)
|
|
|
|
# Capture the env build_agy_launch starts from so we can assert it is preserved
|
|
# without a HOME override (the launch env is the captured spec's ``env`` below).
|
|
monkeypatch.setattr(
|
|
launch_mod, "build_agy_launch", lambda **_kwargs: (("agy",), {"AGY_ENV": "1"})
|
|
)
|
|
|
|
def _noop_reader(*_args: Any, **_kwargs: Any) -> Any:
|
|
async def _runner() -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
return _runner()
|
|
|
|
monkeypatch.setattr(reader_mod, "supervise_reader", _noop_reader)
|
|
|
|
relay_calls: list[dict[str, Any]] = []
|
|
|
|
async def _recording_relay(session_id_arg: str, **kwargs: Any) -> None:
|
|
relay_calls.append({"session_id": session_id_arg, **kwargs})
|
|
|
|
captured_spec: dict[str, Any] = {}
|
|
|
|
snapshot = {"workspace": str(tmp_path / "workspace")}
|
|
|
|
class _SnapshotServerClient:
|
|
async def get(self, url: str, **_kwargs: Any) -> httpx.Response:
|
|
assert url == f"/v1/sessions/{session_id}"
|
|
return httpx.Response(200, json=snapshot, request=httpx.Request("GET", url))
|
|
|
|
async def patch(self, url: str, *, json: dict[str, Any], **_kwargs: Any) -> httpx.Response:
|
|
del json
|
|
return httpx.Response(200, json={}, request=httpx.Request("PATCH", url))
|
|
|
|
class _FakeResourceRegistry:
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
*,
|
|
resource_role: str | None = None,
|
|
**_kwargs: Any,
|
|
) -> SessionResourceView:
|
|
captured_spec["args"] = list(spec.args)
|
|
captured_spec["env"] = dict(spec.env)
|
|
return SessionResourceView(
|
|
id="terminal_antigravity_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Antigravity",
|
|
)
|
|
|
|
try:
|
|
await _auto_create_antigravity_terminal(
|
|
session_id,
|
|
cast(SessionResourceRegistry, _FakeResourceRegistry()),
|
|
lambda _sid, _event: None,
|
|
server_client=cast(httpx.AsyncClient, _SnapshotServerClient()),
|
|
ensure_comment_relay=_recording_relay,
|
|
)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
await runner_app_mod._cancel_auto_forwarder_task(session_id)
|
|
|
|
bridge_dir = bridge_mod.bridge_dir_for_bridge_id(session_id)
|
|
iso_home = bridge_mod.agy_home_dir(bridge_dir)
|
|
iso_gemini = bridge_mod.agy_gemini_dir(bridge_dir)
|
|
|
|
# 1) The relay starter was invoked for this session's bridge dir.
|
|
assert len(relay_calls) == 1
|
|
assert relay_calls[0]["session_id"] == session_id
|
|
assert relay_calls[0]["explicit_bridge_dir"] == bridge_dir
|
|
assert relay_calls[0]["await_notify"] is False
|
|
|
|
# 2) The relay mcp_config.json landed in the ISOLATED agy HOME, not ~/.gemini.
|
|
mcp_config = iso_home / ".gemini" / "config" / "mcp_config.json"
|
|
assert mcp_config.is_file()
|
|
payload = json.loads(mcp_config.read_text(encoding="utf-8"))
|
|
server = payload["mcpServers"]["omnigent"]
|
|
assert server["args"][:4] == ["-I", "-m", "omnigent.claude_native_bridge", "serve-mcp"]
|
|
assert str(bridge_dir) in server["args"]
|
|
assert "sys_session_create" in server["enabledTools"]
|
|
# The bridge token the shared relay needs was written into the bridge dir.
|
|
assert (bridge_dir / "bridge.json").is_file()
|
|
|
|
# 3) The launch args point agy at the isolated Gemini dir, while HOME stays
|
|
# real so platform auth such as macOS Keychain keeps working.
|
|
assert captured_spec["args"] == [f"--gemini_dir={iso_gemini}"]
|
|
assert "HOME" not in captured_spec["env"]
|
|
assert captured_spec["env"]["AGY_ENV"] == "1"
|
|
|
|
# 4) The session workspace is pre-trusted AND the feedback survey is disabled
|
|
# in the SAME isolated settings.json agy reads under --gemini_dir (#1598 +
|
|
# #1494), proving the trust seed and the survey-disable compose in the
|
|
# isolated dir without one clobbering the other (the rebase conflict point).
|
|
settings = iso_gemini / "antigravity-cli" / "settings.json"
|
|
assert settings.is_file()
|
|
settings_data = json.loads(settings.read_text(encoding="utf-8"))
|
|
workspace_key = str((tmp_path / "workspace").resolve())
|
|
assert workspace_key in settings_data.get("trustedWorkspaces", [])
|
|
assert settings_data.get("showFeedbackSurvey") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_antigravity_prepends_gemini_dir_to_generated_flags(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""``--gemini_dir`` is inserted right after the binary, ahead of every other flag.
|
|
|
|
The sibling relay-wiring test mocks ``build_agy_launch`` to emit no flags, so it
|
|
cannot prove the insertion does not corrupt the order of the REAL generated
|
|
flags (``--conversation``, ``--model``, ``--dangerously-skip-permissions``, and
|
|
pass-through ``extra_args``). agy global flags must precede any positional /
|
|
subcommand token, so ``--gemini_dir`` is prepended at index 0 and the rest of
|
|
argv is preserved verbatim. This guards that invariant against a future change
|
|
to the argv-composition line in ``_auto_create_antigravity_terminal``.
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
import omnigent.antigravity_native_launch as launch_mod
|
|
import omnigent.antigravity_native_reader as reader_mod
|
|
import omnigent.antigravity_native_rpc as rpc_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
session_id = "conv_agy_argv"
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(tmp_path / "workspace"))
|
|
(tmp_path / "workspace").mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
monkeypatch.setattr(bridge_mod, "ensure_agy_onboarding_complete", lambda: None)
|
|
monkeypatch.setattr(runner_app_mod, "_terminal_tmux_pane", lambda *_a, **_k: (None, None))
|
|
monkeypatch.setattr(rpc_mod, "_candidate_agy_rpc_ports", list)
|
|
|
|
# A realistic build_agy_launch output: binary + a full set of generated flags.
|
|
# The argv-composition line must preserve these verbatim after the prepend.
|
|
generated_tail = [
|
|
"--conversation",
|
|
"abc-123",
|
|
"--model",
|
|
"gemini-2.5-pro",
|
|
"--dangerously-skip-permissions",
|
|
"--print-timeout",
|
|
"30",
|
|
]
|
|
monkeypatch.setattr(
|
|
launch_mod,
|
|
"build_agy_launch",
|
|
lambda **_kwargs: (("agy", *generated_tail), {}),
|
|
)
|
|
|
|
def _noop_reader(*_args: Any, **_kwargs: Any) -> Any:
|
|
async def _runner() -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
return _runner()
|
|
|
|
monkeypatch.setattr(reader_mod, "supervise_reader", _noop_reader)
|
|
|
|
async def _recording_relay(_session_id_arg: str, **_kwargs: Any) -> None:
|
|
return None
|
|
|
|
captured_spec: dict[str, Any] = {}
|
|
snapshot = {"workspace": str(tmp_path / "workspace")}
|
|
|
|
class _SnapshotServerClient:
|
|
async def get(self, url: str, **_kwargs: Any) -> httpx.Response:
|
|
assert url == f"/v1/sessions/{session_id}"
|
|
return httpx.Response(200, json=snapshot, request=httpx.Request("GET", url))
|
|
|
|
async def patch(self, url: str, *, json: dict[str, Any], **_kwargs: Any) -> httpx.Response:
|
|
del json
|
|
return httpx.Response(200, json={}, request=httpx.Request("PATCH", url))
|
|
|
|
class _FakeResourceRegistry:
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
*,
|
|
resource_role: str | None = None,
|
|
**_kwargs: Any,
|
|
) -> SessionResourceView:
|
|
captured_spec["command"] = spec.command
|
|
captured_spec["args"] = list(spec.args)
|
|
return SessionResourceView(
|
|
id="terminal_antigravity_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Antigravity",
|
|
)
|
|
|
|
try:
|
|
await _auto_create_antigravity_terminal(
|
|
session_id,
|
|
cast(SessionResourceRegistry, _FakeResourceRegistry()),
|
|
lambda _sid, _event: None,
|
|
server_client=cast(httpx.AsyncClient, _SnapshotServerClient()),
|
|
ensure_comment_relay=_recording_relay,
|
|
)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
await runner_app_mod._cancel_auto_forwarder_task(session_id)
|
|
|
|
bridge_dir = bridge_mod.bridge_dir_for_bridge_id(session_id)
|
|
iso_gemini = bridge_mod.agy_gemini_dir(bridge_dir)
|
|
|
|
# The terminal command stays the agy binary; --gemini_dir leads the arg list and
|
|
# every generated flag follows in its original order (none dropped or reordered).
|
|
assert captured_spec["command"] == "agy"
|
|
assert captured_spec["args"] == [f"--gemini_dir={iso_gemini}", *generated_tail]
|
|
|
|
|
|
@pytest.mark.parametrize("parent_host_id", ["host_parent", None])
|
|
@pytest.mark.asyncio
|
|
async def test_codex_subagent_always_needs_runner_terminal(
|
|
parent_host_id: str | None,
|
|
) -> None:
|
|
"""
|
|
Codex-native sub-agent children always need a runner-created terminal.
|
|
|
|
A sub-agent child (created via ``sys_session_send``) carries a
|
|
``parent_session_id`` but no ``host_id`` of its own, and no CLI ever
|
|
manages its terminal. The gate must therefore return ``True`` regardless
|
|
of whether the PARENT was host-spawned (``host_id`` present) or CLI-driven
|
|
(``host_id`` None).
|
|
|
|
The ``parent_host_id=None`` case is the regression: gating the child
|
|
on the parent's ``host_id`` made codex-native sub-agents under a CLI-driven
|
|
parent (e.g. nessie run via ``omnigent run --server``) silently never get
|
|
a terminal, so ``sys_session_send`` dispatch no-op'd. If that case returns
|
|
``False``, the regression has reappeared.
|
|
|
|
:param parent_host_id: The parent session's ``host_id`` value to simulate;
|
|
``"host_parent"`` (web-UI parent) or ``None`` (CLI-driven parent).
|
|
"""
|
|
from omnigent.runner.app import _codex_session_needs_runner_terminal
|
|
|
|
class _Client:
|
|
async def get(self, url: str, *, timeout: float) -> httpx.Response:
|
|
"""
|
|
Return child then parent session snapshots.
|
|
|
|
:param url: Omnigent session snapshot URL.
|
|
:param timeout: HTTP timeout in seconds.
|
|
:returns: Fake Omnigent session response.
|
|
"""
|
|
del timeout
|
|
if url.endswith("/conv_child"):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "conv_child",
|
|
"parent_session_id": "conv_parent",
|
|
"host_id": None,
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
if url.endswith("/conv_parent"):
|
|
return httpx.Response(
|
|
200,
|
|
json={"id": "conv_parent", "host_id": parent_host_id},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
return httpx.Response(404, request=httpx.Request("GET", url))
|
|
|
|
# True for both parent host_ids; a False for the None parent = regressed.
|
|
assert await _codex_session_needs_runner_terminal(_Client(), "conv_child") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_codex_session_needs_runner_terminal_false_without_client() -> None:
|
|
"""
|
|
With no server client (embedded/test runner) the gate cannot confirm a
|
|
host-spawned or sub-agent session, so it returns ``False`` — skipping
|
|
auto-create rather than risking a competing setup.
|
|
"""
|
|
from omnigent.runner.app import _codex_session_needs_runner_terminal
|
|
|
|
assert await _codex_session_needs_runner_terminal(None, "conv_x") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_codex_discover_thread_and_forward_cleans_up_on_discovery_failure(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
When the fresh TUI never starts a thread, the background task must close
|
|
the listener AND the per-session app-server and drop it from the registry.
|
|
Otherwise each failed host-spawned codex session orphans an app-server
|
|
subprocess (and a dangling listener) for the runner's lifetime.
|
|
"""
|
|
from omnigent import codex_native_forwarder
|
|
from omnigent.runner.app import (
|
|
_AUTO_CODEX_APP_SERVERS,
|
|
_codex_discover_thread_and_forward,
|
|
)
|
|
|
|
closed = {"client": False, "app_server": False}
|
|
|
|
class _Client:
|
|
async def close(self) -> None:
|
|
closed["client"] = True
|
|
|
|
class _AppServer:
|
|
async def close(self) -> None:
|
|
closed["app_server"] = True
|
|
|
|
async def _raise_no_thread(*_args: object, **_kwargs: object) -> str:
|
|
raise TimeoutError("no thread/started observed")
|
|
|
|
# The helper lazily imports wait_for_thread_started from the forwarder
|
|
# module on each call, so patching the module attribute takes effect.
|
|
monkeypatch.setattr(codex_native_forwarder, "wait_for_thread_started", _raise_no_thread)
|
|
|
|
session_id = "conv_codex_cleanup_test"
|
|
_AUTO_CODEX_APP_SERVERS[session_id] = _AppServer()
|
|
try:
|
|
await _codex_discover_thread_and_forward(
|
|
session_id=session_id,
|
|
bridge_dir=tmp_path,
|
|
codex_ws_url="ws://127.0.0.1:1",
|
|
codex_home=tmp_path / "codex-home",
|
|
event_client=_Client(), # type: ignore[arg-type]
|
|
)
|
|
finally:
|
|
_AUTO_CODEX_APP_SERVERS.pop(session_id, None)
|
|
|
|
# client closed = no dangling reader task/socket; app_server closed = no
|
|
# orphaned subprocess; dropped from registry = no leaked dict reference.
|
|
assert closed["client"] is True
|
|
assert closed["app_server"] is True
|
|
assert session_id not in _AUTO_CODEX_APP_SERVERS
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("exc", "expected_cause"),
|
|
[
|
|
(TimeoutError("no thread/started observed"), "startup timed out"),
|
|
(
|
|
RuntimeError("event stream ended"),
|
|
"event stream ended before a thread was created",
|
|
),
|
|
],
|
|
)
|
|
async def test_codex_discover_thread_and_forward_records_accurate_startup_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
exc: Exception,
|
|
expected_cause: str,
|
|
) -> None:
|
|
"""
|
|
The startup breadcrumb must describe the actual failure mode: a timeout
|
|
reads as "startup timed out", while a RuntimeError (TUI exited / event
|
|
stream ended) must NOT be mislabeled as a timeout.
|
|
"""
|
|
from omnigent import codex_native_forwarder
|
|
from omnigent.codex_native_bridge import read_bridge_startup_error
|
|
from omnigent.runner.app import (
|
|
_AUTO_CODEX_APP_SERVERS,
|
|
_codex_discover_thread_and_forward,
|
|
)
|
|
|
|
class _Client:
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
class _AppServer:
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
async def _raise(*_args: object, **_kwargs: object) -> str:
|
|
raise exc
|
|
|
|
monkeypatch.setattr(codex_native_forwarder, "wait_for_thread_started", _raise)
|
|
|
|
session_id = "conv_codex_startup_error_test"
|
|
_AUTO_CODEX_APP_SERVERS[session_id] = _AppServer()
|
|
try:
|
|
await _codex_discover_thread_and_forward(
|
|
session_id=session_id,
|
|
bridge_dir=tmp_path,
|
|
codex_ws_url="ws://127.0.0.1:1",
|
|
codex_home=tmp_path / "codex-home",
|
|
event_client=_Client(), # type: ignore[arg-type]
|
|
)
|
|
finally:
|
|
_AUTO_CODEX_APP_SERVERS.pop(session_id, None)
|
|
|
|
recorded = read_bridge_startup_error(tmp_path)
|
|
assert recorded is not None
|
|
assert expected_cause in recorded
|
|
assert type(exc).__name__ in recorded
|
|
# A RuntimeError must never be described as a timeout.
|
|
if not isinstance(exc, TimeoutError):
|
|
assert "timed out" not in recorded
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session() -> None:
|
|
"""``POST /v1/sessions`` spawns harness and returns SessionResponse shape."""
|
|
app, pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_1", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
body = resp.json()
|
|
assert body["id"] == "conv_1"
|
|
assert body["agent_id"] == "ag_1"
|
|
assert body["status"] == "idle"
|
|
assert "created_at" in body
|
|
assert body["items"] == []
|
|
assert pm.has_session("conv_1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_preserves_existing_event_queue() -> None:
|
|
"""Session init must not orphan a stream subscriber's event queue.
|
|
|
|
The Omnigent relay's ``GET /stream`` lazily creates the per-session event
|
|
queue when it connects before ``POST /v1/sessions`` runs (the relay
|
|
can race ahead of init). Init used to *unconditionally replace* that
|
|
queue, orphaning the relay on the now-dead object: ``_publish_event``
|
|
then enqueued onto the new queue while the relay's generator blocked
|
|
forever on the old one, so later events never reached the server. For
|
|
claude-native that dropped the PTY-watcher ``idle`` edge (emitted
|
|
asynchronously after the turn), stranding the session's web status at
|
|
"working". Init must PRESERVE an existing queue — assert the
|
|
pre-attached queue object survives init unchanged.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
|
|
app, _pm, _hc = _build_lifecycle_app()
|
|
# Simulate the relay's GET /stream having already attached (lazily
|
|
# created the queue) before init runs.
|
|
sentinel: asyncio.Queue[Any] = asyncio.Queue()
|
|
_session_event_queues_ref["conv_pre"] = sentinel
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_pre", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
# Same object → a relay already blocked on it keeps receiving
|
|
# events that ``_publish_event`` enqueues after init.
|
|
assert _session_event_queues_ref.get("conv_pre") is sentinel
|
|
finally:
|
|
_session_event_queues_ref.pop("conv_pre", None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_active_work_reports_process_manager_turns() -> None:
|
|
"""The runner idle watchdog sees active harness turns.
|
|
|
|
:returns: None.
|
|
"""
|
|
app, pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_1", "agent_id": "ag_1"},
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
assert app.state.has_active_work() is False
|
|
|
|
pm.mark_turn_active("conv_1")
|
|
|
|
assert app.state.has_active_work() is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_missing_fields() -> None:
|
|
"""``POST /v1/sessions`` with missing fields returns 400."""
|
|
app, _pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_1"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_scaffold_mode() -> None:
|
|
"""``POST /v1/sessions`` returns 501 when process_manager is None."""
|
|
app = create_runner_app(server_client=NullServerClient()) # type: ignore[arg-type]
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_1", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 501
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_status_idle() -> None:
|
|
"""``GET /v1/sessions/{id}`` returns idle after session creation."""
|
|
app, _pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_1", "agent_id": "ag_1"},
|
|
)
|
|
resp = await client.get("/v1/sessions/conv_1")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "idle"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_status_running() -> None:
|
|
"""``GET /v1/sessions/{id}`` returns running when a turn is active."""
|
|
app, pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_1", "agent_id": "ag_1"},
|
|
)
|
|
pm.mark_turn_active("conv_1")
|
|
resp = await client.get("/v1/sessions/conv_1")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "running"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_unknown() -> None:
|
|
"""``GET /v1/sessions/{id}`` returns 404 for unknown session."""
|
|
app, _pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
resp = await client.get("/v1/sessions/nonexistent")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_session() -> None:
|
|
"""``DELETE /v1/sessions/{id}`` releases harness and cleans caches."""
|
|
app, pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_1", "agent_id": "ag_1"},
|
|
)
|
|
resp = await client.delete("/v1/sessions/conv_1")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["deleted"] is True
|
|
assert body["session_id"] == "conv_1"
|
|
assert "conv_1" in pm.released
|
|
assert not pm.has_session("conv_1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_session_with_active_turn() -> None:
|
|
"""``DELETE /v1/sessions/{id}`` cancels active turn before release."""
|
|
app, pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_1", "agent_id": "ag_1"},
|
|
)
|
|
pm.mark_turn_active("conv_1")
|
|
resp = await client.delete("/v1/sessions/conv_1")
|
|
assert resp.status_code == 200
|
|
assert "conv_1" in pm.cancelled
|
|
assert "conv_1" in pm.released
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_stream_receives_events() -> None:
|
|
"""``GET /v1/sessions/{id}/stream`` yields events published by proxy_stream."""
|
|
app, _pm, _hc = _build_lifecycle_app()
|
|
|
|
async with _runner_client(app) as client:
|
|
# Create the session first.
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_s", "agent_id": "ag_1"},
|
|
)
|
|
|
|
import asyncio
|
|
|
|
collected: list[dict[str, Any]] = []
|
|
|
|
async def _subscribe() -> None:
|
|
"""Subscribe to SSE and collect events until [DONE]."""
|
|
async with client.stream("GET", "/v1/sessions/conv_s/stream") as stream:
|
|
async for line in stream.aiter_lines():
|
|
if line.startswith("data: "):
|
|
payload = line[6:]
|
|
if payload == "[DONE]":
|
|
return
|
|
collected.append(json.loads(payload))
|
|
|
|
sub_task = asyncio.create_task(_subscribe())
|
|
await asyncio.sleep(0.05)
|
|
|
|
# Trigger a turn — proxy_stream publishes events via
|
|
# session_stream. The stream stays open across turns;
|
|
# deleting the session sends [DONE].
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_s/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
async for _ in resp.aiter_text():
|
|
pass
|
|
|
|
# Allow turn-end bookkeeping to run.
|
|
await asyncio.sleep(0.05)
|
|
|
|
# Delete the session to close the stream ([DONE]).
|
|
await client.delete("/v1/sessions/conv_s")
|
|
|
|
await asyncio.wait_for(sub_task, timeout=5.0)
|
|
|
|
# session.status=running + harness frames + session.status=idle.
|
|
statuses = [e.get("status") for e in collected if e.get("type") == "session.status"]
|
|
assert "running" in statuses, f"session.status=running must appear, got statuses: {statuses}"
|
|
assert statuses[-1] in ("idle", "failed"), (
|
|
f"last session.status must be idle or failed, got statuses: {statuses}"
|
|
)
|
|
harness_events = [e for e in collected if e.get("type") != "session.status"]
|
|
assert len(harness_events) >= 2, (
|
|
f"Expected at least 2 harness events, got {len(harness_events)}: {harness_events}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_stream_emits_heartbeat_on_idle() -> None:
|
|
"""The session stream emits an immediate and idle ``session.heartbeat``."""
|
|
import omnigent.runner.app as runner_app_module
|
|
|
|
original = runner_app_module._SESSION_STREAM_HEARTBEAT_S
|
|
runner_app_module._SESSION_STREAM_HEARTBEAT_S = 0.05
|
|
try:
|
|
app, _pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_hb", "agent_id": "ag_1"},
|
|
)
|
|
collected: list[dict[str, Any]] = []
|
|
|
|
async def _subscribe() -> None:
|
|
async with client.stream("GET", "/v1/sessions/conv_hb/stream") as stream:
|
|
async for line in stream.aiter_lines():
|
|
if line.startswith("data: "):
|
|
payload = line[6:]
|
|
if payload == "[DONE]":
|
|
return
|
|
collected.append(json.loads(payload))
|
|
|
|
sub_task = asyncio.create_task(_subscribe())
|
|
await asyncio.sleep(0.2)
|
|
await client.delete("/v1/sessions/conv_hb")
|
|
await asyncio.wait_for(sub_task, timeout=5.0)
|
|
|
|
heartbeats = [e for e in collected if e.get("type") == "session.heartbeat"]
|
|
assert len(heartbeats) >= 1, f"Expected at least 1 session.heartbeat, got {collected}"
|
|
assert collected[0] == {"type": "session.heartbeat"}, (
|
|
"The first stream frame must be the ready heartbeat. Omnigent waits "
|
|
"for this before forwarding fast no-replay user input."
|
|
)
|
|
finally:
|
|
runner_app_module._SESSION_STREAM_HEARTBEAT_S = original
|
|
|
|
|
|
# ── Turn sequencing tests (Step 5) ──────────────────────────────────
|
|
|
|
|
|
class _BlockingHarnessClient(_ScriptedHarnessClient):
|
|
"""Harness that blocks mid-stream until an event is set."""
|
|
|
|
def __init__(
|
|
self,
|
|
sse_frames: list[str],
|
|
gate: asyncio.Event,
|
|
) -> None:
|
|
"""
|
|
Wrap scripted frames with a gate that pauses mid-stream.
|
|
|
|
:param sse_frames: SSE frames returned by the harness stream.
|
|
:param gate: Event that releases the stream after the first frame.
|
|
"""
|
|
super().__init__(sse_frames)
|
|
self._gate = gate
|
|
self.post_seen: asyncio.Event = asyncio.Event()
|
|
|
|
def stream(self, method: str, url: str, *, json: dict[str, Any], timeout: Any) -> Any:
|
|
"""Stream that blocks after the first frame until gate is set."""
|
|
del method, url, timeout
|
|
self.posted_bodies.append(json)
|
|
self.post_seen.set()
|
|
frames = self._sse_frames
|
|
gate = self._gate
|
|
|
|
class _BlockingCtx:
|
|
status_code = 200
|
|
|
|
async def __aenter__(self) -> _BlockingHarnessClient._BlockingHandle:
|
|
return _BlockingHarnessClient._BlockingHandle(frames, gate)
|
|
|
|
async def __aexit__(self, *_: Any) -> None:
|
|
return None
|
|
|
|
return _BlockingCtx()
|
|
|
|
class _BlockingHandle:
|
|
"""Stream handle that pauses after the first frame."""
|
|
|
|
status_code = 200
|
|
|
|
def __init__(
|
|
self,
|
|
frames: list[str],
|
|
gate: asyncio.Event,
|
|
) -> None:
|
|
"""Initialize with frames and gate."""
|
|
self._frames = frames
|
|
self._gate = gate
|
|
|
|
async def aiter_text(self) -> AsyncIterator[str]:
|
|
"""Yield first frame, then wait for gate before rest."""
|
|
for i, frame in enumerate(self._frames):
|
|
if i == 1:
|
|
await self._gate.wait()
|
|
yield frame
|
|
|
|
|
|
def _build_blocking_app(
|
|
gate: asyncio.Event,
|
|
) -> tuple[FastAPI, _FakeProcessManager, _BlockingHarnessClient]:
|
|
"""Build a runner app with a blocking harness for concurrency tests.
|
|
|
|
:param gate: Event that unblocks the harness mid-stream.
|
|
:returns: ``(app, process_manager, harness_client)`` tuple.
|
|
"""
|
|
spec = AgentSpec(spec_version=1, name="t")
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_1"}}),
|
|
_sse({"type": "response.output_text.delta", "delta": "hi"}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_1"}}),
|
|
]
|
|
harness_client = _BlockingHarnessClient(sse_frames, gate)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
return app, pm, harness_client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_sequencing_buffers_concurrent_message() -> None:
|
|
"""Second message during an active turn returns 202 (buffered)."""
|
|
import asyncio as _aio
|
|
|
|
gate = _aio.Event()
|
|
app, _pm, _hc = _build_blocking_app(gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_buf", "agent_id": "ag_1"},
|
|
)
|
|
|
|
async def _run_first_turn() -> None:
|
|
"""Start the first turn and drain its response."""
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_buf/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [
|
|
{"type": "input_text", "text": "first"},
|
|
],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
async for _ in resp.aiter_text():
|
|
pass
|
|
|
|
# Start the first turn as a background task — it will
|
|
# block inside the harness stream until gate is set.
|
|
turn_task = _aio.create_task(_run_first_turn())
|
|
await _aio.sleep(0.05)
|
|
|
|
# Second message while turn active → 202 buffered.
|
|
resp2 = await client.post(
|
|
"/v1/sessions/conv_buf/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [
|
|
{"type": "input_text", "text": "second"},
|
|
],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp2.status_code == 202, (
|
|
f"Expected 202 buffered, got {resp2.status_code}: {resp2.text}"
|
|
)
|
|
assert resp2.json()["status"] == "buffered"
|
|
|
|
# Unblock the harness and let the first turn complete.
|
|
gate.set()
|
|
await _aio.wait_for(turn_task, timeout=5.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_lifecycle_events() -> None:
|
|
"""Turn start/complete lifecycle events appear on the session stream."""
|
|
app, _pm, _hc = _build_lifecycle_app()
|
|
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_lc", "agent_id": "ag_1"},
|
|
)
|
|
|
|
import asyncio
|
|
|
|
collected: list[dict[str, Any]] = []
|
|
|
|
async def _sub() -> None:
|
|
"""Collect events until [DONE]."""
|
|
async with client.stream("GET", "/v1/sessions/conv_lc/stream") as stream:
|
|
async for line in stream.aiter_lines():
|
|
if line.startswith("data: "):
|
|
payload = line[6:]
|
|
if payload == "[DONE]":
|
|
return
|
|
collected.append(json.loads(payload))
|
|
|
|
task = asyncio.create_task(_sub())
|
|
await asyncio.sleep(0.05)
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_lc/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
async for _ in resp.aiter_text():
|
|
pass
|
|
await asyncio.sleep(0.05)
|
|
|
|
await client.delete("/v1/sessions/conv_lc")
|
|
await asyncio.wait_for(task, timeout=5.0)
|
|
|
|
lifecycle_events = [e for e in collected if e.get("type") != "session.heartbeat"]
|
|
types = [e.get("type") for e in lifecycle_events]
|
|
assert lifecycle_events, (
|
|
f"Expected turn lifecycle events after ready heartbeat, got {collected}"
|
|
)
|
|
# session.status=running must be the first non-heartbeat event.
|
|
assert types[0] == "session.status", (
|
|
f"First non-heartbeat event must be session.status, got {types[0]}"
|
|
)
|
|
assert lifecycle_events[0].get("status") == "running", (
|
|
f"First session.status must be running, got {lifecycle_events[0].get('status')}"
|
|
)
|
|
# session.status=idle must appear after harness events.
|
|
statuses = [e.get("status") for e in collected if e.get("type") == "session.status"]
|
|
assert statuses[-1] in ("idle", "failed"), (
|
|
f"last session.status must be idle or failed, got statuses: {statuses}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_during_active_turn_cleans_state() -> None:
|
|
"""DELETE cancels the active turn and clears buffers."""
|
|
app, pm, _hc = _build_lifecycle_app()
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_del", "agent_id": "ag_1"},
|
|
)
|
|
# Start a turn (don't drain — turn stays active).
|
|
await client.post(
|
|
"/v1/sessions/conv_del/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
|
|
# Buffer a second message.
|
|
await client.post(
|
|
"/v1/sessions/conv_del/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "bye"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
|
|
# DELETE while turn active.
|
|
del_resp = await client.delete("/v1/sessions/conv_del")
|
|
assert del_resp.status_code == 200
|
|
assert "conv_del" in pm.released
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_turn_continuation() -> None:
|
|
"""Buffered messages are drained and sent to the harness after the first turn."""
|
|
import asyncio as _aio
|
|
|
|
gate = _aio.Event()
|
|
app, _pm, hc = _build_blocking_app(gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_cont", "agent_id": "ag_1"},
|
|
)
|
|
|
|
async def _first() -> None:
|
|
"""Run and drain the first turn."""
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_cont/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "first"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
async for _ in resp.aiter_text():
|
|
pass
|
|
|
|
task = _aio.create_task(_first())
|
|
await _aio.sleep(0.05)
|
|
|
|
# Buffer a second message while the first turn is active.
|
|
resp2 = await client.post(
|
|
"/v1/sessions/conv_cont/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "second"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp2.status_code == 202
|
|
|
|
# Unblock the first turn and wait for it to complete.
|
|
gate.set()
|
|
await _aio.wait_for(task, timeout=5.0)
|
|
|
|
# Allow post-turn continuation to run (drains buffer,
|
|
# starts background turn for the second message).
|
|
await _aio.sleep(0.2)
|
|
|
|
# The harness should have received both messages: the first
|
|
# from the initial turn, the second from the continuation.
|
|
# Each proxy_stream call posts one body to the harness.
|
|
assert len(hc.posted_bodies) >= 2, (
|
|
f"Expected harness to receive 2 messages (initial + "
|
|
f"continuation), got {len(hc.posted_bodies)}"
|
|
)
|
|
|
|
|
|
def _body_contains_text(body: dict[str, Any], needle: str) -> bool:
|
|
"""Return whether *needle* appears in any ``input_text`` block of *body*.
|
|
|
|
The runner posts user text to the harness in two different content
|
|
shapes: a flat list of content blocks (mid-turn injection forwards)
|
|
and a nested list of ``message`` history items (turn-start streams).
|
|
This walks both so a message is detected regardless of the channel
|
|
that carried it.
|
|
|
|
:param body: A harness request body (from ``posted_bodies`` or
|
|
``patched_events``).
|
|
:param needle: Substring to search for in ``input_text`` blocks.
|
|
:returns: ``True`` if any ``input_text`` block contains *needle*.
|
|
"""
|
|
|
|
def _walk(node: Any) -> bool:
|
|
if isinstance(node, dict):
|
|
if node.get("type") == "input_text" and needle in (node.get("text") or ""):
|
|
return True
|
|
return any(_walk(v) for v in node.values())
|
|
if isinstance(node, list):
|
|
return any(_walk(v) for v in node)
|
|
return False
|
|
|
|
return _walk(body)
|
|
|
|
|
|
def _ordered_user_texts(body: dict[str, Any]) -> list[str]:
|
|
"""Return the ``input_text`` strings of *body*'s user messages, in order.
|
|
|
|
Handles both content shapes the runner posts: nested ``message``
|
|
history items (turn-start streams) and flat content blocks. Only
|
|
user-role text is collected, so the result is the sequence of user
|
|
inputs the harness sees for that turn — used to assert submission
|
|
ordering.
|
|
|
|
:param body: A harness request body (from ``posted_bodies``).
|
|
:returns: User ``input_text`` values in document order.
|
|
"""
|
|
texts: list[str] = []
|
|
for item in body.get("content", []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
if item.get("type") == "message" and item.get("role") == "user":
|
|
for block in item.get("content", []):
|
|
if isinstance(block, dict) and block.get("type") == "input_text":
|
|
texts.append(block.get("text", ""))
|
|
elif item.get("type") == "input_text":
|
|
texts.append(item.get("text", ""))
|
|
return texts
|
|
|
|
|
|
class _HandshakeHarnessClient(_ScriptedHarnessClient):
|
|
"""Blocking harness fake that emits ``injection.consumed`` for forwards.
|
|
|
|
Simulates the real consumed-handshake (RUNNER_MESSAGE_INGEST.md
|
|
Part B): when the runner forwards a mid-turn injection via ``post``,
|
|
this captures the injection_id the runner stamped, and the active
|
|
turn's stream emits a matching ``injection.consumed`` frame after the
|
|
gate releases — exactly what the executor adapter emits on a real
|
|
harness once it drains the injection into the running turn.
|
|
"""
|
|
|
|
def __init__(self, gate: asyncio.Event) -> None:
|
|
"""Initialize with the gate that unblocks the turn-1 stream."""
|
|
super().__init__([])
|
|
self._gate = gate
|
|
self._consumed_ids: list[str] = []
|
|
|
|
def stream(self, method: str, url: str, *, json: dict[str, Any], timeout: Any) -> Any:
|
|
"""Turn-1 stream: created → (gate) → consumed markers → completed."""
|
|
del method, url, timeout
|
|
self.posted_bodies.append(json)
|
|
gate = self._gate
|
|
consumed_ids = self._consumed_ids
|
|
|
|
class _Ctx:
|
|
status_code = 200
|
|
|
|
async def __aenter__(self) -> Any:
|
|
return _Handle()
|
|
|
|
async def __aexit__(self, *_: Any) -> None:
|
|
return None
|
|
|
|
class _Handle:
|
|
status_code = 200
|
|
|
|
async def aiter_text(self) -> AsyncIterator[str]:
|
|
yield _sse({"type": "response.created", "response": {"id": "resp_1"}})
|
|
await gate.wait()
|
|
# Mirror the executor adapter: once injections are consumed
|
|
# into the running turn, echo each correlation id back.
|
|
for inj_id in list(consumed_ids):
|
|
yield _sse({"type": "injection.consumed", "injection_id": inj_id})
|
|
yield _sse({"type": "response.completed", "response": {"id": "resp_1"}})
|
|
|
|
return _Ctx()
|
|
|
|
async def post(self, url: str, *, json: dict[str, Any], timeout: Any = None) -> Any:
|
|
"""Record a forwarded injection + capture its injection_id."""
|
|
del url, timeout
|
|
self.patched_events.append(json)
|
|
inj_id = json.get("injection_id")
|
|
if isinstance(inj_id, str) and inj_id:
|
|
self._consumed_ids.append(inj_id)
|
|
|
|
class _Response:
|
|
status_code = 200
|
|
headers: dict[str, str] = {}
|
|
content = b""
|
|
|
|
def raise_for_status(self) -> None:
|
|
pass
|
|
|
|
return _Response()
|
|
|
|
|
|
def _build_handshake_app(
|
|
gate: asyncio.Event,
|
|
) -> tuple[FastAPI, _FakeProcessManager, _HandshakeHarnessClient]:
|
|
"""Build a runner app whose harness emits the consumed-handshake.
|
|
|
|
:param gate: Event that unblocks the turn-1 stream (after which the
|
|
``injection.consumed`` markers and ``response.completed`` flow).
|
|
:returns: ``(app, process_manager, harness_client)``.
|
|
"""
|
|
spec = AgentSpec(spec_version=1, name="t")
|
|
harness_client = _HandshakeHarnessClient(gate)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
return app, pm, harness_client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_midturn_message_not_double_delivered_to_harness() -> None:
|
|
"""A message sent during an active turn must reach the harness once.
|
|
|
|
Covers the web→TUI / claude-native duplication fix. When a user
|
|
message arrives while a turn is in flight, ``post_session_events``
|
|
forwards it as a live mid-turn injection (recorded in
|
|
``patched_events``) AND buffers it. With the consumed-handshake
|
|
(RUNNER_MESSAGE_INGEST.md Part B), the harness echoes an
|
|
``injection.consumed`` marker once it consumes the injection, and the
|
|
runner drops the buffered copy — so the message is NOT re-delivered in
|
|
a continuation turn. Exactly-once: forwarded once, no continuation.
|
|
|
|
The handshake harness here emits that marker on the turn-1 stream,
|
|
mirroring the real executor adapter. Without the runner's dedup (the
|
|
bug), the buffered "second" would still drain into a continuation
|
|
turn — ``posted_bodies`` would grow to 2 and the assertions fail.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
gate = _aio.Event()
|
|
app, _pm, hc = _build_handshake_app(gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_dup", "agent_id": "ag_1"},
|
|
)
|
|
|
|
# Turn 1 starts fire-and-forget (202) and its background task
|
|
# blocks inside the harness stream on `gate`. The 0.05s yield lets
|
|
# the runner mark the turn active before the second message lands.
|
|
resp1 = await client.post(
|
|
"/v1/sessions/conv_dup/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "first"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp1.status_code == 202
|
|
await _aio.sleep(0.05)
|
|
|
|
# "second" arrives while turn 1 is provably still active (blocked
|
|
# on the gate) → the runner buffers it AND forwards it as a live
|
|
# mid-turn injection with a correlation id.
|
|
resp2 = await client.post(
|
|
"/v1/sessions/conv_dup/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "second"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
# 202 "buffered" confirms turn 1 was still active — the precondition
|
|
# for the handshake path. A 200/stream here would mean the race
|
|
# window never opened and the test below would be vacuous.
|
|
assert resp2.status_code == 202, (
|
|
f"Expected 'second' to be buffered against the active turn, "
|
|
f"got {resp2.status_code}: {resp2.text}"
|
|
)
|
|
assert resp2.json()["status"] == "buffered"
|
|
|
|
# Release turn 1; the stream then emits injection.consumed (for
|
|
# "second") and response.completed. The runner drops the buffered
|
|
# copy, so no continuation turn starts. Poll a bounded window: if a
|
|
# continuation were (incorrectly) going to start, posted_bodies
|
|
# would reach 2 within it.
|
|
gate.set()
|
|
for _ in range(100):
|
|
if len(hc.posted_bodies) >= 2:
|
|
break
|
|
await _aio.sleep(0.01)
|
|
|
|
# "second" was forwarded as a live injection (channel 1)...
|
|
midturn_injections = [b for b in hc.patched_events if _body_contains_text(b, "second")]
|
|
assert len(midturn_injections) == 1, (
|
|
f"'second' should be forwarded as exactly one mid-turn injection; "
|
|
f"got {len(midturn_injections)} ({hc.patched_events})"
|
|
)
|
|
# ...and must NOT also be re-sent in a continuation turn (channel 2).
|
|
# Exactly one harness turn stream means no continuation was started.
|
|
assert len(hc.posted_bodies) == 1, (
|
|
f"'second' was double-delivered: a continuation turn started after "
|
|
f"the injection was consumed. The runner must drop the buffered "
|
|
f"copy on injection.consumed.\nposted_bodies={hc.posted_bodies}"
|
|
)
|
|
continuation_has_second = any(_body_contains_text(b, "second") for b in hc.posted_bodies[1:])
|
|
assert not continuation_has_second
|
|
|
|
|
|
class _NativeBlockingHarnessClient(_ScriptedHarnessClient):
|
|
"""Native-style harness fake: first turn blocks; later turns complete.
|
|
|
|
Models a claude-native harness for the runner's native delivery path
|
|
(RUNNER_MESSAGE_INGEST.md Part C): turn 0 blocks on a gate (so the
|
|
test can buffer messages behind it), and every continuation turn
|
|
completes immediately (mirroring claude-native's instant ``run_turn``
|
|
that just types the latest user message and returns).
|
|
"""
|
|
|
|
def __init__(self, gate: asyncio.Event) -> None:
|
|
"""Initialize with the gate that holds the first turn open."""
|
|
super().__init__([])
|
|
self._gate = gate
|
|
self._stream_count = 0
|
|
# Snapshot of each turn's latest user text, captured at stream time.
|
|
# ``posted_bodies[i]["content"]`` aliases the live history list (the
|
|
# runner assigns it by reference), which later drains mutate — so we
|
|
# must extract the latest text NOW, not at assertion time.
|
|
self.turn_latest_texts: list[str] = []
|
|
|
|
def stream(self, method: str, url: str, *, json: dict[str, Any], timeout: Any) -> Any:
|
|
"""Record the turn body; block only the first turn on the gate."""
|
|
del method, url, timeout
|
|
self.posted_bodies.append(json)
|
|
_texts = _ordered_user_texts(json)
|
|
self.turn_latest_texts.append(_texts[-1] if _texts else "")
|
|
n = self._stream_count
|
|
self._stream_count += 1
|
|
gate = self._gate
|
|
|
|
class _Ctx:
|
|
status_code = 200
|
|
|
|
async def __aenter__(self) -> Any:
|
|
return _Handle()
|
|
|
|
async def __aexit__(self, *_: Any) -> None:
|
|
return None
|
|
|
|
class _Handle:
|
|
status_code = 200
|
|
|
|
async def aiter_text(self) -> AsyncIterator[str]:
|
|
yield _sse({"type": "response.created", "response": {"id": f"resp_{n}"}})
|
|
if n == 0:
|
|
await gate.wait()
|
|
yield _sse({"type": "response.completed", "response": {"id": f"resp_{n}"}})
|
|
|
|
return _Ctx()
|
|
|
|
|
|
def _build_native_app(
|
|
gate: asyncio.Event,
|
|
) -> tuple[FastAPI, _FakeProcessManager, _NativeBlockingHarnessClient]:
|
|
"""Build a runner app whose session resolves to a claude-native harness.
|
|
|
|
The spec_resolver returns a spec whose executor harness is
|
|
``codex-native``; the first turn's ``_run_turn_bg`` caches it (before
|
|
streaming), so subsequent buffer decisions take the native path. We use
|
|
``codex-native`` rather than ``claude-native`` because both share the
|
|
identical runner-side ordering path (``_is_native_harness`` covers
|
|
both), but claude-native's turn additionally awaits a live MCP
|
|
comment-tool relay (``_ensure_comment_relay_started``) that a fake
|
|
harness can't satisfy — orthogonal to message ordering.
|
|
|
|
:param gate: Event that unblocks the first turn.
|
|
:returns: ``(app, process_manager, harness_client)``.
|
|
"""
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
harness_client = _NativeBlockingHarnessClient(gate)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
return app, pm, harness_client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_buffered_messages_each_delivered_once_in_order(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""claude-native: every buffered message is delivered once, in order.
|
|
|
|
Repro for the observed ``1 2 4 4 5 6 7 8 9 0`` corruption. claude-native
|
|
turns are instant and ``run_turn`` types only the *latest* user message,
|
|
so the runner's LLM-oriented machinery mis-delivers for native sessions:
|
|
the collapse-batch continuation (``next_body = all_bodies[-1]``) types
|
|
only the last buffered message (dropping the rest), and the mid-turn
|
|
forward races the instant turn's teardown (duplicating).
|
|
|
|
The native path (RUNNER_MESSAGE_INGEST.md Part C) instead skips the
|
|
forward and drains the buffer ONE message at a time, so each buffered
|
|
message gets its own continuation turn — typed exactly once, in order.
|
|
|
|
This test buffers 2, 3, 4 behind a blocked first turn and asserts:
|
|
(a) no mid-turn forward POSTs happen (native skips them), and
|
|
(b) the continuation starts a turn per buffered message carrying 2, 3,
|
|
4 as the latest user text, in order — not a single collapsed "4" turn.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
def _skip_tools_changed_notification(*args: object, **kwargs: object) -> None:
|
|
"""
|
|
Skip MCP tools/list notification in this fake native harness test.
|
|
|
|
:param args: Positional notification arguments.
|
|
:param kwargs: Keyword notification arguments.
|
|
:returns: None.
|
|
"""
|
|
del args, kwargs
|
|
|
|
monkeypatch.setattr(
|
|
claude_native_bridge,
|
|
"post_tools_changed",
|
|
_skip_tools_changed_notification,
|
|
)
|
|
|
|
gate = _aio.Event()
|
|
app, _pm, hc = _build_native_app(gate)
|
|
|
|
async def _post(text: str) -> httpx.Response:
|
|
"""POST one user message carrying an agent_id (drives spec resolve)."""
|
|
return await client.post(
|
|
"/v1/sessions/conv_nat/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"agent_id": "ag_native",
|
|
"content": [{"type": "input_text", "text": text}],
|
|
},
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Message "1" starts turn 0; its _run_turn_bg resolves + caches the
|
|
# claude-native spec (before streaming), then blocks on the gate.
|
|
assert (await _post("1")).status_code == 202
|
|
# Wait until turn 0 has streamed (spec is cached → native detected)
|
|
# and is now blocked on the gate, i.e. provably active.
|
|
for _ in range(200):
|
|
if hc.posted_bodies:
|
|
break
|
|
await _aio.sleep(0.01)
|
|
|
|
# 2, 3, 4 arrive while turn 0 is active → buffered (native: no forward).
|
|
for text in ("2", "3", "4"):
|
|
resp = await _post(text)
|
|
assert resp.status_code == 202, f"{text!r}: {resp.status_code} {resp.text}"
|
|
assert resp.json()["status"] == "buffered"
|
|
|
|
# Release turn 0; the buffer drains one-at-a-time. Each continuation
|
|
# turn completes immediately, re-entering the drain for the next.
|
|
gate.set()
|
|
for _ in range(300):
|
|
if len(hc.posted_bodies) >= 4:
|
|
break
|
|
await _aio.sleep(0.01)
|
|
|
|
# (a) No mid-turn forward for a native session — the forward is the
|
|
# unreliable injection race we removed for native harnesses.
|
|
assert hc.patched_events == [], (
|
|
f"native sessions must not forward mid-turn injections; got {hc.patched_events}"
|
|
)
|
|
# (b) One continuation turn per buffered message, each typing 2, 3, 4
|
|
# as its latest user text, in order (snapshotted at stream time — see
|
|
# turn_latest_texts). Collapse (the bug) would yield a single
|
|
# continuation whose latest text is "4", dropping 2 and 3.
|
|
continuation_latest = hc.turn_latest_texts[1:]
|
|
assert continuation_latest == ["2", "3", "4"], (
|
|
f"expected one continuation turn per buffered message delivering "
|
|
f"2, 3, 4 in order; got {continuation_latest}. A collapsed ['4'] "
|
|
f"means intermediate messages were dropped from the terminal.\n"
|
|
f"turn_latest_texts={hc.turn_latest_texts}"
|
|
)
|
|
|
|
|
|
class _GatedFileServerClient:
|
|
"""Server client that parks the gated file fetch until released.
|
|
|
|
``_resolve_forwarded_message_content`` awaits two GETs per
|
|
``file_id`` block (metadata, then content). Blocking the metadata
|
|
GET parks the message that carries that block *inside* content
|
|
resolution — before it reaches ``post_session_events``' turn-vs-buffer
|
|
gate — so a later, plain-text message can claim the turn first. This
|
|
is the deterministic trigger for the runner's arrival-order vs
|
|
resolution-order defect.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the gate events and the call log."""
|
|
self.meta_fetch_started = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
self.get_calls: list[str] = []
|
|
|
|
async def get(self, url: str, **kwargs: Any) -> Any:
|
|
"""Return a file response; park on the gated file's metadata GET."""
|
|
del kwargs
|
|
self.get_calls.append(url)
|
|
if url.endswith("/content"):
|
|
return _GatedFileServerClient._Resp(body=b"png-bytes")
|
|
# Metadata GET for the gated file: signal that the caller is now
|
|
# parked inside resolution, then block until the test releases it.
|
|
self.meta_fetch_started.set()
|
|
await self.release.wait()
|
|
return _GatedFileServerClient._Resp(
|
|
payload={"id": "file_gated", "filename": "a.png", "content_type": "image/png"}
|
|
)
|
|
|
|
class _Resp:
|
|
"""Minimal httpx-Response stand-in for file metadata/content."""
|
|
|
|
def __init__(self, *, body: bytes = b"", payload: dict[str, Any] | None = None) -> None:
|
|
"""Hold either raw bytes (content) or a metadata payload."""
|
|
self.content = body
|
|
self._payload = payload or {}
|
|
self.headers = {"content-type": self._payload.get("content_type", "image/png")}
|
|
self.status_code = 200
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
"""Return the metadata payload."""
|
|
return self._payload
|
|
|
|
def raise_for_status(self) -> None:
|
|
"""No-op: the gated client never returns error statuses."""
|
|
return
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_messages_reach_harness_in_submission_order() -> None:
|
|
"""Two messages must reach the harness in the order they were sent.
|
|
|
|
Repro for the web→TUI / claude-native out-of-order symptom. In
|
|
``post_session_events`` (omnigent/runner/app.py) the turn-vs-buffer
|
|
decision (the ``if conversation_id in _active_turns`` check at ~4237)
|
|
runs *after* ``await _resolve_forwarded_message_content`` (~4230).
|
|
A message with slow content resolution (e.g. a remote runner inlining
|
|
an uploaded image) is therefore parked before it can claim the turn,
|
|
letting a later plain-text message overtake it and start the first
|
|
turn. The runner orders turns by resolution-completion, not arrival.
|
|
|
|
The test makes this deterministic: "alpha-first" (submitted first,
|
|
carries a gated image) is held inside resolution while "bravo-second"
|
|
(submitted second, plain text) races ahead. The invariant under test
|
|
is that the FIRST turn the harness sees carries the FIRST-submitted
|
|
message.
|
|
"""
|
|
hc = _ScriptedHarnessClient(
|
|
[
|
|
_sse({"type": "response.created", "response": {"id": "resp_1"}}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_1"}}),
|
|
]
|
|
)
|
|
pm = _FakeProcessManager(hc)
|
|
server = _GatedFileServerClient()
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server, # type: ignore[arg-type]
|
|
)
|
|
|
|
# NB: post events directly (first event auto-creates session state),
|
|
# mirroring test_sessions_native_resolves_file_id_before_harness. An
|
|
# explicit POST /v1/sessions needs a spec_resolver this app omits.
|
|
async with _runner_client(app) as client:
|
|
|
|
async def _post_alpha() -> httpx.Response:
|
|
"""POST the first message (gated image + text)."""
|
|
return await client.post(
|
|
"/v1/sessions/conv_ord/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [
|
|
{"type": "input_image", "file_id": "file_gated", "filename": "a.png"},
|
|
{"type": "input_text", "text": "alpha-first"},
|
|
],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
|
|
# Submit alpha first; it takes arrival slot 0, passes the ingest
|
|
# gate, and parks inside content resolution on the gated metadata
|
|
# fetch — holding its slot open.
|
|
alpha_task = asyncio.create_task(_post_alpha())
|
|
await asyncio.wait_for(server.meta_fetch_started.wait(), timeout=5.0)
|
|
|
|
# Submit bravo second, as a task: under the ordering fix it cannot
|
|
# return until alpha's decision completes, so awaiting it inline
|
|
# here would deadlock. Bravo takes arrival slot 1 and blocks at the
|
|
# ingest gate behind alpha even though its plain-text content
|
|
# resolves instantly.
|
|
async def _post_bravo() -> httpx.Response:
|
|
"""POST the second message (plain text)."""
|
|
return await client.post(
|
|
"/v1/sessions/conv_ord/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "bravo-second"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
|
|
bravo_task = asyncio.create_task(_post_bravo())
|
|
# Let bravo reach its steady state: blocked at the gate (fixed) or
|
|
# already racing into the turn-vs-buffer decision (buggy). This is
|
|
# what makes the assertion below catch a regression — without the
|
|
# gate, bravo's plain-text turn starts here while alpha is parked.
|
|
await asyncio.sleep(0.05)
|
|
|
|
# Release alpha; correct ordering requires alpha's decision (start
|
|
# turn) to complete before bravo's (buffer behind the active turn).
|
|
server.release.set()
|
|
alpha_resp = await asyncio.wait_for(alpha_task, timeout=5.0)
|
|
bravo_resp = await asyncio.wait_for(bravo_task, timeout=5.0)
|
|
assert alpha_resp.status_code == 202
|
|
assert bravo_resp.status_code == 202
|
|
|
|
# Wait for the first turn to reach the harness.
|
|
for _ in range(200):
|
|
if hc.posted_bodies:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
|
|
assert hc.posted_bodies, "harness never received a turn"
|
|
# The harness builds each turn from session history, so the order of
|
|
# user messages there reflects the order the runner accepted them.
|
|
# Submission order was alpha → bravo, so "alpha-first" must precede
|
|
# "bravo-second". They are reversed today: "bravo-second" reached the
|
|
# runner's turn gate first (alpha was still parked in content
|
|
# resolution) and so was appended to history first. Containment alone
|
|
# is not enough to assert here — both texts are present — only order
|
|
# distinguishes the bug.
|
|
ordered = _ordered_user_texts(hc.posted_bodies[0])
|
|
assert ordered.index("alpha-first") < ordered.index("bravo-second"), (
|
|
"out-of-order delivery: 'alpha-first' was submitted before "
|
|
"'bravo-second', but the harness sees them in the order "
|
|
f"{ordered}. post_session_events gates turn-vs-buffer AFTER "
|
|
"awaiting content resolution, so a message with slow resolution is "
|
|
"overtaken by a later one."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_buffered_continuation_skips_transient_idle() -> None:
|
|
"""End-of-turn `idle` is suppressed when a buffered message will start a new turn."""
|
|
import asyncio as _aio
|
|
|
|
gate = _aio.Event()
|
|
app, _pm, _hc = _build_blocking_app(gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_skip", "agent_id": "ag_1"},
|
|
)
|
|
|
|
collected: list[dict[str, Any]] = []
|
|
|
|
async def _sub() -> None:
|
|
async with client.stream("GET", "/v1/sessions/conv_skip/stream") as stream:
|
|
async for line in stream.aiter_lines():
|
|
if line.startswith("data: "):
|
|
payload = line[6:]
|
|
if payload == "[DONE]":
|
|
return
|
|
collected.append(json.loads(payload))
|
|
|
|
sub_task = _aio.create_task(_sub())
|
|
await _aio.sleep(0.05)
|
|
|
|
async def _first() -> None:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_skip/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "first"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
async for _ in resp.aiter_text():
|
|
pass
|
|
|
|
turn_task = _aio.create_task(_first())
|
|
await _aio.sleep(0.05)
|
|
|
|
await client.post(
|
|
"/v1/sessions/conv_skip/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "second"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
|
|
gate.set()
|
|
await _aio.wait_for(turn_task, timeout=5.0)
|
|
await _aio.sleep(0.3)
|
|
|
|
await client.delete("/v1/sessions/conv_skip")
|
|
await _aio.wait_for(sub_task, timeout=5.0)
|
|
|
|
statuses = [e["status"] for e in collected if e.get("type") == "session.status"]
|
|
# Buffered continuation: the turn-1 idle must be skipped so the client
|
|
# never sees a running → idle → running flicker that would hide the
|
|
# Working indicator. Expected: running (turn 1), running (turn 2),
|
|
# then a terminal idle once the buffer drains.
|
|
assert "idle" not in statuses[:-1], (
|
|
f"Expected no transient idle between turns; got statuses: {statuses}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_turn_publishes_idle_so_client_unsticks() -> None:
|
|
"""CancelledError in `_drain_streaming_response` must publish idle.
|
|
|
|
Without this, the client sits on stale ``running`` forever after DELETE.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
gate = _aio.Event() # never set → harness stream blocks forever
|
|
app, _pm, _hc = _build_blocking_app(gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_cancel", "agent_id": "ag_1"},
|
|
)
|
|
|
|
collected: list[dict[str, Any]] = []
|
|
|
|
async def _sub() -> None:
|
|
async with client.stream("GET", "/v1/sessions/conv_cancel/stream") as stream:
|
|
async for line in stream.aiter_lines():
|
|
if line.startswith("data: "):
|
|
payload = line[6:]
|
|
if payload == "[DONE]":
|
|
return
|
|
collected.append(json.loads(payload))
|
|
|
|
sub_task = _aio.create_task(_sub())
|
|
await _aio.sleep(0.05)
|
|
|
|
async def _stuck() -> None:
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_cancel/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "blocked"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
async for _ in resp.aiter_text():
|
|
pass
|
|
|
|
turn_task = _aio.create_task(_stuck())
|
|
await _aio.sleep(0.1)
|
|
|
|
# DELETE cancels the turn task — exercises `_drain_streaming_response`'s
|
|
# CancelledError path.
|
|
del_resp = await client.delete("/v1/sessions/conv_cancel")
|
|
assert del_resp.status_code == 200
|
|
|
|
gate.set() # unblock the stuck stream so the test can finish
|
|
with contextlib.suppress(Exception):
|
|
await _aio.wait_for(turn_task, timeout=2.0)
|
|
await _aio.wait_for(sub_task, timeout=2.0)
|
|
|
|
statuses = [e["status"] for e in collected if e.get("type") == "session.status"]
|
|
# Without the fix, the only emitted status is "running" — client stays stuck.
|
|
assert "idle" in statuses, f"Cancelled turn must publish a terminal status; got: {statuses}"
|
|
|
|
|
|
# ── Crash recovery (Step 8.5 Scenario A) ──────────────────────────
|
|
|
|
|
|
class _FakeServerClient:
|
|
"""Fake server_client that returns paginated history items.
|
|
|
|
Items must have an ``"id"`` field. Supports ``after`` cursor
|
|
and ``limit`` params, returns ``has_more`` when more pages
|
|
exist. Tracks GET calls for assertion.
|
|
"""
|
|
|
|
def __init__(
|
|
self, items: list[dict[str, Any]], *, session_snapshot: dict[str, Any] | None = None
|
|
) -> None:
|
|
self._items = items
|
|
self._session_snapshot: dict[str, Any] = session_snapshot or {}
|
|
self.get_calls: list[dict[str, str]] = []
|
|
|
|
async def get(
|
|
self, url: str, *, params: dict[str, str] | None = None, timeout: float = 10.0
|
|
) -> Any:
|
|
del timeout
|
|
params = params or {}
|
|
self.get_calls.append(dict(params))
|
|
|
|
# Session snapshot GET (e.g. /v1/sessions/{id}, no /items suffix).
|
|
if "/items" not in url:
|
|
snapshot = dict(self._session_snapshot)
|
|
|
|
class _SnapshotResp:
|
|
status_code = 200
|
|
|
|
def json(self_inner) -> dict[str, Any]:
|
|
return snapshot
|
|
|
|
return _SnapshotResp()
|
|
|
|
after = params.get("after")
|
|
limit = int(params.get("limit", "100"))
|
|
|
|
# Find start index based on after cursor.
|
|
start = 0
|
|
if after:
|
|
for i, item in enumerate(self._items):
|
|
if item.get("id") == after:
|
|
start = i + 1
|
|
break
|
|
page = self._items[start : start + limit]
|
|
has_more = (start + limit) < len(self._items)
|
|
|
|
class _Resp:
|
|
status_code = 200
|
|
|
|
def json(self_inner) -> dict[str, Any]:
|
|
return {"data": page, "has_more": has_more}
|
|
|
|
return _Resp()
|
|
|
|
|
|
def _build_recovery_app(
|
|
history_items: list[dict[str, Any]],
|
|
*,
|
|
harness_name: str | None = None,
|
|
) -> tuple[FastAPI, _FakeProcessManager, _ScriptedHarnessClient]:
|
|
"""Build a runner app with a fake server_client returning history.
|
|
|
|
:param history_items: Items returned by GET /v1/sessions/{id}/items.
|
|
:param harness_name: Optional harness override for the resolved spec,
|
|
e.g. ``"codex-native"``.
|
|
:returns: ``(app, process_manager, harness_client)`` tuple.
|
|
"""
|
|
spec_kwargs: dict[str, Any] = {
|
|
"spec_version": 1,
|
|
"name": "recovery-test",
|
|
}
|
|
if harness_name is not None:
|
|
spec_kwargs["executor"] = ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": harness_name},
|
|
)
|
|
spec = AgentSpec(**spec_kwargs)
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_r1"}}),
|
|
_sse({"type": "response.output_text.delta", "delta": "recovered"}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_r1"}}),
|
|
]
|
|
hc = _ScriptedHarnessClient(sse_frames)
|
|
pm = _FakeProcessManager(hc)
|
|
server_client = _FakeServerClient(history_items)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
return app, pm, hc
|
|
|
|
|
|
async def _fake_auto_create_codex_terminal(
|
|
session_id: str,
|
|
resource_registry: SessionResourceRegistry,
|
|
publish_event: Any,
|
|
**kwargs: Any,
|
|
) -> SessionResourceView:
|
|
"""
|
|
Return a fake Codex terminal without launching native processes.
|
|
|
|
:param session_id: Session whose Codex terminal would be created.
|
|
:param resource_registry: Runner resource registry passed by the
|
|
production call site.
|
|
:param publish_event: Event publisher passed by the production call
|
|
site.
|
|
:param kwargs: Auto-create keyword-only arguments.
|
|
:returns: Fake Codex terminal resource for the requested session.
|
|
"""
|
|
del resource_registry, publish_event, kwargs
|
|
return SessionResourceView(
|
|
id="terminal_codex_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Codex",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_creation_auto_starts_turn_for_unanswered_user_message() -> None:
|
|
"""POST /v1/sessions with history ending in a user message starts a recovery turn.
|
|
|
|
Breakage this catches: if _run_turn_bg's incomplete-turn detection
|
|
is removed, the session stays idle and the harness receives no POST.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
history = [
|
|
{
|
|
"id": "item_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "hello"}],
|
|
},
|
|
]
|
|
app, _pm, hc = _build_recovery_app(history)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_recover_1", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
# "running" proves the recovery turn was started during
|
|
# session creation. "idle" would mean the incomplete-turn
|
|
# detection didn't fire.
|
|
assert resp.json()["status"] == "running"
|
|
|
|
# Wait for the background turn to POST to harness.
|
|
# The scripted harness completes instantly so 0.5s is
|
|
# generous; event-driven sync isn't possible because the
|
|
# turn runs in a fire-and-forget background task.
|
|
await _aio.sleep(0.5)
|
|
|
|
# 1 POST = the recovery turn sent full history to the harness.
|
|
# 0 would mean _run_turn_bg never ran (detection broken).
|
|
assert len(hc.posted_bodies) == 1, (
|
|
f"Expected exactly 1 harness POST (recovery turn), "
|
|
f"got {len(hc.posted_bodies)}. 0 = detection broken, "
|
|
f">1 = duplicate turn started."
|
|
)
|
|
# The recovery turn's body must include the unanswered user
|
|
# message in its content (loaded from server history).
|
|
body_content = hc.posted_bodies[0].get("content", [])
|
|
assert any(
|
|
item.get("type") == "message" and item.get("role") == "user" for item in body_content
|
|
), (
|
|
"Recovery turn body must contain the unanswered user message "
|
|
"from history. Empty content means _load_history_as_input "
|
|
"failed or _session_histories wasn't populated."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_creation_does_not_replay_trailing_user_for_codex_native(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Codex-native startup must not replay a trailing user item as recovery.
|
|
|
|
Native transcripts are mirrored from Codex. If a Codex turn errors before
|
|
producing an assistant item, Omnigent history can end with the user prompt even
|
|
though Codex already consumed it. Generic crash recovery would treat that
|
|
as an unanswered Omnigent turn and resend the same prompt when ``omnigent
|
|
codex`` reattaches.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture used to bypass real
|
|
terminal auto-create.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
session_id = "conv_codex_failed_recover"
|
|
runner_app_mod._session_histories_ref.pop(session_id, None)
|
|
|
|
monkeypatch.setattr(
|
|
runner_app_mod,
|
|
"_auto_create_codex_terminal",
|
|
_fake_auto_create_codex_terminal,
|
|
)
|
|
|
|
history = [
|
|
{
|
|
"id": "item_user_failed",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "errored prompt"}],
|
|
},
|
|
]
|
|
app, _pm, hc = _build_recovery_app(history, harness_name="codex-native")
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": session_id, "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["status"] == "idle"
|
|
await _aio.sleep(0.1)
|
|
finally:
|
|
runner_app_mod._session_histories_ref.pop(session_id, None)
|
|
|
|
assert hc.posted_bodies == [], (
|
|
"Codex-native session startup must not POST a recovery turn for a "
|
|
"mirrored trailing user item. A POST here means the previous failed "
|
|
"prompt was resent to Codex."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catch_up_scan_skips_codex_native_history_entries(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Catch-up scan must not replay mirrored Codex-native transcript items.
|
|
|
|
Native sessions can enter ``_session_histories`` through normal turn
|
|
processing, not only through ``POST /v1/sessions`` history recovery.
|
|
If catch-up scan treats that native history like a runner-native
|
|
conversation, a tunnel reconnect can fetch a mirrored trailing user
|
|
item and dispatch a duplicate recovery turn to Codex.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture used to bypass real
|
|
terminal auto-create.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
session_id = "conv_codex_catchup_skip"
|
|
saved_histories = dict(runner_app_mod._session_histories_ref)
|
|
runner_app_mod._session_histories_ref.clear()
|
|
missed_user_item = {
|
|
"id": "item_missed_user",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "already typed natively"}],
|
|
}
|
|
server_client = _FakeServerClient([missed_user_item])
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="catchup-codex-native",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native"},
|
|
),
|
|
)
|
|
hc = _ScriptedHarnessClient(
|
|
[
|
|
_sse({"type": "response.created", "response": {"id": "resp_catchup"}}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_catchup"}}),
|
|
]
|
|
)
|
|
pm = _FakeProcessManager(hc)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
monkeypatch.setattr(
|
|
runner_app_mod,
|
|
"_auto_create_codex_terminal",
|
|
_fake_auto_create_codex_terminal,
|
|
)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": session_id, "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["status"] == "idle"
|
|
|
|
# Simulate the turn-processing paths that already populated
|
|
# native in-memory history before a tunnel reconnect.
|
|
runner_app_mod._session_histories_ref[session_id] = [
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "prior native output"}],
|
|
}
|
|
]
|
|
|
|
server_client.get_calls.clear()
|
|
await app.state.catch_up_scan()
|
|
await _aio.sleep(0.1)
|
|
finally:
|
|
runner_app_mod._session_histories_ref.clear()
|
|
runner_app_mod._session_histories_ref.update(saved_histories)
|
|
|
|
assert server_client.get_calls == [], (
|
|
"Catch-up scan must skip Codex-native sessions before fetching Omnigent "
|
|
"items. A GET here means reconnect recovery can observe mirrored "
|
|
"native transcript items and replay them."
|
|
)
|
|
assert hc.posted_bodies == [], (
|
|
"Catch-up scan must not dispatch a recovery turn for a Codex-native "
|
|
"session already present in _session_histories. A POST here means the "
|
|
"mirrored native user item was resent to Codex."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_creation_stays_idle_for_completed_conversation() -> None:
|
|
"""POST /v1/sessions with history ending in an assistant message stays idle.
|
|
|
|
Breakage this catches: if incomplete-turn detection triggers on
|
|
assistant messages, the runner would start spurious recovery turns.
|
|
"""
|
|
history = [
|
|
{
|
|
"id": "item_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "hello"}],
|
|
},
|
|
{
|
|
"id": "item_2",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "hi"}],
|
|
},
|
|
]
|
|
app, _pm, hc = _build_recovery_app(history)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_idle_1", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
# "idle" proves no recovery turn was started. "running"
|
|
# would mean the detection falsely triggered on a completed
|
|
# conversation.
|
|
assert resp.json()["status"] == "idle"
|
|
|
|
# 0 POSTs confirms the harness was never called.
|
|
assert len(hc.posted_bodies) == 0, (
|
|
f"Expected 0 harness POSTs for idle session, "
|
|
f"got {len(hc.posted_bodies)}. >0 = spurious recovery turn."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_creation_auto_starts_turn_for_pending_tool_call() -> None:
|
|
"""POST /v1/sessions with history ending in a function_call starts a recovery turn.
|
|
|
|
Breakage this catches: if the detection only checks for user
|
|
messages and misses pending tool calls, tool-interrupted sessions
|
|
would stay stuck after crash recovery.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
history = [
|
|
{
|
|
"id": "item_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "run ls"}],
|
|
},
|
|
{
|
|
"id": "item_2",
|
|
"type": "function_call",
|
|
"call_id": "call_1",
|
|
"name": "sys_os_shell",
|
|
"arguments": "{}",
|
|
},
|
|
]
|
|
app, _pm, hc = _build_recovery_app(history)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_recover_tc", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["status"] == "running"
|
|
|
|
await _aio.sleep(0.5)
|
|
|
|
# 1 POST = recovery turn for the pending tool call.
|
|
assert len(hc.posted_bodies) == 1, (
|
|
f"Expected 1 harness POST (recovery for pending tool_call), "
|
|
f"got {len(hc.posted_bodies)}. 0 = detection missed "
|
|
f"function_call items."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_creation_no_recovery_for_empty_history() -> None:
|
|
"""POST /v1/sessions with no history stays idle (fresh session).
|
|
|
|
Breakage this catches: if the detection crashes on empty history
|
|
(e.g. IndexError on last item), session creation would fail.
|
|
"""
|
|
app, _pm, hc = _build_recovery_app([])
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_fresh", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["status"] == "idle"
|
|
|
|
assert len(hc.posted_bodies) == 0, (
|
|
"Fresh session with no history must not trigger a recovery turn."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_load_paginates_beyond_100_items() -> None:
|
|
"""_load_history_as_input must paginate when history exceeds one page.
|
|
|
|
Breakage this catches: if the history loader fetches only one page
|
|
(limit=100) and doesn't follow has_more, conversations with >100
|
|
items would silently lose early history.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
# 150 items — requires 2 pages at limit=100.
|
|
history = [
|
|
{
|
|
"id": f"item_{i}",
|
|
"type": "message",
|
|
"role": "user" if i % 2 == 0 else "assistant",
|
|
"content": [{"type": "input_text", "text": f"msg {i}"}],
|
|
}
|
|
for i in range(150)
|
|
]
|
|
# Last item is assistant (i=149, odd) so no recovery turn —
|
|
# we're testing pagination, not recovery.
|
|
server_client = _FakeServerClient(history)
|
|
spec = AgentSpec(spec_version=1, name="paginate-test")
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_p"}}),
|
|
_sse({"type": "response.output_text.delta", "delta": "ok"}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_p"}}),
|
|
]
|
|
hc = _ScriptedHarnessClient(sse_frames)
|
|
pm = _FakeProcessManager(hc)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Create session — loads history via pagination.
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_paginate", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["status"] == "idle"
|
|
|
|
# Now send a message to trigger a turn — the turn uses
|
|
# _session_histories which should have all 150 items.
|
|
resp2 = await client.post(
|
|
"/v1/sessions/conv_paginate/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test",
|
|
"content": [{"type": "input_text", "text": "final"}],
|
|
},
|
|
)
|
|
assert resp2.status_code == 202
|
|
await _aio.sleep(0.5)
|
|
|
|
# The server_client should have been called multiple times
|
|
# (pagination). 2 pages for 150 items at limit=100.
|
|
# get_calls[0] has no after cursor (first page).
|
|
# get_calls[1] has after=item_99 (second page).
|
|
assert len(server_client.get_calls) >= 2, (
|
|
f"Expected at least 2 GET calls (pagination), "
|
|
f"got {len(server_client.get_calls)}. "
|
|
f"1 = pagination broken, loader only fetched first page."
|
|
)
|
|
# The harness received the turn with all history in content.
|
|
assert len(hc.posted_bodies) >= 1
|
|
body_content = hc.posted_bodies[0].get("content", [])
|
|
# Must have at least 150 items from the server (the paginated
|
|
# history) plus the new user message. If only 100, pagination
|
|
# stopped at the first page.
|
|
assert len(body_content) > 100, (
|
|
f"Expected >100 history items (all 150 paginated + new "
|
|
f"user msg), got {len(body_content)}. If <=100, pagination "
|
|
f"broke and only one page was loaded."
|
|
)
|
|
assert len(body_content) >= 151, (
|
|
f"Expected at least 151 items (150 server + 1 new), "
|
|
f"got {len(body_content)}. Some server items were dropped."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_sends_full_history_plus_new_message_to_harness() -> None:
|
|
"""Resumed session sends prior history + new user message to the harness.
|
|
|
|
Simulates the resume scenario: session was created with a completed
|
|
conversation (user + assistant), then a new message is sent. The
|
|
harness must receive ALL prior history items concatenated with the
|
|
new user message so the LLM has full context.
|
|
|
|
Breakage this catches: if _session_histories doesn't include the
|
|
new user message from post_session_events, the harness only sees
|
|
the stale server history (missing the new prompt). If the history
|
|
load fails, the harness sees only the new message (no context).
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
history = [
|
|
{
|
|
"id": "item_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Preresume"}],
|
|
},
|
|
{
|
|
"id": "item_2",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello from preresume"}],
|
|
},
|
|
]
|
|
app, _pm, hc = _build_recovery_app(history)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_resume_1", "agent_id": "ag_1"},
|
|
)
|
|
# History ends with assistant — session stays idle (no recovery).
|
|
assert resp.status_code == 201
|
|
assert resp.json()["status"] == "idle"
|
|
|
|
# Now send a new message (simulating user typing after resume).
|
|
resp2 = await client.post(
|
|
"/v1/sessions/conv_resume_1/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_1",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "Postresume"}],
|
|
},
|
|
)
|
|
# 202 = turn started in background.
|
|
assert resp2.status_code == 202
|
|
|
|
await _aio.sleep(0.5)
|
|
|
|
# Harness received exactly 1 POST (the new turn).
|
|
assert len(hc.posted_bodies) == 1, (
|
|
f"Expected 1 harness POST (resume turn), got {len(hc.posted_bodies)}. "
|
|
f"0 = turn never started, >1 = duplicate turn."
|
|
)
|
|
body = hc.posted_bodies[0]
|
|
content = body.get("content", [])
|
|
|
|
# Content must include at least 3 items: user("Preresume"),
|
|
# assistant("Hello from preresume"), user("Postresume").
|
|
# If only 1, history loading failed. If only 2, the new
|
|
# message wasn't appended to _session_histories.
|
|
# Note: the fake harness stores a dict reference, not a copy.
|
|
# The proxy_stream appends the scripted assistant response to
|
|
# the shared _session_histories list AFTER the harness call,
|
|
# so posted_bodies may show 4 items instead of the 3 the
|
|
# production harness actually received (httpx serializes at
|
|
# call time). Assert >= 3 to cover both shapes.
|
|
assert len(content) >= 3, (
|
|
f"Expected >= 3 history items (2 prior + 1 new), got {len(content)}. Items: {content}"
|
|
)
|
|
# First item: original user message from server history.
|
|
assert content[0].get("type") == "message"
|
|
assert content[0].get("role") == "user"
|
|
|
|
# Second item: assistant response from server history.
|
|
assert content[1].get("type") == "message"
|
|
assert content[1].get("role") == "assistant"
|
|
|
|
# Third item: the new user message sent after resume.
|
|
assert content[2].get("type") == "message"
|
|
assert content[2].get("role") == "user"
|
|
user_content = content[2].get("content", [])
|
|
# Verify the new message text made it through.
|
|
assert any(
|
|
block.get("text") == "Postresume"
|
|
for block in (user_content if isinstance(user_content, list) else [])
|
|
), f"New user message 'Postresume' not found in harness content. Content[2]: {content[2]}"
|
|
|
|
|
|
# ── Compaction tests ─────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_compaction_item_in_history_expands_and_discards_prior() -> None:
|
|
"""History loading expands compaction items and discards pre-compaction items.
|
|
|
|
Breakage this catches: if _convert_raw_items_to_input drops compaction
|
|
items (the old behavior), the harness receives the full uncompacted
|
|
history — context window overflow on long conversations. If it doesn't
|
|
discard pre-compaction items, the summary is prepended but the original
|
|
items remain — defeating the point of compaction.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
history = [
|
|
{
|
|
"id": "item_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "old msg"}],
|
|
},
|
|
{
|
|
"id": "item_2",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "old reply"}],
|
|
},
|
|
{
|
|
"id": "item_3",
|
|
"type": "compaction",
|
|
"summary": "User asked about old stuff. Assistant replied.",
|
|
"last_item_id": "item_2",
|
|
"model": "test-model",
|
|
"token_count": 20,
|
|
},
|
|
{
|
|
"id": "item_4",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "new msg"}],
|
|
},
|
|
]
|
|
app, _pm, hc = _build_recovery_app(history)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Session has a trailing user message → crash recovery fires.
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_compact_1", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["status"] == "running"
|
|
await _aio.sleep(0.5)
|
|
|
|
# The harness received the recovery turn.
|
|
assert len(hc.posted_bodies) == 1, (
|
|
f"Expected 1 harness POST (recovery turn), got {len(hc.posted_bodies)}."
|
|
)
|
|
content = hc.posted_bodies[0].get("content", [])
|
|
|
|
# 3 items expected: synthetic-user (compaction request),
|
|
# synthetic-assistant (summary), post-compaction user msg.
|
|
# If 5, pre-compaction items weren't discarded.
|
|
# If 1, the compaction item was dropped entirely.
|
|
assert len(content) >= 3, (
|
|
f"Expected >= 3 items (2 synthetic + 1 post-compaction user), "
|
|
f"got {len(content)}. If 1, compaction items are dropped. "
|
|
f"Items: {content}"
|
|
)
|
|
# First item: synthetic user requesting summary.
|
|
assert content[0]["role"] == "user"
|
|
assert "summary" in content[0]["content"][0]["text"].lower()
|
|
|
|
# Second item: synthetic assistant with the summary text.
|
|
assert content[1]["role"] == "assistant"
|
|
assert content[1]["content"][0]["text"] == (
|
|
"User asked about old stuff. Assistant replied."
|
|
), "Summary text must match the compaction item's summary field."
|
|
|
|
# Third item: the post-compaction user message.
|
|
assert content[2]["role"] == "user"
|
|
assert content[2]["content"] == [{"type": "input_text", "text": "new msg"}], (
|
|
"Post-compaction items must be converted normally."
|
|
)
|
|
|
|
# Pre-compaction items ("old msg", "old reply") must NOT appear.
|
|
all_texts = json.dumps(content)
|
|
assert "old msg" not in all_texts, (
|
|
"Pre-compaction user message leaked through — "
|
|
"_convert_raw_items_to_input didn't discard items before the compaction boundary."
|
|
)
|
|
assert "old reply" not in all_texts, "Pre-compaction assistant message leaked through."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_item_in_history_is_surfaced_as_error_block_not_dropped() -> None:
|
|
"""History loading surfaces ``error`` items as typed ERROR blocks, not dropped (#1108).
|
|
|
|
Breakage this catches: ``_convert_raw_items_to_input`` used to drop every
|
|
item that wasn't message / function_call / function_call_output, so an
|
|
``error`` item recorded for a failed turn vanished on history reload — the
|
|
next turn replayed as if the failure had never happened ("silent success").
|
|
|
|
The converter now preserves each error item as a typed ``error`` item
|
|
(the ``ErrorData`` shape: ``source`` / ``code`` / ``message``). The fix is
|
|
specifically NOT a synthetic user-role ``input_text`` message: that would
|
|
keep the text visible but mis-attribute the failure to the user's input
|
|
and lose the error semantics. This test pins the typed-error shape and
|
|
guards against a regression back to the user-message shim.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
history = [
|
|
{
|
|
"id": "item_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "do the thing"}],
|
|
},
|
|
{
|
|
"id": "item_2",
|
|
"type": "error",
|
|
"response_id": "resp_failed",
|
|
"source": "execution",
|
|
"code": "codex_turn_error",
|
|
"message": "401 Unauthorized: ChatGPT login expired",
|
|
},
|
|
{
|
|
"id": "item_3",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "try again"}],
|
|
},
|
|
]
|
|
app, _pm, hc = _build_recovery_app(history)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Trailing user message → crash recovery starts a turn, replaying history.
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_err_1", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["status"] == "running"
|
|
await _aio.sleep(0.5)
|
|
|
|
assert len(hc.posted_bodies) == 1, (
|
|
f"Expected 1 harness POST (recovery turn), got {len(hc.posted_bodies)}."
|
|
)
|
|
content = hc.posted_bodies[0].get("content", [])
|
|
error_items = [
|
|
item for item in content if isinstance(item, dict) and item.get("type") == "error"
|
|
]
|
|
# The error item survived the converter as a typed ERROR block.
|
|
assert len(error_items) == 1, (
|
|
"Expected exactly one typed 'error' item in the converted history; "
|
|
f"got {len(error_items)}. If 0, the error item was dropped (the "
|
|
"silent-success regression) or wrongly mapped to another type."
|
|
)
|
|
error_item = error_items[0]
|
|
assert error_item["message"] == "401 Unauthorized: ChatGPT login expired"
|
|
# The stable code/source round-trip so the failure stays attributable.
|
|
assert error_item["code"] == "codex_turn_error"
|
|
assert error_item["source"] == "execution"
|
|
# Crucially, the error is NOT mis-attributed as a user input_text message.
|
|
user_texts = [
|
|
block.get("text", "")
|
|
for item in content
|
|
if isinstance(item, dict) and item.get("type") == "message" and item.get("role") == "user"
|
|
for block in (item.get("content") or [])
|
|
if isinstance(block, dict)
|
|
]
|
|
assert not any("401 Unauthorized" in text for text in user_texts), (
|
|
"Error text leaked into a user message — it must be a typed error block, not user input."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_crash_recovery_with_compaction_uses_post_compaction_history() -> None:
|
|
"""Crash recovery after compaction sees only post-compaction items.
|
|
|
|
Breakage this catches: if crash recovery sees pre-compaction items,
|
|
it might start a spurious recovery turn for an item that's already
|
|
been summarized.
|
|
"""
|
|
history = [
|
|
{
|
|
"id": "item_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "old"}],
|
|
},
|
|
{
|
|
"id": "item_2",
|
|
"type": "compaction",
|
|
"summary": "Prior context summarized.",
|
|
"last_item_id": "item_1",
|
|
"model": "test-model",
|
|
"token_count": 10,
|
|
},
|
|
{
|
|
"id": "item_3",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "post-compaction reply"}],
|
|
},
|
|
]
|
|
app, _pm, hc = _build_recovery_app(history)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_compact_cr", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201
|
|
# History ends with assistant message (post-compaction) → idle.
|
|
# If crash recovery saw the pre-compaction user message ("old"),
|
|
# it would incorrectly start a recovery turn.
|
|
assert resp.json()["status"] == "idle", (
|
|
"Session should be idle — history ends with an assistant message "
|
|
"after the compaction boundary. 'running' would mean crash recovery "
|
|
"looked at pre-compaction items."
|
|
)
|
|
|
|
# No harness POSTs — idle session.
|
|
assert len(hc.posted_bodies) == 0, (
|
|
f"Expected 0 harness POSTs for idle post-compaction session, got {len(hc.posted_bodies)}."
|
|
)
|
|
|
|
|
|
class _OverflowThenSuccessHarnessClient:
|
|
"""Harness that returns context-window overflow on first call, success on second.
|
|
|
|
Used by the reactive compaction test. The first POST returns a
|
|
``response.failed`` SSE event with ``context_length_exceeded``.
|
|
The second POST returns normal ``response.completed``.
|
|
|
|
:param success_frames: SSE frames to return on the second call.
|
|
"""
|
|
|
|
def __init__(self, success_frames: list[str]) -> None:
|
|
"""Initialize with success frames for the retry."""
|
|
self.posted_bodies: list[dict[str, Any]] = []
|
|
self._success_frames = success_frames
|
|
self.patched_events: list[dict[str, Any]] = []
|
|
self._call_count = 0
|
|
|
|
def stream(self, method: str, url: str, *, json: dict[str, Any], timeout: Any) -> Any:
|
|
"""First call returns overflow; second returns success."""
|
|
del method, url, timeout
|
|
self.posted_bodies.append(json)
|
|
self._call_count += 1
|
|
if self._call_count == 1:
|
|
overflow_frames = [
|
|
_sse(
|
|
{
|
|
"type": "response.failed",
|
|
"error": {
|
|
"message": (
|
|
"context_length_exceeded: 5000 tokens > 4096 "
|
|
"maximum context length"
|
|
),
|
|
"code": "context_length_exceeded",
|
|
},
|
|
}
|
|
),
|
|
]
|
|
frames = overflow_frames
|
|
else:
|
|
frames = self._success_frames
|
|
|
|
class _StreamCtx:
|
|
status_code = 200
|
|
|
|
async def __aenter__(self_inner) -> Any:
|
|
return _ScriptedHarnessClient._StreamHandle(frames, None)
|
|
|
|
async def __aexit__(self_inner, *_: Any) -> None:
|
|
return None
|
|
|
|
return _StreamCtx()
|
|
|
|
async def post(self, url: str, *, json: dict[str, Any], timeout: Any = None) -> Any:
|
|
"""Record PATCH events and return 200."""
|
|
del url, timeout
|
|
self.patched_events.append(json)
|
|
|
|
class _Response:
|
|
status_code = 200
|
|
|
|
def raise_for_status(self) -> None:
|
|
pass
|
|
|
|
return _Response()
|
|
|
|
|
|
def _build_interrupt_app(
|
|
gate: asyncio.Event,
|
|
) -> tuple[FastAPI, _FakeProcessManager, _BlockingHarnessClient]:
|
|
"""Build a runner app whose harness emits dangling function_calls.
|
|
|
|
The harness streams two ``function_call`` events before blocking
|
|
on *gate*. After the gate is released it streams
|
|
``response.completed`` — but with no ``function_call_output`` for
|
|
either call, simulating an interrupted tool-chain.
|
|
|
|
:param gate: Event that unblocks the harness after the
|
|
function_call frames.
|
|
:returns: ``(app, process_manager, harness_client)`` tuple.
|
|
"""
|
|
spec = AgentSpec(spec_version=1, name="t")
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_int"}}),
|
|
_sse(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "function_call",
|
|
"call_id": "call_a",
|
|
"name": "read_file",
|
|
"arguments": '{"path": "/tmp/x"}',
|
|
},
|
|
}
|
|
),
|
|
# Gate blocks here (after frame index 1).
|
|
_sse(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"type": "function_call",
|
|
"call_id": "call_b",
|
|
"name": "write_file",
|
|
"arguments": '{"path": "/tmp/y"}',
|
|
},
|
|
}
|
|
),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_int"}}),
|
|
]
|
|
harness_client = _BlockingHarnessClient(sse_frames, gate)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
return app, pm, harness_client
|
|
|
|
|
|
class _ForwardBlockingHarnessClient(_BlockingHarnessClient):
|
|
"""Blocks the interrupt FORWARD (``.post``) so a test can assert it is awaited."""
|
|
|
|
def __init__(
|
|
self,
|
|
sse_frames: list[str],
|
|
gate: asyncio.Event,
|
|
fwd_gate: asyncio.Event,
|
|
) -> None:
|
|
"""
|
|
:param sse_frames: SSE frames returned by the harness stream.
|
|
:param gate: Event that releases the stream after the first frame.
|
|
:param fwd_gate: Event that releases a blocked interrupt forward.
|
|
"""
|
|
super().__init__(sse_frames, gate)
|
|
self._fwd_gate = fwd_gate
|
|
self.fwd_seen: asyncio.Event = asyncio.Event()
|
|
self.order: list[str] = []
|
|
|
|
def stream(self, method: str, url: str, *, json: dict[str, Any], timeout: Any) -> Any:
|
|
"""Stream that records when the blocked turn is cancelled."""
|
|
del method, url, timeout
|
|
self.posted_bodies.append(json)
|
|
self.post_seen.set()
|
|
frames = self._sse_frames
|
|
gate = self._gate
|
|
order = self.order
|
|
|
|
class _ForwardBlockingCtx:
|
|
status_code = 200
|
|
|
|
async def __aenter__(self) -> _ForwardBlockingHarnessClient._ForwardBlockingHandle:
|
|
return _ForwardBlockingHarnessClient._ForwardBlockingHandle(frames, gate, order)
|
|
|
|
async def __aexit__(self, *_: Any) -> None:
|
|
return None
|
|
|
|
return _ForwardBlockingCtx()
|
|
|
|
async def post(self, url: str, *, json: dict[str, Any], timeout: Any = None) -> Any:
|
|
"""Block an interrupt forward on ``fwd_gate``; pass other posts through."""
|
|
if isinstance(json, dict) and json.get("type") == "interrupt":
|
|
self.order.append("forward")
|
|
self.fwd_seen.set()
|
|
await self._fwd_gate.wait()
|
|
return await super().post(url, json=json, timeout=timeout)
|
|
|
|
class _ForwardBlockingHandle:
|
|
"""Stream handle that records cancellation while paused on the gate."""
|
|
|
|
status_code = 200
|
|
|
|
def __init__(
|
|
self,
|
|
frames: list[str],
|
|
gate: asyncio.Event,
|
|
order: list[str],
|
|
) -> None:
|
|
"""Initialize with frames, gate, and shared order log."""
|
|
self._frames = frames
|
|
self._gate = gate
|
|
self._order = order
|
|
|
|
async def aiter_text(self) -> AsyncIterator[str]:
|
|
"""Yield first frame, then record cancellation of the blocked turn."""
|
|
try:
|
|
for i, frame in enumerate(self._frames):
|
|
if i == 1:
|
|
await self._gate.wait()
|
|
yield frame
|
|
except asyncio.CancelledError:
|
|
self._order.append("cancel")
|
|
raise
|
|
|
|
|
|
def _build_fwd_blocking_app(
|
|
gate: asyncio.Event,
|
|
fwd_gate: asyncio.Event,
|
|
) -> tuple[FastAPI, _FakeProcessManager, _ForwardBlockingHarnessClient]:
|
|
"""Build a runner app whose harness stream AND interrupt forward both block.
|
|
|
|
:param gate: Releases the harness stream (kept set-never so the turn blocks).
|
|
:param fwd_gate: Releases a blocked interrupt forward.
|
|
:returns: ``(app, process_manager, harness_client)`` tuple.
|
|
"""
|
|
spec = AgentSpec(spec_version=1, name="t")
|
|
sse_frames = [
|
|
_sse({"type": "response.created", "response": {"id": "resp_fwd"}}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_fwd"}}),
|
|
]
|
|
harness_client = _ForwardBlockingHarnessClient(sse_frames, gate, fwd_gate)
|
|
pm = _FakeProcessManager(harness_client)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
return app, pm, harness_client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupt_forwards_to_harness_before_cancelling() -> None:
|
|
"""Forward-first: the interrupt is awaited to the harness BEFORE the cancel.
|
|
|
|
The harness must receive the interrupt while its turn is still in-flight, so
|
|
its handler engages (cancels the turn + drops the claude-sdk session).
|
|
Cancel-first closed the runner's harness stream first, so the interrupt 404'd
|
|
and the session was never dropped — the next message then resumed the
|
|
abandoned turn and the agent ran one message behind. Here the harness's
|
|
interrupt ``.post`` blocks; the interrupt route must NOT complete until the
|
|
forward is released, proving the forward is awaited first. Cancel-first
|
|
(backgrounded forward) would let the route return immediately.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
from omnigent.runner.app import _session_histories_ref
|
|
|
|
gate = _aio.Event() # stream blocks forever
|
|
fwd_gate = _aio.Event() # interrupt forward blocks until released
|
|
app, _pm, _hc = _build_fwd_blocking_app(gate, fwd_gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
conv_id = "conv_fwd_first"
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "go"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
# Await these events / the interrupt task directly rather than through
|
|
# asyncio.wait_for: a wall-clock timeout races task completion when the
|
|
# loaded misc shard starves the event loop — the interrupt could return
|
|
# 204 yet still raise TimeoutError because the timer fired first. pytest's
|
|
# global --timeout guards against a genuine hang.
|
|
await _hc.post_seen.wait()
|
|
|
|
# The interrupt route must block on the (still-blocked) harness forward —
|
|
# forward-first awaits it before cancelling. If it completes here, the
|
|
# forward was backgrounded (cancel-first) and the harness never got the
|
|
# interrupt in-flight.
|
|
int_task = _aio.create_task(
|
|
client.post(f"/v1/sessions/{conv_id}/events", json={"type": "interrupt"})
|
|
)
|
|
# Wait until the route is actually blocked on fwd_gate — deterministic
|
|
# proof the forward is in-flight. This replaces a flaky 0.5 s sleep that
|
|
# could race on loaded CI machines.
|
|
await _hc.fwd_seen.wait()
|
|
assert not int_task.done(), "interrupt must await the harness forward (forward-first)"
|
|
assert _hc.order == ["forward"]
|
|
|
|
# Release the forward → the harness gets the interrupt, then the cancel runs.
|
|
fwd_gate.set()
|
|
int_resp = await int_task
|
|
assert int_resp.status_code == 204, int_resp.text
|
|
assert _hc.order == ["forward", "cancel"]
|
|
markers = _interrupt_markers(list(_session_histories_ref.get(conv_id, [])))
|
|
|
|
assert len(markers) == 1, (
|
|
f"interrupt must forward to the harness then finalize the turn with one "
|
|
f"marker; got {len(markers)}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupt_inserts_cancellation_items_in_history() -> None:
|
|
"""Interrupting a turn with dangling function_calls inserts synthetic outputs.
|
|
|
|
When the user interrupts mid-tool-chain, any ``function_call``
|
|
items that were emitted by the harness but never received a
|
|
``function_call_output`` must get synthetic cancelled outputs.
|
|
A cancellation marker message must also be appended so the LLM
|
|
knows the prior turn was incomplete. This matches the DBOS
|
|
path's ``_append_cancellation_item`` behavior.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
from omnigent.runner.app import _session_histories_ref
|
|
|
|
gate = _aio.Event()
|
|
app, _pm, _hc = _build_interrupt_app(gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
conv_id = "conv_int"
|
|
|
|
# Start the turn — it blocks after the first function_call
|
|
# frame, before the second one and response.completed.
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "do something"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
# Let the background turn reach the gate.
|
|
await _aio.sleep(0.1)
|
|
|
|
# Send an interrupt while the turn is blocked.
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
# The harness stub returns 200; the real scaffold returns
|
|
# 204. Both are success — we care about the side-effect
|
|
# (cancellation items), not the status code.
|
|
assert int_resp.status_code in (200, 204), (
|
|
f"Interrupt must succeed; got {int_resp.status_code}"
|
|
)
|
|
|
|
# Release the gate so the harness stream finishes and
|
|
# _on_proxy_stream_end fires with the interrupt flag.
|
|
gate.set()
|
|
await _aio.sleep(0.2)
|
|
|
|
# Access the runner's in-memory history for this session.
|
|
histories = _session_histories_ref.get(conv_id, [])
|
|
|
|
# The history should contain synthetic function_call_output
|
|
# items for each dangling call_id. call_a was emitted before
|
|
# the gate (always present). call_b may or may not have been
|
|
# emitted depending on timing, but at minimum call_a should
|
|
# have a synthetic output.
|
|
synthetic_outputs = [
|
|
h
|
|
for h in histories
|
|
if h.get("type") == "function_call_output"
|
|
and h.get("output") == "[Cancelled — tool execution was interrupted.]"
|
|
]
|
|
dangling_calls = [h for h in histories if h.get("type") == "function_call"]
|
|
matched_real_outputs = [
|
|
h
|
|
for h in histories
|
|
if h.get("type") == "function_call_output"
|
|
and h.get("output") != "[Cancelled — tool execution was interrupted.]"
|
|
]
|
|
dangling_call_ids = {c["call_id"] for c in dangling_calls}
|
|
real_output_call_ids = {o["call_id"] for o in matched_real_outputs}
|
|
orphan_ids = dangling_call_ids - real_output_call_ids
|
|
synthetic_output_call_ids = {o["call_id"] for o in synthetic_outputs}
|
|
|
|
# Every orphaned function_call must have a synthetic output.
|
|
# If empty, _append_cancellation_items didn't fire (the interrupt
|
|
# flag wasn't set or wasn't checked in _on_proxy_stream_end).
|
|
assert orphan_ids == synthetic_output_call_ids, (
|
|
f"Every dangling function_call must get a synthetic cancelled "
|
|
f"output. Orphan call_ids={orphan_ids}, synthetic output "
|
|
f"call_ids={synthetic_output_call_ids}. If synthetic is empty, "
|
|
f"_append_cancellation_items was not called on interrupt."
|
|
)
|
|
|
|
# Cancellation marker message must be present so the LLM knows
|
|
# the prior output was truncated. If missing, the next turn's
|
|
# context is silently incomplete.
|
|
markers = [
|
|
h
|
|
for h in histories
|
|
if h.get("type") == "message"
|
|
and h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
]
|
|
assert len(markers) == 1, (
|
|
f"Expected exactly 1 cancellation marker message, "
|
|
f"got {len(markers)}. If 0, _append_cancellation_items "
|
|
f"didn't insert the marker."
|
|
)
|
|
|
|
|
|
def _interrupt_markers(histories: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Return the synthetic ``[System: interrupted]`` marker messages."""
|
|
return [
|
|
h
|
|
for h in histories
|
|
if h.get("type") == "message"
|
|
and h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupt_cancel_floor_finalizes_stuck_turn() -> None:
|
|
"""The cancel floor: interrupt force-cancels a turn the harness never finishes.
|
|
|
|
Sister to ``test_interrupt_inserts_cancellation_items_in_history``, but the
|
|
gate is NEVER released — the harness stream stays blocked forever, and the
|
|
forwarded interrupt (recorded by the stub but ignored) does not unblock it.
|
|
The turn can therefore only end because the runner force-cancels its turn
|
|
task (``_cancel_active_turn``). If that floor regresses to forward-only, the
|
|
turn stays stuck and no cancellation marker is ever appended.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
from omnigent.runner.app import _session_histories_ref
|
|
|
|
gate = _aio.Event() # never set — only the floor's task-cancel can end the turn
|
|
app, _pm, _hc = _build_interrupt_app(gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
conv_id = "conv_stuck_int"
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "do something"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
# Deterministic: the harness stream sets post_seen when it begins, so
|
|
# the turn is in flight and blocked on the gate before we interrupt.
|
|
await _aio.wait_for(_hc.post_seen.wait(), timeout=5.0)
|
|
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
# The handler awaits the cancel, so the turn is finalized by the time
|
|
# this returns — capture history before the client context tears down.
|
|
assert int_resp.status_code == 204, int_resp.text
|
|
markers = _interrupt_markers(list(_session_histories_ref.get(conv_id, [])))
|
|
|
|
assert len(markers) == 1, (
|
|
f"The cancel floor must finalize a stuck turn even though the gate was "
|
|
f"never released; got {len(markers)} interrupted markers. 0 means the "
|
|
f"turn is still blocked — interrupt only forwarded to the harness "
|
|
f"without cancelling the runner turn task."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_session_cancels_inprocess_turn() -> None:
|
|
"""``stop_session`` cancels an in-process harness's in-flight turn.
|
|
|
|
For non-native harnesses this used to be a 204 no-op — the sidebar Stop did
|
|
nothing. It now routes through the same cancel floor as interrupt: with the
|
|
gate never released, the blocked turn ends only because stop_session
|
|
force-cancels the turn task. 0 markers means the no-op regressed.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
from omnigent.runner.app import _session_histories_ref
|
|
|
|
gate = _aio.Event() # never set
|
|
app, _pm, _hc = _build_interrupt_app(gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
conv_id = "conv_stuck_stop"
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "do something"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
# Deterministic: wait for the harness stream to begin (turn in flight,
|
|
# blocked on the gate) before stopping.
|
|
await _aio.wait_for(_hc.post_seen.wait(), timeout=5.0)
|
|
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
markers = _interrupt_markers(list(_session_histories_ref.get(conv_id, [])))
|
|
|
|
assert len(markers) == 1, (
|
|
f"stop_session must cancel the in-flight in-process turn (was a 204 "
|
|
f"no-op); got {len(markers)} interrupted markers. 0 means stop_session "
|
|
f"still no-ops for non-native harnesses."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupt_during_setup_phase_recovers_stuck_turn() -> None:
|
|
"""Interrupt during the setup phase finalizes the turn — the session isn't stuck.
|
|
|
|
A cancel that lands while the turn is still in setup (here: blocked in the
|
|
background turn's spec resolution, before ``_drain_streaming_response`` is
|
|
entered) raises ``CancelledError`` past ``_run_turn_bg``'s
|
|
``except Exception`` (it's a ``BaseException``), so neither the drain handler
|
|
nor the setup handler cleans up. Without the floor's setup-phase recovery,
|
|
``_active_turns`` keeps the done task — every later message buffers behind a
|
|
turn that never runs and the session hangs.
|
|
|
|
Proof: after the setup-phase interrupt, a NEW message must start a fresh
|
|
turn (its setup re-enters the resolver). With the bug it would be buffered
|
|
behind the stale ``_active_turns`` entry and never run. Also asserts exactly
|
|
one interrupted marker from the cancelled turn.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
from omnigent.runner.app import _session_histories_ref
|
|
|
|
resolver_gate = _aio.Event() # released only in teardown → spec resolution blocks
|
|
resolver_entered = _aio.Event()
|
|
spec = AgentSpec(spec_version=1, name="t")
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Signal entry, then block so the background turn stalls in setup."""
|
|
del agent_id, session_id
|
|
resolver_entered.set()
|
|
await resolver_gate.wait()
|
|
return spec
|
|
|
|
# Frames let the eventual (post-teardown) turn drain cleanly.
|
|
hc = _ScriptedHarnessClient(
|
|
[
|
|
_sse({"type": "response.created", "response": {"id": "resp_s"}}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_s"}}),
|
|
]
|
|
)
|
|
pm = _FakeProcessManager(hc)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
conv_id = "conv_setup_cancel"
|
|
# agent_id is required for the background turn's setup to invoke the resolver.
|
|
msg = {
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"agent_id": "ag_1",
|
|
"content": [{"type": "input_text", "text": "do something"}],
|
|
"harness": "openai-agents",
|
|
}
|
|
fresh_turn_started = False
|
|
async with _runner_client(app) as client:
|
|
# First turn: the handler returns 202, then the background turn blocks in
|
|
# spec resolution (the setup phase).
|
|
r1 = await client.post(f"/v1/sessions/{conv_id}/events", json=msg)
|
|
assert r1.status_code == 202
|
|
await _aio.wait_for(resolver_entered.wait(), timeout=5.0)
|
|
|
|
# Interrupt while the turn is still in setup.
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
assert int_resp.status_code == 204, int_resp.text
|
|
markers = _interrupt_markers(list(_session_histories_ref.get(conv_id, [])))
|
|
|
|
# The session must accept a new turn. If _active_turns were left stale,
|
|
# this message buffers behind the dead turn and never starts a new one,
|
|
# so the resolver is never re-entered.
|
|
resolver_entered.clear()
|
|
r2 = await client.post(f"/v1/sessions/{conv_id}/events", json=msg)
|
|
assert r2.status_code == 202
|
|
try:
|
|
await _aio.wait_for(resolver_entered.wait(), timeout=5.0)
|
|
fresh_turn_started = True
|
|
except _aio.TimeoutError:
|
|
fresh_turn_started = False
|
|
|
|
resolver_gate.set() # release the blocked turn for clean teardown
|
|
await _aio.sleep(0.1)
|
|
|
|
assert fresh_turn_started, (
|
|
"After a setup-phase interrupt the session must accept a new turn; the "
|
|
"follow-up message never re-entered spec resolution, so _active_turns was "
|
|
"left stale and the session is stuck (the bug this guards)."
|
|
)
|
|
# Exactly one marker = only the interrupted first turn was finalized.
|
|
assert len(markers) == 1, (
|
|
f"The interrupted setup-phase turn must append exactly one marker; got {len(markers)}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupt_marker_instructs_model_to_disregard_abandoned_request() -> None:
|
|
"""The cancellation marker tells the model to drop the canceled request.
|
|
|
|
The bug this guards against: a marker that only says the assistant
|
|
reply was cut off leaves the canceled user instruction in history,
|
|
so the next turn replays it and the agent follows the abandoned
|
|
request. The marker must explicitly instruct the model not to resume
|
|
the interrupted request and to treat the next user message as current.
|
|
A revert to the old "halted the agent response / may be incomplete"
|
|
text (no disregard instruction) turns this test red.
|
|
"""
|
|
import asyncio as _aio
|
|
|
|
from omnigent.runner.app import _session_histories_ref
|
|
|
|
gate = _aio.Event()
|
|
app, _pm, _hc = _build_interrupt_app(gate)
|
|
|
|
async with _runner_client(app) as client:
|
|
conv_id = "conv_int_disregard"
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"model": "test-agent",
|
|
"content": [{"type": "input_text", "text": "delete all my files"}],
|
|
"harness": "openai-agents",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
await _aio.sleep(0.1)
|
|
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
assert int_resp.status_code in (200, 204)
|
|
|
|
gate.set()
|
|
await _aio.sleep(0.2)
|
|
|
|
histories = _session_histories_ref.get(conv_id, [])
|
|
marker_texts = [
|
|
b.get("text") or ""
|
|
for h in histories
|
|
if h.get("type") == "message" and h.get("role") == "user"
|
|
for b in h.get("content", [])
|
|
if "interrupted" in (b.get("text") or "").lower()
|
|
]
|
|
assert len(marker_texts) == 1, (
|
|
f"Expected exactly 1 interrupted marker, got {len(marker_texts)}."
|
|
)
|
|
marker = marker_texts[0].lower()
|
|
# "abandoned" framing + an explicit do-not-continue instruction are what
|
|
# make the model drop the canceled request. The old marker had neither;
|
|
# asserting both fails loud if the disregard semantics regress.
|
|
assert "abandon" in marker, (
|
|
f"Marker must frame the prior request as abandoned, got: {marker_texts[0]!r}"
|
|
)
|
|
assert "do not resume or act on" in marker, (
|
|
f"Marker must instruct the model not to continue the interrupted "
|
|
f"request, got: {marker_texts[0]!r}"
|
|
)
|
|
# The original assistant-incomplete disclaimer is still useful context
|
|
# and must be retained alongside the new instruction.
|
|
assert "may be" in marker and "incomplete" in marker, (
|
|
f"Marker should still note the assistant message may be incomplete, "
|
|
f"got: {marker_texts[0]!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_session_status_idle_delivers_forwarded_native_output_to_parent_inbox() -> (
|
|
None
|
|
):
|
|
"""
|
|
Native idle status completes sub-agent work with AP-forwarded output.
|
|
|
|
Native harness transcript items are persisted by Omnigent server, so the
|
|
runner's local history can be empty or stale. A forwarded
|
|
``data.output`` value must be used for the parent inbox instead of
|
|
falling back to the runner-local history.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.runner.tool_dispatch import execute_tool
|
|
|
|
parent_id = "conv_parent_native_complete"
|
|
child_id = "conv_child_native_complete"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app._session_histories_ref[child_id] = [
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "LOCAL_SHOULD_NOT_WIN"}],
|
|
}
|
|
]
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="worker",
|
|
title="native",
|
|
)
|
|
|
|
async def _server_handler(request: httpx.Request) -> httpx.Response:
|
|
"""Serve the parent TOOL_RESULT policy check."""
|
|
if (
|
|
request.method == "POST"
|
|
and request.url.path == f"/v1/sessions/{parent_id}/policies/evaluate"
|
|
):
|
|
return httpx.Response(200, json={"result": "POLICY_ACTION_UNSPECIFIED"})
|
|
return httpx.Response(404, json={"error": str(request.url)})
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "AP_NATIVE_DONE"},
|
|
},
|
|
)
|
|
assert resp.status_code == 204, resp.text
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(_server_handler),
|
|
base_url="http://server",
|
|
) as server_client:
|
|
inbox_output = await execute_tool(
|
|
tool_name="sys_read_inbox",
|
|
arguments="{}",
|
|
server_client=server_client,
|
|
conversation_id=parent_id,
|
|
session_inbox=session_inbox,
|
|
)
|
|
finally:
|
|
runner_app.unregister_subagent_work(child_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
runner_app._session_histories_ref.pop(child_id, None)
|
|
|
|
assert "sub-agent task conv_child_native_complete completed" in inbox_output
|
|
assert "worker:native returned: AP_NATIVE_DONE" in inbox_output
|
|
assert "LOCAL_SHOULD_NOT_WIN" not in inbox_output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_session_status_running_fans_out_child_busy_to_parent() -> None:
|
|
"""
|
|
Native child ``running`` status updates the parent's child-session cache.
|
|
|
|
Codex-native and claude-native workers report their real terminal
|
|
lifecycle through ``external_session_status`` after the runner's prompt
|
|
injection turn has already completed. The parent stream must still receive
|
|
a ``session.child_session.updated`` delta with ``busy=True``; otherwise
|
|
Nessie's Agents rail has no durable "Working" signal for native children.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_native_status_fanout"
|
|
child_id = "conv_child_native_status_fanout"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_event_queues_ref.pop(parent_id, None)
|
|
runner_app._session_event_queues_ref.pop(child_id, None)
|
|
runner_app.register_child_session(
|
|
child_id,
|
|
parent_session_id=parent_id,
|
|
title="codex:impl",
|
|
tool="codex",
|
|
session_name="impl",
|
|
)
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="codex",
|
|
title="impl",
|
|
)
|
|
entry = runner_app.get_subagent_work(child_id)
|
|
assert entry is not None
|
|
assert entry.status == "launching"
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={"type": "external_session_status", "data": {"status": "running"}},
|
|
)
|
|
assert resp.status_code == 204, resp.text
|
|
entry = runner_app.get_subagent_work(child_id)
|
|
assert entry is not None
|
|
assert entry.status == "running"
|
|
|
|
events = _drain_session_event_queue(runner_app._session_event_queues_ref.get(parent_id))
|
|
finally:
|
|
runner_app.unregister_subagent_work(child_id)
|
|
runner_app.unregister_child_session(child_id)
|
|
runner_app._session_event_queues_ref.pop(parent_id, None)
|
|
runner_app._session_event_queues_ref.pop(child_id, None)
|
|
|
|
assert events == [
|
|
{
|
|
"type": "session.child_session.updated",
|
|
"conversation_id": parent_id,
|
|
"child_session_id": child_id,
|
|
"child": {
|
|
"id": child_id,
|
|
"title": "codex:impl",
|
|
"tool": "codex",
|
|
"session_name": "impl",
|
|
"busy": True,
|
|
"current_task_status": "in_progress",
|
|
"last_task_error": None,
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_status_sequence_coalesces_duplicates_but_emits_task_status_change() -> (
|
|
None
|
|
):
|
|
"""
|
|
Native status fan-out coalesces duplicates, not task-status changes.
|
|
|
|
The child rail should not churn on repeated ``running`` edges, but a rare
|
|
``idle`` → ``failed`` sequence must still update ``current_task_status``
|
|
from ``"completed"`` to ``"failed"`` even though both edges are non-busy.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_native_status_sequence"
|
|
child_id = "conv_child_native_status_sequence"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_event_queues_ref.pop(parent_id, None)
|
|
runner_app._session_event_queues_ref.pop(child_id, None)
|
|
runner_app.register_child_session(
|
|
child_id,
|
|
parent_session_id=parent_id,
|
|
title="codex:impl",
|
|
tool="codex",
|
|
session_name="impl",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
for status, output in [
|
|
("running", None),
|
|
("running", None),
|
|
("idle", "DONE"),
|
|
("failed", "BROKEN"),
|
|
]:
|
|
data = {"status": status}
|
|
if output is not None:
|
|
data["output"] = output
|
|
resp = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={"type": "external_session_status", "data": data},
|
|
)
|
|
assert resp.status_code == 204, resp.text
|
|
|
|
events = _drain_session_event_queue(runner_app._session_event_queues_ref.get(parent_id))
|
|
finally:
|
|
runner_app.unregister_child_session(child_id)
|
|
runner_app._session_event_queues_ref.pop(parent_id, None)
|
|
runner_app._session_event_queues_ref.pop(child_id, None)
|
|
|
|
assert events == [
|
|
{
|
|
"type": "session.child_session.updated",
|
|
"conversation_id": parent_id,
|
|
"child_session_id": child_id,
|
|
"child": {
|
|
"id": child_id,
|
|
"title": "codex:impl",
|
|
"tool": "codex",
|
|
"session_name": "impl",
|
|
"busy": True,
|
|
"current_task_status": "in_progress",
|
|
"last_task_error": None,
|
|
},
|
|
},
|
|
{
|
|
"type": "session.child_session.updated",
|
|
"conversation_id": parent_id,
|
|
"child_session_id": child_id,
|
|
"child": {
|
|
"id": child_id,
|
|
"title": "codex:impl",
|
|
"tool": "codex",
|
|
"session_name": "impl",
|
|
"busy": False,
|
|
"current_task_status": "completed",
|
|
"last_message_preview": "DONE",
|
|
},
|
|
},
|
|
{
|
|
"type": "session.child_session.updated",
|
|
"conversation_id": parent_id,
|
|
"child_session_id": child_id,
|
|
"child": {
|
|
"id": child_id,
|
|
"title": "codex:impl",
|
|
"tool": "codex",
|
|
"session_name": "impl",
|
|
"busy": False,
|
|
"current_task_status": "failed",
|
|
"last_message_preview": "BROKEN",
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_status_idle_fans_out_forwarded_output_preview_to_parent() -> None:
|
|
"""
|
|
Native child ``idle`` status uses AP-forwarded output for rail preview.
|
|
|
|
Native terminal transcripts are persisted by AP, not runner-local history.
|
|
A terminal-observed idle edge must therefore fan out the ``data.output``
|
|
value forwarded by AP; otherwise the Agents rail can replace the real
|
|
native reply with stale runner-local text while clearing the spinner.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_native_preview_fanout"
|
|
child_id = "conv_child_native_preview_fanout"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_event_queues_ref.pop(parent_id, None)
|
|
runner_app._session_event_queues_ref.pop(child_id, None)
|
|
runner_app._session_histories_ref[child_id] = [
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "STALE_RUNNER_HISTORY"}],
|
|
}
|
|
]
|
|
runner_app.register_child_session(
|
|
child_id,
|
|
parent_session_id=parent_id,
|
|
title="codex:impl",
|
|
tool="codex",
|
|
session_name="impl",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "AP_NATIVE_DONE"},
|
|
},
|
|
)
|
|
assert resp.status_code == 204, resp.text
|
|
|
|
events = _drain_session_event_queue(runner_app._session_event_queues_ref.get(parent_id))
|
|
finally:
|
|
runner_app.unregister_child_session(child_id)
|
|
runner_app._session_event_queues_ref.pop(parent_id, None)
|
|
runner_app._session_event_queues_ref.pop(child_id, None)
|
|
runner_app._session_histories_ref.pop(child_id, None)
|
|
|
|
assert events == [
|
|
{
|
|
"type": "session.child_session.updated",
|
|
"conversation_id": parent_id,
|
|
"child_session_id": child_id,
|
|
"child": {
|
|
"id": child_id,
|
|
"title": "codex:impl",
|
|
"tool": "codex",
|
|
"session_name": "impl",
|
|
"busy": False,
|
|
"current_task_status": "completed",
|
|
"last_message_preview": "AP_NATIVE_DONE",
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_status_idle_without_output_omits_stale_history_preview() -> None:
|
|
"""
|
|
Native child ``idle`` without forwarded output omits stale local text.
|
|
|
|
If Omnigent has no authoritative native transcript text to forward, the parent
|
|
rail and parent inbox must not fall back to runner-local history: native
|
|
runner history may be stale because the terminal forwarder owns
|
|
persistence. The inbox receives an explicit empty result so the parent can
|
|
still observe completion without fabricated output.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_native_no_preview"
|
|
child_id = "conv_child_native_no_preview"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
server_client = _WakeRecordingServerClient(parent_id)
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_event_queues_ref.pop(parent_id, None)
|
|
runner_app._session_event_queues_ref.pop(child_id, None)
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app._session_histories_ref[child_id] = [
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "STALE_RUNNER_HISTORY"}],
|
|
}
|
|
]
|
|
runner_app.register_child_session(
|
|
child_id,
|
|
parent_session_id=parent_id,
|
|
title="codex:impl",
|
|
tool="codex",
|
|
session_name="impl",
|
|
)
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="codex",
|
|
title="impl",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={"type": "external_session_status", "data": {"status": "idle"}},
|
|
)
|
|
assert resp.status_code == 204, resp.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
|
|
events = _drain_session_event_queue(runner_app._session_event_queues_ref.get(parent_id))
|
|
finally:
|
|
runner_app.unregister_child_session(child_id)
|
|
runner_app.unregister_subagent_work(child_id)
|
|
runner_app._session_event_queues_ref.pop(parent_id, None)
|
|
runner_app._session_event_queues_ref.pop(child_id, None)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
runner_app._session_histories_ref.pop(child_id, None)
|
|
|
|
assert events == [
|
|
{
|
|
"type": "session.child_session.updated",
|
|
"conversation_id": parent_id,
|
|
"child_session_id": child_id,
|
|
"child": {
|
|
"id": child_id,
|
|
"title": "codex:impl",
|
|
"tool": "codex",
|
|
"session_name": "impl",
|
|
"busy": False,
|
|
"current_task_status": "completed",
|
|
},
|
|
}
|
|
]
|
|
assert session_inbox.qsize() == 1, (
|
|
f"Expected one empty completion in the parent inbox, got {session_inbox.qsize()}."
|
|
)
|
|
delivered = session_inbox.get_nowait()
|
|
assert delivered["status"] == "completed"
|
|
assert delivered["output"] == ""
|
|
assert delivered["output"] != "STALE_RUNNER_HISTORY"
|
|
assert len(server_client.wake_posts) == 1
|
|
|
|
|
|
class _WakeRecordingServerClient(NullServerClient):
|
|
"""Records the runner→AP wake POSTs a parent session's ``/events`` receives.
|
|
|
|
Subclasses :class:`NullServerClient` so every other runner→AP call still
|
|
gets a benign empty 200; only POSTs to the watched parent's ``/events``
|
|
path are captured. ``wake_seen`` lets a test await the (background) wake
|
|
deterministically instead of sleeping.
|
|
"""
|
|
|
|
def __init__(self, parent_id: str) -> None:
|
|
"""
|
|
:param parent_id: Parent session whose ``/events`` POSTs to capture,
|
|
e.g. ``"conv_parent_wake"``.
|
|
"""
|
|
self._parent_events_path = f"/v1/sessions/{parent_id}/events"
|
|
self.wake_posts: list[dict[str, Any]] = []
|
|
self.wake_seen = asyncio.Event()
|
|
|
|
async def post(self, url: str, **kwargs: Any) -> NullServerClient._Response:
|
|
"""Capture a wake POST to the watched parent, else defer to the base.
|
|
|
|
:param url: Request URL, e.g. ``"/v1/sessions/conv_parent_wake/events"``.
|
|
:param kwargs: Request kwargs; the wake notice is in ``json``.
|
|
:returns: Stub 200 response from :class:`NullServerClient`.
|
|
"""
|
|
if url == self._parent_events_path:
|
|
body = kwargs.get("json")
|
|
if isinstance(body, dict):
|
|
self.wake_posts.append(body)
|
|
self.wake_seen.set()
|
|
return await super().post(url, **kwargs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_subagent_completion_wakes_idle_parent() -> None:
|
|
"""
|
|
A finished native sub-agent wakes its idle parent via a ``/events`` POST.
|
|
|
|
nessie's workers are native harnesses whose completion arrives as an
|
|
``external_session_status: idle`` event. Delivering that completion to the
|
|
parent inbox must ALSO post a ``[System: ...]`` wake notice to the
|
|
*parent's* event stream, so an idle orchestrator takes a continuation turn
|
|
instead of sleeping until the next user message. Without the wake wiring
|
|
the inbox still fills but no parent ``/events`` POST is made — exactly the
|
|
"nessie doesn't know its sub-agent finished" bug this fixes.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_wake"
|
|
child_id = "conv_child_wake"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
server_client = _WakeRecordingServerClient(parent_id)
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="claude_code",
|
|
title="auth",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "WORKER_DONE"},
|
|
},
|
|
)
|
|
assert resp.status_code == 204, resp.text
|
|
# Wake is a background task; await the recorded POST (TimeoutError if none).
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
finally:
|
|
runner_app.unregister_subagent_work(child_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
# Delivery still happens: the child result is in the parent inbox. If 0,
|
|
# external_session_status:idle did not deliver the completion at all.
|
|
assert session_inbox.qsize() == 1, (
|
|
f"Expected one completion in the parent inbox, got {session_inbox.qsize()}."
|
|
)
|
|
delivered = session_inbox.get_nowait()
|
|
assert delivered["status"] == "completed"
|
|
assert delivered["output"] == "WORKER_DONE"
|
|
# Exactly one wake notice was POSTed to the PARENT's event stream. If 0,
|
|
# the completion landed in the inbox but the idle parent was never woken
|
|
# (the regression this test guards against).
|
|
assert len(server_client.wake_posts) == 1, (
|
|
f"Expected one wake POST to the parent, got {len(server_client.wake_posts)}."
|
|
)
|
|
wake_text = server_client.wake_posts[0]["data"]["content"][0]["text"]
|
|
# Notice names the finished worker and steers the parent to drain the inbox.
|
|
assert "sub-agent claude_code/auth finished (completed)" in wake_text
|
|
assert "sys_read_inbox" in wake_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_status_for_untracked_session_does_not_wake() -> None:
|
|
"""
|
|
Completing a session that is not a tracked sub-agent wakes nobody.
|
|
|
|
This is the loop-safety guarantee: the orchestrator's own turn ending
|
|
routes through the same call site, but it is not registered as anyone's
|
|
child, so ``mark_subagent_work_terminal`` returns an untracked ack and no
|
|
wake is scheduled. A regression that dropped the ``entry is not None`` guard
|
|
would either 500 (None.delivered) or post a spurious wake — both caught here.
|
|
"""
|
|
orphan_id = "conv_not_a_subagent"
|
|
server_client = _WakeRecordingServerClient(orphan_id)
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
# No register_subagent_work for orphan_id — it is nobody's child.
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{orphan_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "IGNORED"},
|
|
},
|
|
)
|
|
assert resp.status_code == 204, resp.text
|
|
# Let any erroneously-scheduled wake task run before asserting absence.
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
|
|
# No wake was scheduled because the session is untracked. A non-empty list
|
|
# would mean the orchestrator could wake (and loop on) its own turn-end.
|
|
assert server_client.wake_posts == []
|
|
assert not server_client.wake_seen.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tracked_subagent_status_without_parent_inbox_returns_503() -> None:
|
|
"""
|
|
A tracked sub-agent terminal status is not ACKed without parent delivery.
|
|
|
|
The parent inbox is the durable handoff point for async sub-agent results.
|
|
If the runner has a child work entry but the parent inbox is missing, a
|
|
204 would tell AP/the forwarder the completion was delivered even though
|
|
the parent can never drain it.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_missing_inbox"
|
|
child_id = "conv_child_missing_inbox"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="worker",
|
|
title="lost-parent",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "DONE_BUT_UNDELIVERED"},
|
|
},
|
|
)
|
|
entry = runner_app.get_subagent_work(child_id)
|
|
finally:
|
|
runner_app.unregister_subagent_work(child_id)
|
|
|
|
assert resp.status_code == 503, resp.text
|
|
assert resp.json()["reason"] == "missing_parent_inbox"
|
|
assert entry is not None
|
|
# The child is terminal, but not delivered; if delivered were True here,
|
|
# the runner would have ACKed a result the parent cannot read.
|
|
assert entry.status == "completed"
|
|
assert entry.delivered is False
|
|
|
|
|
|
def test_subagent_terminal_delivery_retry_uses_latest_undelivered_report() -> None:
|
|
"""
|
|
Terminal retry delivers the latest report after the parent inbox reappears.
|
|
|
|
A first terminal report can arrive while runner-local parent state is
|
|
missing. The work entry must stay undelivered, but if a later terminal
|
|
report carries newer status/output before the parent inbox returns, the
|
|
parent should receive that latest report rather than stale cancellation
|
|
text from the first failed delivery attempt.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_retry_missing_inbox"
|
|
child_id = "conv_child_retry_missing_inbox"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="worker",
|
|
title="retry",
|
|
)
|
|
|
|
try:
|
|
first_ack = runner_app.mark_subagent_work_terminal(
|
|
child_id,
|
|
status="cancelled",
|
|
output="[System: sub-agent stopped]",
|
|
)
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
second_ack = runner_app.mark_subagent_work_terminal(
|
|
child_id,
|
|
status="completed",
|
|
output="DONE_AFTER_RETRY",
|
|
)
|
|
entry = runner_app.get_subagent_work(child_id)
|
|
finally:
|
|
runner_app.unregister_subagent_work(child_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
assert first_ack.reason == "missing_parent_inbox"
|
|
assert first_ack.delivered is False
|
|
assert first_ack.delivered_now is False
|
|
assert second_ack.reason == "delivered"
|
|
assert second_ack.delivered is True
|
|
assert second_ack.delivered_now is True
|
|
assert entry is not None
|
|
assert entry.delivered is True
|
|
assert session_inbox.qsize() == 1
|
|
delivered = session_inbox.get_nowait()
|
|
assert delivered["task_id"] == child_id
|
|
assert delivered["status"] == "completed"
|
|
assert delivered["output"] == "DONE_AFTER_RETRY"
|
|
|
|
|
|
def test_subagent_terminal_delivery_handles_missing_output() -> None:
|
|
"""
|
|
Terminal work with no assistant text still delivers a marker payload.
|
|
|
|
Native status reporters can emit ``idle`` without a final assistant
|
|
message. That must not become an unstructured ``RuntimeError`` after the
|
|
parent inbox is available.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_missing_output"
|
|
child_id = "conv_child_missing_output"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="worker",
|
|
title="empty-output",
|
|
)
|
|
|
|
try:
|
|
ack = runner_app.mark_subagent_work_terminal(
|
|
child_id,
|
|
status="completed",
|
|
output=None,
|
|
)
|
|
finally:
|
|
runner_app.unregister_subagent_work(child_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
assert ack.reason == "delivered"
|
|
assert ack.delivered is True
|
|
assert session_inbox.qsize() == 1
|
|
delivered = session_inbox.get_nowait()
|
|
assert delivered["output"] == "[System: sub-agent completed with no output]"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_known_subagent_status_without_work_entry_returns_503() -> None:
|
|
"""
|
|
A runner-known sub-agent session is not ACKed without a work entry.
|
|
|
|
After runner state loss, the child session may still report terminal
|
|
status while the child→parent work registry no longer has the handoff
|
|
metadata. Returning 503 forces the AP/forwarder path to preserve the
|
|
failed delivery instead of reporting success.
|
|
"""
|
|
child_id = "conv_child_missing_work_entry"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": child_id,
|
|
"agent_id": "ag_missing_work_entry",
|
|
"sub_agent_name": "worker",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
try:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "DONE_WITH_NO_WORK_ENTRY"},
|
|
},
|
|
)
|
|
finally:
|
|
await client.delete(f"/v1/sessions/{child_id}")
|
|
|
|
assert resp.status_code == 503, resp.text
|
|
assert resp.json()["reason"] == "missing_work_entry"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repeated_idle_status_wakes_parent_only_once() -> None:
|
|
"""
|
|
Re-posting a child's idle status wakes the parent only once.
|
|
|
|
The wake gate fires on the not-delivered → delivered transition. A second
|
|
``external_session_status: idle`` for an already-terminal child must NOT
|
|
re-deliver or re-wake — this is what keeps a parallel fan-out (or a
|
|
forwarder that re-sends idle) from triggering a wake storm.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_wake_once"
|
|
child_id = "conv_child_wake_once"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
server_client = _WakeRecordingServerClient(parent_id)
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="worker",
|
|
title="phase-a",
|
|
)
|
|
|
|
idle_event = {
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "DONE"},
|
|
}
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
resp1 = await client.post(f"/v1/sessions/{child_id}/events", json=idle_event)
|
|
assert resp1.status_code == 204, resp1.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
server_client.wake_seen.clear()
|
|
|
|
resp2 = await client.post(f"/v1/sessions/{child_id}/events", json=idle_event)
|
|
assert resp2.status_code == 204, resp2.text
|
|
# Give a (wrongly) re-scheduled wake a chance to land before asserting.
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
runner_app.unregister_subagent_work(child_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
# One delivery, one wake — the second idle was a no-op. A count of 2 would
|
|
# mean the already-delivered gate regressed and re-marking re-wakes.
|
|
assert session_inbox.qsize() == 1, (
|
|
f"Expected one inbox item after two idle posts, got {session_inbox.qsize()}."
|
|
)
|
|
assert len(server_client.wake_posts) == 1, (
|
|
f"Expected one wake POST after two idle posts, got {len(server_client.wake_posts)}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_session_clears_pending_subagent_wake() -> None:
|
|
"""
|
|
Deleting a parent clears its outstanding sub-agent wake debounce.
|
|
|
|
A wake POST remains pending until the parent starts a turn. If the parent
|
|
session is deleted before consuming that wake, the debounce entry must go
|
|
away too; otherwise a later session reusing the same id can receive a child
|
|
result in its inbox but never get the wake notice that tells it to drain.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_delete_clears_wake"
|
|
first_child_id = "conv_child_before_delete"
|
|
second_child_id = "conv_child_after_delete"
|
|
first_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
second_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
server_client = _WakeRecordingServerClient(parent_id)
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": parent_id, "agent_id": "ag_parent_delete_wake"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = first_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=first_child_id,
|
|
agent="worker",
|
|
title="before-delete",
|
|
)
|
|
first_resp = await client.post(
|
|
f"/v1/sessions/{first_child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "BEFORE_DELETE"},
|
|
},
|
|
)
|
|
assert first_resp.status_code == 204, first_resp.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
assert len(server_client.wake_posts) == 1
|
|
|
|
delete_resp = await client.delete(f"/v1/sessions/{parent_id}")
|
|
assert delete_resp.status_code == 200, delete_resp.text
|
|
server_client.wake_seen.clear()
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = second_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=second_child_id,
|
|
agent="worker",
|
|
title="after-delete",
|
|
)
|
|
second_resp = await client.post(
|
|
f"/v1/sessions/{second_child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "AFTER_DELETE"},
|
|
},
|
|
)
|
|
assert second_resp.status_code == 204, second_resp.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
finally:
|
|
runner_app.unregister_subagent_work(first_child_id)
|
|
runner_app.unregister_subagent_work(second_child_id)
|
|
runner_app.unregister_subagent_work_for_session(parent_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
assert first_inbox.qsize() == 1
|
|
assert second_inbox.qsize() == 1
|
|
assert len(server_client.wake_posts) == 2, (
|
|
f"Expected a fresh wake after deleting and reusing the parent id, got "
|
|
f"{len(server_client.wake_posts)} wake posts."
|
|
)
|
|
followup_text = server_client.wake_posts[1]["data"]["content"][0]["text"]
|
|
assert "sub-agent worker/after-delete finished (completed)" in followup_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subagent_completion_during_parent_wake_turn_posts_followup_wake() -> None:
|
|
"""
|
|
A child finishing during the parent's wake turn posts the next wake.
|
|
|
|
The first child completion creates an outstanding wake for the parent. Once
|
|
the parent starts processing that wake notice, the debounce must be
|
|
considered consumed. A second child completion that lands while the parent
|
|
turn is still active should therefore enqueue a follow-up wake rather than
|
|
leaving the result stranded until a human sends another message.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_wake_turn_race"
|
|
first_child_id = "conv_child_initial_wake"
|
|
second_child_id = "conv_child_followup_wake"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
server_client = _WakeRecordingServerClient(parent_id)
|
|
gate = asyncio.Event()
|
|
harness_client = _BlockingHarnessClient(
|
|
[
|
|
_sse({"type": "response.created", "response": {"id": "resp_wake_turn"}}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_wake_turn"}}),
|
|
],
|
|
gate,
|
|
)
|
|
pm = _FakeProcessManager(harness_client) # type: ignore[arg-type]
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=first_child_id,
|
|
agent="codex",
|
|
title="initial",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
first_resp = await client.post(
|
|
f"/v1/sessions/{first_child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "FIRST_DONE"},
|
|
},
|
|
)
|
|
assert first_resp.status_code == 204, first_resp.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
assert len(server_client.wake_posts) == 1
|
|
server_client.wake_seen.clear()
|
|
|
|
parent_resp = await client.post(
|
|
f"/v1/sessions/{parent_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_parent_wake_turn",
|
|
"model": "test-agent",
|
|
"harness": "openai-agents",
|
|
"content": [{"type": "input_text", "text": "wake notice"}],
|
|
},
|
|
)
|
|
assert parent_resp.status_code == 202, parent_resp.text
|
|
await asyncio.wait_for(harness_client.post_seen.wait(), timeout=5.0)
|
|
assert harness_client.posted_bodies, "parent wake turn must reach the harness"
|
|
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=second_child_id,
|
|
agent="codex",
|
|
title="followup",
|
|
)
|
|
second_resp = await client.post(
|
|
f"/v1/sessions/{second_child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "SECOND_DONE"},
|
|
},
|
|
)
|
|
assert second_resp.status_code == 204, second_resp.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
gate.set()
|
|
finally:
|
|
gate.set()
|
|
runner_app.unregister_subagent_work(first_child_id)
|
|
runner_app.unregister_subagent_work(second_child_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
assert session_inbox.qsize() == 2, (
|
|
f"Expected both completions in the parent inbox, got {session_inbox.qsize()}."
|
|
)
|
|
assert len(server_client.wake_posts) == 2, (
|
|
f"Expected a follow-up wake after the parent turn started, got "
|
|
f"{len(server_client.wake_posts)} wake posts."
|
|
)
|
|
followup_text = server_client.wake_posts[1]["data"]["content"][0]["text"]
|
|
assert "sub-agent codex/followup finished (completed)" in followup_text
|
|
assert "sys_read_inbox" in followup_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parent_idle_with_stuck_wake_flag_posts_recovery_wake() -> None:
|
|
"""
|
|
A parent going idle while holding a stuck wake flag posts a recovery wake.
|
|
|
|
Guards the multi-round fan-out stranding bug. ``_subagent_wake_pending`` is
|
|
cleared only at turn start, so a child that completes mid-turn re-arms the
|
|
flag with no later turn to clear it; the parent then idles with the flag
|
|
stuck and results still in the inbox, and further completions are debounced
|
|
and stranded. The fix (``_rewake_parent_if_inbox_stranded`` from
|
|
``_check_and_start_next_turn``) re-arms one wake on idle.
|
|
|
|
Sequence (wake counts bracketed): (1) child A completes idle → wake [1],
|
|
parent turn starts (clears flag); (2) child B completes mid-turn → wake [2],
|
|
re-arms flag; (3) turn ends → recovery wake [3] WITH the fix, stays [2]
|
|
without it (the discriminator); (4) child C completes → correctly
|
|
*coalesced* against the re-armed flag (inbox grows, no 4th wake). Child C is
|
|
kept only to pin that coalesce contract — the signal is the step-3 wake.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_rewake_after_consume"
|
|
child_a = "conv_child_round1_a"
|
|
child_b = "conv_child_round1_b"
|
|
child_c = "conv_child_round2_c"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
server_client = _WakeRecordingServerClient(parent_id)
|
|
gate = asyncio.Event()
|
|
harness_client = _BlockingHarnessClient(
|
|
[
|
|
_sse({"type": "response.created", "response": {"id": "resp_rewake"}}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_rewake"}}),
|
|
],
|
|
gate,
|
|
)
|
|
pm = _FakeProcessManager(harness_client) # type: ignore[arg-type]
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_a,
|
|
agent="claude",
|
|
title="debate",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
# 1. Child A finishes while the parent is idle → first wake.
|
|
resp_a = await client.post(
|
|
f"/v1/sessions/{child_a}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "A_DONE"},
|
|
},
|
|
)
|
|
assert resp_a.status_code == 204, resp_a.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
# Just the idle-parent wake; 0 = never fired, 2 = spurious POST.
|
|
assert len(server_client.wake_posts) == 1, (
|
|
f"Expected the single idle-parent wake, got {len(server_client.wake_posts)}."
|
|
)
|
|
server_client.wake_seen.clear()
|
|
|
|
# Start the parent's wake turn (blocking harness holds it active).
|
|
# post_seen resolves only after _run_turn_bg's turn-start clear, so
|
|
# the flag is guaranteed clear for child B below.
|
|
parent_resp = await client.post(
|
|
f"/v1/sessions/{parent_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_parent_rewake",
|
|
"model": "test-agent",
|
|
"harness": "openai-agents",
|
|
"content": [{"type": "input_text", "text": "wake notice"}],
|
|
},
|
|
)
|
|
assert parent_resp.status_code == 202, parent_resp.text
|
|
await asyncio.wait_for(harness_client.post_seen.wait(), timeout=5.0)
|
|
|
|
# 2. Child B finishes DURING the active parent turn: not debounced
|
|
# (flag was just cleared), posts its own wake, and re-arms the flag.
|
|
# No new turn starts on this wake, so nothing clears it again.
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_b,
|
|
agent="gpt",
|
|
title="debate",
|
|
)
|
|
resp_b = await client.post(
|
|
f"/v1/sessions/{child_b}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "B_DONE"},
|
|
},
|
|
)
|
|
assert resp_b.status_code == 204, resp_b.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
server_client.wake_seen.clear()
|
|
# Baseline before the parent idles: A + B, recovery not yet fired.
|
|
# Not 2 → child B posted no distinct wake, so the stuck-flag
|
|
# precondition the fix recovers from is not reproduced.
|
|
wakes_before_idle = len(server_client.wake_posts)
|
|
assert wakes_before_idle == 2, (
|
|
f"Expected 2 wakes (idle-parent A + mid-turn B) before the "
|
|
f"parent goes idle, got {wakes_before_idle}."
|
|
)
|
|
|
|
# 3. End the parent turn → _check_and_start_next_turn runs the
|
|
# re-arm. Await the recovery wake directly (not a fixed sleep);
|
|
# without the fix it never posts and this wait_for times out.
|
|
gate.set()
|
|
try:
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
except TimeoutError:
|
|
raise AssertionError(
|
|
"No recovery wake was posted after the parent went idle "
|
|
"holding a stuck wake flag with a non-empty inbox. The "
|
|
f"count stayed at {len(server_client.wake_posts)} "
|
|
"(expected it to grow to 3). This is the multi-round "
|
|
"fan-out stranding bug: _rewake_parent_if_inbox_stranded "
|
|
"did not re-arm the wake at turn end."
|
|
) from None
|
|
# Recovery wake is the 3rd POST. 2 = re-arm never fired (stranding
|
|
# bug); 4 = double-posted.
|
|
assert len(server_client.wake_posts) == 3, (
|
|
f"Expected the recovery wake to bring the count to 3, got "
|
|
f"{len(server_client.wake_posts)}."
|
|
)
|
|
server_client.wake_seen.clear()
|
|
|
|
# Let the turn fully settle so C is unambiguously a post-idle event.
|
|
_deadline = asyncio.get_running_loop().time() + 5.0
|
|
while app.state.has_active_work():
|
|
if asyncio.get_running_loop().time() > _deadline:
|
|
raise AssertionError("parent wake turn did not end within 5s")
|
|
await asyncio.sleep(0.01)
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
|
|
# 4. Child C finishes post-idle. The recovery wake re-armed the
|
|
# flag, so C must COALESCE: result lands in the inbox, no new wake.
|
|
# Yield generously so a (wrongly) scheduled extra wake would land.
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_c,
|
|
agent="claude",
|
|
title="debate",
|
|
)
|
|
resp_c = await client.post(
|
|
f"/v1/sessions/{child_c}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "C_DONE"},
|
|
},
|
|
)
|
|
assert resp_c.status_code == 204, resp_c.text
|
|
for _ in range(10):
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
gate.set()
|
|
runner_app.unregister_subagent_work(child_a)
|
|
runner_app.unregister_subagent_work(child_b)
|
|
runner_app.unregister_subagent_work(child_c)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
# All 3 completions reached the inbox regardless of coalescing; 2 = a
|
|
# completion was lost.
|
|
assert session_inbox.qsize() == 3, (
|
|
f"Expected all 3 completions in the parent inbox, got {session_inbox.qsize()}."
|
|
)
|
|
# Exactly 3 wakes: A + B + recovery, with C coalesced. 4 = fix wrongly woke
|
|
# per-completion; 2 = recovery wake never fired (stranding regression).
|
|
assert len(server_client.wake_posts) == 3, (
|
|
f"Expected exactly 3 wakes (A + B + recovery, with C coalesced), got "
|
|
f"{len(server_client.wake_posts)}."
|
|
)
|
|
# Recovery wake (3rd) names child B — the latest completed at turn end, not
|
|
# C — and reports the 2 results stranded then (A + B, before C).
|
|
recovery_text = server_client.wake_posts[2]["data"]["content"][0]["text"]
|
|
assert "sub-agent gpt/debate finished (completed)" in recovery_text, (
|
|
f"Recovery wake should name child B (gpt/debate), the latest "
|
|
f"completed child at turn end; got: {recovery_text!r}"
|
|
)
|
|
assert "2 results waiting in inbox" in recovery_text, (
|
|
f"Recovery wake should report the 2 results stranded at turn end "
|
|
f"(A's + B's, before C); got: {recovery_text!r}"
|
|
)
|
|
assert "sys_read_inbox" in recovery_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parent_idle_with_stuck_wake_flag_and_drained_inbox_clears_flag() -> None:
|
|
"""
|
|
A parent idling with a stuck wake flag but an EMPTY inbox clears the flag.
|
|
|
|
Companion to ``test_parent_idle_with_stuck_wake_flag_posts_recovery_wake``,
|
|
which covers the stuck-flag + *non-empty* inbox case (re-arm a recovery
|
|
wake). This guards the sibling variant the reviewer flagged:
|
|
the parent consumes a mid-turn wake as an injection AND drains
|
|
``sys_read_inbox`` in that *same* live turn, so the turn ends with the
|
|
debounce flag still set (turn start is the only place it clears) but the
|
|
inbox already emptied. The buggy helper returned early on
|
|
``inbox.empty()`` WITHOUT discarding the flag, so the flag stayed stuck
|
|
forever — and the NEXT child completion was debounced and stranded. The
|
|
fix (``_rewake_parent_if_inbox_stranded``) discards the flag on idle
|
|
*regardless* of inbox state, posting a recovery wake only when results
|
|
remain.
|
|
|
|
The closure-local ``_subagent_wake_pending`` set lives inside
|
|
``create_runner_app`` and has no module-level ref (unlike
|
|
``_session_inboxes_ref``), so the flag-clear is asserted *behaviorally* —
|
|
which is also the stronger, user-facing claim: a subsequent child
|
|
completion WAKES the parent (fresh POST) instead of being silently
|
|
debounced. A stuck flag would suppress that wake, which is exactly the
|
|
stranding the fix prevents. This is the discriminator: it goes red on the
|
|
buggy ``inbox.empty()``-returns-without-discard ordering and green on the
|
|
fix.
|
|
|
|
Sequence (wake counts bracketed): (1) child A completes idle → wake [1],
|
|
parent turn starts (clears flag); (2) child B completes mid-turn → wake
|
|
[2], re-arms flag (A's + B's results now both queued); (3) the test drains
|
|
the inbox to EMPTY in-turn (mirrors the parent draining via
|
|
``sys_read_inbox`` during its live turn) — flag stays set; (4) the turn
|
|
ends idle → helper discards the stuck flag and, because the inbox is
|
|
empty, posts NO recovery wake (count stays [2]); (5) child C completes
|
|
post-idle → because the flag was cleared, C is NOT debounced and posts a
|
|
fresh wake [3]. Under the bug, step 4 leaves the flag set, so step 5's C
|
|
is debounced (count stays [2]) and C's result strands.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_rewake_drained_inbox"
|
|
child_a = "conv_child_drained_a"
|
|
child_b = "conv_child_drained_b"
|
|
child_c = "conv_child_drained_c"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
server_client = _WakeRecordingServerClient(parent_id)
|
|
gate = asyncio.Event()
|
|
harness_client = _BlockingHarnessClient(
|
|
[
|
|
_sse({"type": "response.created", "response": {"id": "resp_drained"}}),
|
|
_sse({"type": "response.completed", "response": {"id": "resp_drained"}}),
|
|
],
|
|
gate,
|
|
)
|
|
pm = _FakeProcessManager(harness_client) # type: ignore[arg-type]
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_a,
|
|
agent="claude",
|
|
title="debate",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
# 1. Child A finishes while the parent is idle → first wake.
|
|
resp_a = await client.post(
|
|
f"/v1/sessions/{child_a}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "A_DONE"},
|
|
},
|
|
)
|
|
assert resp_a.status_code == 204, resp_a.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
# Just the idle-parent wake; 0 = never fired, 2 = spurious POST.
|
|
assert len(server_client.wake_posts) == 1, (
|
|
f"Expected the single idle-parent wake, got {len(server_client.wake_posts)}."
|
|
)
|
|
server_client.wake_seen.clear()
|
|
|
|
# Start the parent's wake turn (blocking harness holds it active).
|
|
# post_seen resolves only after _run_turn_bg's turn-start clear, so
|
|
# the flag is guaranteed clear for child B below.
|
|
parent_resp = await client.post(
|
|
f"/v1/sessions/{parent_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"agent_id": "ag_parent_drained",
|
|
"model": "test-agent",
|
|
"harness": "openai-agents",
|
|
"content": [{"type": "input_text", "text": "wake notice"}],
|
|
},
|
|
)
|
|
assert parent_resp.status_code == 202, parent_resp.text
|
|
await asyncio.wait_for(harness_client.post_seen.wait(), timeout=5.0)
|
|
|
|
# 2. Child B finishes DURING the active parent turn: not debounced
|
|
# (flag was just cleared), posts its own wake, and re-arms the flag.
|
|
# No new turn starts on this wake, so nothing clears it again.
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_b,
|
|
agent="gpt",
|
|
title="debate",
|
|
)
|
|
resp_b = await client.post(
|
|
f"/v1/sessions/{child_b}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "B_DONE"},
|
|
},
|
|
)
|
|
assert resp_b.status_code == 204, resp_b.text
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
server_client.wake_seen.clear()
|
|
# Baseline before the drain: A + B wakes fired, recovery not yet.
|
|
# Not 2 → child B posted no distinct wake, so the stuck-flag
|
|
# precondition the fix recovers from is not reproduced.
|
|
wakes_before_drain = len(server_client.wake_posts)
|
|
assert wakes_before_drain == 2, (
|
|
f"Expected 2 wakes (idle-parent A + mid-turn B) before the "
|
|
f"inbox is drained, got {wakes_before_drain}."
|
|
)
|
|
|
|
# 3. Drain the inbox to EMPTY while the turn is still active — the
|
|
# B wake_seen above guarantees both completions are already queued
|
|
# (delivery put_nowait precedes the wake task). This stands in for
|
|
# the parent draining sys_read_inbox during its live turn: the
|
|
# debounce flag is still set (only turn start clears it), but the
|
|
# inbox is now empty. This is the precondition the non-empty-inbox
|
|
# sibling test deliberately never creates.
|
|
drained = 0
|
|
while not session_inbox.empty():
|
|
session_inbox.get_nowait()
|
|
drained += 1
|
|
# A + B were delivered into the inbox before either wake task ran.
|
|
# Not 2 → a completion never reached the inbox (delivery regression),
|
|
# which would invalidate the empty-inbox precondition below.
|
|
assert drained == 2, (
|
|
f"Expected to drain A's + B's queued completions (2), got "
|
|
f"{drained}; the empty-inbox-at-idle precondition is invalid."
|
|
)
|
|
assert session_inbox.empty(), "inbox must be empty before the turn ends"
|
|
|
|
# 4. End the parent turn → _check_and_start_next_turn runs the
|
|
# stuck-flag clear. The inbox is empty, so NO recovery wake posts;
|
|
# the only observable here is that the flag was discarded, which
|
|
# step 5 proves. Wait for the turn to fully end (no recovery wake
|
|
# to await on, unlike the non-empty-inbox sibling).
|
|
gate.set()
|
|
_deadline = asyncio.get_running_loop().time() + 5.0
|
|
while app.state.has_active_work():
|
|
if asyncio.get_running_loop().time() > _deadline:
|
|
raise AssertionError("parent wake turn did not end within 5s")
|
|
await asyncio.sleep(0.01)
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
# No recovery wake fired on the empty inbox: still 2. A 3rd here
|
|
# would mean the helper wrongly re-armed a wake with nothing
|
|
# stranded (the non-empty path leaking into the empty case).
|
|
assert len(server_client.wake_posts) == 2, (
|
|
f"Expected no recovery wake on the drained (empty) inbox, "
|
|
f"got {len(server_client.wake_posts)} wakes."
|
|
)
|
|
|
|
# 5. Child C finishes post-idle. If step 4 cleared the stuck flag
|
|
# (the fix), C is NOT debounced and posts a FRESH wake. If the flag
|
|
# stayed stuck (the bug: inbox.empty() returned before discard), C
|
|
# is debounced and its result strands with no wake — the exact
|
|
# regression this guards. Await the wake directly: under the bug it
|
|
# never posts and this wait_for times out.
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_c,
|
|
agent="claude",
|
|
title="debate",
|
|
)
|
|
resp_c = await client.post(
|
|
f"/v1/sessions/{child_c}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "C_DONE"},
|
|
},
|
|
)
|
|
assert resp_c.status_code == 204, resp_c.text
|
|
try:
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
except TimeoutError:
|
|
raise AssertionError(
|
|
"No wake was posted for child C after the parent idled with "
|
|
"a drained (empty) inbox. The wake count stayed at "
|
|
f"{len(server_client.wake_posts)} (expected it to grow to "
|
|
"3). This is the drained-inbox stuck-flag bug: "
|
|
"_rewake_parent_if_inbox_stranded returned on inbox.empty() "
|
|
"WITHOUT discarding _subagent_wake_pending, so child C was "
|
|
"debounced and stranded."
|
|
) from None
|
|
finally:
|
|
gate.set()
|
|
runner_app.unregister_subagent_work(child_a)
|
|
runner_app.unregister_subagent_work(child_b)
|
|
runner_app.unregister_subagent_work(child_c)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
# Child C posted the 3rd wake: the stuck flag was cleared on idle. 2 = flag
|
|
# stayed stuck and C was debounced (the regression); 4 = a spurious extra
|
|
# wake (e.g. an erroneous empty-inbox recovery POST in step 4).
|
|
assert len(server_client.wake_posts) == 3, (
|
|
f"Expected exactly 3 wakes (A + B + C's post-clear wake), got "
|
|
f"{len(server_client.wake_posts)}."
|
|
)
|
|
# The 3rd wake is C's: it names child C and steers the parent to drain.
|
|
# A wrong name would mean a different completion posted this wake.
|
|
c_wake_text = server_client.wake_posts[2]["data"]["content"][0]["text"]
|
|
assert "sub-agent claude/debate finished (completed)" in c_wake_text, (
|
|
f"Third wake should name child C (claude/debate); got: {c_wake_text!r}"
|
|
)
|
|
assert "sys_read_inbox" in c_wake_text
|
|
# C's completion reached the inbox (1 item, queued after the step-3 drain).
|
|
# Under the bug C still delivers, but with no wake the parent never learns
|
|
# to drain it — 0 here would instead mean C's delivery itself regressed.
|
|
assert session_inbox.qsize() == 1, (
|
|
f"Expected child C's completion alone in the drained inbox, got {session_inbox.qsize()}."
|
|
)
|
|
delivered_c = session_inbox.get_nowait()
|
|
assert delivered_c["status"] == "completed"
|
|
assert delivered_c["output"] == "C_DONE"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replayed_idle_status_after_inbox_drain_is_acknowledged() -> None:
|
|
"""
|
|
Replayed terminal status after parent drain is a benign duplicate.
|
|
|
|
``sys_read_inbox`` removes delivered work after the parent collects it.
|
|
The runner keeps a delivered tombstone so a later native forwarder replay
|
|
sees an already-delivered ack instead of a false ``missing_work_entry``
|
|
503 for a still-known child session.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.runner.tool_dispatch import execute_tool
|
|
|
|
parent_id = "conv_parent_drain_replay"
|
|
child_id = "conv_child_drain_replay"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
server_client = _WakeRecordingServerClient(parent_id)
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="worker",
|
|
title="drained",
|
|
)
|
|
|
|
idle_event = {
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "DONE_AND_DRAINED"},
|
|
}
|
|
|
|
async def _policy_handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Allow the parent TOOL_RESULT policy check during inbox drain.
|
|
|
|
:param request: Policy-evaluation request from ``sys_read_inbox``.
|
|
:returns: Allow verdict for the delayed sub-agent output.
|
|
"""
|
|
if (
|
|
request.method == "POST"
|
|
and request.url.path == f"/v1/sessions/{parent_id}/policies/evaluate"
|
|
):
|
|
return httpx.Response(200, json={"result": "POLICY_ACTION_UNSPECIFIED"})
|
|
return httpx.Response(404, json={"error": str(request.url)})
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": child_id,
|
|
"agent_id": "ag_drain_replay",
|
|
"sub_agent_name": "worker",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
first_resp = await client.post(f"/v1/sessions/{child_id}/events", json=idle_event)
|
|
assert first_resp.status_code == 204, first_resp.text
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(_policy_handler),
|
|
base_url="http://server",
|
|
) as policy_client:
|
|
drain_output = await execute_tool(
|
|
tool_name="sys_read_inbox",
|
|
arguments="{}",
|
|
server_client=policy_client,
|
|
conversation_id=parent_id,
|
|
session_inbox=session_inbox,
|
|
)
|
|
assert runner_app.get_subagent_work(child_id) is None
|
|
replay_resp = await client.post(f"/v1/sessions/{child_id}/events", json=idle_event)
|
|
finally:
|
|
runner_app.unregister_subagent_work(child_id)
|
|
runner_app.unregister_subagent_work_for_session(child_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
assert "DONE_AND_DRAINED" in drain_output
|
|
assert replay_resp.status_code == 204, replay_resp.text
|
|
assert session_inbox.qsize() == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_subagent_completions_coalesce_into_one_wake() -> None:
|
|
"""
|
|
A fan-out's completions debounce to a single wake POST.
|
|
|
|
When a parent dispatches several workers and they finish close together,
|
|
each completion is delivered to the parent inbox, but only the FIRST posts
|
|
a wake notice — the rest are suppressed while that wake is outstanding.
|
|
The one wake turn drains the whole inbox via sys_read_inbox. Without the
|
|
debounce, N completions POST N synthetic /events messages, churning turns
|
|
and tripping the executor's per-turn tool-context guard ("no active turn
|
|
context") — the regression this guards against.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
|
|
parent_id = "conv_parent_fanout"
|
|
child_ids = ["conv_child_fan_a", "conv_child_fan_b", "conv_child_fan_c"]
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
server_client = _WakeRecordingServerClient(parent_id)
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
for idx, child_id in enumerate(child_ids):
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=child_id,
|
|
agent="claude_code",
|
|
title=f"worker-{idx}",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
for child_id in child_ids:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": f"DONE_{child_id}"},
|
|
},
|
|
)
|
|
assert resp.status_code == 204, resp.text
|
|
# Let the (single, debounced) wake task and any suppressed ones run.
|
|
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
for child_id in child_ids:
|
|
runner_app.unregister_subagent_work(child_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
# All three completions were delivered to the parent inbox...
|
|
assert session_inbox.qsize() == 3, (
|
|
f"Expected all 3 completions in the parent inbox, got {session_inbox.qsize()}."
|
|
)
|
|
# ...but only ONE wake notice was posted (the other two were debounced).
|
|
# A count of 3 means the debounce regressed and the wake storm is back.
|
|
assert len(server_client.wake_posts) == 1, (
|
|
f"Expected exactly one (debounced) wake POST for the fan-out, got "
|
|
f"{len(server_client.wake_posts)}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_interrupt_on_native_session_injects_escape_without_marker(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` with ``{"type": "interrupt"}`` on a claude-native
|
|
session sends Escape to the pane — and nothing else to the transcript.
|
|
|
|
The interrupt handler's whole job is the Escape keystroke. This test
|
|
pins the three properties of that handler:
|
|
|
|
1. ``inject_interrupt`` is called with the bridge dir derived from
|
|
the conversation id (the Escape keystroke that actually stops
|
|
Claude).
|
|
2. NO ``[System: interrupted]`` marker is appended to the runner's
|
|
in-memory ``_session_histories``. That synthetic marker is for
|
|
in-process LLM harnesses, where the runner's history is the
|
|
model's next-turn context; Claude-native owns its own session and
|
|
records the interrupt in its own (forwarder-mirrored) transcript,
|
|
so persisting a forged ``role:"user"`` marker only diverged the
|
|
AP-side mirror from Claude's real transcript.
|
|
3. NO ``session.status: idle`` is enqueued: idle on interrupt now
|
|
comes solely from the terminal's PTY-activity watcher (it sees
|
|
the pane quiesce after the Escape), which also keeps the session
|
|
``running`` if the interrupt didn't take. Synthesizing idle here
|
|
would bypass the watcher's running/idle dedupe and could strand
|
|
the UI on idle.
|
|
|
|
If side effect 1 is missing, the Escape never lands and Claude keeps
|
|
generating; if a marker reappears in 2, the holdover that forged the
|
|
user bubble is back; if a synthesized idle reappears in 3, the
|
|
watcher desync bug is back.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref, _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured_inject: list[Any] = []
|
|
|
|
def _fake_inject(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Record the call and return without touching tmux."""
|
|
captured_inject.append((bridge_dir, timeout_s))
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_interrupt", _fake_inject)
|
|
|
|
# Native spec: executor.type="omnigent" + config.harness="claude-native"
|
|
# is the canonical shape the runner reads at session start to
|
|
# populate _session_spec_cache; _session_harness_name reads it
|
|
# back at interrupt time to pick the right dispatch branch.
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# POST /v1/sessions seeds _session_spec_cache so the
|
|
# interrupt dispatch can detect "claude-native".
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_int", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
# POST /events with type=interrupt. By the time this returns,
|
|
# ``_handle_claude_native_interrupt`` has fully run — the sync
|
|
# history mutation (``_append_cancellation_items``) and the
|
|
# sub-agent wake finished before the response. So we can read
|
|
# both ``_session_histories`` and ``_session_event_queues`` from
|
|
# the test without any subscribe / sleep dance.
|
|
int_resp = await client.post(
|
|
"/v1/sessions/conv_native_int/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
# Snapshot history + drain queue BEFORE deleting the session.
|
|
# DELETE clears ``_session_histories`` and pops the queue from
|
|
# ``_session_event_queues``, so reading after delete would
|
|
# always see empty.
|
|
captured_history = list(_session_histories_ref.get("conv_native_int", []))
|
|
queue = _session_event_queues_ref.get("conv_native_int")
|
|
assert queue is not None, (
|
|
"Session creation should have initialized the event queue "
|
|
"for ``conv_native_int``; ``_session_event_queues_ref`` is "
|
|
"missing the entry, so we couldn't drain it to verify the "
|
|
"interrupt handler did not enqueue a synthesized idle."
|
|
)
|
|
queued_events: list[dict[str, Any]] = []
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
# 1) tmux Escape was sent via inject_interrupt.
|
|
# 0 = the dispatch fell through to the generic forward-to-harness
|
|
# path (which 404s for native — silent regression).
|
|
assert int_resp.status_code == 204, (
|
|
f"Native interrupt must return 204 from /events; "
|
|
f"got {int_resp.status_code}: {int_resp.text}"
|
|
)
|
|
assert len(captured_inject) == 1, (
|
|
f"Expected one inject_interrupt call, got {len(captured_inject)}. "
|
|
f"If 0, the dispatch in /events did not route to the native "
|
|
f"handler — possibly _session_harness_name returned the wrong "
|
|
f"canonical name."
|
|
)
|
|
bridge_dir, timeout_s = captured_inject[0]
|
|
assert bridge_dir == bridge_dir_for_conversation_id("conv_native_int")
|
|
# 1.0s short timeout: UI stop must feel snappy. If this becomes
|
|
# the helper's 30s default, the user's click would hang on any
|
|
# missing tmux.json.
|
|
assert timeout_s == 1.0
|
|
|
|
# 2) NO cancellation marker is appended to the runner's in-memory
|
|
# history. The native interrupt must not forge a [System: interrupted]
|
|
# user message into the AP-side mirror — Claude records the interrupt
|
|
# in its own transcript. If a marker reappears, _append_cancellation_items
|
|
# was wired back into the native handler.
|
|
markers = [
|
|
h
|
|
for h in captured_history
|
|
if h.get("type") == "message"
|
|
and h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
]
|
|
assert markers == [], (
|
|
f"Expected no [System: interrupted] marker in history, got "
|
|
f"{len(markers)}. If 1, _append_cancellation_items was re-invoked "
|
|
f"by the native interrupt handler — the holdover that forged a "
|
|
f"user bubble into the mirror is back. History: {captured_history!r}"
|
|
)
|
|
|
|
# 3) The interrupt handler must NOT synthesize session.status: idle.
|
|
# Idle on interrupt now comes from the terminal's PTY activity watcher
|
|
# (it sees the pane quiesce after the Escape) — the single source of
|
|
# truth, which also keeps the session ``running`` if the interrupt
|
|
# didn't take. Synthesizing idle here would bypass the watcher's
|
|
# running/idle dedupe and could strand the UI on idle. This guards
|
|
# against re-adding the pre-PTY synthesized idle.
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert status_idle == [], (
|
|
f"The native interrupt handler must not enqueue session.status: idle "
|
|
f"(the PTY watcher emits it on quiesce); got {status_idle!r} on the "
|
|
f"queue: {queued_events!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("harness", "expected_statuses"),
|
|
[
|
|
# claude-native's working status is owned by the PTY-activity
|
|
# watcher, so the runner injection task must not publish its
|
|
# own running/idle edges.
|
|
("claude-native", []),
|
|
# codex-native may use the runner's running edge so the thread
|
|
# shows work as soon as Omnigent accepts the turn, but must not use the
|
|
# runner's idle edge because the injection task completes before
|
|
# the user-visible Codex turn.
|
|
("codex-native", ["running"]),
|
|
# antigravity-native shares codex's shape: the executor's
|
|
# SendUserCascadeMessage returns as soon as agy accepts the turn, so the
|
|
# RPC read driver (not the runner injection task) owns idle. Publishing
|
|
# the runner's idle here fires ~2s before agy's output streams and
|
|
# prematurely completes the response (the live-e2e "double-idle").
|
|
("antigravity-native", ["running"]),
|
|
# Non-terminal harnesses have no external lifecycle observer; the
|
|
# runner turn remains their source of truth.
|
|
("openai-agents", ["running", "idle"]),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_message_turn_lifecycle_status_suppressed_for_terminal_backed_harnesses(
|
|
harness: str,
|
|
expected_statuses: list[str],
|
|
) -> None:
|
|
"""
|
|
Runner lifecycle status is edge-specific for terminal-backed harnesses.
|
|
|
|
First-principles invariant: the thread's "Working…" indicator should
|
|
represent the user-visible model turn. For claude-native, the runner
|
|
turn is only a pane-injection task, so its ``running`` and ``idle`` edges
|
|
are both suppressed. For codex-native, the runner's ``running`` edge is a
|
|
useful immediate signal that Omnigent accepted the turn, but its ``idle`` edge
|
|
is invalid because the injection task finishes before Codex is done.
|
|
|
|
Drives the real ``POST /events`` message path and waits for the
|
|
background injection turn to finish, so both the synchronous ``running``
|
|
edge and the invalid injection-task ``idle`` edge are observable if they
|
|
regress.
|
|
|
|
:param harness: Harness configured for the session, e.g.
|
|
``"codex-native"``.
|
|
:param expected_statuses: Runner-published lifecycle statuses expected
|
|
on the session stream, e.g. ``["running", "idle"]``.
|
|
:returns: None.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
|
|
session_id = f"conv_ts_{harness.replace('-', '_')}"
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
# executor.type="omnigent" + config.harness=<harness> is the
|
|
# canonical shape the runner reads at session start to populate
|
|
# _session_spec_cache; _session_harness_name reads it back to
|
|
# decide whether a PTY watcher owns this session's status.
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": harness}),
|
|
)
|
|
stream_finished = asyncio.Event()
|
|
harness_client = _ScriptedHarnessClient([], stream_finished=stream_finished)
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(harness_client), # type: ignore[arg-type]
|
|
spec_resolver=await _spec_resolver_returning(spec),
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# POST /v1/sessions seeds _session_spec_cache so the turn path can
|
|
# resolve the harness and decide whether to suppress turn status.
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": session_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Native session creation may try to auto-create a terminal. This
|
|
# fixture intentionally has no real terminal registry / workspace, so
|
|
# that setup path can enqueue ``session.status: failed`` before the
|
|
# message turn under test. Drain creation-time events so the assertion
|
|
# below isolates only the runner turn lifecycle around POST /events.
|
|
queue = _session_event_queues_ref.get(session_id)
|
|
assert queue is not None, (
|
|
f"session creation must initialize the per-session event queue "
|
|
f"for {session_id!r}; missing means the turn-status publish had "
|
|
f"nowhere to land."
|
|
)
|
|
while not queue.empty():
|
|
queue.get_nowait()
|
|
|
|
msg_resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "test"}],
|
|
"agent_id": "ag_1",
|
|
},
|
|
)
|
|
assert msg_resp.status_code in (200, 202), msg_resp.text
|
|
|
|
if harness != "claude-native":
|
|
# Wait until the background injection task drains its scripted
|
|
# empty stream. If the runner still owns terminal-backed status,
|
|
# this is where the invalid ``idle`` edge appears.
|
|
await asyncio.wait_for(stream_finished.wait(), timeout=1.0)
|
|
|
|
# Let the task resume after stream exhaustion and publish its
|
|
# terminal lifecycle edge before the queue is drained below.
|
|
await asyncio.sleep(0)
|
|
|
|
# Drain BEFORE deleting: DELETE removes the queue. By this point both
|
|
# the synchronous turn-start edge and the async turn-end edge have run.
|
|
statuses: list[str] = []
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict) and item.get("type") == "session.status":
|
|
statuses.append(item.get("status"))
|
|
|
|
# DELETE cancels the background turn so it can't outlive the test.
|
|
await client.delete(f"/v1/sessions/{session_id}")
|
|
|
|
assert statuses == expected_statuses, (
|
|
f"harness={harness}: expected runner turn lifecycle statuses "
|
|
f"{expected_statuses!r}, got {statuses!r}. Claude-native must rely "
|
|
f"fully on the PTY watcher, Codex-native must keep only the runner "
|
|
f"running edge, and non-terminal harnesses must keep the full runner "
|
|
f"lifecycle source."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_interrupt_on_native_session_503_skips_cleanup_when_inject_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` interrupt returns 503 and skips cleanup when
|
|
``inject_interrupt`` can't reach tmux.
|
|
|
|
Sister to the happy-path test. The contract is: if the runner
|
|
can't actually deliver Escape (e.g. tmux pane gone, bridge dir
|
|
not yet advertised), it must not (a) persist any
|
|
``[System: interrupted]`` marker (native never appends one — this
|
|
also confirms the 503 early-return doesn't) and (b) publish
|
|
``session.status: idle`` — that would lie to the web UI ("we
|
|
stopped it") while Claude keeps generating. The right signal is a
|
|
503 so the caller can surface a failure (the spinner staying is
|
|
correct).
|
|
|
|
This was previously pinned by an AP-side test
|
|
(``..._skips_idle_publish_on_runner_failure``); after the
|
|
refactor the responsibility lives on the runner, so the
|
|
invariant is pinned here.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref, _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_interrupt", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_int_fail", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
int_resp = await client.post(
|
|
"/v1/sessions/conv_native_int_fail/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
captured_history = list(_session_histories_ref.get("conv_native_int_fail", []))
|
|
queue = _session_event_queues_ref.get("conv_native_int_fail")
|
|
assert queue is not None, (
|
|
"Session creation should have initialized the event queue; "
|
|
"the failure path still needs the queue to exist so we can "
|
|
"assert nothing was published into it."
|
|
)
|
|
queued_events: list[dict[str, Any]] = []
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
# 1) The route must surface the failure as 503 so the caller
|
|
# treats the cancel as not delivered. 204 would let the caller
|
|
# claim success and (e.g.) clear the spinner client-side.
|
|
assert int_resp.status_code == 503, (
|
|
f"Native interrupt with inject_interrupt failure must return "
|
|
f"503; got {int_resp.status_code}: {int_resp.text}"
|
|
)
|
|
body = int_resp.json()
|
|
assert body.get("error") == "claude_native_interrupt_failed", (
|
|
f"503 body must carry the bridge-failure error code; got {body!r}"
|
|
)
|
|
|
|
# 2) No [System: interrupted] marker must be persisted. Native never
|
|
# appends one; this additionally guards the failure path against a
|
|
# reorder bug that appended the marker before the 503 early return.
|
|
markers = [
|
|
h
|
|
for h in captured_history
|
|
if h.get("type") == "message"
|
|
and h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
]
|
|
assert markers == [], (
|
|
f"No [System: interrupted] marker should be persisted on the "
|
|
f"inject_interrupt failure path; got {markers!r}. "
|
|
f"If non-empty, _append_cancellation_items fired before the "
|
|
f"503 early return — likely a reordering bug in "
|
|
f"_handle_claude_native_interrupt."
|
|
)
|
|
|
|
# 3) No session.status: idle on the failure path. Idle would
|
|
# tell the web UI the cancel landed — the spinner clearing while
|
|
# Claude keeps generating is exactly the misleading state we
|
|
# need to avoid.
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert status_idle == [], (
|
|
f"No session.status: idle should be enqueued when the Escape "
|
|
f"injection failed; got {status_idle!r}. "
|
|
f"If non-empty, _publish_event fired before the 503 early "
|
|
f"return — same reordering concern as the marker."
|
|
)
|
|
|
|
|
|
class _EventRecordingServerClient(NullServerClient):
|
|
"""Records Omnigent ``external_*`` event POSTs for assertion.
|
|
|
|
Subclasses :class:`NullServerClient` so all other runner→AP calls still
|
|
succeed silently; captures ``external_conversation_item`` bodies so a
|
|
test can assert that NO interrupt marker was persisted, and
|
|
``external_mcp_startup`` bodies so Stop tests can assert the cancelled
|
|
MCP map was published.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.posted_items: list[dict[str, Any]] = []
|
|
self.posted_mcp_startup: list[dict[str, Any]] = []
|
|
|
|
async def post(self, url: str, **kwargs: Any) -> NullServerClient._Response:
|
|
"""Record ``external_conversation_item`` / ``external_mcp_startup`` bodies."""
|
|
del url
|
|
body = kwargs.get("json")
|
|
if isinstance(body, dict) and body.get("type") == "external_conversation_item":
|
|
self.posted_items.append(body.get("data") or {})
|
|
if isinstance(body, dict) and body.get("type") == "external_mcp_startup":
|
|
self.posted_mcp_startup.append(body.get("data") or {})
|
|
return self._Response()
|
|
|
|
|
|
class _RecordingCodexAppServerClient:
|
|
"""
|
|
Test double for Codex app-server JSON-RPC controls.
|
|
|
|
:param transport: Transport passed to
|
|
:func:`omnigent.codex_native_app_server.client_for_transport`, e.g.
|
|
``"ws://127.0.0.1:1234"``.
|
|
:param client_name: App-server client name, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
"""
|
|
|
|
def __init__(self, transport: str, client_name: str) -> None:
|
|
self.transport = transport
|
|
self.client_name = client_name
|
|
self.connected = False
|
|
self.closed = False
|
|
self.requests: list[tuple[str, dict[str, Any]]] = []
|
|
self.model_list_responses: list[dict[str, Any]] = []
|
|
|
|
async def connect(self) -> None:
|
|
"""
|
|
Mark the fake client connected.
|
|
|
|
:returns: None.
|
|
"""
|
|
self.connected = True
|
|
|
|
async def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Capture a JSON-RPC request.
|
|
|
|
:param method: JSON-RPC method, e.g. ``"turn/interrupt"``.
|
|
:param params: JSON-RPC params, e.g.
|
|
``{"threadId": "thread_123", "turnId": "turn_123"}``.
|
|
:returns: Empty successful JSON-RPC result.
|
|
"""
|
|
self.requests.append((method, params))
|
|
if method == "model/list" and self.model_list_responses:
|
|
return self.model_list_responses.pop(0)
|
|
return {"result": {}}
|
|
|
|
async def close(self) -> None:
|
|
"""
|
|
Mark the fake client closed.
|
|
|
|
:returns: None.
|
|
"""
|
|
self.closed = True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"event_payload,expected_params",
|
|
[
|
|
(
|
|
{"type": "model_change", "model": "gpt-5.4"},
|
|
{"threadId": "thread_codex", "model": "gpt-5.4"},
|
|
),
|
|
(
|
|
{"type": "effort_change", "effort": "xhigh"},
|
|
{"threadId": "thread_codex", "effort": "xhigh"},
|
|
),
|
|
(
|
|
{"type": "plan_mode_change", "enabled": True},
|
|
{
|
|
"threadId": "thread_codex",
|
|
"collaborationMode": {
|
|
"mode": "plan",
|
|
"settings": {
|
|
"model": "gpt-5.4",
|
|
"reasoning_effort": None,
|
|
"developer_instructions": None,
|
|
},
|
|
},
|
|
},
|
|
),
|
|
],
|
|
ids=["model_change", "effort_change", "plan_mode_change"],
|
|
)
|
|
async def test_events_codex_native_settings_change_uses_thread_settings_update(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
event_payload: dict[str, Any],
|
|
expected_params: dict[str, Any],
|
|
) -> None:
|
|
"""
|
|
Codex-native model / effort updates call ``thread/settings/update``.
|
|
|
|
The web UI persists model and effort through Omnigent's normal session
|
|
PATCH path. The runner must translate the forwarded control event into
|
|
Codex app-server's structured settings RPC, not type into the terminal or
|
|
204 as a no-op. The update is a next-turn setting: it is valid even when
|
|
no active turn id is recorded.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_codex_native_settings"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43210",
|
|
thread_id="thread_codex",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43210",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the recorded bridge state.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43210"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5.4"},
|
|
),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json=event_payload,
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"codex-native {event_payload['type']} must return 204; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert fake_client.connected
|
|
assert fake_client.closed
|
|
assert fake_client.requests == [
|
|
("thread/settings/update", expected_params),
|
|
], (
|
|
f"codex-native {event_payload['type']} must call thread/settings/update "
|
|
f"with next-turn settings; got {fake_client.requests!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_codex_native_model_options_returns_503_until_bridge_state_exists(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Runner model-options endpoint is retryable before Codex bridge startup.
|
|
|
|
The AP server caches successful runner responses. A codex-native runner
|
|
must therefore not return ``200 {"models": []}`` while the Codex terminal
|
|
is still creating its app-server bridge; that would permanently hide the
|
|
Web UI model picker for the session.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
|
|
conv_id = "conv_codex_native_model_options_not_ready"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
|
|
def _client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Fail the test if the endpoint reaches Codex without bridge state.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43210"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Never returns; raises if called.
|
|
"""
|
|
raise AssertionError(
|
|
f"client_for_transport must not be called before bridge state exists: "
|
|
f"{transport=} {client_name=}"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.get(f"/v1/sessions/{conv_id}/codex-model-options")
|
|
|
|
# A retryable 503 keeps the AP server from caching an empty model list;
|
|
# returning 200 here would recreate the missing-picker regression.
|
|
assert resp.status_code == 503, resp.text
|
|
assert resp.json() == {
|
|
"error": "codex_native_model_options_failed",
|
|
"detail": "Codex-native model options are not ready yet.",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_codex_native_model_options_query_model_list(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Runner model-options endpoint queries Codex ``model/list``.
|
|
|
|
The Web UI must not carry its own Codex model / effort catalog. The
|
|
runner is the process that can reach the session's Codex app-server, so
|
|
this endpoint should ask Codex for models and return those model objects
|
|
unchanged for the AP snapshot.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_codex_native_model_options"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43210",
|
|
thread_id="thread_codex",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43210",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
fake_client.model_list_responses = [
|
|
{
|
|
"result": {
|
|
"data": [
|
|
{
|
|
"id": "gpt-5.5",
|
|
"model": "databricks-gpt-5-5",
|
|
"displayName": "GPT-5.5",
|
|
"defaultReasoningEffort": "high",
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "low", "description": "Low"},
|
|
{"reasoningEffort": "medium", "description": "Medium"},
|
|
],
|
|
"isDefault": True,
|
|
}
|
|
],
|
|
"nextCursor": "next-page",
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": [
|
|
{
|
|
"id": "gpt-5.4-mini",
|
|
"model": "databricks-gpt-5-4-mini",
|
|
"displayName": "GPT-5.4 mini",
|
|
"defaultReasoningEffort": "medium",
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "minimal", "description": "Minimal"}
|
|
],
|
|
"isDefault": False,
|
|
}
|
|
],
|
|
"nextCursor": None,
|
|
}
|
|
},
|
|
]
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the recorded bridge state.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43210"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client scripted with ``model/list`` pages.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.get(f"/v1/sessions/{conv_id}/codex-model-options")
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {
|
|
"models": [
|
|
{
|
|
"id": "gpt-5.5",
|
|
"model": "databricks-gpt-5-5",
|
|
"displayName": "GPT-5.5",
|
|
"defaultReasoningEffort": "high",
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "low", "description": "Low"},
|
|
{"reasoningEffort": "medium", "description": "Medium"},
|
|
],
|
|
"isDefault": True,
|
|
},
|
|
{
|
|
"id": "gpt-5.4-mini",
|
|
"model": "databricks-gpt-5-4-mini",
|
|
"displayName": "GPT-5.4 mini",
|
|
"defaultReasoningEffort": "medium",
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "minimal", "description": "Minimal"}
|
|
],
|
|
"isDefault": False,
|
|
},
|
|
]
|
|
}
|
|
assert fake_client.requests == [
|
|
("model/list", {"includeHidden": False}),
|
|
("model/list", {"includeHidden": False, "cursor": "next-page"}),
|
|
]
|
|
assert fake_client.connected
|
|
assert fake_client.closed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_codex_native_plan_mode_requires_loaded_bridge(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex-native Plan-mode updates fail when no Codex bridge is loaded.
|
|
|
|
The AP server treats a 2xx runner response as proof that the UI can show
|
|
Plan mode. Returning 204 when no bridge state exists would therefore
|
|
persist a false Plan indicator even though Codex app-server never received
|
|
``thread/settings/update``.
|
|
"""
|
|
conv_id = "conv_codex_native_plan_no_bridge"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5.4"},
|
|
),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""
|
|
Return the codex-native spec for any agent id.
|
|
|
|
:param agent_id: Agent identifier, e.g. ``"ag_1"``.
|
|
:param session_id: Session identifier, e.g. ``"conv_abc123"``.
|
|
:returns: Codex-native agent spec.
|
|
"""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "plan_mode_change", "enabled": True},
|
|
)
|
|
|
|
assert resp.status_code == 503, resp.text
|
|
assert "loaded Codex bridge" in resp.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_interrupt_on_codex_native_uses_turn_interrupt_without_marker(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` interrupt on a codex-native session calls
|
|
Codex app-server ``turn/interrupt``.
|
|
|
|
Codex's TUI interrupt key is only a UI shortcut for the structured
|
|
app-server call. The runner/web path must use the app-server protocol
|
|
directly so Codex validates the active turn id and returns only after the
|
|
abort is accepted. Codex records the interrupt as a turn-status edge, not
|
|
as a message, so the runner still must not synthesize a
|
|
``[System: interrupted]`` bubble.
|
|
|
|
Pins:
|
|
1. ``turn/interrupt`` is sent with the recorded thread/turn ids.
|
|
2. NO ``[System: interrupted]`` marker is persisted to AP.
|
|
3. The session is NOT added to ``_interrupted_sessions``; no marker in
|
|
``_session_histories``.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.runner.app import _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_codex_native_int"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43210",
|
|
thread_id="thread_codex",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_codex",
|
|
),
|
|
)
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43210",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the recorded bridge state.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43210"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Seeds _session_spec_cache so the dispatch detects "codex-native".
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
captured_history = list(_session_histories_ref.get(conv_id, []))
|
|
flagged = conv_id in app.state.interrupted_sessions
|
|
|
|
assert int_resp.status_code == 204, (
|
|
f"codex-native interrupt must return 204; got {int_resp.status_code}: {int_resp.text}"
|
|
)
|
|
|
|
# 1) The runner reached Codex app-server's structured interrupt path. If
|
|
# this is empty, the handler regressed to a terminal-only or no-op cancel.
|
|
assert fake_client.connected
|
|
assert fake_client.closed
|
|
assert fake_client.requests == [
|
|
(
|
|
"turn/interrupt",
|
|
{
|
|
"threadId": "thread_codex",
|
|
"turnId": "turn_codex",
|
|
},
|
|
)
|
|
], (
|
|
f"codex-native interrupt must call turn/interrupt with the active "
|
|
f"thread/turn ids; got {fake_client.requests!r}."
|
|
)
|
|
|
|
# 2) NO marker persisted — a synthesized [System: interrupted] would diverge
|
|
# the web UI from Codex's own session (the mismatch this revert removes).
|
|
marker_texts = [
|
|
b.get("text")
|
|
for data in server_client.posted_items
|
|
for b in (data.get("item_data") or {}).get("content", [])
|
|
if isinstance(b, dict)
|
|
]
|
|
assert not any("interrupted" in (t or "").lower() for t in marker_texts), (
|
|
f"codex-native interrupt must NOT persist an interrupted marker; "
|
|
f"posted item texts were {marker_texts!r}."
|
|
)
|
|
|
|
# 3) Not flagged, and nothing leaks into the runner's in-memory history.
|
|
assert not flagged, f"codex-native session {conv_id!r} must not be flagged interrupted."
|
|
assert all(
|
|
not (
|
|
h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
)
|
|
for h in captured_history
|
|
), f"no interrupt marker should enter _session_histories; got {captured_history!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_on_codex_native_uses_turn_interrupt_without_marker(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` ``stop_session`` on codex-native interrupts the active turn.
|
|
|
|
Regression guard for the cancel-floor work: ``stop_session`` only
|
|
special-cased claude-native, so codex-native fell into
|
|
``_cancel_inprocess_turn``, which flags the session interrupted and (on the
|
|
next turn or a live-task race) synthesizes the ``[System: interrupted]``
|
|
marker Codex never emits. codex-native must reach the same app-server
|
|
``turn/interrupt`` path as the interrupt branch.
|
|
|
|
Pins (sister to ``...interrupt_on_codex_native...``):
|
|
1. ``turn/interrupt`` is sent with the recorded thread/turn ids.
|
|
2. NO ``[System: interrupted]`` marker is persisted to AP.
|
|
3. The session is NOT added to ``_interrupted_sessions``; no marker leaks
|
|
into ``_session_histories``.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.runner.app import _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_codex_native_stop"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43211",
|
|
thread_id="thread_codex_stop",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_codex_stop",
|
|
),
|
|
)
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43211",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the stop-session path.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43211"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
captured_history = list(_session_histories_ref.get(conv_id, []))
|
|
flagged = conv_id in app.state.interrupted_sessions
|
|
|
|
assert stop_resp.status_code == 204, (
|
|
f"codex-native stop_session must return 204; got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
|
|
# 1) The runner reached Codex app-server's structured interrupt path. If
|
|
# this is empty, stop_session regressed to the in-process cancel floor or
|
|
# the old terminal-key path.
|
|
assert fake_client.connected
|
|
assert fake_client.closed
|
|
assert fake_client.requests == [
|
|
(
|
|
"turn/interrupt",
|
|
{
|
|
"threadId": "thread_codex_stop",
|
|
"turnId": "turn_codex_stop",
|
|
},
|
|
)
|
|
], (
|
|
f"codex-native stop_session must call turn/interrupt with the active "
|
|
f"thread/turn ids; got {fake_client.requests!r}."
|
|
)
|
|
|
|
# 2) NO marker persisted — the in-process floor would have synthesized one.
|
|
marker_texts = [
|
|
b.get("text")
|
|
for data in server_client.posted_items
|
|
for b in (data.get("item_data") or {}).get("content", [])
|
|
if isinstance(b, dict)
|
|
]
|
|
assert not any("interrupted" in (t or "").lower() for t in marker_texts), (
|
|
f"codex-native stop_session must NOT persist an interrupted marker; "
|
|
f"posted item texts were {marker_texts!r}."
|
|
)
|
|
|
|
# 3) Not flagged (the in-process floor's _interrupted_sessions.add never ran),
|
|
# and nothing leaks into the runner's in-memory history.
|
|
assert not flagged, (
|
|
f"codex-native session {conv_id!r} must not be flagged interrupted — a "
|
|
f"stale flag would taint the next turn with a bogus marker."
|
|
)
|
|
assert all(
|
|
not (
|
|
h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
)
|
|
for h in captured_history
|
|
), f"no interrupt marker should enter _session_histories; got {captured_history!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("event_type", ["interrupt", "stop_session"])
|
|
async def test_events_stop_on_codex_native_cancels_mcp_startup_without_active_turn(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
event_type: str,
|
|
) -> None:
|
|
"""
|
|
Stop/interrupt with no active turn cancels in-flight MCP startup.
|
|
|
|
During codex-native startup no turn id is recorded yet, so Stop used to
|
|
204 no-op while Codex sat wedged on a slow or failing MCP server
|
|
(issue #2058). The handler must flip the bridge's pending servers to
|
|
``cancelled`` (unblocking the executor's first-turn gate) and send the
|
|
Codex TUI's startup interrupt — ``turn/interrupt`` with an empty turn
|
|
id — instead of doing nothing.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = f"conv_codex_native_mcp_{event_type}"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
# Abort the session-create auto-terminal path before it reaches
|
|
# ``clear_bridge_state`` — otherwise the seeded bridge state below is
|
|
# wiped on hosts where the codex CLI/provider config exist (in CI the
|
|
# auto-create aborts on its own before the clear).
|
|
import omnigent.runner.app as runner_app_module
|
|
|
|
async def _fail_launch_config(**kwargs: Any) -> None:
|
|
"""Abort codex auto-create before it clears bridge state."""
|
|
del kwargs
|
|
raise RuntimeError("launch config disabled in test")
|
|
|
|
monkeypatch.setattr(runner_app_module, "_codex_native_launch_config", _fail_launch_config)
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43212",
|
|
thread_id="thread_codex_mcp",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
codex_native_bridge.update_mcp_server_startup(bridge_dir, "storage-console", "starting")
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43212",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the startup-cancel path.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43212"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": event_type},
|
|
)
|
|
|
|
assert stop_resp.status_code == 204, (
|
|
f"codex-native {event_type} must return 204; got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
# The Codex TUI's startup interrupt shape: turn/interrupt with an
|
|
# EMPTY turn id (its ``startup_interrupt``); a recorded-turn shape
|
|
# here would be rejected by the app-server mid-startup.
|
|
assert fake_client.requests == [
|
|
(
|
|
"turn/interrupt",
|
|
{"threadId": "thread_codex_mcp", "turnId": ""},
|
|
)
|
|
], (
|
|
f"codex-native {event_type} during MCP startup must send the startup "
|
|
f"interrupt (empty turnId); got {fake_client.requests!r}."
|
|
)
|
|
# The local flip is authoritative even if Codex never acknowledges
|
|
# the interrupt.
|
|
assert codex_native_bridge.read_mcp_startup(bridge_dir) == {
|
|
"storage-console": {"status": "cancelled", "error": None}
|
|
}
|
|
# And the flipped map is PUBLISHED: the forwarder only reposts when it
|
|
# changes the map itself and codex's cancelled edges are owner-only,
|
|
# so without this post the web band would stay stuck on "starting".
|
|
assert server_client.posted_mcp_startup == [
|
|
{"servers": {"storage-console": {"status": "cancelled", "error": None}}}
|
|
], (
|
|
f"codex-native {event_type} must publish the cancelled MCP map; "
|
|
f"got {server_client.posted_mcp_startup!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_interrupt_on_codex_native_with_turn_and_mcp_stops_both(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Stop during a startup-deferred turn interrupts the turn AND the startup.
|
|
|
|
Codex accepts ``turn/start`` mid-MCP-startup and defers its execution
|
|
until the round settles, so a Stop pressed in that window finds an
|
|
active turn id recorded. Interrupting only the turn would leave the
|
|
user watching a startup they asked to stop — the handler must also
|
|
send the startup interrupt (empty turn id, best-effort, first) and
|
|
flip the bridge's pending servers to ``cancelled``.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_codex_native_dual_stop"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
# Keep the seeded bridge state alive through session create (see the
|
|
# sister startup-cancel test for why auto-create must abort early).
|
|
import omnigent.runner.app as runner_app_module
|
|
|
|
async def _fail_launch_config(**kwargs: Any) -> None:
|
|
"""Abort codex auto-create before it clears bridge state."""
|
|
del kwargs
|
|
raise RuntimeError("launch config disabled in test")
|
|
|
|
monkeypatch.setattr(runner_app_module, "_codex_native_launch_config", _fail_launch_config)
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43214",
|
|
thread_id="thread_codex_dual",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_deferred",
|
|
),
|
|
)
|
|
codex_native_bridge.update_mcp_server_startup(bridge_dir, "storage-console", "starting")
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43214",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the dual-stop path.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43214"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
assert int_resp.status_code == 204, int_resp.text
|
|
# Startup interrupt (empty turnId) first — best-effort — then the
|
|
# recorded turn's interrupt.
|
|
assert fake_client.requests == [
|
|
("turn/interrupt", {"threadId": "thread_codex_dual", "turnId": ""}),
|
|
("turn/interrupt", {"threadId": "thread_codex_dual", "turnId": "turn_deferred"}),
|
|
], f"dual stop must send startup interrupt then turn interrupt; got {fake_client.requests!r}."
|
|
assert codex_native_bridge.read_mcp_startup(bridge_dir) == {
|
|
"storage-console": {"status": "cancelled", "error": None}
|
|
}
|
|
# The cancelled map is published to the session (band + snapshot update).
|
|
assert server_client.posted_mcp_startup == [
|
|
{"servers": {"storage-console": {"status": "cancelled", "error": None}}}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_interrupt_on_codex_native_without_turn_or_mcp_is_noop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Stop with no active turn and no pending MCP startup stays a 204 no-op.
|
|
|
|
An idle codex-native session must not send spurious ``turn/interrupt``
|
|
requests to the app-server on every Stop press.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_codex_native_idle_stop"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
# Keep the seeded bridge state alive through session create (see the
|
|
# sister startup-cancel test for why auto-create must abort early).
|
|
import omnigent.runner.app as runner_app_module
|
|
|
|
async def _fail_launch_config(**kwargs: Any) -> None:
|
|
"""Abort codex auto-create before it clears bridge state."""
|
|
del kwargs
|
|
raise RuntimeError("launch config disabled in test")
|
|
|
|
monkeypatch.setattr(runner_app_module, "_codex_native_launch_config", _fail_launch_config)
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43213",
|
|
thread_id="thread_codex_idle",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
|
|
def _fail_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""Fail the test if the runner opens an app-server connection."""
|
|
raise AssertionError(
|
|
f"idle codex-native interrupt must not reach the app-server; "
|
|
f"attempted connect to {transport!r} as {client_name!r}"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fail_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
assert int_resp.status_code == 204, int_resp.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("event_type", ["interrupt", "stop_session"])
|
|
async def test_events_interrupt_and_stop_on_pi_native_enqueue_bridge_interrupt(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
event_type: str,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` interrupt / stop_session on a pi-native session queues an
|
|
interrupt payload to the Pi extension inbox.
|
|
|
|
A pi-native turn runs inside the resident Pi TUI process; the runner's
|
|
harness task only enqueues the user message and returns, so the in-process
|
|
cancel floor has nothing to cancel. Both the ``interrupt`` and
|
|
``stop_session`` dispatch must route to ``_handle_pi_native_interrupt``,
|
|
which drops an ``interrupt`` payload into the bridge inbox for the extension
|
|
to consume via ``ExtensionContext.abort()``.
|
|
|
|
Regression guard: both branches originally enumerated only claude-native
|
|
and codex-native, so pi-native silently fell through to the no-op
|
|
``_cancel_inprocess_turn`` floor — clicking Stop on a Pi turn did nothing.
|
|
|
|
Pins:
|
|
1. 204 returned.
|
|
2. An ``interrupt_*`` payload is written to the session's bridge inbox.
|
|
3. NO ``[System: interrupted]`` marker is persisted (the floor never ran).
|
|
"""
|
|
import omnigent.pi_native_bridge as pi_native_bridge
|
|
from omnigent.runner.app import _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = f"conv_pi_native_{event_type}"
|
|
monkeypatch.setattr(pi_native_bridge, "_BRIDGE_ROOT", tmp_path / "pi-bridge")
|
|
|
|
pi_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "pi-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the pi-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return pi_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Seeds _session_spec_cache so the dispatch detects "pi-native".
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": event_type},
|
|
)
|
|
|
|
captured_history = list(_session_histories_ref.get(conv_id, []))
|
|
flagged = conv_id in app.state.interrupted_sessions
|
|
|
|
assert resp.status_code == 204, (
|
|
f"pi-native {event_type} must return 204; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
# 1) The request reached the bridge inbox (the extension's abort channel). If
|
|
# empty, the dispatch fell through to the no-op in-process cancel floor
|
|
# instead of _handle_pi_native_interrupt.
|
|
inbox = pi_native_bridge.bridge_dir_for_session_id(conv_id) / "inbox"
|
|
queued = sorted(p.name for p in inbox.glob("*.json")) if inbox.exists() else []
|
|
assert any("interrupt_" in name for name in queued), (
|
|
f"pi-native {event_type} must enqueue an interrupt payload to the bridge "
|
|
f"inbox; inbox contained {queued!r}."
|
|
)
|
|
|
|
# 2) No synthesized marker — pi-native never goes through the in-process floor.
|
|
marker_texts = [
|
|
b.get("text")
|
|
for data in server_client.posted_items
|
|
for b in (data.get("item_data") or {}).get("content", [])
|
|
if isinstance(b, dict)
|
|
]
|
|
assert not any("interrupted" in (t or "").lower() for t in marker_texts), (
|
|
f"pi-native {event_type} must NOT persist an interrupted marker; got {marker_texts!r}."
|
|
)
|
|
|
|
# 3) Not flagged, and nothing leaks into the runner's in-memory history.
|
|
assert not flagged, f"pi-native session {conv_id!r} must not be flagged interrupted."
|
|
assert all(
|
|
not (
|
|
h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
)
|
|
for h in captured_history
|
|
), f"no interrupt marker should enter _session_histories; got {captured_history!r}"
|
|
|
|
|
|
def test_interrupted_sessions_isolated_per_app_instance() -> None:
|
|
"""
|
|
Each ``create_runner_app()`` gets its own ``_interrupted_sessions`` set.
|
|
|
|
Regression guard: when ``_interrupted_sessions`` was a module-global,
|
|
interrupt flags leaked between distinct app instances in the same
|
|
process — app1 flagging a conv made app2 append a bogus
|
|
``[System: interrupted]`` marker on a normal turn for the same conv id.
|
|
Keeping the set closure-local (exposed on ``app.state`` only for test
|
|
inspection) prevents that.
|
|
"""
|
|
app1 = create_runner_app(server_client=NullServerClient()) # type: ignore[arg-type]
|
|
app2 = create_runner_app(server_client=NullServerClient()) # type: ignore[arg-type]
|
|
|
|
# Distinct objects: a shared module-global would make these identical, so
|
|
# a flag added to one app would be visible from the other.
|
|
assert app1.state.interrupted_sessions is not app2.state.interrupted_sessions, (
|
|
"Each app instance must own its _interrupted_sessions set; if they are "
|
|
"the same object, the set is module-global again and flags leak across apps."
|
|
)
|
|
|
|
app1.state.interrupted_sessions.add("conv_x")
|
|
assert "conv_x" not in app2.state.interrupted_sessions, (
|
|
"app2 must not observe app1's interrupt flag. If it does, "
|
|
"_interrupted_sessions is shared process-global state and a stale flag "
|
|
"would fire a bogus [System: interrupted] marker on app2's next turn."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_on_native_kills_tmux_and_publishes_idle(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` ``{"type": "stop_session"}`` on a claude-native
|
|
session kills the tmux session and clears the spinner.
|
|
|
|
"Stop session" is the web UI affordance for terminating a
|
|
claude-native session without re-attaching to tmux. Unlike
|
|
``interrupt`` (a single Escape that cancels the current response
|
|
but leaves the session alive), it must:
|
|
|
|
1. Call ``kill_session`` with the bridge dir derived from the
|
|
conversation id and the snappy 1.0s timeout — this is what
|
|
actually ends the ``claude`` process.
|
|
2. Enqueue exactly one ``session.status: idle`` event so the web
|
|
UI's "Working…" spinner clears immediately (Claude's ``Stop``
|
|
hook never fires on a hard kill).
|
|
3. NOT append a ``[System: interrupted]`` marker — the session is
|
|
being torn down, not interrupted mid-turn. A stray marker would
|
|
be the interrupt handler leaking into the stop path.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref, _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured_kill: list[Any] = []
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Record the call and return without touching tmux."""
|
|
captured_kill.append((bridge_dir, timeout_s))
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", _fake_kill)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_stop", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
stop_resp = await client.post(
|
|
"/v1/sessions/conv_native_stop/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
captured_history = list(_session_histories_ref.get("conv_native_stop", []))
|
|
queue = _session_event_queues_ref.get("conv_native_stop")
|
|
assert queue is not None, (
|
|
"Session creation should have initialized the event queue "
|
|
"for ``conv_native_stop``; without it ``_publish_event`` had "
|
|
"nowhere to land its idle event."
|
|
)
|
|
queued_events: list[dict[str, Any]] = []
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
# 1) 204 + exactly one kill_session call on the conversation's
|
|
# bridge dir. 0 = the dispatch fell through to the generic
|
|
# forward-to-harness path (which 404s for native — silent
|
|
# regression); 2+ = the handler ran twice.
|
|
assert stop_resp.status_code == 204, (
|
|
f"Native stop_session must return 204 from /events; "
|
|
f"got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
assert len(captured_kill) == 1, (
|
|
f"Expected one kill_session call, got {len(captured_kill)}. "
|
|
f"If 0, the dispatch in /events did not route to the native "
|
|
f"stop handler — possibly _session_harness_name returned the "
|
|
f"wrong canonical name."
|
|
)
|
|
bridge_dir, timeout_s = captured_kill[0]
|
|
assert bridge_dir == bridge_dir_for_conversation_id("conv_native_stop")
|
|
# 1.0s short timeout: the UI stop must feel snappy. The helper's
|
|
# 30s default would hang the user's click on a missing tmux.json.
|
|
assert timeout_s == 1.0
|
|
|
|
# 2) session.status: idle enqueued exactly once so the spinner
|
|
# clears. 0 = _publish_event was skipped; 2+ = double-publish.
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert len(status_idle) == 1, (
|
|
f"Expected exactly one session.status: idle event after a "
|
|
f"native stop, got {len(status_idle)}. Full queue: {queued_events!r}."
|
|
)
|
|
|
|
# 3) No [System: interrupted] marker — stop is a teardown, not a
|
|
# mid-turn interrupt. A marker here means the interrupt handler's
|
|
# _append_cancellation_items leaked into the stop path.
|
|
markers = [
|
|
h
|
|
for h in captured_history
|
|
if h.get("type") == "message"
|
|
and h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
]
|
|
assert markers == [], (
|
|
f"stop_session must not append a [System: interrupted] marker; "
|
|
f"got {markers!r}. If non-empty, the stop handler is reusing the "
|
|
f"interrupt cleanup path."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_session_on_native_subagent_reclaims_work_entry(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Hard-stopping a claude-native SUB-AGENT worker reclaims its work entry.
|
|
|
|
When the stopped session is a tracked sub-agent, ``_handle_claude_native_stop``
|
|
must mark the work entry ``cancelled`` and deliver a terminal payload to the
|
|
parent's inbox — so the orchestrator (via ``sys_cancel_task`` → ``stop_session``)
|
|
learns the worker is gone instead of waiting on the wrapper's reconnect loop.
|
|
Pre-fix the kill happened but the entry was never reclaimed (the parent could
|
|
hang thinking the worker was still running).
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
parent_id = "conv_parent_stop_reclaim"
|
|
worker_id = "conv_worker_stop_reclaim"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", lambda *a, **k: None)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=worker_id,
|
|
agent="claude_code",
|
|
title="task",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": worker_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{worker_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
finally:
|
|
runner_app.unregister_subagent_work(worker_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
# The killed worker's entry was reclaimed: a single cancelled completion
|
|
# landed in the parent's inbox. If 0, the stop path killed the pane but
|
|
# left the parent thinking the worker was still running (the bug).
|
|
assert session_inbox.qsize() == 1, (
|
|
f"Expected one cancelled completion in the parent inbox after stopping "
|
|
f"the worker, got {session_inbox.qsize()}."
|
|
)
|
|
delivered = session_inbox.get_nowait()
|
|
assert delivered["status"] == "cancelled"
|
|
assert delivered["task_id"] == worker_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_session_on_native_subagent_without_parent_inbox_returns_204(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Hard-stopping a tracked native sub-agent succeeds after the kill lands.
|
|
|
|
``stop_session`` is user-initiated stop orchestration, not the native
|
|
terminal-status ACK path. Once the pane is killed, the runner must return
|
|
204 so Omnigent can finish host-runner teardown and write the deliberate-stop
|
|
label even if parent delivery cannot be confirmed.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
parent_id = "conv_parent_stop_missing_inbox"
|
|
worker_id = "conv_worker_stop_missing_inbox"
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", lambda *a, **k: None)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""
|
|
Resolve every test session to a claude-native spec.
|
|
|
|
:param agent_id: Agent id requested by the runner.
|
|
:param session_id: Optional session id being spawned.
|
|
:returns: Native executor spec for the test.
|
|
"""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=worker_id,
|
|
agent="claude_code",
|
|
title="task",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": worker_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{worker_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
entry = runner_app.get_subagent_work(worker_id)
|
|
finally:
|
|
runner_app.unregister_subagent_work(worker_id)
|
|
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
assert entry is not None
|
|
# The worker was marked cancelled, but delivery is still unconfirmed. The
|
|
# external_session_status path remains responsible for enforcing delivery
|
|
# ACK failures; explicit stop must not report a failed kill after success.
|
|
assert entry.status == "cancelled"
|
|
assert entry.delivered is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_on_native_returns_503_when_kill_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` stop_session returns 503 when ``kill_session``
|
|
can't reach tmux, and publishes no idle.
|
|
|
|
Sister to the happy-path test. If the runner can't deliver the
|
|
kill (tmux pane gone, bridge dir not yet advertised) it must
|
|
surface a 503 rather than lie to the web UI with a 204 + idle
|
|
that says "stopped" while the session may still be alive.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", _fake_kill)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_stop_fail", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
stop_resp = await client.post(
|
|
"/v1/sessions/conv_native_stop_fail/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
queue = _session_event_queues_ref.get("conv_native_stop_fail")
|
|
assert queue is not None
|
|
queued_events: list[dict[str, Any]] = []
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert stop_resp.status_code == 503, (
|
|
f"Native stop_session with kill failure must return 503; "
|
|
f"got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
body = stop_resp.json()
|
|
assert body.get("error") == "claude_native_stop_failed", (
|
|
f"503 body must carry the stop-failure error code; got {body!r}"
|
|
)
|
|
# No idle on the failure path — clearing the spinner would tell the
|
|
# UI the session stopped when the kill didn't actually land.
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert status_idle == [], (
|
|
f"No session.status: idle should be enqueued when kill_session "
|
|
f"failed; got {status_idle!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_on_non_native_session_is_204_noop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Non-native sessions accept stop_session and 204 without killing tmux.
|
|
|
|
In-process harnesses have no external tmux process for the runner to
|
|
kill: stop cancels the in-flight turn via the cancel floor, or — with
|
|
no turn in flight, as here — is a clean 204 no-op. The Omnigent server is
|
|
harness-agnostic and forwards stop_session for any session, so the
|
|
runner must accept it and 204 — never reach ``kill_session``.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Fail the test if a non-native session reaches the killer."""
|
|
del bridge_dir, timeout_s
|
|
raise AssertionError(
|
|
"kill_session must never be called for non-native sessions — "
|
|
"stop_session is a no-op for in-process harnesses."
|
|
)
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", _fake_kill)
|
|
|
|
# Default harness (in-process LLM loop), NOT claude-native.
|
|
default_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the default spec for any agent_id."""
|
|
del agent_id
|
|
return default_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_default_stop", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_default_stop/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
# 204 = dispatch saw a non-native harness and short-circuited
|
|
# before any kill. Anything else means the event leaked into a
|
|
# code path it shouldn't reach.
|
|
assert resp.status_code == 204, (
|
|
f"Non-native stop_session must return 204 no-op; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_closes_terminal_and_publishes_deleted(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Native stop tears the session's terminal resource down.
|
|
|
|
A host-spawned (web-UI-created) claude-native session has no CLI
|
|
wrapper watching the pane, so after ``kill_session`` ends ``claude``
|
|
nothing else removes the terminal resource — the web UI keeps showing
|
|
a live terminal for the stopped session (the user-reported bug). The
|
|
stop handler must therefore close each of the session's terminals and
|
|
publish ``session.resource.deleted`` so connected clients drop them.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Record nothing; the stub terminal needs no real tmux kill."""
|
|
del bridge_dir, timeout_s
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", _fake_kill)
|
|
|
|
# Seed the runner's terminal registry with the session's live
|
|
# ``claude:main`` terminal, mirroring what the host-spawned
|
|
# auto-create path leaves behind. Private-attr seed matches the
|
|
# existing resource-registry test convention (no real tmux).
|
|
conv_id = "conv_native_stop_term"
|
|
terminal_registry = TerminalRegistry(
|
|
conversation_link_base_url="http://127.0.0.1:8000",
|
|
)
|
|
instance = make_test_terminal_instance("claude", "main", tmp_path)
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("claude", "main")] = instance
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Precondition: the terminal is live before the stop, so a later
|
|
# absence proves the stop closed it (not that it was never there).
|
|
assert terminal_registry.get(conv_id, "claude", "main") is not None
|
|
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
queue = _session_event_queues_ref.get(conv_id)
|
|
queued_events: list[dict[str, Any]] = []
|
|
while queue is not None and not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
|
|
# The terminal is gone from the registry → the resource list the web
|
|
# UI reads no longer shows a live terminal. Still present = the stop
|
|
# handler skipped teardown (the bug this guards against).
|
|
assert terminal_registry.get(conv_id, "claude", "main") is None, (
|
|
"stop_session must close the session's terminal; it is still "
|
|
"registered, so the web UI would keep showing a live terminal."
|
|
)
|
|
|
|
# Exactly one session.resource.deleted for the claude terminal so
|
|
# connected clients drop it live (the server relay also persists it).
|
|
# 0 = teardown didn't publish (UI never updates); 2+ = double-publish.
|
|
deleted = [e for e in queued_events if e.get("type") == "session.resource.deleted"]
|
|
assert deleted == [
|
|
{
|
|
"type": "session.resource.deleted",
|
|
"resource_id": "terminal_claude_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
}
|
|
], f"expected one terminal session.resource.deleted event, got {deleted!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_required_terminal_exit_publishes_deleted_and_failed(tmp_path: Path) -> None:
|
|
"""
|
|
A required terminal disappearing fails the owning session.
|
|
|
|
This uses a generic ``worker`` terminal name to pin the lifecycle rule,
|
|
not a Claude-specific branch: if the terminal was registered as required,
|
|
the runner must publish both resource deletion and ``session.status:
|
|
failed`` when its watcher reports that tmux disappeared.
|
|
|
|
:param tmp_path: Temporary directory for fake terminal paths.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
parent_id = f"conv_parent_required_exit_{uuid.uuid4().hex[:12]}"
|
|
conv_id = f"conv_required_exit_{uuid.uuid4().hex[:12]}"
|
|
parent_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
terminal_registry = TerminalRegistry()
|
|
instance = make_test_terminal_instance("worker", "main", tmp_path)
|
|
instance.command = "worker-cli"
|
|
instance.args = ["--profile", "test"]
|
|
instance.launch_cwd = str(tmp_path)
|
|
instance._remember_pane_snapshot("startup failed\ncomplete setup first")
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("worker", "main")] = instance
|
|
callbacks: dict[str, Any] = {}
|
|
|
|
def _capture_watcher(
|
|
on_idle: object | None = None,
|
|
*,
|
|
on_activity: object | None = None,
|
|
on_exit: object | None = None,
|
|
idle_threshold_s: float | None = None,
|
|
poll_interval_s: float | None = None,
|
|
replace: bool = False,
|
|
) -> None:
|
|
del on_idle, on_activity, idle_threshold_s, poll_interval_s
|
|
callbacks["on_exit"] = on_exit
|
|
callbacks["replace"] = replace
|
|
|
|
instance.start_idle_watcher_thread = _capture_watcher # type: ignore[method-assign]
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
pm._sessions.add(conv_id)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
resource_registry = app.state.session_resource_registry
|
|
runner_app._session_inboxes_ref[parent_id] = parent_inbox
|
|
runner_app.register_child_session(
|
|
conv_id,
|
|
parent_session_id=parent_id,
|
|
title="worker:main",
|
|
tool="worker",
|
|
session_name="main",
|
|
)
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=conv_id,
|
|
agent="worker",
|
|
title="main",
|
|
)
|
|
|
|
async def _collect_exit_events() -> list[dict[str, Any]]:
|
|
while True:
|
|
queue = _session_event_queues_ref.get(conv_id)
|
|
if queue is not None and queue.qsize() >= 2:
|
|
events: list[dict[str, Any]] = []
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
events.append(item)
|
|
return events
|
|
await asyncio.sleep(0)
|
|
|
|
try:
|
|
await resource_registry.observe_required_terminal(
|
|
conv_id,
|
|
"worker",
|
|
"main",
|
|
instance,
|
|
)
|
|
on_exit = callbacks.get("on_exit")
|
|
assert callable(on_exit)
|
|
on_exit()
|
|
queued_events = await asyncio.wait_for(_collect_exit_events(), timeout=1.0)
|
|
for _ in range(100):
|
|
if pm.released:
|
|
break
|
|
await asyncio.sleep(0)
|
|
parent_events = _drain_session_event_queue(_session_event_queues_ref.get(parent_id))
|
|
finally:
|
|
_session_event_queues_ref.pop(conv_id, None)
|
|
_session_event_queues_ref.pop(parent_id, None)
|
|
runner_app.unregister_subagent_work(conv_id)
|
|
runner_app.unregister_child_session(conv_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
assert terminal_registry.get(conv_id, "worker", "main") is None
|
|
assert {
|
|
"type": "session.resource.deleted",
|
|
"resource_id": "terminal_worker_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
} in queued_events
|
|
failed_events = [
|
|
event
|
|
for event in queued_events
|
|
if event.get("type") == "session.status" and event.get("status") == "failed"
|
|
]
|
|
assert len(failed_events) == 1, f"expected one failed status, got {queued_events!r}"
|
|
assert failed_events[0]["error"]["code"] == "required_terminal_exited"
|
|
assert "Required terminal exited unexpectedly" in failed_events[0]["error"]["message"]
|
|
assert parent_events == [
|
|
{
|
|
"type": "session.child_session.updated",
|
|
"conversation_id": parent_id,
|
|
"child_session_id": conv_id,
|
|
"child": {
|
|
"id": conv_id,
|
|
"title": "worker:main",
|
|
"tool": "worker",
|
|
"session_name": "main",
|
|
"busy": False,
|
|
"current_task_status": "failed",
|
|
"last_task_error": failed_events[0]["error"],
|
|
},
|
|
}
|
|
]
|
|
assert pm.released == [conv_id]
|
|
inbox_item = parent_inbox.get_nowait()
|
|
assert inbox_item["status"] == "failed"
|
|
assert "Required terminal exited unexpectedly" in inbox_item["output"]
|
|
assert "command: worker-cli (2 args; argv omitted" in inbox_item["output"]
|
|
assert f"cwd: {tmp_path}" in inbox_item["output"]
|
|
assert "startup failed\ncomplete setup first" in inbox_item["output"]
|
|
assert "Suggested next checks" not in inbox_item["output"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_required_terminal_exit_while_idle_does_not_fail_session(tmp_path: Path) -> None:
|
|
"""
|
|
A required terminal that exits while the session is idle is a clean shutdown.
|
|
|
|
The native agent terminal is long-lived and goes ``idle`` once its turn
|
|
completes. When the pane then disappears, the work for that turn was already
|
|
delivered, so the runner must NOT publish ``session.status: failed`` — doing
|
|
so was the source of spurious "failed" chats in the UI. The terminal
|
|
resource is still removed and the harness subprocess released; the runner
|
|
going offline is surfaced separately via liveness, not a failure.
|
|
|
|
:param tmp_path: Temporary directory for fake terminal paths.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
parent_id = f"conv_parent_idle_exit_{uuid.uuid4().hex[:12]}"
|
|
conv_id = f"conv_idle_exit_{uuid.uuid4().hex[:12]}"
|
|
parent_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
terminal_registry = TerminalRegistry()
|
|
instance = make_test_terminal_instance("worker", "main", tmp_path)
|
|
instance.command = "worker-cli"
|
|
instance.launch_cwd = str(tmp_path)
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("worker", "main")] = instance
|
|
callbacks: dict[str, Any] = {}
|
|
|
|
def _capture_watcher(
|
|
on_idle: object | None = None,
|
|
*,
|
|
on_activity: object | None = None,
|
|
on_exit: object | None = None,
|
|
idle_threshold_s: float | None = None,
|
|
poll_interval_s: float | None = None,
|
|
replace: bool = False,
|
|
) -> None:
|
|
del on_idle, on_activity, idle_threshold_s, poll_interval_s, replace
|
|
callbacks["on_exit"] = on_exit
|
|
|
|
instance.start_idle_watcher_thread = _capture_watcher # type: ignore[method-assign]
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
pm._sessions.add(conv_id)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
resource_registry = app.state.session_resource_registry
|
|
runner_app._session_inboxes_ref[parent_id] = parent_inbox
|
|
runner_app.register_child_session(
|
|
conv_id,
|
|
parent_session_id=parent_id,
|
|
title="worker:main",
|
|
tool="worker",
|
|
session_name="main",
|
|
)
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=conv_id,
|
|
agent="worker",
|
|
title="main",
|
|
)
|
|
|
|
try:
|
|
await resource_registry.observe_required_terminal(
|
|
conv_id,
|
|
"worker",
|
|
"main",
|
|
instance,
|
|
)
|
|
# The session reached idle (turn completed) before the pane vanished.
|
|
resource_registry._last_session_status[conv_id] = "idle"
|
|
on_exit = callbacks.get("on_exit")
|
|
assert callable(on_exit)
|
|
on_exit()
|
|
# Terminal-exit cleanup fans out across two independent background
|
|
# tasks: one publishes the resource events, a second releases the
|
|
# harness subprocess. Their completion order is not guaranteed, so
|
|
# settle on BOTH the published ``deleted`` event and the subprocess
|
|
# release — accumulating drained events each tick — rather than using
|
|
# the release as a proxy for the publish (which raced: the release
|
|
# task could finish first, leaving the drain empty).
|
|
deleted_event = {
|
|
"type": "session.resource.deleted",
|
|
"resource_id": "terminal_worker_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
}
|
|
queued_events: list[dict[str, Any]] = []
|
|
parent_events: list[dict[str, Any]] = []
|
|
for _ in range(1000):
|
|
queued_events.extend(
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
)
|
|
parent_events.extend(
|
|
_drain_session_event_queue(_session_event_queues_ref.get(parent_id))
|
|
)
|
|
if pm.released and deleted_event in queued_events:
|
|
break
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
_session_event_queues_ref.pop(conv_id, None)
|
|
_session_event_queues_ref.pop(parent_id, None)
|
|
runner_app.unregister_subagent_work(conv_id)
|
|
runner_app.unregister_child_session(conv_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
# The terminal resource is still removed...
|
|
assert terminal_registry.get(conv_id, "worker", "main") is None
|
|
assert deleted_event in queued_events
|
|
# ...but no failure is published, and the parent is not woken as failed.
|
|
assert [
|
|
event
|
|
for event in queued_events
|
|
if event.get("type") == "session.status" and event.get("status") == "failed"
|
|
] == []
|
|
assert parent_events == []
|
|
assert parent_inbox.empty()
|
|
# The harness subprocess is still released — the terminal is gone.
|
|
assert pm.released == [conv_id]
|
|
|
|
|
|
@pytest.mark.parametrize("terminal_name", ["qwen", "antigravity"])
|
|
@pytest.mark.asyncio
|
|
async def test_required_terminal_clean_quit_publishes_idle_not_failed(
|
|
terminal_name: str,
|
|
) -> None:
|
|
"""A clean ``/quit`` of qwen/antigravity-native is not a crash.
|
|
|
|
Both harnesses leave the exit-classification memo stuck on ``running`` at
|
|
quit time — qwen's "powering down" redraw trips the PTY-activity watcher,
|
|
and antigravity-native is deliberately excluded from the PTY ``emit_status``
|
|
role set (the RPC reader owns working-status). So ``session_was_idle`` is
|
|
``False`` even though the user quit normally. The runner must special-case
|
|
these terminals: publish a final ``idle`` (to clear the web "Working…"
|
|
spinner) and release the harness, but never render the spurious red
|
|
``required_terminal_exited`` failure card.
|
|
|
|
:param terminal_name: The native terminal that the user quit cleanly.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.runner.resource_registry import (
|
|
TerminalExitEvent,
|
|
TerminalLifecycle,
|
|
)
|
|
|
|
conv_id = f"conv_clean_quit_{terminal_name}_{uuid.uuid4().hex[:12]}"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
pm._sessions.add(conv_id)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
resource_registry = app.state.session_resource_registry
|
|
# Grab the runner's terminal-exit publisher (the branch under test) and
|
|
# drive it directly, mimicking the registry firing on a clean quit.
|
|
publish_exit = resource_registry._terminal_exit_publisher
|
|
assert callable(publish_exit)
|
|
|
|
try:
|
|
publish_exit(
|
|
TerminalExitEvent(
|
|
session_id=conv_id,
|
|
terminal_id=f"terminal_{terminal_name}_main",
|
|
terminal_name=terminal_name,
|
|
session_key="main",
|
|
lifecycle=TerminalLifecycle.REQUIRED,
|
|
# The memo never flipped to idle, so the generic guard would
|
|
# otherwise misclassify this normal quit as a crash.
|
|
session_was_idle=False,
|
|
)
|
|
)
|
|
queued_events: list[dict[str, Any]] = []
|
|
for _ in range(1000):
|
|
queued_events.extend(
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
)
|
|
if pm.released:
|
|
break
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
_session_event_queues_ref.pop(conv_id, None)
|
|
runner_app.unregister_child_session(conv_id)
|
|
|
|
# The terminal resource is removed and a final idle clears the spinner...
|
|
assert {
|
|
"type": "session.resource.deleted",
|
|
"resource_id": f"terminal_{terminal_name}_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
} in queued_events
|
|
assert {"type": "session.status", "status": "idle"} in queued_events
|
|
# ...but no spurious failure card renders — the user quit normally.
|
|
assert [
|
|
event
|
|
for event in queued_events
|
|
if event.get("type") == "session.status" and event.get("status") == "failed"
|
|
] == []
|
|
# The harness subprocess is still released — the terminal is gone.
|
|
assert pm.released == [conv_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_idle_status_makes_required_terminal_exit_clean(tmp_path: Path) -> None:
|
|
"""
|
|
A structured native ``idle`` status prevents a later pane close from failing.
|
|
|
|
Kiro completion is observed from its persisted JSONL session, not only from
|
|
PTY diff-idle. After a web turn marks the required terminal ``running``, the
|
|
forwarded ``external_session_status: idle`` must update the same exit memo
|
|
used by the required-terminal watcher; otherwise a normal user close after
|
|
Kiro answered is misclassified as ``required_terminal_exited``.
|
|
|
|
:param tmp_path: Temporary directory for fake terminal paths.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
conv_id = f"conv_kiro_external_idle_exit_{uuid.uuid4().hex[:12]}"
|
|
terminal_registry = TerminalRegistry()
|
|
instance = make_test_terminal_instance("kiro", "main", tmp_path)
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("kiro", "main")] = instance
|
|
callbacks: dict[str, Any] = {}
|
|
|
|
def _capture_watcher(
|
|
on_idle: object | None = None,
|
|
*,
|
|
on_activity: object | None = None,
|
|
on_exit: object | None = None,
|
|
idle_threshold_s: float | None = None,
|
|
poll_interval_s: float | None = None,
|
|
replace: bool = False,
|
|
) -> None:
|
|
del on_idle, on_activity, idle_threshold_s, poll_interval_s, replace
|
|
callbacks["on_exit"] = on_exit
|
|
|
|
instance.start_idle_watcher_thread = _capture_watcher # type: ignore[method-assign]
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
pm._sessions.add(conv_id)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
resource_registry = app.state.session_resource_registry
|
|
|
|
try:
|
|
await resource_registry.observe_required_terminal(
|
|
conv_id,
|
|
"kiro",
|
|
"main",
|
|
instance,
|
|
resource_role=KIRO_NATIVE_TERMINAL_ROLE,
|
|
)
|
|
resource_registry.note_session_turn_started(conv_id)
|
|
async with _runner_client(app) as client:
|
|
status_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "external_session_status", "data": {"status": "idle"}},
|
|
)
|
|
assert status_resp.status_code == 204, status_resp.text
|
|
|
|
on_exit = callbacks.get("on_exit")
|
|
assert callable(on_exit)
|
|
on_exit()
|
|
deleted_event = {
|
|
"type": "session.resource.deleted",
|
|
"resource_id": "terminal_kiro_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
}
|
|
queued_events: list[dict[str, Any]] = []
|
|
for _ in range(1000):
|
|
queued_events.extend(
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
)
|
|
if pm.released and deleted_event in queued_events:
|
|
break
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
_session_event_queues_ref.pop(conv_id, None)
|
|
|
|
assert terminal_registry.get(conv_id, "kiro", "main") is None
|
|
assert deleted_event in queued_events
|
|
assert [
|
|
event
|
|
for event in queued_events
|
|
if event.get("type") == "session.status" and event.get("status") == "failed"
|
|
] == []
|
|
assert pm.released == [conv_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_effort_change_on_native_session_types_slash_command(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` with ``{"type":"effort_change","effort":"high"}``
|
|
on a claude-native session injects ``/effort high`` into tmux.
|
|
|
|
With the unified-effort refactor Omnigent server no longer POSTs to
|
|
``/claude-native-effort`` — every PATCH effort goes through the
|
|
generic ``/events`` path. The runner's ``/events`` dispatch must
|
|
recognize the native harness and route to
|
|
``_handle_claude_native_effort_change``, which assembles the
|
|
slash command and types it into the pane.
|
|
|
|
A regression in the dispatch (wrong harness name, missing branch)
|
|
would fall through to the generic harness-forward and 404, leaving
|
|
the dropdown click silently ineffective.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[Any] = []
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Record the call and return without touching tmux."""
|
|
captured.append((bridge_dir, command, timeout_s))
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Seed _session_spec_cache so /events can detect "claude-native".
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_effort", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Drain creation-time events (the claude-native auto-create path
|
|
# enqueues session.terminal_pending) so the post-effort_change
|
|
# drain below isolates only what the control event emits.
|
|
_drain_session_event_queue(_session_event_queues_ref.get("conv_native_effort"))
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_native_effort/events",
|
|
json={"type": "effort_change", "effort": "high"},
|
|
)
|
|
|
|
# Drain the event queue before delete clears it, so we can
|
|
# assert that effort_change does NOT enqueue spurious events
|
|
# (it's a control signal, not a session-state change).
|
|
queue = _session_event_queues_ref.get("conv_native_effort")
|
|
queued_events: list[dict[str, Any]] = []
|
|
if queue is not None:
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
# 1) 204 = the dispatch correctly routed to the native handler and
|
|
# the handler completed cleanly. 404 would mean the dispatch fell
|
|
# through to the generic harness-forward.
|
|
assert resp.status_code == 204, (
|
|
f"Native effort_change must return 204 from /events; got {resp.status_code}: {resp.text}"
|
|
)
|
|
# 2) Exactly one inject call. 0 = native dispatch missed (likely
|
|
# _session_harness_name returned the wrong canonical name); 2+ =
|
|
# the handler ran twice.
|
|
assert len(captured) == 1, (
|
|
f"Expected one inject_slash_command call from native effort_change, got {len(captured)}."
|
|
)
|
|
bridge_dir, command, timeout_s = captured[0]
|
|
assert bridge_dir == bridge_dir_for_conversation_id("conv_native_effort")
|
|
# Body contract: ``/effort high`` is the literal Claude Code's TUI
|
|
# accepts. A regression in shape (``/efforthigh``, ``effort high``,
|
|
# missing leading slash) would either 404 on the slash router or
|
|
# land as plain text in the prompt.
|
|
assert command == "/effort high", f"Expected '/effort high' literal, got {command!r}."
|
|
# 1.0s short timeout: missing tmux.json means the pane isn't
|
|
# attached; persisted effort still applies on next spawn. A 30s
|
|
# default would hang the Omnigent PATCH whenever the pane is detached.
|
|
assert timeout_s == 1.0
|
|
# 3) effort_change is a control signal, not a state change.
|
|
# Any session.status enqueued here would mislead the Omnigent relay.
|
|
assert queued_events == [], (
|
|
f"effort_change must not publish session events; got "
|
|
f"{queued_events!r}. If non-empty, the native handler is "
|
|
f"emitting spurious status events."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"effort_value",
|
|
# ``EFFORT_VALUES`` is a superset of ``CLAUDE_EFFORTS``:
|
|
# PATCH accepts {none, minimal, low, medium, high, xhigh, max}
|
|
# but Claude Code's ``/effort`` slash only accepts the last five.
|
|
# ``none`` and ``minimal`` must skip injection (typing ``/effort
|
|
# none`` would land as a TUI error). ``None`` (clear) must skip
|
|
# too — Claude has no slash form for "use spawn default".
|
|
["none", "minimal", None],
|
|
)
|
|
async def test_events_effort_change_on_native_session_skips_inject_for_unsupported_level(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
effort_value: str | None,
|
|
) -> None:
|
|
"""
|
|
Unsupported / null effort values 204 without typing into tmux.
|
|
|
|
Omnigent server is harness-agnostic — it always forwards the new
|
|
persisted effort to ``/events``. The runner's native handler
|
|
owns the level-validation, skipping injection when the value
|
|
isn't in Claude's accepted set. Persistence already happened on
|
|
the Omnigent side; the next spawn picks up the value via ``--effort``.
|
|
|
|
Pins that the validation lives in the runner (where the
|
|
harness-specific knowledge belongs), not in the Omnigent server.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Fail the test if the runner reaches inject for an unsupported level."""
|
|
del bridge_dir, command, timeout_s
|
|
raise AssertionError(
|
|
f"inject_slash_command must not be called for effort={effort_value!r}; "
|
|
f"the native handler should skip unsupported / null levels."
|
|
)
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_effort_skip", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_native_effort_skip/events",
|
|
json={"type": "effort_change", "effort": effort_value},
|
|
)
|
|
|
|
# 204 = the handler ran and decided to skip. 502 would mean it
|
|
# fell through to the harness-forward path. The fake inject above
|
|
# asserts loudly if injection was attempted — silence here proves
|
|
# the skip took effect.
|
|
assert resp.status_code == 204, (
|
|
f"Native effort_change with unsupported / null level must "
|
|
f"return 204 (no-op); got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_effort_change_on_native_session_returns_503_when_bridge_not_ready(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Bridge-not-ready RuntimeError surfaces as 503 from /events.
|
|
|
|
Sister to the happy-path test. Pins that the failure mode of the
|
|
native effort dispatch (tmux pane gone / bridge dir not yet
|
|
advertised) returns 503 with the same error code shape the
|
|
legacy route returns. Omnigent server's PATCH swallows this 503 and
|
|
still returns 200 with the persisted value — the next spawn
|
|
will apply the new effort via ``--effort``.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, command, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_effort_fail", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_native_effort_fail/events",
|
|
json={"type": "effort_change", "effort": "high"},
|
|
)
|
|
|
|
assert resp.status_code == 503, (
|
|
f"Native effort_change with inject failure must return 503; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
body = resp.json()
|
|
# ``claude_native_effort_failed`` is the same error code the
|
|
# legacy route uses — keeps the failure shape stable for callers.
|
|
assert body.get("error") == "claude_native_effort_failed", (
|
|
f"503 body must carry the bridge-failure error code; got {body!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_effort_change_on_non_native_session_is_204_noop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Non-native sessions accept effort_change and 204 without side effects.
|
|
|
|
In-process harnesses (default / claude-sdk / openai-agents / codex)
|
|
re-read the persisted ``reasoning_effort`` from store on each
|
|
turn, so they need no runtime notification when it changes. The
|
|
Omnigent server still POSTs ``effort_change`` to ``/events`` for every
|
|
PATCH (it's harness-agnostic), so the runner must accept the
|
|
event and 204 — never reach the slash-command injector, never
|
|
forward to the harness scaffold.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Fail the test if a non-native session reaches the injector."""
|
|
del bridge_dir, command, timeout_s
|
|
raise AssertionError(
|
|
"inject_slash_command must never be called for non-native "
|
|
"sessions — effort_change is a no-op for in-process harnesses."
|
|
)
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
# Default harness (in-process LLM loop), NOT claude-native.
|
|
default_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the default spec for any agent_id."""
|
|
del agent_id
|
|
return default_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_default_effort", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_default_effort/events",
|
|
json={"type": "effort_change", "effort": "high"},
|
|
)
|
|
|
|
# 204 = the dispatch saw a non-native harness and returned the
|
|
# no-op short-circuit before any forward / inject. Anything else
|
|
# (200/202/4xx/5xx) would mean the event leaked into a code path
|
|
# it shouldn't reach.
|
|
assert resp.status_code == 204, (
|
|
f"Non-native effort_change must return 204 no-op; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_native_session_types_slash_command(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` with ``{"type":"compact"}`` on a claude-native
|
|
session injects ``/compact`` into tmux and returns 200.
|
|
|
|
Explicit compaction on a claude-native session must run inside
|
|
Claude Code (it owns its own context window in the terminal); the
|
|
Omnigent server's own compaction would only summarise the transcript
|
|
mirror. The runner's ``/events`` dispatch recognises the native
|
|
harness and routes to ``_handle_claude_native_compact``, which
|
|
types the slash command into the pane.
|
|
|
|
The 200 (not 204) is load-bearing: the Omnigent server reads it to know
|
|
the control was handled in the terminal and skips its own
|
|
in-process compaction. A regression returning 204 here would make
|
|
the Omnigent server fall through to ``_run_compact_locked``, which 400s
|
|
on the LLM-less claude-native pseudo-agent — the original bug.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[Any] = []
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Record the call (including auto_confirm) without touching tmux."""
|
|
captured.append((bridge_dir, command, timeout_s, auto_confirm))
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_compact", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Drain creation-time events (claude-native auto-create enqueues
|
|
# session.terminal_pending) so the drain below isolates only
|
|
# what /compact emits.
|
|
_drain_session_event_queue(_session_event_queues_ref.get("conv_native_compact"))
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_native_compact/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
# Drain the event queue: /compact is a control signal and must
|
|
# not enqueue session.status events.
|
|
queue = _session_event_queues_ref.get("conv_native_compact")
|
|
queued_events: list[dict[str, Any]] = []
|
|
if queue is not None:
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
# 200 = native dispatch routed to the compact handler and it
|
|
# injected successfully. 204 would mean the handler returned the
|
|
# in-process no-op (wrong harness branch) → Omnigent falls through to
|
|
# _run_compact_locked and 400s. 404 = the dispatch fell through to
|
|
# the generic harness-forward.
|
|
assert resp.status_code == 200, (
|
|
f"Native compact must return 200 from /events; got {resp.status_code}: {resp.text}"
|
|
)
|
|
# Exactly one inject call. 0 = native dispatch missed; 2+ = handler ran twice.
|
|
assert len(captured) == 1, (
|
|
f"Expected one inject_slash_command call from native compact, got {len(captured)}."
|
|
)
|
|
bridge_dir, command, timeout_s, auto_confirm = captured[0]
|
|
assert bridge_dir == bridge_dir_for_conversation_id("conv_native_compact")
|
|
# Body contract: the literal ``/compact`` is what Claude Code's TUI
|
|
# accepts. A shape regression (``compact``, missing slash) would
|
|
# land as plain prompt text instead of running compaction.
|
|
assert command == "/compact", f"Expected '/compact' literal, got {command!r}."
|
|
# 1.0s short timeout: missing tmux.json means the pane isn't
|
|
# attached, so there's no live Claude to compact.
|
|
assert timeout_s == 1.0
|
|
# auto_confirm must be False — unlike /effort and /model, /compact
|
|
# does not pop a confirmation dialog, so an extra Enter would land
|
|
# on the prompt and submit a stray empty turn.
|
|
assert auto_confirm is False, (
|
|
f"compact must not auto-confirm; got auto_confirm={auto_confirm!r}."
|
|
)
|
|
# /compact is a control signal, not a state change.
|
|
assert queued_events == [], f"compact must not publish session events; got {queued_events!r}."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_native_session_returns_503_when_bridge_not_ready(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Bridge-not-ready RuntimeError surfaces as 503 from /events.
|
|
|
|
Sister to the happy-path test. When the tmux pane isn't attached
|
|
there is no live Claude to compact, so the native compact handler
|
|
returns 503 with the ``claude_native_compact_failed`` code. The AP
|
|
server treats a non-200/204 runner response as an error rather
|
|
than silently running its own (wrong) compaction.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, command, timeout_s, auto_confirm
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_compact_fail", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_native_compact_fail/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
assert resp.status_code == 503, (
|
|
f"Native compact with inject failure must return 503; got {resp.status_code}: {resp.text}"
|
|
)
|
|
body = resp.json()
|
|
assert body.get("error") == "claude_native_compact_failed", (
|
|
f"503 body must carry the bridge-failure error code; got {body!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_codex_native_injects_slash_command(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` with ``{"type":"compact"}`` on a codex-native
|
|
session injects ``/compact`` into the codex tmux pane and returns 200.
|
|
|
|
Codex owns its own context window in the terminal, so explicit
|
|
compaction must run inside Codex — the same rationale as the
|
|
claude-native path. The pane coordinates come from the resource
|
|
registry (not a ``tmux.json`` sidecar). The 200 return is
|
|
load-bearing: the Omnigent server reads it to skip its own
|
|
AP-side compaction.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
captured: list[tuple[str, list[str]]] = []
|
|
|
|
def _fake_run_tmux(socket_path: str, *args: str) -> None:
|
|
"""Record tmux send-keys calls without touching tmux."""
|
|
captured.append((socket_path, list(args)))
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "_run_tmux", _fake_run_tmux)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
conv_id = "conv_codex_compact"
|
|
terminal_registry = TerminalRegistry()
|
|
instance = make_test_terminal_instance("codex", "main", tmp_path)
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("codex", "main")] = instance
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
# Drain the event queue: /compact is a control signal and must
|
|
# not enqueue session.status events.
|
|
queue = _session_event_queues_ref.get(conv_id)
|
|
queued_events: list[dict[str, Any]] = []
|
|
if queue is not None:
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
# 200 = codex-native dispatch routed to the compact handler and it
|
|
# injected successfully.
|
|
assert resp.status_code == 200, (
|
|
f"Codex-native compact must return 200 from /events; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
# Exactly 3 tmux send-keys calls: C-u, -l /compact, Enter.
|
|
assert len(captured) == 3, (
|
|
f"Expected 3 tmux send-keys calls (C-u, /compact, Enter), got {len(captured)}."
|
|
)
|
|
socket = str(instance.socket_path)
|
|
# 1. Clear draft: C-u
|
|
assert captured[0] == (socket, ["send-keys", "-t", "main", "C-u"]), (
|
|
f"First call must clear draft with C-u; got {captured[0]!r}."
|
|
)
|
|
# 2. Type /compact literally
|
|
assert captured[1] == (socket, ["send-keys", "-l", "-t", "main", "/compact"]), (
|
|
f"Second call must type /compact literally; got {captured[1]!r}."
|
|
)
|
|
# 3. Submit with Enter
|
|
assert captured[2] == (socket, ["send-keys", "-t", "main", "Enter"]), (
|
|
f"Third call must submit with Enter; got {captured[2]!r}."
|
|
)
|
|
# /compact is a control signal, not a state change.
|
|
assert queued_events == [], f"compact must not publish session events; got {queued_events!r}."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_codex_native_returns_204_when_no_terminal() -> None:
|
|
"""
|
|
Codex-native compact returns 204 when no live terminal is registered.
|
|
|
|
Without a running codex terminal the ``/compact`` slash command
|
|
has nowhere to go. 204 tells the Omnigent server to fall back to
|
|
its own AP-side compaction (or skip it).
|
|
"""
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
conv_id = "conv_codex_compact_no_term"
|
|
# Empty registry — no codex terminal registered.
|
|
terminal_registry = TerminalRegistry()
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"Codex-native compact with no terminal must return 204; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_codex_native_returns_503_on_tmux_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex-native compact returns 503 when the tmux send-keys call fails.
|
|
|
|
The 503 tells the Omnigent server the control was NOT handled, so it
|
|
can surface an error rather than silently running its own (wrong)
|
|
compaction.
|
|
"""
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
def _failing_run_tmux(socket_path: str, *args: str) -> None:
|
|
"""Simulate a tmux pane that is no longer alive."""
|
|
del socket_path, args
|
|
raise RuntimeError("no server running on /tmp/dead.sock")
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "_run_tmux", _failing_run_tmux)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
conv_id = "conv_codex_compact_fail"
|
|
terminal_registry = TerminalRegistry()
|
|
instance = make_test_terminal_instance("codex", "main", tmp_path)
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("codex", "main")] = instance
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
assert resp.status_code == 503, (
|
|
f"Codex-native compact with tmux failure must return 503; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
body = resp.json()
|
|
assert body.get("error") == "codex_native_compact_failed", (
|
|
f"503 body must carry the codex bridge-failure error code; got {body!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_cursor_native_pastes_summarize_and_raises_spinner(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` with ``{"type":"compact"}`` on a cursor-native
|
|
session submits ``/summarize`` via bracketed paste, returns 200, and
|
|
raises the "Compacting…" spinner — but does NOT complete it.
|
|
|
|
cursor-agent manages its own context window in the TUI, so explicit
|
|
compaction must run there (its built-in ``/summarize`` command) rather
|
|
than as AP-side compaction — the same rationale as the claude-native
|
|
path. The 200 (not 204) is load-bearing: the Omnigent server reads it to
|
|
skip its own ``_run_compact_locked`` (which 400s on the LLM-less native
|
|
pseudo-agent).
|
|
|
|
Two properties are pinned here:
|
|
|
|
1. **The command must go through the bracketed-paste path**
|
|
(``inject_user_message``), NOT a ``send-keys``-typed slash command.
|
|
Typing the literal ``/summarize`` opens cursor-agent's slash-command
|
|
autocomplete dropdown, and the single submit Enter then confirms the
|
|
highlighted completion instead of submitting the command — so the
|
|
command was never sent (the original bug, seen as ``/summarize`` left
|
|
sitting in the input box).
|
|
2. **The handler raises the spinner but must NOT complete it.** It publishes
|
|
``response.compaction.in_progress`` (→ "Compacting conversation…") only.
|
|
cursor-agent runs the summarization asynchronously in the pane after the
|
|
submit, so completing here would flash "Conversation compacted" while
|
|
the TUI is still summarizing. The ``completed`` edge is emitted later by
|
|
the cursor forwarder when it observes the summary blob (covered by
|
|
``tests/test_cursor_native_forwarder.py``).
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
monkeypatch.setattr(cursor_native_bridge, "_BRIDGE_ROOT", tmp_path / "cursor-bridge")
|
|
|
|
captured: list[tuple[Any, str, float]] = []
|
|
|
|
def _fake_inject(bridge_dir: Any, *, content: str, timeout_s: float) -> None:
|
|
"""Record the bracketed-paste call without touching tmux."""
|
|
captured.append((bridge_dir, content, timeout_s))
|
|
|
|
monkeypatch.setattr(cursor_native_bridge, "inject_user_message", _fake_inject)
|
|
|
|
cursor_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the cursor-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return cursor_native_spec
|
|
|
|
conv_id = "conv_cursor_compact"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Drain creation-time events so the drain below isolates only what
|
|
# /compact emits.
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
queued_events = _drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
# 200 = cursor-native dispatch routed to the compact handler and the paste
|
|
# succeeded. 204 would mean the dispatch fell through to the in-process
|
|
# no-op branch (the original gap) → Omnigent runs its own compaction and 400s.
|
|
assert resp.status_code == 200, (
|
|
f"Cursor-native compact must return 200 from /events; got {resp.status_code}: {resp.text}"
|
|
)
|
|
# Exactly one paste call. 0 = dispatch missed the cursor branch.
|
|
assert len(captured) == 1, (
|
|
f"Expected one inject_user_message call from cursor compact, got {len(captured)}."
|
|
)
|
|
bridge_dir, content, timeout_s = captured[0]
|
|
assert bridge_dir == cursor_native_bridge.bridge_dir_for_session_id(conv_id)
|
|
# The literal ``/summarize`` is cursor-agent's compaction command. It is
|
|
# delivered as *paste content*, not a typed slash command, so the
|
|
# autocomplete dropdown never opens and the submit Enter sends the command.
|
|
assert content == "/summarize", f"Expected '/summarize' paste content, got {content!r}."
|
|
# 1.0s short timeout: a missing tmux target means the pane isn't attached,
|
|
# so there is no live cursor TUI to compact.
|
|
assert timeout_s == 1.0
|
|
|
|
# The handler raises the spinner (in_progress) but must NOT complete it —
|
|
# completion is the forwarder's job once the summary blob actually lands.
|
|
# A regression re-adding ``completed`` here would flash the permanent
|
|
# "Conversation compacted" marker while the TUI is still summarizing.
|
|
compaction_types = [
|
|
e.get("type")
|
|
for e in queued_events
|
|
if str(e.get("type", "")).startswith("response.compaction")
|
|
]
|
|
assert compaction_types == ["response.compaction.in_progress"], (
|
|
f"Handler must publish only in_progress (forwarder completes it); "
|
|
f"got {compaction_types!r}."
|
|
)
|
|
in_progress = next(
|
|
e for e in queued_events if e.get("type") == "response.compaction.in_progress"
|
|
)
|
|
assert in_progress.get("task_id") == conv_id, (
|
|
f"in_progress must carry the session id as task_id; got {in_progress!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("inject_exc", "label"),
|
|
[
|
|
# Pane not attached: _wait_for_tmux_info / _run_tmux raise RuntimeError.
|
|
(RuntimeError("tmux target is not advertised"), "runtime"),
|
|
# Filesystem fault writing the paste tempfile into bridge_dir (disk
|
|
# full, perms, dir removed). cursor's inject_user_message has this
|
|
# surface; the claude-native analog does not. A narrow
|
|
# ``except (RuntimeError, ValueError)`` would let this escape AFTER
|
|
# in_progress fired, stranding the spinner with no failed edge.
|
|
(OSError("No space left on device"), "oserror"),
|
|
],
|
|
)
|
|
async def test_events_compact_on_cursor_native_503_dismisses_spinner_on_inject_failure(
|
|
inject_exc: Exception,
|
|
label: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
An injection failure surfaces as 503 AND dismisses the spinner.
|
|
|
|
The handler publishes ``response.compaction.in_progress`` before injecting,
|
|
so every failure path must publish ``response.compaction.failed`` to
|
|
dismiss the "Compacting…" spinner — otherwise it is stranded forever — and
|
|
must NOT publish ``completed`` (the history was never compacted). Covers
|
|
both the tmux ``RuntimeError`` and the tempfile ``OSError`` surfaces; the
|
|
latter is unique to cursor's bracketed-paste path.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
monkeypatch.setattr(cursor_native_bridge, "_BRIDGE_ROOT", tmp_path / "cursor-bridge")
|
|
|
|
def _fake_inject(bridge_dir: Any, *, content: str, timeout_s: float) -> None:
|
|
"""Simulate an injection failure (tmux down, or tempfile write fault)."""
|
|
del bridge_dir, content, timeout_s
|
|
raise inject_exc
|
|
|
|
monkeypatch.setattr(cursor_native_bridge, "inject_user_message", _fake_inject)
|
|
|
|
cursor_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the cursor-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return cursor_native_spec
|
|
|
|
conv_id = f"conv_cursor_compact_fail_{label}"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
queued_events = _drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
assert resp.status_code == 503, (
|
|
f"Cursor-native compact with no live pane must return 503; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
body = resp.json()
|
|
assert body.get("error") == "cursor_native_compact_failed", (
|
|
f"503 body must carry the cursor bridge-failure error code; got {body!r}"
|
|
)
|
|
|
|
compaction_types = [
|
|
e.get("type")
|
|
for e in queued_events
|
|
if str(e.get("type", "")).startswith("response.compaction")
|
|
]
|
|
# in_progress raised the spinner; failed must dismiss it. completed must
|
|
# never fire — the history was not compacted.
|
|
assert compaction_types == [
|
|
"response.compaction.in_progress",
|
|
"response.compaction.failed",
|
|
], f"Expected in_progress then failed (no completed); got {compaction_types!r}."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_pi_native_enqueues_compact_payload(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` with ``{"type":"compact"}`` on a pi-native session
|
|
queues a ``compact`` payload to the Pi extension inbox and returns 200.
|
|
|
|
Pi owns its context window inside the resident Pi TUI process, so explicit
|
|
compaction must run there (the Omnigent server's AP-side compaction would
|
|
only summarise the transcript mirror and desync the two, and 400s on the
|
|
LLM-less pi-native pseudo-agent). The runner's ``compact`` dispatch routes
|
|
to ``_handle_pi_native_compact``, which drops a ``compact`` payload into the
|
|
bridge inbox; the resident extension consumes it and calls Pi's
|
|
``ExtensionContext.compact()``.
|
|
|
|
Regression guard: the dispatch originally enumerated only claude/codex/
|
|
cursor-native, so pi-native fell through to the 204 no-op.
|
|
|
|
Pins:
|
|
1. 200 returned (not 204) so the Omnigent server skips its own AP-side
|
|
compaction.
|
|
2. A ``compact_*`` payload is written to the session's bridge inbox.
|
|
3. /compact is a control signal and publishes no ``session.status`` events.
|
|
"""
|
|
import omnigent.pi_native_bridge as pi_native_bridge
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_pi_native_compact"
|
|
monkeypatch.setattr(pi_native_bridge, "_BRIDGE_ROOT", tmp_path / "pi-bridge")
|
|
|
|
pi_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "pi-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the pi-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return pi_native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Seeds _session_spec_cache so the dispatch detects "pi-native".
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Drain creation-time events (pi-native auto-create enqueues
|
|
# session.terminal_pending and, with no real Pi terminal in the test,
|
|
# a session.status failure) so the drain below isolates only what
|
|
# /compact emits.
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
# /compact is a control signal; it must not enqueue session.status events.
|
|
queue = _session_event_queues_ref.get(conv_id)
|
|
queued_events: list[dict[str, Any]] = []
|
|
if queue is not None:
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
# 1) 200 means pi-native owns its context, so the control was handled in the
|
|
# terminal and the server must skip its own compaction. 204 would mean the
|
|
# dispatch fell through to the no-op (the original bug).
|
|
assert resp.status_code == 200, (
|
|
f"pi-native compact must return 200; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
# 2) The compact request reached the bridge inbox (the extension's
|
|
# compaction channel). If empty, the dispatch fell through to the no-op.
|
|
inbox = pi_native_bridge.bridge_dir_for_session_id(conv_id) / "inbox"
|
|
queued = sorted(p.name for p in inbox.glob("*.json")) if inbox.exists() else []
|
|
assert any("compact_" in name for name in queued), (
|
|
f"pi-native compact must enqueue a compact payload to the bridge inbox; "
|
|
f"inbox contained {queued!r}."
|
|
)
|
|
|
|
# 3) No session.status events; /compact is a control signal, not a state change.
|
|
assert queued_events == [], (
|
|
f"pi-native compact must not publish session events; got {queued_events!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_pi_native_returns_503_when_inbox_unwritable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Pi-native compact returns 503 when the bridge inbox cannot be written.
|
|
|
|
Sister to the happy-path test. If the inbox enqueue raises OSError (e.g. a
|
|
filesystem fault), the handler surfaces 503 with the
|
|
``pi_native_compact_failed`` code rather than silently swallowing the
|
|
request; the Omnigent server then treats it as not-handled.
|
|
"""
|
|
import omnigent.pi_native_bridge as pi_native_bridge
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_pi_native_compact_fail"
|
|
monkeypatch.setattr(pi_native_bridge, "_BRIDGE_ROOT", tmp_path / "pi-bridge")
|
|
|
|
def _boom(*_args: Any, **_kwargs: Any) -> str:
|
|
"""Simulate an unwritable inbox."""
|
|
raise OSError("inbox is read-only")
|
|
|
|
monkeypatch.setattr(pi_native_bridge, "enqueue_compact", _boom)
|
|
|
|
pi_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "pi-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the pi-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return pi_native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
assert resp.status_code == 503, (
|
|
f"pi-native compact with unwritable inbox must return 503; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
body = resp.json()
|
|
assert body.get("error") == "pi_native_compact_failed", (
|
|
f"503 body must carry the pi bridge-failure error code; got {body!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_qwen_native_submits_compress_and_raises_spinner(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` ``{"type":"compact"}`` on a qwen-native session submits
|
|
``/compress`` via the input file, returns 200, and raises the spinner only.
|
|
|
|
qwen owns its context window inside the TUI, so compaction runs there
|
|
(``/compress``), not as AP-side compaction; same rationale as cursor-native.
|
|
Unlike cursor, injection is file-based: a ``submit`` line routes through
|
|
qwen's ``RemoteInputWatcher`` then ``submitQuery``, which processes the slash
|
|
command (no autocomplete-dropdown trap). The 200 is load-bearing (server
|
|
skips its own ``_run_compact_locked``). The handler publishes only
|
|
``in_progress``; the ``completed`` edge is the compaction mirror's job once
|
|
the ``chat_compression`` record lands (covered in test_qwen_native_forwarder).
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[tuple[Any, str]] = []
|
|
|
|
def _fake_submit(bridge_dir: Any, *, content: str) -> None:
|
|
"""Record the input-file submit without touching disk."""
|
|
captured.append((bridge_dir, content))
|
|
|
|
monkeypatch.setattr(qwen_native_bridge, "submit_user_message", _fake_submit)
|
|
|
|
qwen_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "qwen-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return qwen_native_spec
|
|
|
|
conv_id = "conv_qwen_compact"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
queued_events = _drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
assert resp.status_code == 200, (
|
|
f"qwen-native compact must return 200 from /events; got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert len(captured) == 1, (
|
|
f"Expected one submit_user_message call from qwen compact, got {len(captured)}."
|
|
)
|
|
bridge_dir, content = captured[0]
|
|
assert bridge_dir == qwen_native_bridge.bridge_dir_for_session_id(conv_id)
|
|
assert content == "/compress", f"Expected '/compress' submit content, got {content!r}."
|
|
|
|
compaction_types = [
|
|
e.get("type")
|
|
for e in queued_events
|
|
if str(e.get("type", "")).startswith("response.compaction")
|
|
]
|
|
assert compaction_types == ["response.compaction.in_progress"], (
|
|
f"Handler must publish only in_progress (mirror completes it); got {compaction_types!r}."
|
|
)
|
|
in_progress = next(
|
|
e for e in queued_events if e.get("type") == "response.compaction.in_progress"
|
|
)
|
|
assert in_progress.get("task_id") == conv_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_qwen_native_503_dismisses_spinner_on_submit_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A submit failure surfaces as 503 AND dismisses the spinner (in_progress->failed)."""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_submit(bridge_dir: Any, *, content: str) -> None:
|
|
del bridge_dir, content
|
|
raise RuntimeError("input file unwritable")
|
|
|
|
monkeypatch.setattr(qwen_native_bridge, "submit_user_message", _fake_submit)
|
|
|
|
qwen_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "qwen-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return qwen_native_spec
|
|
|
|
conv_id = "conv_qwen_compact_fail"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
queued_events = _drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
|
|
assert resp.status_code == 503, f"got {resp.status_code}: {resp.text}"
|
|
assert resp.json().get("error") == "qwen_native_compact_failed"
|
|
compaction_types = [
|
|
e.get("type")
|
|
for e in queued_events
|
|
if str(e.get("type", "")).startswith("response.compaction")
|
|
]
|
|
assert compaction_types == [
|
|
"response.compaction.in_progress",
|
|
"response.compaction.failed",
|
|
], f"Expected in_progress then failed (no completed); got {compaction_types!r}."
|
|
|
|
|
|
# ── opencode-native compact (POST /session/{id}/summarize) ─────────────
|
|
|
|
|
|
class _FakeOpenCodeCompactClient:
|
|
"""OpenCode client stub recording ``summarize`` calls for compact tests.
|
|
|
|
Stands in for :class:`omnigent.opencode_native_client.OpenCodeClient` so
|
|
the opencode-native compact handler's model-resolution + ``/summarize``
|
|
call is observable without a live ``opencode serve``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
session: Any,
|
|
messages: list[dict[str, Any]],
|
|
summarize_error: BaseException | None = None,
|
|
) -> None:
|
|
"""
|
|
Initialize with the session/messages the handler will resolve from.
|
|
|
|
:param session: The :class:`OpenCodeSession` ``get_session`` returns
|
|
(or ``None``).
|
|
:param messages: The list ``list_messages`` returns.
|
|
:param summarize_error: When set, ``summarize`` raises it instead of
|
|
recording the call (drives the 503 path).
|
|
:returns: None.
|
|
"""
|
|
self._session = session
|
|
self._messages = messages
|
|
self._summarize_error = summarize_error
|
|
self.summarize_calls: list[tuple[str, str, str]] = []
|
|
self.closed = False
|
|
|
|
async def get_session(self, session_id: str) -> Any:
|
|
"""Return the scripted session."""
|
|
del session_id
|
|
return self._session
|
|
|
|
async def list_messages(self, session_id: str) -> list[dict[str, Any]]:
|
|
"""Return the scripted messages."""
|
|
del session_id
|
|
return self._messages
|
|
|
|
async def summarize(self, session_id: str, *, provider_id: str, model_id: str) -> bool:
|
|
"""Record the compaction call (or raise the scripted error)."""
|
|
if self._summarize_error is not None:
|
|
raise self._summarize_error
|
|
self.summarize_calls.append((session_id, provider_id, model_id))
|
|
return True
|
|
|
|
async def aclose(self) -> None:
|
|
"""Mark the client closed (the handler always closes in ``finally``)."""
|
|
self.closed = True
|
|
|
|
|
|
class _FakeOpenCodeCompactServer:
|
|
"""``OpenCodeNativeServer`` stub whose ``client()`` returns a fixed stub."""
|
|
|
|
def __init__(self, client: _FakeOpenCodeCompactClient) -> None:
|
|
"""Wrap *client* so :meth:`client` returns it."""
|
|
self._client = client
|
|
|
|
def client(self, *, directory: str | None = None) -> _FakeOpenCodeCompactClient:
|
|
"""Return the fixed compact client."""
|
|
del directory
|
|
return self._client
|
|
|
|
|
|
async def _drive_opencode_native_compact(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
*,
|
|
conv_id: str,
|
|
session_payload: dict[str, Any] | None,
|
|
messages: list[dict[str, Any]],
|
|
model_override: str | None,
|
|
summarize_error: BaseException | None = None,
|
|
) -> tuple[httpx.Response, _FakeOpenCodeCompactClient]:
|
|
"""
|
|
Build an opencode-native runner app and POST a ``compact`` control event.
|
|
|
|
Registers a pre-built opencode terminal so session creation skips the real
|
|
``_auto_create_opencode_terminal`` launch, injects a live fake server into
|
|
``_AUTO_OPENCODE_SERVERS``, and stubs ``read_bridge_state`` so the handler
|
|
resolves an ``opencode_session_id`` (+ optional ``model_override``).
|
|
|
|
:param conv_id: Conversation id to create and compact.
|
|
:param session_payload: Payload for the :class:`OpenCodeSession`
|
|
``get_session`` returns, or ``None`` for no session.
|
|
:param messages: Messages ``list_messages`` returns.
|
|
:param model_override: Bridge-state ``model_override`` (qualified
|
|
``provider/model``), or ``None``.
|
|
:param summarize_error: When set, ``summarize`` raises it (503 path).
|
|
:returns: ``(response, fake_client)`` for the compact POST.
|
|
"""
|
|
from omnigent import opencode_native_bridge
|
|
from omnigent.opencode_native_bridge import OpenCodeNativeBridgeState
|
|
from omnigent.opencode_native_client import OpenCodeSession
|
|
from omnigent.runner.app import _AUTO_OPENCODE_SERVERS, _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
opencode_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "opencode-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the opencode-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return opencode_native_spec
|
|
|
|
# A pre-registered opencode terminal makes the create path's auto-launch a
|
|
# no-op (the per-session ensure-lock sees a live terminal and skips it).
|
|
terminal_registry = TerminalRegistry()
|
|
instance = make_test_terminal_instance("opencode", "main", tmp_path)
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("opencode", "main")] = instance
|
|
|
|
session = (
|
|
OpenCodeSession.from_payload(session_payload) if session_payload is not None else None
|
|
)
|
|
client = _FakeOpenCodeCompactClient(
|
|
session=session, messages=messages, summarize_error=summarize_error
|
|
)
|
|
server = _FakeOpenCodeCompactServer(client)
|
|
|
|
state = OpenCodeNativeBridgeState(
|
|
session_id=conv_id,
|
|
server_base_url="http://127.0.0.1:1",
|
|
opencode_session_id="ses_x",
|
|
model_override=model_override,
|
|
)
|
|
monkeypatch.setattr(opencode_native_bridge, "read_bridge_state", lambda _dir: state)
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as http_client:
|
|
create_resp = await http_client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
# Inject AFTER create so the create flow's cleanup cannot evict it.
|
|
_AUTO_OPENCODE_SERVERS[conv_id] = server
|
|
resp = await http_client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "compact"},
|
|
)
|
|
return resp, client
|
|
finally:
|
|
_AUTO_OPENCODE_SERVERS.pop(conv_id, None)
|
|
_session_event_queues_ref.pop(conv_id, None)
|
|
|
|
|
|
def test_resolve_opencode_compact_model_prefers_latest_assistant_message() -> None:
|
|
"""
|
|
The latest assistant message's live model wins over session/override.
|
|
|
|
On a MESSAGE the model keys are ``providerID`` + ``modelID``. The chain
|
|
must iterate in reverse and ignore user-role messages, picking the live
|
|
model even when a session ``model`` and a ``model_override`` also resolve.
|
|
"""
|
|
from omnigent.opencode_native_client import OpenCodeSession
|
|
from omnigent.runner.app import _resolve_opencode_compact_model
|
|
|
|
session = OpenCodeSession.from_payload(
|
|
{"id": "ses_x", "model": {"providerID": "stale", "id": "stale-model"}}
|
|
)
|
|
messages = [
|
|
{"info": {"role": "user"}, "parts": []},
|
|
{"info": {"role": "assistant", "providerID": "openai", "modelID": "gpt-old"}, "parts": []},
|
|
{"info": {"role": "user"}, "parts": []},
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"providerID": "anthropic",
|
|
"modelID": "claude-sonnet-4-5",
|
|
},
|
|
"parts": [],
|
|
},
|
|
]
|
|
|
|
provider_id, model_id = _resolve_opencode_compact_model(
|
|
session, messages, "override-prov/override-model"
|
|
)
|
|
|
|
assert (provider_id, model_id) == ("anthropic", "claude-sonnet-4-5")
|
|
|
|
|
|
def test_resolve_opencode_compact_model_falls_back_to_session_model() -> None:
|
|
"""
|
|
With no usable assistant message, the session ``model`` field resolves.
|
|
|
|
On the SESSION object the keys are ``providerID`` + ``id`` (NOT
|
|
``modelID``). An assistant message missing ``modelID`` must be skipped so
|
|
the session field is used.
|
|
"""
|
|
from omnigent.opencode_native_client import OpenCodeSession
|
|
from omnigent.runner.app import _resolve_opencode_compact_model
|
|
|
|
session = OpenCodeSession.from_payload(
|
|
{"id": "ses_x", "model": {"providerID": "anthropic", "id": "claude-opus-4"}}
|
|
)
|
|
# Assistant message without a modelID is not usable → fall through.
|
|
messages = [{"info": {"role": "assistant", "providerID": "anthropic"}, "parts": []}]
|
|
|
|
provider_id, model_id = _resolve_opencode_compact_model(session, messages, None)
|
|
|
|
assert (provider_id, model_id) == ("anthropic", "claude-opus-4")
|
|
|
|
|
|
def test_resolve_opencode_compact_model_falls_back_to_model_override() -> None:
|
|
"""
|
|
With no message/session model, ``model_override`` splits on the first ``/``.
|
|
|
|
A model id may itself contain ``/`` (e.g. an OpenRouter slug), so only the
|
|
FIRST separator delimits provider from model.
|
|
"""
|
|
from omnigent.opencode_native_client import OpenCodeSession
|
|
from omnigent.runner.app import _resolve_opencode_compact_model
|
|
|
|
session = OpenCodeSession.from_payload({"id": "ses_x"})
|
|
|
|
provider_id, model_id = _resolve_opencode_compact_model(
|
|
session, [], "openrouter/anthropic/claude-3.5"
|
|
)
|
|
|
|
assert (provider_id, model_id) == ("openrouter", "anthropic/claude-3.5")
|
|
|
|
|
|
def test_resolve_opencode_compact_model_returns_none_when_unresolvable() -> None:
|
|
"""
|
|
Nothing resolvable → ``(None, None)`` so the handler 204s to AP-side.
|
|
|
|
Covers the live Omnigent flow: the session is created without a model and
|
|
has no assistant turn yet, and no override is set.
|
|
"""
|
|
from omnigent.opencode_native_client import OpenCodeSession
|
|
from omnigent.runner.app import _resolve_opencode_compact_model
|
|
|
|
session = OpenCodeSession.from_payload({"id": "ses_x"})
|
|
|
|
assert _resolve_opencode_compact_model(session, [], None) == (None, None)
|
|
# A bare token without ``/`` is not a qualified override.
|
|
assert _resolve_opencode_compact_model(None, [], "not-qualified") == (None, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_opencode_native_summarizes_from_assistant_message(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
opencode-native compact resolves the live model and calls ``/summarize``.
|
|
|
|
The model comes from the latest assistant message (``providerID`` +
|
|
``modelID``) because Omnigent creates the session without a model. A 200
|
|
return is load-bearing: the Omnigent server reads it to skip its AP-side
|
|
compaction (the native ``/summarize`` path was previously dead, always
|
|
204ing because ``session.raw["model"]`` is empty).
|
|
"""
|
|
resp, client = await _drive_opencode_native_compact(
|
|
monkeypatch,
|
|
tmp_path,
|
|
conv_id="conv_opencode_compact_msg",
|
|
session_payload={"id": "ses_x"},
|
|
messages=[
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"providerID": "anthropic",
|
|
"modelID": "claude-sonnet-4-5",
|
|
},
|
|
"parts": [],
|
|
}
|
|
],
|
|
model_override=None,
|
|
)
|
|
|
|
assert resp.status_code == 200, (
|
|
f"opencode-native compact must 200 once /summarize is accepted; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert client.summarize_calls == [("ses_x", "anthropic", "claude-sonnet-4-5")], (
|
|
f"summarize must run with the assistant message's model; got {client.summarize_calls!r}."
|
|
)
|
|
assert client.closed, "the handler must close the client in its finally block."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_opencode_native_summarizes_from_session_model(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
With no assistant message, the session ``model`` field drives ``/summarize``.
|
|
|
|
Covers create-with-model / TUI ``switchModel`` sessions: the SESSION keys
|
|
are ``providerID`` + ``id``.
|
|
"""
|
|
resp, client = await _drive_opencode_native_compact(
|
|
monkeypatch,
|
|
tmp_path,
|
|
conv_id="conv_opencode_compact_session",
|
|
session_payload={
|
|
"id": "ses_x",
|
|
"model": {"providerID": "anthropic", "id": "claude-opus-4"},
|
|
},
|
|
messages=[],
|
|
model_override=None,
|
|
)
|
|
|
|
assert resp.status_code == 200, f"got {resp.status_code}: {resp.text}"
|
|
assert client.summarize_calls == [("ses_x", "anthropic", "claude-opus-4")], (
|
|
f"summarize must run with the session model; got {client.summarize_calls!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_opencode_native_summarizes_from_model_override(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
With no message/session model, bridge-state ``model_override`` resolves it.
|
|
|
|
The override is a qualified ``provider/model`` string split on the first
|
|
``/``.
|
|
"""
|
|
resp, client = await _drive_opencode_native_compact(
|
|
monkeypatch,
|
|
tmp_path,
|
|
conv_id="conv_opencode_compact_override",
|
|
session_payload={"id": "ses_x"},
|
|
messages=[],
|
|
model_override="openai/gpt-5",
|
|
)
|
|
|
|
assert resp.status_code == 200, f"got {resp.status_code}: {resp.text}"
|
|
assert client.summarize_calls == [("ses_x", "openai", "gpt-5")], (
|
|
f"summarize must run with the override model; got {client.summarize_calls!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_opencode_native_204_when_model_unresolvable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
No resolvable model → 204 and ``/summarize`` is never called.
|
|
|
|
The 204 tells the Omnigent server to run its own AP-side compaction.
|
|
"""
|
|
resp, client = await _drive_opencode_native_compact(
|
|
monkeypatch,
|
|
tmp_path,
|
|
conv_id="conv_opencode_compact_none",
|
|
session_payload={"id": "ses_x"},
|
|
messages=[],
|
|
model_override=None,
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"opencode-native compact must 204 when no model resolves; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert client.summarize_calls == [], (
|
|
f"summarize must NOT run when no model resolves; got {client.summarize_calls!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_opencode_native_503_when_summarize_raises(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
A failing ``/summarize`` surfaces 503 with the opencode error code.
|
|
|
|
The Omnigent server must see the failure (rather than a silent fallback)
|
|
so it does not run a duplicate compaction.
|
|
"""
|
|
from omnigent.opencode_native_client import OpenCodeClientError
|
|
|
|
resp, client = await _drive_opencode_native_compact(
|
|
monkeypatch,
|
|
tmp_path,
|
|
conv_id="conv_opencode_compact_fail",
|
|
session_payload={"id": "ses_x"},
|
|
messages=[
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"providerID": "anthropic",
|
|
"modelID": "claude-sonnet-4-5",
|
|
},
|
|
"parts": [],
|
|
}
|
|
],
|
|
model_override=None,
|
|
summarize_error=OpenCodeClientError("summarize failed: 500"),
|
|
)
|
|
|
|
assert resp.status_code == 503, (
|
|
f"opencode-native compact must 503 on a failed /summarize; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert resp.json().get("error") == "opencode_native_compact_failed"
|
|
assert client.closed, "the handler must close the client even on failure."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_compact_on_non_native_session_is_204_noop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Non-native sessions accept compact and 204 without side effects.
|
|
|
|
For in-process harnesses, explicit compaction is an AP-side
|
|
operation (``_run_compact_locked`` → ``compact_conversation_now``).
|
|
The Omnigent server forwards ``compact`` to ``/events`` for every harness
|
|
(it stays harness-agnostic), so the runner must accept the event
|
|
and 204 — never reach the slash-command injector. The 204 tells the
|
|
Omnigent server to run its own compaction.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Fail the test if a non-native session reaches the injector."""
|
|
del bridge_dir, command, timeout_s, auto_confirm
|
|
raise AssertionError(
|
|
"inject_slash_command must never be called for non-native "
|
|
"sessions — compact is an AP-side operation for in-process harnesses."
|
|
)
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
# Default harness (in-process LLM loop), NOT claude-native.
|
|
default_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the default spec for any agent_id."""
|
|
del agent_id
|
|
return default_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_default_compact", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_default_compact/events",
|
|
json={"type": "compact"},
|
|
)
|
|
|
|
# 204 = the dispatch saw a non-native harness and returned the
|
|
# no-op short-circuit before any inject. The fake injector above
|
|
# asserts loudly if reached, so silence here proves the no-op.
|
|
assert resp.status_code == 204, (
|
|
f"Non-native compact must return 204 no-op; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"event_payload,inject_attr",
|
|
# ``/fork`` creates a new conversation that reuses the
|
|
# same Claude process (same bridge_dir), so the new session has
|
|
# bridge_id != conv_id, stored on the ``omnigent.claude_native
|
|
# .bridge_id`` label. The runner-side native dispatch MUST
|
|
# resolve bridge_id via ``_claude_native_bridge_id_for_session``
|
|
# so the slash command lands in the right pane. Using
|
|
# ``bridge_dir_for_conversation_id(conv_id)`` would target a
|
|
# stale / non-existent dir and silently 503.
|
|
#
|
|
# This is the bug that forced a revert. Pinned
|
|
# here for both effort_change and model_change.
|
|
[
|
|
({"type": "effort_change", "effort": "high"}, "inject_slash_command"),
|
|
({"type": "model_change", "model": "claude-opus-4-7"}, "inject_slash_command"),
|
|
],
|
|
ids=["effort_change", "model_change"],
|
|
)
|
|
async def test_events_native_dispatch_resolves_bridge_id_via_label_lookup(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
event_payload: dict[str, Any],
|
|
inject_attr: str,
|
|
) -> None:
|
|
"""
|
|
Native effort / model dispatch must call
|
|
``_claude_native_bridge_id_for_session`` to resolve the
|
|
bridge_id, not pass conv_id straight to
|
|
``bridge_dir_for_conversation_id``.
|
|
|
|
Regression test for the bug that forced a revert. The
|
|
handlers used ``bridge_dir_for_conversation_id(conv_id)``
|
|
directly, which is broken for ``/fork`` sessions (bridge_id !=
|
|
conv_id, stored on label
|
|
``omnigent.claude_native.bridge_id``).
|
|
|
|
Strategy: monkeypatch ``_claude_native_bridge_id_for_session``
|
|
to return a sentinel bridge_id distinct from conv_id. Then
|
|
assert that the dispatch resolves the bridge_dir from the
|
|
sentinel — proving the handler went through the label-lookup
|
|
path rather than calling ``bridge_dir_for_conversation_id``
|
|
directly. If the handler regresses to the conv_id-only path,
|
|
the assertion fails.
|
|
"""
|
|
from omnigent.claude_native_bridge import bridge_dir_for_bridge_id
|
|
from omnigent.runner import app as runner_app_module
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured_bridge_dir: list[Any] = []
|
|
|
|
def _fake_inject(bridge_dir: Any, **kwargs: Any) -> None:
|
|
"""Record the bridge_dir the dispatch resolved."""
|
|
del kwargs
|
|
captured_bridge_dir.append(bridge_dir)
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
sentinel_bridge_id = "bridge_from_fork_label_xyz"
|
|
|
|
async def _fake_bridge_id_lookup(*, server_client: Any, session_id: str) -> str:
|
|
"""Pretend the session's bridge_id label is the sentinel."""
|
|
del server_client, session_id
|
|
return sentinel_bridge_id
|
|
|
|
monkeypatch.setattr(
|
|
runner_app_module,
|
|
"_claude_native_bridge_id_for_session",
|
|
_fake_bridge_id_lookup,
|
|
)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
conv_id = "conv_fork_bridge_check"
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json=event_payload,
|
|
)
|
|
|
|
# The dispatch ran the native handler (inject was called via the
|
|
# fake, which doesn't raise) and returned 204.
|
|
assert resp.status_code == 204, (
|
|
f"Native dispatch for {event_payload['type']!r} must return "
|
|
f"204; got {resp.status_code}: {resp.text}"
|
|
)
|
|
# Exactly one inject call, with the bridge_dir derived from the
|
|
# sentinel bridge_id — NOT from the conv_id.
|
|
assert len(captured_bridge_dir) == 1, (
|
|
f"Expected one inject call, got {len(captured_bridge_dir)}"
|
|
)
|
|
expected = bridge_dir_for_bridge_id(sentinel_bridge_id)
|
|
assert captured_bridge_dir[0] == expected, (
|
|
f"Native dispatch used the wrong bridge_dir. Expected the "
|
|
f"bridge_id-label path ({expected!r}); got "
|
|
f"{captured_bridge_dir[0]!r}. If this matches the conv_id-"
|
|
f"hashed path, the handler regressed to ``bridge_dir_for_"
|
|
f"conversation_id(conv_id)`` and would silently 503 against "
|
|
f"the stale dir on real /fork sessions — the same bug that "
|
|
f"previously forced a revert."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_model_change_on_native_session_types_slash_command(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` with ``{"type":"model_change","model":"claude-opus-4-7"}``
|
|
on a claude-native session injects ``/model claude-opus-4-7`` into tmux.
|
|
|
|
Mirrors the effort_change happy-path test. Pins that the new
|
|
runner dispatch routes model_change to the native handler and
|
|
assembles the right slash command.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[Any] = []
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Record the call and return without touching tmux."""
|
|
captured.append((bridge_dir, command, timeout_s))
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_model", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Drain creation-time events (claude-native auto-create enqueues
|
|
# session.terminal_pending) so the drain below isolates only
|
|
# what model_change emits.
|
|
_drain_session_event_queue(_session_event_queues_ref.get("conv_native_model"))
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_native_model/events",
|
|
json={"type": "model_change", "model": "claude-opus-4-7"},
|
|
)
|
|
|
|
# Drain the event queue before delete clears it. model_change
|
|
# is a control signal, not a state change — no events should
|
|
# land on the SSE queue.
|
|
queue = _session_event_queues_ref.get("conv_native_model")
|
|
queued_events: list[dict[str, Any]] = []
|
|
if queue is not None:
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"Native model_change must return 204 from /events; got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert len(captured) == 1, (
|
|
f"Expected one inject_slash_command call from native model_change, got {len(captured)}."
|
|
)
|
|
_bridge_dir, command, timeout_s = captured[0]
|
|
assert command == "/model claude-opus-4-7", (
|
|
f"Expected '/model claude-opus-4-7' literal, got {command!r}."
|
|
)
|
|
assert timeout_s == 1.0
|
|
assert queued_events == [], (
|
|
f"model_change must not publish session events; got {queued_events!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_model_change_on_kiro_session_types_slash_command(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` ``{"type":"model_change","model":"claude-haiku-4.5"}`` on a
|
|
kiro-native session drives ``inject_model_command`` (which types
|
|
``/model claude-haiku-4.5`` into the live kiro TUI).
|
|
|
|
Pins that the runner dispatch routes model_change to the kiro handler.
|
|
Mirrors ``test_events_model_change_on_native_session_types_slash_command``.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[Any] = []
|
|
|
|
def _fake_inject(bridge_dir: Any, *, model: str, timeout_s: float) -> None:
|
|
"""Record the call and return without touching tmux."""
|
|
captured.append((bridge_dir, model, timeout_s))
|
|
|
|
monkeypatch.setattr(kiro_native_bridge, "inject_model_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the kiro-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_kiro_model", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Drain kiro auto-create events so nothing below trips on them.
|
|
_drain_session_event_queue(_session_event_queues_ref.get("conv_kiro_model"))
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_kiro_model/events",
|
|
json={"type": "model_change", "model": "claude-haiku-4.5"},
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"Kiro model_change must return 204 from /events; got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert len(captured) == 1, (
|
|
f"Expected one inject_model_command call from kiro model_change, got {len(captured)}."
|
|
)
|
|
_bridge_dir, model, timeout_s = captured[0]
|
|
assert model == "claude-haiku-4.5"
|
|
assert timeout_s == 1.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"model_value",
|
|
# Claude Code has no slash form for "use spawn default", so
|
|
# ``None`` (clear) must skip injection. Empty / whitespace-only
|
|
# strings must also skip — typing ``/model `` with nothing after
|
|
# would land as a TUI error.
|
|
[None, "", " "],
|
|
)
|
|
async def test_events_model_change_on_native_session_skips_inject_for_empty_or_null(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
model_value: str | None,
|
|
) -> None:
|
|
"""
|
|
Null / empty / whitespace-only model values 204 without typing.
|
|
|
|
Pins that the empty-value validation lives in the runner native
|
|
handler, not in the Omnigent server.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Fail the test if the runner reaches inject for an empty value."""
|
|
del bridge_dir, command, timeout_s
|
|
raise AssertionError(
|
|
f"inject_slash_command must not be called for model={model_value!r}; "
|
|
f"the native handler should skip empty / null values."
|
|
)
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_model_skip", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_native_model_skip/events",
|
|
json={"type": "model_change", "model": model_value},
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"Native model_change with empty / null value must return "
|
|
f"204 (no-op); got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_model_change_on_native_session_returns_503_when_bridge_not_ready(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Bridge-not-ready RuntimeError surfaces as 503 from /events.
|
|
|
|
Sister to the happy-path test. Pins that the failure mode of the
|
|
native model dispatch (tmux pane gone / bridge dir not yet
|
|
advertised) returns 503 with the same error code shape the
|
|
legacy ``/claude-native-model`` route used. Omnigent server's PATCH
|
|
swallows this 503 and still returns 200 with the persisted
|
|
value — the next spawn applies the new model via ``--model``.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, command, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_native_model_fail", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_native_model_fail/events",
|
|
json={"type": "model_change", "model": "claude-opus-4-7"},
|
|
)
|
|
|
|
assert resp.status_code == 503, (
|
|
f"Native model_change with inject failure must return 503; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
body = resp.json()
|
|
assert body.get("error") == "claude_native_model_failed", (
|
|
f"503 body must carry the bridge-failure error code; got {body!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_model_change_on_non_native_session_is_204_noop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Non-native sessions accept model_change and 204 without side effects.
|
|
|
|
In-process harnesses re-read the persisted ``model_override`` on
|
|
each turn (or via the per-event override). Omnigent server is harness-
|
|
agnostic and POSTs model_change for every PATCH, so the runner
|
|
must accept the event with a 204 — never reach the slash-command
|
|
injector.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
) -> None:
|
|
"""Fail the test if a non-native session reaches the injector."""
|
|
del bridge_dir, command, timeout_s
|
|
raise AssertionError(
|
|
"inject_slash_command must never be called for non-native "
|
|
"sessions — model_change is a no-op for in-process harnesses."
|
|
)
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
# Default harness (in-process LLM loop), NOT claude-native.
|
|
default_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the default spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return default_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_default_model", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_default_model/events",
|
|
json={"type": "model_change", "model": "claude-opus-4-7"},
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"Non-native model_change must return 204 no-op; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_model_change_on_cursor_native_session_types_slash_command(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` with ``model_change`` on a cursor-native session
|
|
drives cursor-agent's ``/model`` picker via ``inject_model_command``.
|
|
|
|
Cursor analog of the claude-native happy-path test: the runner
|
|
dispatch must route cursor-native model_change to its TUI handler
|
|
(not the claude slash injector and not a 204 no-op) and pass the
|
|
model id straight through.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[tuple[Any, str, float]] = []
|
|
|
|
def _fake_inject(bridge_dir: Any, *, model: str, timeout_s: float) -> None:
|
|
"""Record the call and return without touching tmux."""
|
|
captured.append((bridge_dir, model, timeout_s))
|
|
|
|
monkeypatch.setattr(cursor_native_bridge, "inject_model_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the cursor-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_cursor_model", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_cursor_model/events",
|
|
json={"type": "model_change", "model": "gpt-5.2"},
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"cursor-native model_change must return 204 from /events; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert len(captured) == 1, f"Expected one inject_model_command call, got {len(captured)}."
|
|
_bridge_dir, model, timeout_s = captured[0]
|
|
assert model == "gpt-5.2", f"Expected the model id passed through, got {model!r}."
|
|
assert timeout_s == 1.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("model_value", [None, "", " "])
|
|
async def test_events_model_change_on_cursor_native_session_skips_inject_for_empty(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
model_value: str | None,
|
|
) -> None:
|
|
"""
|
|
Null / empty / whitespace-only model values 204 without driving the picker.
|
|
|
|
cursor-agent has no slash form for "use the spawn default", so a
|
|
clear only takes effect on the next spawn — mirrors the claude-native
|
|
skip test.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(bridge_dir: Any, *, model: str, timeout_s: float) -> None:
|
|
"""Fail the test if the runner reaches inject for an empty value."""
|
|
del bridge_dir, timeout_s
|
|
raise AssertionError(f"inject_model_command must not be called for model={model_value!r}.")
|
|
|
|
monkeypatch.setattr(cursor_native_bridge, "inject_model_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the cursor-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_cursor_model_skip", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_cursor_model_skip/events",
|
|
json={"type": "model_change", "model": model_value},
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"cursor-native model_change with empty / null value must return "
|
|
f"204 (no-op); got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_model_change_on_cursor_native_session_returns_503_when_not_ready(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Bridge-not-ready RuntimeError surfaces as 503 from /events.
|
|
|
|
Cursor analog of the claude-native 503 test: a missing tmux target
|
|
(pane not attached yet) returns 503 with the cursor-specific error
|
|
code; Omnigent server swallows it and the next spawn applies ``--model``.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(bridge_dir: Any, *, model: str, timeout_s: float) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, model, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(cursor_native_bridge, "inject_model_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the cursor-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_cursor_model_fail", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_cursor_model_fail/events",
|
|
json={"type": "model_change", "model": "gpt-5.2"},
|
|
)
|
|
|
|
assert resp.status_code == 503, (
|
|
f"cursor-native model_change with inject failure must return 503; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
body = resp.json()
|
|
assert body.get("error") == "cursor_native_model_failed", (
|
|
f"503 body must carry the cursor bridge-failure error code; got {body!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("effort_value", ["high", "medium", "low", "xhigh", None, ""])
|
|
async def test_events_effort_change_on_cursor_native_session_is_disabled_noop(
|
|
effort_value: str | None,
|
|
) -> None:
|
|
"""
|
|
cursor-native effort switching is intentionally dropped (for now): a model
|
|
switch resets cursor's per-model effort to that model's default, so a web
|
|
effort would silently diverge from the TUI. The dispatch must 204 for ANY
|
|
effort value (cursor-native is excluded from the effort_change gate, and the
|
|
effort injector no longer exists).
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the cursor-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_cursor_effort", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/conv_cursor_effort/events",
|
|
json={"type": "effort_change", "effort": effort_value},
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"cursor-native effort_change must 204 (disabled); got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_claude_terminal_registers_permission_hook(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Host-spawned terminal launch wires the PermissionRequest hook.
|
|
|
|
The runner's ``_auto_create_claude_terminal`` is the launch path
|
|
used when a claude-native session is created with no CLI client
|
|
present (web-UI sessions, the ``omnigent host`` host API). It
|
|
must pass the Omnigent server URL into ``augment_claude_args`` so
|
|
``build_hook_settings`` registers the ``PermissionRequest`` command
|
|
hook and writes permission_hook.json. Without it, approval prompts
|
|
silently never reach the web UI even though every other hook is
|
|
present (the regression observed in production: settings carried
|
|
SessionStart/Stop/.../PreCompact + statusLine but no
|
|
PermissionRequest).
|
|
"""
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
|
|
# The real forwarder opens an HTTP stream to the server; stub it so
|
|
# the auto-create flow runs without network. The created task is
|
|
# scheduled and completes immediately. Capture the kwargs so the
|
|
# test can assert the forwarder gets a refresh-capable auth.
|
|
forwarder_kwargs: dict[str, Any] = {}
|
|
|
|
async def _no_op_forwarder(**kwargs: Any) -> None:
|
|
forwarder_kwargs.update(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_forwarder.supervise_forwarder",
|
|
_no_op_forwarder,
|
|
)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Captures the launched terminal spec; no live terminal registry."""
|
|
|
|
# ``_publish_tmux_target_for_bridge`` early-returns when this is
|
|
# None, so the test doesn't need a real terminal instance.
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Record the spec and return a terminal resource view."""
|
|
del terminal_name, session_key
|
|
captured["spec"] = spec
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="claude:main",
|
|
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
|
)
|
|
|
|
await _auto_create_claude_terminal(
|
|
"conv_abc",
|
|
_FakeResourceRegistry(),
|
|
lambda _sid, _evt: None,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
spec = captured["spec"]
|
|
assert spec.command == "claude"
|
|
assert spec.env["ENABLE_TOOL_SEARCH"] == "true"
|
|
# The claude-native terminal must opt into keeping its private tmux server
|
|
# alive past an inner-CLI exit, so a sub-agent worker whose `claude` exits
|
|
# early no longer cascades into "no server running" (#540).
|
|
assert spec.keep_alive_after_exit is True
|
|
args = spec.args
|
|
settings = json.loads(args[args.index("--settings") + 1])
|
|
assert "PermissionRequest" in settings["hooks"]
|
|
permission_hook = settings["hooks"]["PermissionRequest"][0]["hooks"][0]
|
|
assert permission_hook["type"] == "command"
|
|
assert "claude_native_hook permission-request" in permission_hook["command"]
|
|
|
|
# The hook reads the server URL back out of this file at hook time,
|
|
# so it must be written with the runner's Omnigent server URL.
|
|
config = read_permission_hook_config(bridge_dir_for_bridge_id("conv_abc"))
|
|
assert config["ap_server_url"] == "http://127.0.0.1:8000"
|
|
|
|
# The forwarder must get a refresh-capable httpx.Auth (not just a
|
|
# one-shot Authorization header) so a long-running host-spawned
|
|
# session keeps forwarding after the ~1h OAuth token expires.
|
|
# ``_auto_create_claude_terminal`` schedules the forwarder as a task;
|
|
# yield once so the stub records its kwargs before asserting.
|
|
from omnigent.runner._entry import _RunnerDatabricksAuth
|
|
|
|
await asyncio.sleep(0)
|
|
assert isinstance(forwarder_kwargs.get("auth"), _RunnerDatabricksAuth)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_pi_terminal_launches_required_terminal(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Pi-native auto-create must launch a *required* terminal.
|
|
|
|
Regression guard for a missed call site. The pi-native runtime *is* the
|
|
terminal process (parity with claude-native), so when the lifecycle-aware
|
|
launch API replaced ``launch_terminal`` with ``launch_required_terminal`` /
|
|
``launch_auxiliary_terminal``, ``_auto_create_pi_terminal`` had to move to
|
|
``launch_required_terminal``. The fake registry below exposes *only*
|
|
``launch_required_terminal`` (no ``launch_terminal``), so a stale call site
|
|
raises ``AttributeError`` here in CI instead of crashing in production the
|
|
moment a real pi-native session boots.
|
|
|
|
:param tmp_path: Pytest-provided temporary directory.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
import omnigent.pi_native as pi_native
|
|
import omnigent.pi_native_bridge as pi_native_bridge
|
|
import omnigent.pi_native_credentials as pi_native_credentials
|
|
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
monkeypatch.setattr(pi_native_bridge, "_BRIDGE_ROOT", tmp_path / "pi-bridge")
|
|
# The lifecycle of the launch — not the binary or credentials — is under
|
|
# test, so neither a real Pi install nor a configured provider is needed.
|
|
monkeypatch.setattr(pi_native, "resolve_pi_executable", lambda: "pi")
|
|
# Accept the ``model`` kwarg the runner now threads through (the spec model
|
|
# → models.json path); None still skips provider injection here.
|
|
monkeypatch.setattr(
|
|
pi_native_credentials, "resolve_pi_native_provider", lambda **_kwargs: None
|
|
)
|
|
|
|
# Skip the GET /v1/sessions round-trip: hand the flow a ready launch
|
|
# config pointing at the tmp workspace.
|
|
async def _fake_launch_config(**_kwargs: Any) -> _PiNativeLaunchConfig:
|
|
return _PiNativeLaunchConfig(
|
|
workspace=tmp_path,
|
|
server_url="http://127.0.0.1:8000",
|
|
terminal_launch_args=None,
|
|
external_session_id=None,
|
|
)
|
|
|
|
monkeypatch.setattr("omnigent.runner.app._pi_native_launch_config", _fake_launch_config)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Records the launch; exposes ONLY the required-terminal launch API."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Record the launch and return a terminal resource view."""
|
|
captured["terminal_name"] = terminal_name
|
|
captured["session_key"] = session_key
|
|
captured["resource_role"] = resource_role
|
|
captured["spec"] = spec
|
|
return SessionResourceView(
|
|
id="terminal_pi_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="pi:main",
|
|
metadata={"terminal_name": "pi", "session_key": "main", "running": True},
|
|
)
|
|
|
|
published: list[dict[str, Any]] = []
|
|
|
|
await _auto_create_pi_terminal(
|
|
"conv_pi",
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, evt: published.append(evt),
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
# Required lifecycle (parity with claude-native), correct terminal identity.
|
|
assert captured["terminal_name"] == "pi"
|
|
assert captured["session_key"] == "main"
|
|
assert captured["resource_role"] == PI_NATIVE_TERMINAL_ROLE
|
|
assert captured["spec"].command == "pi"
|
|
# The fresh terminal is surfaced on the live stream for the Terminal toggle.
|
|
assert any(evt.get("type") == "session.resource.created" for evt in published)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_kiro_terminal_launches_required_terminal_with_isolated_env(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Kiro-native auto-create launches the TUI and session forwarder."""
|
|
import omnigent.kiro_native as kiro_native
|
|
|
|
monkeypatch.setenv("PATH", "/usr/bin")
|
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:6767")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "must-not-leak")
|
|
monkeypatch.setattr(kiro_native_bridge, "_BRIDGE_ROOT", tmp_path / "kiro-bridge")
|
|
monkeypatch.setattr(
|
|
kiro_native,
|
|
"resolve_kiro_executable",
|
|
lambda **_kwargs: "/usr/bin/kiro-cli",
|
|
)
|
|
forwarder_calls: list[dict[str, Any]] = []
|
|
permission_mirror_calls: list[dict[str, Any]] = []
|
|
|
|
async def _fake_supervise_kiro_session_forwarder(**kwargs: Any) -> None:
|
|
forwarder_calls.append(kwargs)
|
|
|
|
async def _fake_supervise_kiro_permission_mirror(**kwargs: Any) -> None:
|
|
permission_mirror_calls.append(kwargs)
|
|
|
|
relay_calls: list[dict[str, Any]] = []
|
|
|
|
async def _spy_ensure_relay(session_id: str, **kwargs: Any) -> None:
|
|
relay_calls.append({"session_id": session_id, **kwargs})
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.kiro_native_session_forwarder.supervise_kiro_session_forwarder",
|
|
_fake_supervise_kiro_session_forwarder,
|
|
)
|
|
monkeypatch.setattr(
|
|
"omnigent.kiro_native_permissions.supervise_kiro_permission_mirror",
|
|
_fake_supervise_kiro_permission_mirror,
|
|
)
|
|
|
|
async def _fake_launch_config(**_kwargs: Any) -> _KiroNativeLaunchConfig:
|
|
return _KiroNativeLaunchConfig(
|
|
workspace=tmp_path,
|
|
terminal_launch_args=["--model", "auto", "--effort", "high", "hello"],
|
|
external_session_id="kiro-session-123",
|
|
)
|
|
|
|
monkeypatch.setattr("omnigent.runner.app._kiro_native_launch_config", _fake_launch_config)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Records the launch; exposes ONLY the required-terminal launch API."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
del parent_os_env
|
|
captured["terminal_name"] = terminal_name
|
|
captured["session_key"] = session_key
|
|
captured["resource_role"] = resource_role
|
|
captured["spec"] = spec
|
|
return SessionResourceView(
|
|
id="terminal_kiro_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="kiro:main",
|
|
metadata={"terminal_name": "kiro", "session_key": "main", "running": True},
|
|
)
|
|
|
|
published: list[dict[str, Any]] = []
|
|
|
|
await _auto_create_kiro_terminal(
|
|
"conv_kiro",
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, evt: published.append(evt),
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
ensure_comment_relay=_spy_ensure_relay,
|
|
)
|
|
for _ in range(20):
|
|
if forwarder_calls and permission_mirror_calls:
|
|
break
|
|
await asyncio.sleep(0)
|
|
|
|
spec = captured["spec"]
|
|
assert captured["terminal_name"] == "kiro"
|
|
assert captured["session_key"] == "main"
|
|
assert captured["resource_role"] == KIRO_NATIVE_TERMINAL_ROLE
|
|
assert spec.command == "/usr/bin/kiro-cli"
|
|
assert spec.args == [
|
|
"chat",
|
|
"--tui",
|
|
"--resume-id",
|
|
"kiro-session-123",
|
|
"--model",
|
|
"auto",
|
|
"--effort",
|
|
"high",
|
|
"hello",
|
|
]
|
|
assert spec.inherit_env is False
|
|
assert "OPENAI_API_KEY" not in spec.env
|
|
assert "OPENAI_API_KEY" in spec.env_unset
|
|
assert spec.env[kiro_native_bridge.KIRO_NATIVE_BRIDGE_DIR_ENV_VAR] == str(
|
|
kiro_native_bridge.bridge_dir_for_session_id("conv_kiro")
|
|
)
|
|
assert spec.env[kiro_native_bridge.KIRO_ACP_RECORD_PATH_ENV_VAR] == str(
|
|
kiro_native_bridge.acp_record_path(
|
|
kiro_native_bridge.bridge_dir_for_session_id("conv_kiro")
|
|
)
|
|
)
|
|
assert any(evt.get("type") == "session.resource.created" for evt in published)
|
|
assert forwarder_calls
|
|
assert forwarder_calls[0]["base_url"] == "http://127.0.0.1:6767"
|
|
assert forwarder_calls[0]["session_id"] == "conv_kiro"
|
|
assert forwarder_calls[0]["agent_name"] == "kiro-native-ui"
|
|
assert forwarder_calls[0]["workspace"] == str(tmp_path)
|
|
assert permission_mirror_calls
|
|
assert permission_mirror_calls[0]["base_url"] == "http://127.0.0.1:6767"
|
|
assert permission_mirror_calls[0]["session_id"] == "conv_kiro"
|
|
# The Omnigent MCP tool relay is seeded for this session's bridge dir.
|
|
assert relay_calls == [
|
|
{
|
|
"session_id": "conv_kiro",
|
|
"explicit_bridge_dir": kiro_native_bridge.bridge_dir_for_session_id("conv_kiro"),
|
|
"await_notify": False,
|
|
}
|
|
]
|
|
# And the Omnigent MCP server is declared in the workspace-scoped kiro config.
|
|
workspace_mcp = tmp_path / ".kiro" / "settings" / "mcp.json"
|
|
assert workspace_mcp.exists()
|
|
mcp_servers = json.loads(workspace_mcp.read_text())["mcpServers"]
|
|
assert "serve-mcp" in mcp_servers["omnigent"]["args"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_kiro_terminal_skips_mcp_wiring_without_relay(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Without a comment-relay callback, the Omnigent MCP is NOT wired.
|
|
|
|
The workspace mcp.json write + relay seed are gated on ``server_client`` AND
|
|
``ensure_comment_relay`` together, so serve-mcp never launches with no relay
|
|
to route calls back to. With ``ensure_comment_relay`` absent the gate must
|
|
short-circuit: no workspace ``mcp.json`` is written.
|
|
"""
|
|
import omnigent.kiro_native as kiro_native
|
|
|
|
monkeypatch.setenv("PATH", "/usr/bin")
|
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:6767")
|
|
monkeypatch.setattr(kiro_native_bridge, "_BRIDGE_ROOT", tmp_path / "kiro-bridge")
|
|
monkeypatch.setattr(
|
|
kiro_native,
|
|
"resolve_kiro_executable",
|
|
lambda **_kwargs: "/usr/bin/kiro-cli",
|
|
)
|
|
|
|
async def _noop_supervise(**_kwargs: Any) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.kiro_native_session_forwarder.supervise_kiro_session_forwarder",
|
|
_noop_supervise,
|
|
)
|
|
monkeypatch.setattr(
|
|
"omnigent.kiro_native_permissions.supervise_kiro_permission_mirror",
|
|
_noop_supervise,
|
|
)
|
|
mcp_writes: list[Any] = []
|
|
monkeypatch.setattr(
|
|
kiro_native_bridge,
|
|
"write_kiro_workspace_mcp_config",
|
|
lambda *args, **kwargs: mcp_writes.append((args, kwargs)),
|
|
)
|
|
|
|
async def _fake_launch_config(**_kwargs: Any) -> _KiroNativeLaunchConfig:
|
|
return _KiroNativeLaunchConfig(
|
|
workspace=tmp_path,
|
|
terminal_launch_args=["hello"],
|
|
external_session_id=None,
|
|
)
|
|
|
|
monkeypatch.setattr("omnigent.runner.app._kiro_native_launch_config", _fake_launch_config)
|
|
|
|
class _FakeResourceRegistry:
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self, *, session_id: str, **_kwargs: Any
|
|
) -> SessionResourceView:
|
|
return SessionResourceView(
|
|
id="terminal_kiro_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="kiro:main",
|
|
metadata={"terminal_name": "kiro", "session_key": "main", "running": True},
|
|
)
|
|
|
|
# No ``ensure_comment_relay`` argument -> the MCP wiring gate stays closed.
|
|
await _auto_create_kiro_terminal(
|
|
"conv_kiro_no_relay",
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, _evt: None,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
assert mcp_writes == []
|
|
assert not (tmp_path / ".kiro" / "settings" / "mcp.json").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_pi_terminal_inherits_agent_sandbox(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Pi-native auto-create must honour the agent's ``os_env.sandbox``.
|
|
|
|
Regression for the sandbox-override bug: the pi-native auto-create path
|
|
built a fresh ``TerminalEnvSpec`` whose ``os_env`` carried no ``sandbox``
|
|
and passed no ``parent_os_env``, so ``launch_required_terminal`` fell back
|
|
to ``_default_sandbox_for_platform`` (``linux_bwrap`` on Linux) and ignored
|
|
the agent YAML. A pi-native agent that declares
|
|
``os_env.sandbox.type: none`` was wrongly forced into bwrap, which failed
|
|
on a hardened host.
|
|
|
|
Asserts the launched terminal spec's ``os_env.sandbox`` is the agent's
|
|
``none`` sandbox (not the platform default) and that the agent's ``os_env``
|
|
is threaded through as the launch ``parent_os_env`` so the rest of the
|
|
policy (egress_rules / env_passthrough) is also inherited.
|
|
|
|
:param tmp_path: Pytest-provided temporary directory.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
import omnigent.pi_native as pi_native
|
|
import omnigent.pi_native_bridge as pi_native_bridge
|
|
import omnigent.pi_native_credentials as pi_native_credentials
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
monkeypatch.setattr(pi_native_bridge, "_BRIDGE_ROOT", tmp_path / "pi-bridge")
|
|
monkeypatch.setattr(pi_native, "resolve_pi_executable", lambda: "pi")
|
|
# Accept the ``model`` kwarg the runner now threads through (the spec model
|
|
# → models.json path); None still skips provider injection here.
|
|
monkeypatch.setattr(
|
|
pi_native_credentials, "resolve_pi_native_provider", lambda **_kwargs: None
|
|
)
|
|
|
|
async def _fake_launch_config(**_kwargs: Any) -> _PiNativeLaunchConfig:
|
|
return _PiNativeLaunchConfig(
|
|
workspace=tmp_path,
|
|
server_url="http://127.0.0.1:8000",
|
|
terminal_launch_args=None,
|
|
external_session_id=None,
|
|
)
|
|
|
|
monkeypatch.setattr("omnigent.runner.app._pi_native_launch_config", _fake_launch_config)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Captures the launched terminal spec and parent os_env."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Record the spec + parent_os_env and return a resource view."""
|
|
del terminal_name, session_key
|
|
captured["spec"] = spec
|
|
captured["parent_os_env"] = parent_os_env
|
|
return SessionResourceView(
|
|
id="terminal_pi_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="pi:main",
|
|
metadata={"terminal_name": "pi", "session_key": "main", "running": True},
|
|
)
|
|
|
|
# An agent that declares sandbox: none (runs unconfined; outer
|
|
# container/VM is the security boundary).
|
|
agent_os_env = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd=".",
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
)
|
|
agent_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="pi_code",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "pi-native", "model": "pi-default"},
|
|
),
|
|
os_env=agent_os_env,
|
|
)
|
|
|
|
await _auto_create_pi_terminal(
|
|
"conv_pi_sandbox_none",
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, _evt: None,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
agent_spec=agent_spec,
|
|
)
|
|
|
|
launched_sandbox = captured["spec"].os_env.sandbox
|
|
assert launched_sandbox is not None, (
|
|
"auto-create dropped the agent's sandbox; launch_required_terminal will "
|
|
"fall back to _default_sandbox_for_platform (bwrap), overriding sandbox: none"
|
|
)
|
|
assert launched_sandbox.type == "none"
|
|
# The whole os_env is threaded through as the inheritance parent.
|
|
assert captured["parent_os_env"] is agent_os_env
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_claude_terminal_passes_session_effort(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Host-spawned terminal launch reads session effort and passes ``--effort``.
|
|
|
|
When the Omnigent server returns a session with a persisted
|
|
``reasoning_effort``, the auto-create path must include
|
|
``--effort <value>`` in the Claude CLI args so the terminal
|
|
starts at the user's chosen effort level.
|
|
|
|
:param tmp_path: Pytest-provided temporary directory.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
|
|
async def _no_op_forwarder(**kwargs: Any) -> None:
|
|
del kwargs
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_forwarder.supervise_forwarder",
|
|
_no_op_forwarder,
|
|
)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Captures the launched terminal spec."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Record the spec and return a terminal resource view."""
|
|
del terminal_name, session_key
|
|
captured["spec"] = spec
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="claude:main",
|
|
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
|
)
|
|
|
|
# Fake Omnigent server client that returns a session with reasoning_effort.
|
|
fake_client = httpx.AsyncClient(
|
|
base_url="http://test-server",
|
|
transport=httpx.MockTransport(
|
|
lambda req: httpx.Response(
|
|
200,
|
|
json={"reasoning_effort": "high", "labels": {}},
|
|
)
|
|
),
|
|
)
|
|
|
|
await _auto_create_claude_terminal(
|
|
"conv_effort",
|
|
_FakeResourceRegistry(),
|
|
lambda _sid, _evt: None,
|
|
server_client=fake_client,
|
|
)
|
|
|
|
args = captured["spec"].args
|
|
assert "--effort" in args
|
|
effort_idx = args.index("--effort")
|
|
assert args[effort_idx + 1] == "high"
|
|
|
|
await fake_client.aclose()
|
|
|
|
|
|
def test_agent_os_env_from_spec_unwraps_resolved_and_handles_none() -> None:
|
|
"""
|
|
``_agent_os_env_from_spec`` reads ``os_env`` through the resolved wrapper.
|
|
|
|
The auto-create terminals receive either a bare ``AgentSpec`` or a
|
|
``ResolvedSpec`` wrapping one (the codex path passes ``ResolvedSpec``).
|
|
The helper must unwrap the latter and return the inner ``os_env``, and
|
|
return ``None`` when there is no spec — so the launch falls back to the
|
|
platform default only when there is genuinely no agent policy to honour.
|
|
"""
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
|
|
os_env = OSEnvSpec(type="caller_process", cwd=".", sandbox=OSEnvSandboxSpec(type="none"))
|
|
bare = AgentSpec(
|
|
spec_version=1,
|
|
name="agent",
|
|
executor=ExecutorSpec(type="omnigent", config={}),
|
|
os_env=os_env,
|
|
)
|
|
|
|
# Bare AgentSpec: returned directly.
|
|
assert _agent_os_env_from_spec(bare) is os_env
|
|
# ResolvedSpec wrapper: unwrapped to the inner spec's os_env.
|
|
assert _agent_os_env_from_spec(ResolvedSpec(spec=bare, workdir=None)) is os_env
|
|
# No spec at all: None (caller then uses the platform default).
|
|
assert _agent_os_env_from_spec(None) is None
|
|
# AgentSpec without an os_env block: None.
|
|
no_os_env = AgentSpec(
|
|
spec_version=1,
|
|
name="agent",
|
|
executor=ExecutorSpec(type="omnigent", config={}),
|
|
)
|
|
assert _agent_os_env_from_spec(no_os_env) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_claude_terminal_inherits_agent_sandbox(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Host-spawned Claude terminal honours the agent's ``os_env.sandbox``.
|
|
|
|
Regression for the sandbox-override bug: the auto-create path built a
|
|
fresh ``TerminalEnvSpec`` whose ``os_env`` carried no ``sandbox`` and
|
|
passed no ``parent_os_env``, so ``launch_terminal`` fell back to
|
|
``_default_sandbox_for_platform`` (``linux_bwrap`` / ``darwin_seatbelt``)
|
|
and ignored the agent YAML. A claude-native agent that declares
|
|
``os_env.sandbox.type: none`` (e.g. Polly's ``claude_code`` worker,
|
|
which relies on the outer container/VM as the boundary) was wrongly
|
|
forced into bwrap, which then failed to start in a hardened container.
|
|
|
|
Asserts the launched terminal spec's ``os_env.sandbox`` is the agent's
|
|
``none`` sandbox (not the platform default) and that the agent's
|
|
``os_env`` is threaded through as the launch ``parent_os_env`` so the
|
|
rest of the policy (egress_rules / env_passthrough) is inherited too.
|
|
|
|
:param tmp_path: Pytest-provided temporary directory.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
|
|
async def _no_op_forwarder(**kwargs: Any) -> None:
|
|
del kwargs
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_forwarder.supervise_forwarder",
|
|
_no_op_forwarder,
|
|
)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Captures the launched terminal spec and parent os_env."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Record the spec + parent_os_env and return a resource view."""
|
|
del terminal_name, session_key
|
|
captured["spec"] = spec
|
|
captured["parent_os_env"] = parent_os_env
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="claude:main",
|
|
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
|
)
|
|
|
|
fake_client = httpx.AsyncClient(
|
|
base_url="http://test-server",
|
|
transport=httpx.MockTransport(
|
|
lambda req: httpx.Response(200, json={"labels": {}}),
|
|
),
|
|
)
|
|
|
|
# An agent that declares sandbox: none (runs unconfined; the outer
|
|
# container/VM is the boundary) — exactly Polly's coding sub-agents.
|
|
agent_os_env = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd=".",
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
)
|
|
agent_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="claude_code",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "claude-native", "model": "claude-default"},
|
|
),
|
|
os_env=agent_os_env,
|
|
)
|
|
|
|
await _auto_create_claude_terminal(
|
|
"conv_sandbox_none",
|
|
_FakeResourceRegistry(),
|
|
lambda _sid, _evt: None,
|
|
server_client=fake_client,
|
|
agent_spec=agent_spec,
|
|
)
|
|
|
|
launched_sandbox = captured["spec"].os_env.sandbox
|
|
assert launched_sandbox is not None, (
|
|
"auto-create dropped the agent's sandbox; launch_terminal will fall "
|
|
"back to _default_sandbox_for_platform (bwrap), overriding sandbox: none"
|
|
)
|
|
assert launched_sandbox.type == "none"
|
|
# The whole os_env is threaded through as the inheritance parent.
|
|
assert captured["parent_os_env"] is agent_os_env
|
|
|
|
await fake_client.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_claude_terminal_injects_ucode_gateway_config(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Host-spawned launch injects the ucode Databricks gateway config.
|
|
|
|
On the daemon / web-UI path the runner — not the CLI — launches
|
|
Claude, so it must reproduce the gateway auth the CLI normally
|
|
injects: the ``ANTHROPIC_BASE_URL`` env, the ``apiKeyHelper`` token
|
|
command, and the gateway default model. The runner derives this from
|
|
the user's provider config (here the legacy global ``auth:`` block —
|
|
the ambient ``DATABRICKS_CONFIG_PROFILE`` env var deliberately no
|
|
longer steers credentials). Without it, Claude would launch with
|
|
empty env and no token and could not reach the Databricks model —
|
|
the exact regression that blocked daemon-routing.
|
|
|
|
:param tmp_path: Pytest-provided temporary directory.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.claude_native import ClaudeNativeUcodeConfig
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
# The supported credential source for a host-spawned runner: the
|
|
# global config's ``auth:`` block (written by ``omnigent setup``),
|
|
# isolated to a temp config home so the developer's real config
|
|
# can't leak in.
|
|
config_home = tmp_path / "config-home"
|
|
config_home.mkdir()
|
|
(config_home / "config.yaml").write_text(
|
|
"auth:\n type: databricks\n profile: test-profile\n"
|
|
)
|
|
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(config_home))
|
|
|
|
async def _no_op_forwarder(**kwargs: Any) -> None:
|
|
del kwargs
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_forwarder.supervise_forwarder",
|
|
_no_op_forwarder,
|
|
)
|
|
|
|
gateway_env = {"ANTHROPIC_BASE_URL": "https://gw.example/anthropic"}
|
|
ucode = ClaudeNativeUcodeConfig(
|
|
env=dict(gateway_env),
|
|
api_key_helper="databricks auth token --fake-helper",
|
|
model="databricks-claude-opus-4-7",
|
|
)
|
|
# The runner imports ``_ucode_config_for_profile`` from
|
|
# ``omnigent.claude_native`` per call, so patch it at the source.
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native._ucode_config_for_profile",
|
|
lambda profile: ucode,
|
|
)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Captures the launched terminal spec."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Record the spec and return a terminal resource view."""
|
|
del terminal_name, session_key
|
|
captured["spec"] = spec
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="claude:main",
|
|
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
|
)
|
|
|
|
fake_client = httpx.AsyncClient(
|
|
base_url="http://test-server",
|
|
transport=httpx.MockTransport(
|
|
lambda req: httpx.Response(200, json={"labels": {}}),
|
|
),
|
|
)
|
|
|
|
await _auto_create_claude_terminal(
|
|
"conv_ucode",
|
|
_FakeResourceRegistry(),
|
|
lambda _sid, _evt: None,
|
|
server_client=fake_client,
|
|
)
|
|
|
|
spec = captured["spec"]
|
|
# The gateway env points ``claude`` at the Databricks gateway, and
|
|
# ENABLE_TOOL_SEARCH forces Claude Code to defer MCP tool schemas
|
|
# instead of loading all 200+ bridge tools into startup context.
|
|
assert spec.env == {
|
|
**gateway_env,
|
|
"ENABLE_TOOL_SEARCH": "true",
|
|
"CLAUDE_CODE_DISABLE_AGENT_VIEW": "1",
|
|
}
|
|
assert spec.command == "claude"
|
|
# The gateway default model is applied (no per-session override here).
|
|
assert "--model" in spec.args
|
|
assert spec.args[spec.args.index("--model") + 1] == "databricks-claude-opus-4-7"
|
|
# The apiKeyHelper threaded into the Claude settings augment so the
|
|
# gateway token command is registered.
|
|
assert "databricks auth token --fake-helper" in " ".join(spec.args)
|
|
|
|
await fake_client.aclose()
|
|
|
|
|
|
async def _run_auto_create_cursor_terminal(
|
|
*,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
agent_spec: AgentSpec | None,
|
|
terminal_launch_args: list[str] | None,
|
|
) -> Any:
|
|
"""Drive ``_auto_create_cursor_terminal`` and return the captured launch spec.
|
|
|
|
Stubs the cursor-agent binary lookup, the transcript forwarder, and the
|
|
runner auth factory so the model-injection branch runs without a real
|
|
``cursor-agent`` install or Databricks credentials. The session snapshot
|
|
(workspace + ``terminal_launch_args``) is served from an in-memory
|
|
``httpx.MockTransport``, and a fake registry records the ``spec`` passed to
|
|
``launch_required_terminal`` so the test can assert on ``spec.args``.
|
|
"""
|
|
from omnigent.runner import _entry as _runner_entry
|
|
|
|
workspace = tmp_path / "ws"
|
|
workspace.mkdir()
|
|
monkeypatch.setattr(cursor_native_bridge, "_BRIDGE_ROOT", tmp_path / "cursor-bridge")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
monkeypatch.setattr("omnigent.cursor_native.resolve_cursor_executable", lambda: "cursor-agent")
|
|
|
|
async def _no_op_forwarder(**kwargs: Any) -> None:
|
|
del kwargs
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.cursor_native_forwarder.supervise_cursor_forwarder",
|
|
_no_op_forwarder,
|
|
)
|
|
# The forwarder is stubbed, so the auth it would carry is never used — keep
|
|
# the factory from reaching for ambient Databricks credentials in tests.
|
|
monkeypatch.setattr(_runner_entry, "_make_auth_token_factory", lambda *a, **k: None)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Captures the launched terminal spec; no real terminal registry."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Record the spec and return a terminal resource view."""
|
|
del terminal_name, session_key, resource_role, parent_os_env
|
|
captured["spec"] = spec
|
|
return SessionResourceView(
|
|
id="terminal_cursor_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="cursor:main",
|
|
metadata={"terminal_name": "cursor", "session_key": "main", "running": True},
|
|
)
|
|
|
|
snapshot = {"workspace": str(workspace), "terminal_launch_args": terminal_launch_args}
|
|
fake_client = httpx.AsyncClient(
|
|
base_url="http://test-server",
|
|
transport=httpx.MockTransport(lambda req: httpx.Response(200, json=snapshot)),
|
|
)
|
|
try:
|
|
await _auto_create_cursor_terminal(
|
|
"conv_cursor_model",
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, _evt: None,
|
|
server_client=fake_client,
|
|
ensure_comment_relay=None,
|
|
agent_spec=agent_spec,
|
|
)
|
|
finally:
|
|
await fake_client.aclose()
|
|
return captured["spec"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_cursor_terminal_injects_spec_model(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A spec-pinned cursor model is threaded into the cursor-agent launch args.
|
|
|
|
The web-UI / daemon path launches ``cursor-agent`` from the runner, so the
|
|
session's ``executor.model`` (from ``--model`` or config.yaml ``model:``)
|
|
must reach the TUI as ``--model <id>`` — the regression #933 fixes.
|
|
"""
|
|
spec = await _run_auto_create_cursor_terminal(
|
|
tmp_path=tmp_path,
|
|
monkeypatch=monkeypatch,
|
|
agent_spec=AgentSpec(
|
|
spec_version=1, name="cursor", executor=ExecutorSpec(model="sonnet-4-thinking")
|
|
),
|
|
terminal_launch_args=None,
|
|
)
|
|
assert spec.command == "cursor-agent"
|
|
assert "--model" in spec.args
|
|
assert spec.args[spec.args.index("--model") + 1] == "sonnet-4-thinking"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"passthrough",
|
|
[
|
|
# Both the split (``-- --model X``) and joined (``--model=X``) forms a
|
|
# user can pass through must suppress injection — otherwise cursor-agent
|
|
# sees two ``--model`` values and selection is ambiguous.
|
|
["--model", "gpt-5"],
|
|
["--model=gpt-5"],
|
|
["-m", "gpt-5"],
|
|
],
|
|
ids=["split", "joined", "short"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_cursor_terminal_user_model_wins(
|
|
passthrough: list[str],
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A user-pinned passthrough model wins; the spec model is not injected."""
|
|
spec = await _run_auto_create_cursor_terminal(
|
|
tmp_path=tmp_path,
|
|
monkeypatch=monkeypatch,
|
|
agent_spec=AgentSpec(
|
|
spec_version=1, name="cursor", executor=ExecutorSpec(model="sonnet-4-thinking")
|
|
),
|
|
terminal_launch_args=passthrough,
|
|
)
|
|
# Exactly the user's args survive — no second ``--model`` / spec model added.
|
|
assert spec.args.count("--model") == passthrough.count("--model")
|
|
assert "sonnet-4-thinking" not in spec.args
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"spec_model",
|
|
[None, "", "databricks-claude-opus", "databricks/claude-opus"],
|
|
ids=["none", "empty", "databricks-dash", "databricks-slash"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_cursor_terminal_omits_model_when_unusable(
|
|
spec_model: str | None,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""No usable cursor model id → no ``--model`` (cursor-agent keeps its default).
|
|
|
|
Gateway-routed ``databricks-*`` ids are not cursor-agent model ids, so they
|
|
are dropped rather than passed through (which would error on launch).
|
|
"""
|
|
spec = await _run_auto_create_cursor_terminal(
|
|
tmp_path=tmp_path,
|
|
monkeypatch=monkeypatch,
|
|
agent_spec=AgentSpec(
|
|
spec_version=1, name="cursor", executor=ExecutorSpec(model=spec_model)
|
|
),
|
|
terminal_launch_args=None,
|
|
)
|
|
assert "--model" not in spec.args
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("snapshot_external_id", "expected_start_at_end"),
|
|
[
|
|
("02857840-6362-408f-b41f-309e396ed7c6", True),
|
|
(None, False),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_claude_terminal_forwarder_skips_replayed_transcript_on_resume(
|
|
snapshot_external_id: str | None,
|
|
expected_start_at_end: bool,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Host-spawned resume starts the forwarder past the replayed transcript.
|
|
|
|
On cold resume the runner synthesizes Claude's local transcript from
|
|
AP's committed history and launches ``claude --resume``, so the
|
|
transcript file already holds every item Omnigent has at offset 0. The
|
|
forwarder must therefore start at the transcript end
|
|
(``start_at_end=True``); starting at offset 0 would re-post the whole
|
|
history as new ``external_conversation_item`` records — which carry no
|
|
server-side dedup — duplicating the visible conversation on every
|
|
resume. A fresh session has no ``--resume`` and an empty
|
|
transcript, so it must forward from the beginning
|
|
(``start_at_end=False``). This mirrors the CLI client's
|
|
``prepared.cold_resumed`` handling in ``claude_native.py``.
|
|
|
|
:param snapshot_external_id: ``external_session_id`` returned in the AP
|
|
session snapshot, e.g.
|
|
``"02857840-6362-408f-b41f-309e396ed7c6"`` for a resume, or
|
|
``None`` for a fresh session.
|
|
:param expected_start_at_end: The ``start_at_end`` the forwarder must
|
|
be launched with for that snapshot.
|
|
:param tmp_path: Pytest-provided temporary directory.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(tmp_path / "workspace"))
|
|
# Pin the launch config to Claude's native auth so the test does not
|
|
# depend on the runner process's ambient Databricks profile.
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
|
|
forwarder_kwargs: dict[str, Any] = {}
|
|
|
|
async def _capture_forwarder(**kwargs: Any) -> None:
|
|
"""Record the forwarder launch kwargs without opening a stream."""
|
|
forwarder_kwargs.update(kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_forwarder.supervise_forwarder",
|
|
_capture_forwarder,
|
|
)
|
|
|
|
# Transcript synthesis from Omnigent history has its own coverage; stub it to
|
|
# return a path so the resume branch sets ``resume_external_session_id``
|
|
# without a real item fetch. A non-None return mirrors the production
|
|
# contract: it means ``--resume`` will be passed, which is precisely the
|
|
# condition that makes the replayed transcript hazard real.
|
|
synth_calls: list[str] = []
|
|
|
|
async def _fake_synth(
|
|
client: Any,
|
|
*,
|
|
session_id: str,
|
|
external_session_id: str,
|
|
workspace: Path,
|
|
) -> Path:
|
|
"""Record the resume id and return a transcript path."""
|
|
del client, session_id, workspace
|
|
synth_calls.append(external_session_id)
|
|
return tmp_path / f"{external_session_id}.jsonl"
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native._ensure_local_claude_resume_transcript",
|
|
_fake_synth,
|
|
)
|
|
|
|
snapshot: dict[str, Any] = {}
|
|
if snapshot_external_id is not None:
|
|
snapshot["external_session_id"] = snapshot_external_id
|
|
|
|
class _SnapshotServerClient(NullServerClient):
|
|
"""Server client whose session snapshot carries the resume id."""
|
|
|
|
async def get(self, url: str, **kwargs: Any) -> NullServerClient._Response:
|
|
"""Return the session snapshot, or labels for the cleared-bridge check."""
|
|
del kwargs
|
|
|
|
# auto-create reads the bridge_id label to honour a /clear "-cleared"
|
|
# re-key. Report none here so it falls back to session_id (no /clear).
|
|
if url.endswith("/labels"):
|
|
|
|
class _LabelsResponse(NullServerClient._Response):
|
|
"""Empty labels → bridge_id resolves to session_id."""
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
"""Return an empty labels payload."""
|
|
return {"labels": {}}
|
|
|
|
return _LabelsResponse()
|
|
|
|
assert url == "/v1/sessions/conv_resume"
|
|
|
|
class _SnapResponse(NullServerClient._Response):
|
|
"""Snapshot response carrying the parametrized resume id."""
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
"""Return the session snapshot dict."""
|
|
return snapshot
|
|
|
|
return _SnapResponse()
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Resource registry that returns a terminal without launching."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Return a terminal resource view without spawning a TTY."""
|
|
del terminal_name, session_key, spec
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="claude:main",
|
|
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
|
)
|
|
|
|
await _auto_create_claude_terminal(
|
|
"conv_resume",
|
|
_FakeResourceRegistry(),
|
|
lambda _sid, _evt: None,
|
|
server_client=_SnapshotServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
# The forwarder runs as a scheduled task; yield so the stub records its
|
|
# kwargs before asserting.
|
|
await asyncio.sleep(0)
|
|
|
|
# Crux of the fix: resume skips the replayed transcript, a fresh session
|
|
# forwards from the start. A regression to the old hardcoded
|
|
# ``start_at_end=False`` flips the resume case False and reintroduces
|
|
# the duplicate-history bug; the ``is`` comparison also fails if a
|
|
# truthy non-bool leaks through.
|
|
assert forwarder_kwargs.get("start_at_end") is expected_start_at_end, (
|
|
f"forwarder start_at_end={forwarder_kwargs.get('start_at_end')!r}; "
|
|
f"expected {expected_start_at_end!r} for external_session_id="
|
|
f"{snapshot_external_id!r}. False on resume means the whole "
|
|
f"transcript is re-posted."
|
|
)
|
|
|
|
# ``start_at_end`` must be correct *because* the resume branch ran, not
|
|
# by coincidence: synthesis happens exactly when (and only when) the
|
|
# snapshot carried an external session id.
|
|
if snapshot_external_id is None:
|
|
assert synth_calls == []
|
|
else:
|
|
assert synth_calls == [snapshot_external_id]
|
|
|
|
|
|
def _drain_session_event_queue(queue: asyncio.Queue[Any] | None) -> list[dict[str, Any]]:
|
|
"""
|
|
Drain and return every dict item currently on a runner session queue.
|
|
|
|
Used by the native control-event tests to clear creation-time events
|
|
(e.g. the ``session.terminal_pending`` pair the claude-native
|
|
auto-create path enqueues) so a later drain isolates only the events
|
|
a specific control signal produced.
|
|
|
|
:param queue: The per-session event queue from
|
|
``_session_event_queues_ref``, or ``None`` when the session has
|
|
no queue (already deleted / never created).
|
|
:returns: The dict items drained, in FIFO order. Empty when the
|
|
queue is ``None`` or held only non-dict sentinels.
|
|
"""
|
|
drained: list[dict[str, Any]] = []
|
|
if queue is None:
|
|
return drained
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
drained.append(item)
|
|
return drained
|
|
|
|
|
|
@dataclass
|
|
class _PublishedEvent:
|
|
"""
|
|
One event captured from the runner's per-session publisher.
|
|
|
|
:param session_id: Routing session id the event was published under,
|
|
e.g. ``"conv_emit"``.
|
|
:param event: The published SSE event dict.
|
|
"""
|
|
|
|
session_id: str
|
|
event: dict[str, Any]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_claude_terminal_emits_resource_created_event(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Host-spawned terminal launch publishes a live ``session.resource.created``.
|
|
|
|
The web UI sources its terminal list purely from SSE
|
|
``session.resource.created`` events, so the auto-create path must
|
|
emit one (the agent-tool / REST launch paths already do via
|
|
``_emit_terminal_resource_event``). Without it the Terminal toggle
|
|
stays gray until a refresh re-lists terminals via
|
|
snapshot-on-connect.
|
|
"""
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
|
|
async def _no_op_forwarder(**kwargs: Any) -> None:
|
|
del kwargs
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_forwarder.supervise_forwarder",
|
|
_no_op_forwarder,
|
|
)
|
|
|
|
class _ViewResourceRegistry:
|
|
"""Returns a terminal resource view; no live terminal registry."""
|
|
|
|
# ``_publish_tmux_target_for_bridge`` early-returns when this is None.
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Return the terminal resource view the runner would launch."""
|
|
del spec
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name=f"{terminal_name}:{session_key}",
|
|
metadata={
|
|
"terminal_name": terminal_name,
|
|
"session_key": session_key,
|
|
"running": True,
|
|
},
|
|
)
|
|
|
|
published: list[_PublishedEvent] = []
|
|
|
|
def _capture(session_id: str, event: dict[str, Any]) -> None:
|
|
published.append(_PublishedEvent(session_id=session_id, event=event))
|
|
|
|
await _auto_create_claude_terminal(
|
|
"conv_emit",
|
|
_ViewResourceRegistry(),
|
|
_capture,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
# Exactly one session.resource.created for the auto-created terminal.
|
|
# 0 means the live publish was dropped (the bug: the toggle would stay
|
|
# gray until a refresh triggers snapshot-on-connect).
|
|
created = [p for p in published if p.event.get("type") == "session.resource.created"]
|
|
assert len(created) == 1, (
|
|
f"auto-create must publish exactly one session.resource.created; got {published}"
|
|
)
|
|
# Routed under the session id so the Omnigent relay forwards it to that
|
|
# session's web stream.
|
|
assert created[0].session_id == "conv_emit"
|
|
resource = created[0].event["resource"]
|
|
assert resource["type"] == "terminal"
|
|
assert resource["id"] == "terminal_claude_main"
|
|
# metadata.running is what the web rail reads for the live terminal.
|
|
assert resource["metadata"]["running"] is True
|
|
|
|
|
|
def test_publish_terminal_pending_emits_pending_then_clear() -> None:
|
|
"""
|
|
``_publish_terminal_pending`` emits the wire shape the Omnigent relay
|
|
consumes for the Terminal-pill spinner.
|
|
|
|
The session-creation handler calls this with ``True`` before
|
|
auto-creating a terminal-first session's terminal and ``False`` in a
|
|
``finally`` (so a failed launch also clears the spinner). The AP
|
|
relay matches on ``type == "session.terminal_pending"`` and reads
|
|
the ``pending`` flag, so both fields must be present and correct, or
|
|
the spinner would never appear (or never clear).
|
|
"""
|
|
published: list[_PublishedEvent] = []
|
|
|
|
def _capture(session_id: str, event: dict[str, Any]) -> None:
|
|
published.append(_PublishedEvent(session_id=session_id, event=event))
|
|
|
|
_publish_terminal_pending(_capture, "conv_pending", True)
|
|
_publish_terminal_pending(_capture, "conv_pending", False)
|
|
|
|
assert [p.event for p in published] == [
|
|
{"type": "session.terminal_pending", "pending": True},
|
|
{"type": "session.terminal_pending", "pending": False},
|
|
]
|
|
# Routed under the session id so the Omnigent relay forwards it to that
|
|
# session's web stream.
|
|
assert all(p.session_id == "conv_pending" for p in published)
|
|
|
|
|
|
def test_publish_native_terminal_start_error_emits_failed_status_only(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
Native terminal startup failure publishes a generic ``failed`` status.
|
|
|
|
The runner must stay alive when terminal auto-create fails, but the
|
|
affected session should only receive ``session.status: failed`` from
|
|
this startup path. A bare ``response.error`` is turn-scoped; if the
|
|
runner publishes one here, Omnigent can persist an orphan transcript error
|
|
and then publish/persist a second error when the user message
|
|
fast-fails against the same terminal.
|
|
|
|
The published/returned message is a fixed, client-safe string — the raw
|
|
exception text (which can embed paths/CLI details) is logged for
|
|
operators, not surfaced on the session stream.
|
|
|
|
:param caplog: Pytest log capture fixture, used to confirm the raw
|
|
cause is logged server-side.
|
|
"""
|
|
published: list[_PublishedEvent] = []
|
|
|
|
def _capture(session_id: str, event: dict[str, Any]) -> None:
|
|
published.append(_PublishedEvent(session_id=session_id, event=event))
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
error = _publish_native_terminal_start_error(
|
|
_capture,
|
|
"conv_codex",
|
|
"Codex",
|
|
ImportError("Native Codex requires the 'codex' CLI on PATH."),
|
|
)
|
|
|
|
# Generic, client-safe payload — no raw exception text.
|
|
assert error == {
|
|
"code": "native_terminal_start_failed",
|
|
"message": "Native Codex terminal failed to start; see runner logs for details.",
|
|
}
|
|
# The raw cause must NOT leak into the surfaced message, but MUST be
|
|
# logged for operators. If this fails, the redaction regressed (raw
|
|
# text back in the payload) or the server-side log was dropped.
|
|
assert "requires the 'codex' CLI" not in error["message"]
|
|
assert "requires the 'codex' CLI on PATH." in caplog.text
|
|
assert [p.event for p in published] == [
|
|
{
|
|
"type": "session.status",
|
|
"status": "failed",
|
|
"error": error,
|
|
},
|
|
]
|
|
assert all(p.session_id == "conv_codex" for p in published)
|
|
|
|
|
|
def test_terminal_lookup_miss_log_explains_stopped_registered_terminal(
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
Terminal GET miss logs identify a stopped registered terminal.
|
|
|
|
The CLI polls ``GET /resources/terminals/terminal_claude_main`` while
|
|
waiting to attach. If a tmux pane was registered and then failed the
|
|
liveness probe, the runner log must say that instead of looking like
|
|
auto-create never ran.
|
|
|
|
:param tmp_path: Temporary directory for the fake tmux socket path.
|
|
:param caplog: Pytest log capture fixture.
|
|
:returns: None.
|
|
"""
|
|
terminal_registry = TerminalRegistry()
|
|
instance = TerminalInstance(
|
|
name="claude",
|
|
session_key="main",
|
|
socket_path=tmp_path / "tmux.sock",
|
|
private_dir=tmp_path,
|
|
running=False,
|
|
)
|
|
terminal_registry._by_conversation["conv_lookup"] = {("claude", "main"): instance}
|
|
resource_registry = SessionResourceRegistry(terminal_registry=terminal_registry)
|
|
|
|
_terminal_lookup_miss_log_state.clear()
|
|
try:
|
|
with caplog.at_level(logging.INFO, logger="omnigent.runner.app"):
|
|
_log_terminal_lookup_miss(
|
|
resource_registry,
|
|
"conv_lookup",
|
|
"terminal_claude_main",
|
|
)
|
|
_log_terminal_lookup_miss(
|
|
resource_registry,
|
|
"conv_lookup",
|
|
"terminal_claude_main",
|
|
)
|
|
finally:
|
|
_terminal_lookup_miss_log_state.clear()
|
|
|
|
messages = [
|
|
record.getMessage()
|
|
for record in caplog.records
|
|
if "Terminal resource lookup miss" in record.getMessage()
|
|
]
|
|
assert len(messages) == 1, (
|
|
f"lookup miss logging should be throttled per reason; got {messages!r}"
|
|
)
|
|
assert "terminal_registered_but_not_running" in messages[0]
|
|
assert "terminal_claude_main" in messages[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_claude_terminal_resets_stale_bridge_id_label(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Auto-create corrects a stale ``bridge_id`` label on the Omnigent session.
|
|
|
|
If a prior rotation left ``BRIDGE_ID_LABEL_KEY`` set to an older
|
|
bridge id (e.g. ``"m0-bridge_from_prior_rotation"``),
|
|
``_auto_create_claude_terminal`` must PATCH the label to
|
|
``session_id`` before proceeding. Without the correction,
|
|
``_ensure_comment_relay_started`` would later read the stale label
|
|
and write ``tool_relay.json`` into the wrong bridge dir — the bridge
|
|
MCP subprocess would never see it and the relay tools
|
|
(``list_comments``, ``sys_session_list``, etc.) would be absent.
|
|
"""
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
|
|
async def _no_op_forwarder(**kwargs: Any) -> None:
|
|
del kwargs
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_forwarder.supervise_forwarder",
|
|
_no_op_forwarder,
|
|
)
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Returns a minimal terminal view; no live terminal registry."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Return a minimal terminal view so the launch doesn't error."""
|
|
del spec
|
|
# Guards that _auto_create_claude_terminal tags the agent
|
|
# terminal with the claude-native role — the runner gates
|
|
# PTY-activity → session.status emission on this role, so
|
|
# dropping it would silently disable working-status updates.
|
|
assert resource_role == CLAUDE_NATIVE_TERMINAL_ROLE
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name=f"{terminal_name}:{session_key}",
|
|
metadata={
|
|
"terminal_name": terminal_name,
|
|
"session_key": session_key,
|
|
"running": True,
|
|
},
|
|
)
|
|
|
|
# Capture all HTTP requests made to the fake Omnigent server.
|
|
recorded_requests: list[httpx.Request] = []
|
|
|
|
def _handle(req: httpx.Request) -> httpx.Response:
|
|
"""Record every request; return 200 with minimal session payload."""
|
|
recorded_requests.append(req)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"reasoning_effort": None,
|
|
"labels": {BRIDGE_ID_LABEL_KEY: "m0-bridge_from_prior_rotation"},
|
|
},
|
|
request=req,
|
|
)
|
|
|
|
fake_client = httpx.AsyncClient(
|
|
base_url="http://test-server",
|
|
transport=httpx.MockTransport(_handle),
|
|
)
|
|
|
|
await _auto_create_claude_terminal(
|
|
"conv_relay_label_fix",
|
|
_FakeResourceRegistry(),
|
|
lambda _sid, _evt: None,
|
|
server_client=fake_client,
|
|
)
|
|
|
|
await fake_client.aclose()
|
|
|
|
# Exactly one PATCH request must have been sent to correct the label.
|
|
# 0 means the fix was not applied and the relay would target the wrong dir.
|
|
patch_requests = [r for r in recorded_requests if r.method == "PATCH"]
|
|
assert len(patch_requests) == 1, (
|
|
f"Expected exactly one PATCH to correct the stale bridge_id label; "
|
|
f"got {len(patch_requests)}. 0 means _auto_create_claude_terminal did "
|
|
f"not update the label, so _ensure_comment_relay_started would write "
|
|
f"tool_relay.json to a dir the bridge subprocess never reads."
|
|
)
|
|
|
|
import json as _json
|
|
|
|
patch_body = _json.loads(patch_requests[0].content)
|
|
assert patch_body.get("labels", {}).get(BRIDGE_ID_LABEL_KEY) == "conv_relay_label_fix", (
|
|
f"PATCH must set {BRIDGE_ID_LABEL_KEY!r} to the session_id "
|
|
f"'conv_relay_label_fix' so _ensure_comment_relay_started finds the "
|
|
f"correct bridge dir; got {patch_body.get('labels', {})!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_claude_terminal_honours_cleared_bridge_label(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A session re-keyed to "{id}-cleared" by /clear resumes in its OWN dir.
|
|
|
|
The /clear rotation hands the live pane to the new session and re-keys the
|
|
superseded session's bridge_id label to ``{session_id}-cleared``. When that
|
|
session is later resumed, ``_auto_create_claude_terminal`` must honour the
|
|
marker and prepare the isolated ``D({session_id}-cleared)`` — NOT the
|
|
natural ``D(session_id)`` (the new session's live dir, which would
|
|
double-mirror the transcript and trip the executor guard).
|
|
"""
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
|
|
async def _no_op_forwarder(**kwargs: Any) -> None:
|
|
del kwargs
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_forwarder.supervise_forwarder",
|
|
_no_op_forwarder,
|
|
)
|
|
|
|
class _FakeInstance:
|
|
"""Minimal live terminal instance for the tmux-target publish."""
|
|
|
|
running = True
|
|
socket_path = "/tmp/fake-claude.sock"
|
|
tmux_target = "claude:0.0"
|
|
|
|
class _FakeTerminalRegistry:
|
|
"""Returns the live instance for any (session, terminal, key) lookup."""
|
|
|
|
def get(self, conversation_id: str, terminal_name: str, session_key: str) -> Any:
|
|
"""Return the fake live instance."""
|
|
del conversation_id, terminal_name, session_key
|
|
return _FakeInstance()
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Resource registry exposing a live terminal registry."""
|
|
|
|
terminal_registry = _FakeTerminalRegistry()
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Return a minimal terminal view so the launch doesn't error."""
|
|
del spec, resource_role
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name=f"{terminal_name}:{session_key}",
|
|
metadata={
|
|
"terminal_name": terminal_name,
|
|
"session_key": session_key,
|
|
"running": True,
|
|
},
|
|
)
|
|
|
|
recorded_requests: list[httpx.Request] = []
|
|
|
|
def _handle(req: httpx.Request) -> httpx.Response:
|
|
"""Report the session's bridge_id label as the cleared marker."""
|
|
recorded_requests.append(req)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"reasoning_effort": None,
|
|
"labels": {BRIDGE_ID_LABEL_KEY: "conv_cleared-cleared"},
|
|
},
|
|
request=req,
|
|
)
|
|
|
|
fake_client = httpx.AsyncClient(
|
|
base_url="http://test-server",
|
|
transport=httpx.MockTransport(_handle),
|
|
)
|
|
|
|
await _auto_create_claude_terminal(
|
|
"conv_cleared",
|
|
_FakeResourceRegistry(),
|
|
lambda _sid, _evt: None,
|
|
server_client=fake_client,
|
|
)
|
|
await fake_client.aclose()
|
|
|
|
cleared_dir = claude_native_bridge.bridge_dir_for_bridge_id("conv_cleared-cleared")
|
|
natural_dir = claude_native_bridge.bridge_dir_for_bridge_id("conv_cleared")
|
|
# The isolated cleared dir is prepared; the natural (live-sibling) dir is not.
|
|
assert cleared_dir.exists()
|
|
assert not natural_dir.exists()
|
|
# tmux.json (what the executor reads to inject) must land in the SAME dir the
|
|
# executor + forwarder use — the cleared dir — NOT the natural session_id dir.
|
|
# Hardcoding session_id there was the "tmux target not advertised yet" bug.
|
|
assert (cleared_dir / "tmux.json").exists()
|
|
assert not (natural_dir / "tmux.json").exists()
|
|
|
|
import json as _json
|
|
|
|
patch_requests = [r for r in recorded_requests if r.method == "PATCH"]
|
|
assert len(patch_requests) == 1
|
|
patch_body = _json.loads(patch_requests[0].content)
|
|
assert patch_body.get("labels", {}).get(BRIDGE_ID_LABEL_KEY) == "conv_cleared-cleared"
|
|
|
|
|
|
@dataclass
|
|
class _AutoCreateScenario:
|
|
"""
|
|
One parametrized case for the claude-native auto-create guard.
|
|
|
|
:param case_id: Human-readable scenario id used as the pytest id,
|
|
e.g. ``"clear_rotation_target_skips"``.
|
|
:param active_session_id: ``active_session_id`` to seed into the
|
|
shared bridge config, e.g. ``"conv_old"``. ``None`` seeds no
|
|
bridge dir at all (models a genuinely fresh session).
|
|
:param terminal_under: Session id to seed a live ``claude:main``
|
|
terminal under in the registry, e.g. ``"conv_old"``. ``None``
|
|
seeds no terminal (models a dead/absent original terminal).
|
|
:param bridge_id_label: Value returned for the new session's
|
|
``BRIDGE_ID_LABEL_KEY`` label, e.g. ``"bridge_shared"`` for a
|
|
rotation target (shares the original's bridge) or ``"conv_new"``
|
|
for a fresh session (own bridge).
|
|
:param expect_auto_create: Whether the guard should invoke
|
|
``_auto_create_claude_terminal`` for the new session.
|
|
"""
|
|
|
|
case_id: str
|
|
active_session_id: str | None
|
|
terminal_under: str | None
|
|
bridge_id_label: str
|
|
expect_auto_create: bool
|
|
|
|
|
|
class _LabelsAndEmptyHistoryServerClient:
|
|
"""
|
|
Server-client stub for the auto-create guard route test.
|
|
|
|
Answers the two GETs ``create_session`` issues for a claude-native
|
|
session: the session snapshot (returns a ``BRIDGE_ID_LABEL_KEY``
|
|
label so the guard can resolve the bridge id) and the items page
|
|
(returns empty history so no crash-recovery turn starts). A real
|
|
stub class — not ``MagicMock`` — so an unexpected call shape fails
|
|
loudly instead of silently returning a mock.
|
|
"""
|
|
|
|
def __init__(self, bridge_id_label: str) -> None:
|
|
"""
|
|
:param bridge_id_label: Bridge id to report on the session's
|
|
``labels``, e.g. ``"bridge_shared"``.
|
|
"""
|
|
self._bridge_id_label = bridge_id_label
|
|
|
|
async def get(self, url: str, **kwargs: Any) -> Any:
|
|
"""
|
|
Return a canned snapshot or empty items page for *url*.
|
|
|
|
:param url: Request path, e.g. ``"/v1/sessions/conv_new"`` or
|
|
``"/v1/sessions/conv_new/items"``.
|
|
:returns: A response object exposing ``status_code`` and
|
|
``json()`` matching the subset the runner reads.
|
|
"""
|
|
del kwargs
|
|
|
|
class _Response:
|
|
"""Minimal httpx-like response with the fields the runner reads."""
|
|
|
|
def __init__(self, payload: dict[str, Any]) -> None:
|
|
""":param payload: JSON body returned by ``json()``."""
|
|
self.status_code = 200
|
|
self._payload = payload
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
""":returns: The canned JSON payload."""
|
|
return self._payload
|
|
|
|
if url.endswith("/items"):
|
|
return _Response({"data": [], "has_more": False})
|
|
return _Response({"labels": {BRIDGE_ID_LABEL_KEY: self._bridge_id_label}})
|
|
|
|
|
|
_AUTO_CREATE_SCENARIOS = [
|
|
# Rotation target: the bridge's active session (conv_old) still owns
|
|
# the live terminal that is about to be transferred onto conv_new.
|
|
_AutoCreateScenario(
|
|
case_id="clear_rotation_target_skips",
|
|
active_session_id="conv_old",
|
|
terminal_under="conv_old",
|
|
bridge_id_label="bridge_shared",
|
|
expect_auto_create=False,
|
|
),
|
|
# Fresh host-spawned session: its own bridge has no recorded active
|
|
# session and no terminal, so it must bootstrap its own Claude.
|
|
_AutoCreateScenario(
|
|
case_id="fresh_session_creates",
|
|
active_session_id=None,
|
|
terminal_under=None,
|
|
bridge_id_label="conv_new",
|
|
expect_auto_create=True,
|
|
),
|
|
# The bridge's active session is conv_new itself (e.g. a relaunch
|
|
# after the terminal died) — not a rotation, so auto-create proceeds.
|
|
_AutoCreateScenario(
|
|
case_id="active_is_self_creates",
|
|
active_session_id="conv_new",
|
|
terminal_under=None,
|
|
bridge_id_label="bridge_shared",
|
|
expect_auto_create=True,
|
|
),
|
|
# The bridge names an active sibling (conv_old) but no live terminal
|
|
# exists under it — nothing to transfer in, so auto-create proceeds.
|
|
_AutoCreateScenario(
|
|
case_id="dead_terminal_under_active_creates",
|
|
active_session_id="conv_old",
|
|
terminal_under=None,
|
|
bridge_id_label="bridge_shared",
|
|
expect_auto_create=True,
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"scenario", _AUTO_CREATE_SCENARIOS, ids=[s.case_id for s in _AUTO_CREATE_SCENARIOS]
|
|
)
|
|
async def test_create_session_auto_create_guard_skips_rotation_targets(
|
|
scenario: _AutoCreateScenario,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The claude-native auto-create guard skips ``/clear`` rotation targets.
|
|
|
|
A ``/clear`` or ``/fork`` rotation binds the runner to a fresh AP
|
|
session, then transfers the existing Claude terminal onto it. The
|
|
bind reaches the runner's ``POST /v1/sessions`` before the transfer
|
|
runs, so the new session momentarily has no terminal. Previously,
|
|
``create_session`` always auto-created a second Claude here, which
|
|
made the subsequent transfer 409 and looped the rotation into
|
|
unbounded session/process spawning. The guard now skips auto-create
|
|
when the new session's bridge already has a *different* session
|
|
owning a live ``claude:main`` terminal — the one about to be
|
|
transferred in.
|
|
|
|
Drives the real route with the real guard. Each scenario seeds the
|
|
shared bridge's ``active_session_id`` and the terminal registry, then
|
|
asserts whether ``_auto_create_claude_terminal`` ran. Reverting the
|
|
guard turns the ``clear_rotation_target_skips`` case red (auto-create
|
|
fires for a rotation target again).
|
|
"""
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
|
|
# Seed the shared bridge config so the guard reads the original
|
|
# (terminal-owning) session as the bridge's active session.
|
|
if scenario.active_session_id is not None:
|
|
prepare_bridge_dir(
|
|
scenario.active_session_id,
|
|
bridge_id=scenario.bridge_id_label,
|
|
workspace=tmp_path,
|
|
)
|
|
|
|
# Seed a live claude:main terminal under the original session so the
|
|
# guard's registry probe finds the terminal that would be transferred.
|
|
# Poking ``_by_conversation`` directly is the established registry-test
|
|
# idiom (see tests/terminals/test_registry.py) — a real TerminalInstance
|
|
# without launching tmux.
|
|
terminal_registry = TerminalRegistry()
|
|
if scenario.terminal_under is not None:
|
|
instance = TerminalInstance(
|
|
name="claude",
|
|
session_key="main",
|
|
socket_path=tmp_path / "claude.sock",
|
|
private_dir=tmp_path / "claude",
|
|
running=True,
|
|
)
|
|
terminal_registry._by_conversation[scenario.terminal_under] = {
|
|
("claude", "main"): instance
|
|
}
|
|
|
|
created: list[str] = []
|
|
|
|
async def _recording_auto_create(
|
|
session_id: str, resource_registry: Any, publish_event: Any, **_kwargs: Any
|
|
) -> None:
|
|
"""
|
|
Record the auto-create call instead of launching a real Claude.
|
|
|
|
:param session_id: Session id the guard chose to auto-create for,
|
|
e.g. ``"conv_new"``.
|
|
:param resource_registry: Unused — the real launch path is stubbed.
|
|
:param publish_event: Unused — the real launch path is stubbed.
|
|
:param _kwargs: Absorbs keyword args added to the real function
|
|
(e.g. ``server_client``).
|
|
:returns: None.
|
|
"""
|
|
del resource_registry, publish_event
|
|
created.append(session_id)
|
|
|
|
monkeypatch.setattr("omnigent.runner.app._auto_create_claude_terminal", _recording_auto_create)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""
|
|
Return the claude-native spec for any agent id.
|
|
|
|
:param agent_id: Requested agent id (unused — fixed spec).
|
|
:param session_id: Requested session id (unused — fixed spec).
|
|
:returns: The claude-native :class:`AgentSpec`.
|
|
"""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=_LabelsAndEmptyHistoryServerClient( # type: ignore[arg-type]
|
|
scenario.bridge_id_label
|
|
),
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_new", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
|
|
if scenario.expect_auto_create:
|
|
# Fresh / no-live-sibling sessions must still bootstrap their own
|
|
# Claude — the guard only suppresses true rotation targets. An
|
|
# empty ``created`` here would mean the guard over-fired and a
|
|
# host-spawned session would never get a terminal.
|
|
assert created == ["conv_new"], (
|
|
f"Expected auto-create for {scenario.case_id}; got {created}"
|
|
)
|
|
else:
|
|
# The rotation target's terminal arrives via transfer. Auto-create
|
|
# here is the regression: it 409s the transfer and loops the
|
|
# rotation into unbounded session spawning.
|
|
assert created == [], f"Auto-create must be skipped for {scenario.case_id}; got {created}"
|
|
|
|
|
|
@dataclass
|
|
class _AntigravityAutoCreateScenario:
|
|
"""
|
|
One parametrized case for the antigravity-native auto-create guard.
|
|
|
|
:param case_id: Human-readable scenario id used as the pytest id,
|
|
e.g. ``"clear_rotation_target_skips"``.
|
|
:param bridge_state_session: ``session_id`` to seed into the shared
|
|
bridge state, e.g. ``"conv_old"``. ``None`` seeds no bridge state
|
|
at all (models a genuinely fresh session).
|
|
:param terminal_under: Session id to seed a live ``antigravity:main``
|
|
terminal under in the registry, e.g. ``"conv_old"``. ``None``
|
|
seeds no terminal (models a dead/absent original terminal).
|
|
:param bridge_id_label: Value returned for the new session's
|
|
:data:`ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY` label, e.g.
|
|
``"bridge_shared"`` for a rotation target (shares the original's
|
|
bridge) or ``"conv_new"`` for a fresh session (own bridge).
|
|
:param expect_auto_create: Whether the guard should invoke
|
|
``_auto_create_antigravity_terminal`` for the new session.
|
|
"""
|
|
|
|
case_id: str
|
|
bridge_state_session: str | None
|
|
terminal_under: str | None
|
|
bridge_id_label: str
|
|
expect_auto_create: bool
|
|
|
|
|
|
class _AntigravitySnapshotServerClient:
|
|
"""
|
|
Server-client stub for the antigravity auto-create guard route test.
|
|
|
|
Answers the two GETs the antigravity branch issues for ``conv_new``: the
|
|
session snapshot (``/v1/sessions/conv_new`` — non-``None`` so
|
|
``_session_payload_for_host_spawn_check`` reports the session needs a
|
|
terminal) and the labels lookup (``/v1/sessions/conv_new/labels`` — returns
|
|
the bridge-id label so the transfer-inbound check can resolve the shared
|
|
bridge dir). A real stub class — not ``MagicMock`` — so an unexpected call
|
|
shape fails loudly instead of silently returning a mock.
|
|
"""
|
|
|
|
def __init__(self, bridge_id_label: str) -> None:
|
|
"""
|
|
:param bridge_id_label: Bridge id to report on the session's
|
|
``labels``, e.g. ``"bridge_shared"``.
|
|
"""
|
|
self._bridge_id_label = bridge_id_label
|
|
|
|
async def get(self, url: str, **kwargs: Any) -> Any:
|
|
"""
|
|
Return a canned snapshot or labels payload for *url*.
|
|
|
|
:param url: Request path, e.g. ``"/v1/sessions/conv_new"`` or
|
|
``"/v1/sessions/conv_new/labels"``.
|
|
:returns: A response object exposing ``status_code`` and ``json()``
|
|
matching the subset the runner reads.
|
|
"""
|
|
del kwargs
|
|
labels = {ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY: self._bridge_id_label}
|
|
|
|
class _Response:
|
|
"""Minimal httpx-like response with the fields the runner reads."""
|
|
|
|
def __init__(self, payload: dict[str, Any]) -> None:
|
|
""":param payload: JSON body returned by ``json()``."""
|
|
self.status_code = 200
|
|
self._payload = payload
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
""":returns: The canned JSON payload."""
|
|
return self._payload
|
|
|
|
if url.endswith("/labels"):
|
|
return _Response({"labels": labels})
|
|
# The session snapshot: non-None so the host-spawn check reports the
|
|
# session needs a terminal, and carries the same labels.
|
|
return _Response({"id": "conv_new", "labels": labels})
|
|
|
|
|
|
_ANTIGRAVITY_AUTO_CREATE_SCENARIOS = [
|
|
# Rotation target: the bridge's active session (conv_old) still owns the
|
|
# live agy terminal that is about to be transferred onto conv_new.
|
|
_AntigravityAutoCreateScenario(
|
|
case_id="clear_rotation_target_skips",
|
|
bridge_state_session="conv_old",
|
|
terminal_under="conv_old",
|
|
bridge_id_label="bridge_shared",
|
|
expect_auto_create=False,
|
|
),
|
|
# Fresh host-spawned session: its own bridge has no recorded state and no
|
|
# terminal, so it must bootstrap (cold-start) its own agy.
|
|
_AntigravityAutoCreateScenario(
|
|
case_id="fresh_session_creates",
|
|
bridge_state_session=None,
|
|
terminal_under=None,
|
|
bridge_id_label="conv_new",
|
|
expect_auto_create=True,
|
|
),
|
|
# The bridge's recorded session is conv_new itself (e.g. a relaunch after
|
|
# the terminal died) — not a rotation, so auto-create proceeds.
|
|
_AntigravityAutoCreateScenario(
|
|
case_id="active_is_self_creates",
|
|
bridge_state_session="conv_new",
|
|
terminal_under=None,
|
|
bridge_id_label="bridge_shared",
|
|
expect_auto_create=True,
|
|
),
|
|
# The bridge names a sibling (conv_old) but no live terminal exists under
|
|
# it — nothing to transfer in, so auto-create proceeds.
|
|
_AntigravityAutoCreateScenario(
|
|
case_id="dead_terminal_under_active_creates",
|
|
bridge_state_session="conv_old",
|
|
terminal_under=None,
|
|
bridge_id_label="bridge_shared",
|
|
expect_auto_create=True,
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"scenario",
|
|
_ANTIGRAVITY_AUTO_CREATE_SCENARIOS,
|
|
ids=[s.case_id for s in _ANTIGRAVITY_AUTO_CREATE_SCENARIOS],
|
|
)
|
|
async def test_create_session_antigravity_auto_create_guard_skips_rotation_targets(
|
|
scenario: _AntigravityAutoCreateScenario,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The antigravity-native auto-create guard skips ``/clear`` rotation targets.
|
|
|
|
A ``/clear`` rotation binds the runner to a fresh Omnigent session, then
|
|
transfers the existing agy terminal onto it — agy is one long-lived process
|
|
hosting many cascades, so the rotation re-homes the SAME process. The bind
|
|
reaches the runner's ``POST /v1/sessions`` before the transfer runs, so the
|
|
new session momentarily has no terminal. Auto-creating a second agy here
|
|
cold-starts a brand-new process whose own ``external_session_id`` then 400s
|
|
the rotation's PATCH and loops it into unbounded session/process spawning
|
|
(the bug found by live e2e). The guard now skips auto-create when the new
|
|
session's bridge already has a *different* session owning a live
|
|
``antigravity:main`` terminal — the one about to be transferred in. Mirrors
|
|
the claude-native guard test above.
|
|
|
|
Drives the real route with the real guard. Each scenario seeds the shared
|
|
bridge state's ``session_id`` and the terminal registry, then asserts whether
|
|
``_auto_create_antigravity_terminal`` ran. Reverting the guard turns the
|
|
``clear_rotation_target_skips`` case red (auto-create fires for a rotation
|
|
target again).
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
|
|
# Seed the shared bridge state so the guard reads the original
|
|
# (terminal-owning) session as the bridge's active session.
|
|
if scenario.bridge_state_session is not None:
|
|
seed_dir = bridge_mod.prepare_bridge_dir(scenario.bridge_id_label)
|
|
bridge_mod.write_bridge_state(
|
|
seed_dir,
|
|
bridge_mod.AntigravityNativeBridgeState(
|
|
session_id=scenario.bridge_state_session,
|
|
conversation_id="cascade_old",
|
|
),
|
|
)
|
|
|
|
# Seed a live antigravity:main terminal under the original session so the
|
|
# guard's registry probe finds the terminal that would be transferred.
|
|
# Poking ``_by_conversation`` directly is the established registry-test
|
|
# idiom — a real TerminalInstance without launching tmux.
|
|
terminal_registry = TerminalRegistry()
|
|
if scenario.terminal_under is not None:
|
|
instance = TerminalInstance(
|
|
name="antigravity",
|
|
session_key="main",
|
|
socket_path=tmp_path / "antigravity.sock",
|
|
private_dir=tmp_path / "antigravity",
|
|
running=True,
|
|
)
|
|
terminal_registry._by_conversation[scenario.terminal_under] = {
|
|
("antigravity", "main"): instance
|
|
}
|
|
|
|
created: list[str] = []
|
|
|
|
async def _recording_auto_create(
|
|
session_id: str, resource_registry: Any, publish_event: Any, **_kwargs: Any
|
|
) -> None:
|
|
"""
|
|
Record the auto-create call instead of launching a real agy.
|
|
|
|
:param session_id: Session id the guard chose to auto-create for,
|
|
e.g. ``"conv_new"``.
|
|
:param resource_registry: Unused — the real launch path is stubbed.
|
|
:param publish_event: Unused — the real launch path is stubbed.
|
|
:param _kwargs: Absorbs keyword args added to the real function
|
|
(e.g. ``server_client``).
|
|
:returns: None.
|
|
"""
|
|
del resource_registry, publish_event
|
|
created.append(session_id)
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.runner.app._auto_create_antigravity_terminal", _recording_auto_create
|
|
)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "antigravity-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""
|
|
Return the antigravity-native spec for any agent id.
|
|
|
|
:param agent_id: Requested agent id (unused — fixed spec).
|
|
:param session_id: Requested session id (unused — fixed spec).
|
|
:returns: The antigravity-native :class:`AgentSpec`.
|
|
"""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=_AntigravitySnapshotServerClient( # type: ignore[arg-type]
|
|
scenario.bridge_id_label
|
|
),
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_new", "agent_id": "ag_1"},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
|
|
if scenario.expect_auto_create:
|
|
# Fresh / no-live-sibling sessions must still bootstrap their own agy —
|
|
# the guard only suppresses true rotation targets. An empty ``created``
|
|
# here would mean the guard over-fired and a host-spawned session would
|
|
# never get a terminal.
|
|
assert created == ["conv_new"], (
|
|
f"Expected auto-create for {scenario.case_id}; got {created}"
|
|
)
|
|
else:
|
|
# The rotation target's terminal arrives via transfer. Auto-create here
|
|
# is the regression: it cold-starts a redundant agy that 400s the
|
|
# rotation's external_session_id PATCH and loops the rotation.
|
|
assert created == [], f"Auto-create must be skipped for {scenario.case_id}; got {created}"
|
|
|
|
|
|
@dataclass
|
|
class _EnsureTerminalCase:
|
|
"""
|
|
One routing case for the claude-native ``create_session_terminal``
|
|
ensure-path branch.
|
|
|
|
:param case_id: Human-readable id for the parametrize label.
|
|
:param body: The ``POST /resources/terminals`` JSON body.
|
|
:param existing: Whether a live ``claude``/``main`` terminal already
|
|
exists (drives the stubbed ``get_terminal_resource``).
|
|
:param expect_auto_create: Whether the request must route to
|
|
``_auto_create_claude_terminal`` (the ensure path).
|
|
:param expect_launch: Whether the request must route to the generic
|
|
terminal launch path instead.
|
|
:param expect_name: ``name`` of the resource the route must return —
|
|
identifies which collaborator produced the response.
|
|
"""
|
|
|
|
case_id: str
|
|
body: dict[str, object]
|
|
existing: bool
|
|
expect_auto_create: bool
|
|
expect_launch: bool
|
|
expect_name: str
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"case",
|
|
[
|
|
_EnsureTerminalCase(
|
|
case_id="ensure_no_terminal_auto_creates",
|
|
body={"terminal": "claude", "session_key": "main", "ensure_native_terminal": True},
|
|
existing=False,
|
|
expect_auto_create=True,
|
|
expect_launch=False,
|
|
expect_name="auto-created",
|
|
),
|
|
_EnsureTerminalCase(
|
|
case_id="ensure_existing_returns_live",
|
|
body={"terminal": "claude", "session_key": "main", "ensure_native_terminal": True},
|
|
existing=True,
|
|
expect_auto_create=False,
|
|
expect_launch=False,
|
|
expect_name="existing",
|
|
),
|
|
_EnsureTerminalCase(
|
|
# No ensure marker => a plain claude/main launch must take the
|
|
# generic path, NOT the ensure branch. This is the exact body
|
|
# test_comment_relay's plain launch sends; keying on the marker
|
|
# (not on absent spec/bridge) is what keeps that path intact.
|
|
case_id="no_marker_uses_generic_launch",
|
|
body={"terminal": "claude", "session_key": "main"},
|
|
existing=False,
|
|
expect_auto_create=False,
|
|
expect_launch=True,
|
|
expect_name="launched",
|
|
),
|
|
],
|
|
ids=lambda c: c.case_id,
|
|
)
|
|
async def test_create_session_terminal_ensure_routes_claude_native(
|
|
case: _EnsureTerminalCase,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``POST /resources/terminals`` routes a claude/main request correctly.
|
|
|
|
Guards the resume "ensure" branch added so a reattach onto a reused
|
|
daemon runner re-creates the torn-down Claude terminal: a request with
|
|
no ``spec`` and no ``bridge_inject_dir`` must go to the full native
|
|
``_auto_create_claude_terminal`` (or return the live terminal if one
|
|
exists), while a request carrying ``spec``/``bridge_inject_dir`` (the
|
|
fresh-launch wrapper path) must still use the generic terminal launch.
|
|
|
|
The three collaborators are stubbed so the routing decision is the only
|
|
thing under test; each returns a distinctly-named real
|
|
:class:`SessionResourceView`, so the response ``name`` proves which path
|
|
handled the request. Remove the ensure branch and the auto-create cases
|
|
fall through to the generic terminal launch path (wrong name,
|
|
``launched`` recorded); drop the ``not spec`` guard and the explicit-spec
|
|
case wrongly auto-creates — either way this test fails.
|
|
|
|
:param case: The parametrized routing scenario.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
sid = "conv_ensure"
|
|
auto_create_calls: list[str] = []
|
|
launch_calls: list[str] = []
|
|
|
|
async def _stub_auto_create(
|
|
session_id: str,
|
|
resource_registry: object,
|
|
publish_event: object,
|
|
**_kwargs: object,
|
|
) -> SessionResourceView:
|
|
"""Record the ensure-path call and return a tagged terminal view."""
|
|
del resource_registry, publish_event
|
|
auto_create_calls.append(session_id)
|
|
return SessionResourceView(
|
|
id="terminal_claude_main", type="terminal", session_id=session_id, name="auto-created"
|
|
)
|
|
|
|
async def _stub_get_terminal(
|
|
self: object, session_id: str, terminal_id: str
|
|
) -> SessionResourceView | None:
|
|
"""Return a live view only when the case seeds an existing terminal."""
|
|
del self
|
|
if case.existing and terminal_id == "terminal_claude_main":
|
|
return SessionResourceView(
|
|
id=terminal_id, type="terminal", session_id=session_id, name="existing"
|
|
)
|
|
return None
|
|
|
|
async def _stub_launch_auxiliary_terminal(
|
|
self: object, *, session_id: str, terminal_name: str, session_key: str, **_kwargs: object
|
|
) -> SessionResourceView:
|
|
"""Record the generic-launch call and return a tagged terminal view."""
|
|
del self, _kwargs
|
|
launch_calls.append(f"{terminal_name}:{session_key}")
|
|
return SessionResourceView(
|
|
id=terminal_resource_id(terminal_name, session_key),
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="launched",
|
|
)
|
|
|
|
monkeypatch.setattr("omnigent.runner.app._auto_create_claude_terminal", _stub_auto_create)
|
|
monkeypatch.setattr(SessionResourceRegistry, "get_terminal_resource", _stub_get_terminal)
|
|
monkeypatch.setattr(
|
|
SessionResourceRegistry,
|
|
"launch_auxiliary_terminal",
|
|
_stub_launch_auxiliary_terminal,
|
|
)
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=TerminalRegistry(),
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(f"/v1/sessions/{sid}/resources/terminals", json=case.body)
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
# The response name identifies the path taken: only the routed
|
|
# collaborator's view reaches the client.
|
|
assert resp.json()["name"] == case.expect_name, (
|
|
f"{case.case_id}: response came from the wrong path "
|
|
f"(expected {case.expect_name!r}, got {resp.json()['name']!r})"
|
|
)
|
|
# auto_create fires iff this is an ensure request with no live terminal.
|
|
# If empty when expected, the ensure branch did not route to the native
|
|
# auto-create; if populated when not expected, the spec discriminator
|
|
# leaked and a wrapper launch was hijacked.
|
|
assert (auto_create_calls == [sid]) == case.expect_auto_create, (
|
|
f"{case.case_id}: auto_create_calls={auto_create_calls}, "
|
|
f"expected_auto_create={case.expect_auto_create}"
|
|
)
|
|
# The generic launch fires iff the request carried a spec (wrapper path).
|
|
assert (launch_calls == ["claude:main"]) == case.expect_launch, (
|
|
f"{case.case_id}: launch_calls={launch_calls}, expected_launch={case.expect_launch}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_terminal_ensure_failure_returns_json_without_live_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Native terminal ensure failures are reported to AP, not published live.
|
|
|
|
``ensure_native_terminal`` is called by the Omnigent server while handling a
|
|
user message. Omnigent owns that failed transcript turn: it persists the
|
|
consumed user message, appends the sibling ``error`` item, and
|
|
publishes the live banner. If the runner endpoint also publishes
|
|
``response.error`` before returning its structured 500, the same
|
|
terminal failure is rendered twice live and can be persisted twice by
|
|
the relay.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
sid = "conv_ensure_failure"
|
|
|
|
async def _failing_auto_create(
|
|
session_id: str,
|
|
resource_registry: object,
|
|
publish_event: object,
|
|
**_kwargs: object,
|
|
) -> SessionResourceView:
|
|
"""Raise the native startup error the endpoint must return as JSON."""
|
|
del session_id, resource_registry, publish_event, _kwargs
|
|
raise ImportError("Native Claude requires the 'claude' CLI on PATH.")
|
|
|
|
def _unexpected_live_publish(*_args: object, **_kwargs: object) -> None:
|
|
"""Fail if the ensure endpoint tries to publish the live banner."""
|
|
raise AssertionError("ensure endpoint must not publish response.error")
|
|
|
|
monkeypatch.setattr("omnigent.runner.app._auto_create_claude_terminal", _failing_auto_create)
|
|
monkeypatch.setattr(
|
|
"omnigent.runner.app._publish_native_terminal_start_error",
|
|
_unexpected_live_publish,
|
|
)
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=TerminalRegistry(),
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(
|
|
f"/v1/sessions/{sid}/resources/terminals",
|
|
json={"terminal": "claude", "session_key": "main", "ensure_native_terminal": True},
|
|
)
|
|
|
|
assert resp.status_code == 500
|
|
# Structured code is preserved; the message is a fixed client-safe
|
|
# string. The raw ImportError text ("requires the 'claude' CLI") must
|
|
# not appear in the HTTP body — it is logged on the runner instead.
|
|
body = resp.json()
|
|
assert body["error"]["code"] == "native_terminal_start_failed"
|
|
assert body["error"]["message"] == (
|
|
"Native Claude terminal failed to start; see runner logs for details."
|
|
)
|
|
assert "requires the 'claude' CLI" not in body["error"]["message"]
|
|
|
|
|
|
@dataclass
|
|
class _EnsureCodexTerminalCase:
|
|
"""
|
|
One routing case for the codex-native ensure terminal branch.
|
|
|
|
:param case_id: Human-readable id for the parametrized case.
|
|
:param body: ``POST /resources/terminals`` JSON body.
|
|
:param existing: Whether a live ``codex``/``main`` terminal exists.
|
|
:param existing_native: Whether the existing terminal metadata looks
|
|
like a runner-owned Codex remote TUI.
|
|
:param expect_auto_create: Whether the route should call the full
|
|
codex-native auto-create helper.
|
|
:param expect_launch: Whether the route should fall through to the
|
|
generic terminal launch path.
|
|
:param expect_close: Whether the existing terminal should be closed
|
|
before native auto-create.
|
|
:param expect_name: Resource ``name`` expected in the HTTP response.
|
|
"""
|
|
|
|
case_id: str
|
|
body: dict[str, object]
|
|
existing: bool
|
|
existing_native: bool
|
|
expect_auto_create: bool
|
|
expect_launch: bool
|
|
expect_close: bool
|
|
expect_name: str
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"case",
|
|
[
|
|
_EnsureCodexTerminalCase(
|
|
case_id="ensure_no_terminal_auto_creates",
|
|
body={"terminal": "codex", "session_key": "main", "ensure_native_terminal": True},
|
|
existing=False,
|
|
existing_native=False,
|
|
expect_auto_create=True,
|
|
expect_launch=False,
|
|
expect_close=False,
|
|
expect_name="auto-created",
|
|
),
|
|
_EnsureCodexTerminalCase(
|
|
case_id="ensure_existing_returns_live",
|
|
body={"terminal": "codex", "session_key": "main", "ensure_native_terminal": True},
|
|
existing=True,
|
|
existing_native=True,
|
|
expect_auto_create=False,
|
|
expect_launch=False,
|
|
expect_close=False,
|
|
expect_name="existing",
|
|
),
|
|
_EnsureCodexTerminalCase(
|
|
case_id="ensure_existing_bash_terminal_replaces",
|
|
body={"terminal": "codex", "session_key": "main", "ensure_native_terminal": True},
|
|
existing=True,
|
|
existing_native=False,
|
|
expect_auto_create=True,
|
|
expect_launch=False,
|
|
expect_close=True,
|
|
expect_name="auto-created",
|
|
),
|
|
_EnsureCodexTerminalCase(
|
|
case_id="no_marker_uses_generic_launch",
|
|
body={"terminal": "codex", "session_key": "main"},
|
|
existing=False,
|
|
existing_native=False,
|
|
expect_auto_create=False,
|
|
expect_launch=True,
|
|
expect_close=False,
|
|
expect_name="launched",
|
|
),
|
|
],
|
|
ids=lambda c: c.case_id,
|
|
)
|
|
async def test_create_session_terminal_ensure_routes_codex_native(
|
|
case: _EnsureCodexTerminalCase,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``POST /resources/terminals`` routes a codex/main ensure request.
|
|
|
|
The ensure marker must invoke the runner-owned Codex setup
|
|
(app-server, forwarder, and TUI terminal) or return an existing
|
|
terminal. Without the marker, a plain codex terminal launch remains a
|
|
generic terminal request. Removing this branch makes the auto-create
|
|
cases return ``"launched"``; over-broad routing makes the generic case
|
|
return ``"auto-created"``.
|
|
|
|
:param case: Parametrized routing scenario.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
sid = "conv_codex_ensure"
|
|
auto_create_calls: list[str] = []
|
|
auto_create_kwargs: list[dict[str, object]] = []
|
|
launch_calls: list[str] = []
|
|
close_calls: list[str] = []
|
|
route_events: list[str] = []
|
|
|
|
async def _stub_auto_create(
|
|
session_id: str,
|
|
resource_registry: object,
|
|
publish_event: object,
|
|
**kwargs: object,
|
|
) -> SessionResourceView:
|
|
"""
|
|
Record the codex-native ensure path.
|
|
|
|
:param session_id: Session id being ensured, e.g.
|
|
``"conv_codex_ensure"``.
|
|
:param resource_registry: Runner resource registry collaborator.
|
|
:param publish_event: Runner event publisher collaborator.
|
|
:param kwargs: Additional keyword arguments such as
|
|
``server_client`` and ``agent_spec``.
|
|
:returns: Tagged terminal resource view.
|
|
"""
|
|
del resource_registry, publish_event
|
|
route_events.append("auto-create")
|
|
auto_create_calls.append(session_id)
|
|
auto_create_kwargs.append(kwargs)
|
|
return SessionResourceView(
|
|
id="terminal_codex_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="auto-created",
|
|
)
|
|
|
|
async def _stub_get_terminal(
|
|
self: object,
|
|
session_id: str,
|
|
terminal_id: str,
|
|
) -> SessionResourceView | None:
|
|
"""
|
|
Return an existing terminal only for seeded cases.
|
|
|
|
:param self: Bound registry instance.
|
|
:param session_id: Session id being queried, e.g.
|
|
``"conv_codex_ensure"``.
|
|
:param terminal_id: Terminal resource id, e.g.
|
|
``"terminal_codex_main"``.
|
|
:returns: Existing resource view or ``None``.
|
|
"""
|
|
del self
|
|
if case.existing and terminal_id == "terminal_codex_main":
|
|
return SessionResourceView(
|
|
id=terminal_id,
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="existing",
|
|
metadata={
|
|
"terminal_name": "codex",
|
|
"session_key": "main",
|
|
},
|
|
)
|
|
return None
|
|
|
|
def _stub_terminal_resource_role(
|
|
self: object,
|
|
session_id: str,
|
|
terminal_id: str,
|
|
) -> str | None:
|
|
"""
|
|
Return the private resource role for seeded existing terminals.
|
|
|
|
:param self: Bound registry instance.
|
|
:param session_id: Session id being queried, e.g.
|
|
``"conv_codex_ensure"``.
|
|
:param terminal_id: Terminal resource id, e.g.
|
|
``"terminal_codex_main"``.
|
|
:returns: ``"codex-native"`` for native seeded terminals.
|
|
"""
|
|
del self, session_id
|
|
if case.existing and case.existing_native and terminal_id == "terminal_codex_main":
|
|
return CODEX_NATIVE_TERMINAL_ROLE
|
|
return None
|
|
|
|
async def _stub_close_terminal(
|
|
self: object,
|
|
session_id: str,
|
|
terminal_id: str,
|
|
) -> bool:
|
|
"""
|
|
Record stale terminal replacement closes.
|
|
|
|
:param self: Bound registry instance.
|
|
:param session_id: Session id being modified, e.g.
|
|
``"conv_codex_ensure"``.
|
|
:param terminal_id: Terminal resource id, e.g.
|
|
``"terminal_codex_main"``.
|
|
:returns: ``True`` to allow replacement.
|
|
"""
|
|
del self
|
|
assert route_events == []
|
|
route_events.append("close")
|
|
close_calls.append(f"{session_id}:{terminal_id}")
|
|
return True
|
|
|
|
async def _stub_launch_auxiliary_terminal(
|
|
self: object,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
**kwargs: object,
|
|
) -> SessionResourceView:
|
|
"""
|
|
Record generic terminal launch calls.
|
|
|
|
:param self: Bound registry instance.
|
|
:param session_id: Session id being launched, e.g.
|
|
``"conv_codex_ensure"``.
|
|
:param terminal_name: Terminal name, e.g. ``"codex"``.
|
|
:param session_key: Terminal session key, e.g. ``"main"``.
|
|
:param kwargs: Additional launch keyword arguments.
|
|
:returns: Tagged terminal resource view.
|
|
"""
|
|
del self, kwargs
|
|
launch_calls.append(f"{terminal_name}:{session_key}")
|
|
return SessionResourceView(
|
|
id=terminal_resource_id(terminal_name, session_key),
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="launched",
|
|
)
|
|
|
|
monkeypatch.setattr("omnigent.runner.app._auto_create_codex_terminal", _stub_auto_create)
|
|
monkeypatch.setattr(SessionResourceRegistry, "get_terminal_resource", _stub_get_terminal)
|
|
monkeypatch.setattr(
|
|
SessionResourceRegistry,
|
|
"terminal_resource_role",
|
|
_stub_terminal_resource_role,
|
|
)
|
|
monkeypatch.setattr(SessionResourceRegistry, "close_terminal", _stub_close_terminal)
|
|
monkeypatch.setattr(
|
|
SessionResourceRegistry,
|
|
"launch_auxiliary_terminal",
|
|
_stub_launch_auxiliary_terminal,
|
|
)
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=TerminalRegistry(),
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post(f"/v1/sessions/{sid}/resources/terminals", json=case.body)
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["name"] == case.expect_name
|
|
assert (auto_create_calls == [sid]) == case.expect_auto_create
|
|
if case.expect_auto_create:
|
|
assert auto_create_kwargs[0]["server_client"] is not None
|
|
assert "agent_spec" in auto_create_kwargs[0]
|
|
else:
|
|
assert auto_create_kwargs == []
|
|
assert (launch_calls == ["codex:main"]) == case.expect_launch
|
|
assert (close_calls == [f"{sid}:terminal_codex_main"]) == case.expect_close
|
|
expected_events = ["close", "auto-create"] if case.expect_close else []
|
|
if case.expect_auto_create and not case.expect_close:
|
|
expected_events = ["auto-create"]
|
|
assert route_events == expected_events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_late_status_for_deleted_sub_agent_child_is_not_a_spurious_503() -> None:
|
|
"""
|
|
A terminal status arriving after a sub-agent child is deleted is a no-op.
|
|
|
|
A child created with ``sub_agent_name`` is tracked in the runner's
|
|
sub-agent name map; that registration is what turns a no-work-entry
|
|
terminal status into a 503 (preserve-the-handoff — see
|
|
``test_known_subagent_status_without_work_entry_returns_503``). Once the
|
|
child is deleted there is nothing to preserve, so ``delete_session`` must
|
|
drop the name. Without the pop, the lingering name makes the late status
|
|
read ``is_runner_known_subagent=True`` with no work entry → a spurious
|
|
``503 subagent_delivery_not_confirmed`` (which Omnigent then retries) plus an
|
|
unbounded leak of the name map across deleted sessions.
|
|
"""
|
|
child_id = "conv_child_late_status_after_delete"
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": child_id,
|
|
"agent_id": "ag_late_status_after_delete",
|
|
"sub_agent_name": "worker",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
del_resp = await client.delete(f"/v1/sessions/{child_id}")
|
|
assert del_resp.status_code == 200, del_resp.text
|
|
|
|
late_status = await client.post(
|
|
f"/v1/sessions/{child_id}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle", "output": "LATE_AFTER_DELETE"},
|
|
},
|
|
)
|
|
|
|
# The fix: delete drops the runner-known name, so the late status is a
|
|
# clean 204 no-op. Without the pop the name lingers and this returns a
|
|
# spurious 503 subagent_delivery_not_confirmed.
|
|
assert late_status.status_code == 204, late_status.text
|
|
|
|
|
|
# ── Omnigent REPL terminal auto-create (SDK sessions) ────────────────
|
|
|
|
|
|
@dataclass
|
|
class _RecordedPatch:
|
|
"""
|
|
A PATCH captured from the REPL terminal auto-create helper.
|
|
|
|
:param url: Request path, e.g. ``"/v1/sessions/conv_repl"``.
|
|
:param json: JSON body, e.g. ``{"labels": {"omnigent.ui": "terminal"}}``.
|
|
"""
|
|
|
|
url: str
|
|
json: dict[str, Any]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_repl_terminal_launches_attach_and_stamps_label(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The REPL terminal hosts ``omnigent attach`` and stamps the UI label.
|
|
|
|
The web UI embeds the framework's own TUI for SDK sessions through
|
|
this terminal: the spec must run ``omnigent attach <session_id>
|
|
--server <runner's server URL>`` (a co-drive client of the live
|
|
session), defer the process to first attach, pin the cwd to the
|
|
runner workspace, stamp the ``omnigent.ui: terminal`` label that
|
|
gates the web Chat/Terminal pill, and publish the resource on the
|
|
live stream. Each wrong value maps to a distinct user-facing break:
|
|
wrong command/args → dead pane or wrong session; missing label →
|
|
no pill; missing publish → pill stays gray until refresh.
|
|
|
|
:param tmp_path: Temporary directory for the fake runner workspace.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
from omnigent._wrapper_labels import UI_MODE_LABEL_KEY, UI_MODE_TERMINAL_VALUE
|
|
|
|
session_id = "conv_repl"
|
|
workspace = tmp_path / "workspace"
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(workspace))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
|
|
launched_specs: list[Any] = []
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Resource registry that records the launched REPL terminal spec."""
|
|
|
|
async def launch_auxiliary_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""
|
|
Record the terminal launch request.
|
|
|
|
:param session_id: Session id being launched.
|
|
:param terminal_name: Terminal name, e.g. ``"tui"``.
|
|
:param session_key: Terminal session key, e.g. ``"main"``.
|
|
:param spec: Terminal launch spec.
|
|
:param resource_role: Private runner resource marker.
|
|
:returns: Terminal resource view.
|
|
"""
|
|
assert session_id == "conv_repl"
|
|
assert terminal_name == "tui"
|
|
assert session_key == "main"
|
|
# The REPL role marks the pane for recreate-on-attach (a
|
|
# dead REPL pane is relaunched instead of rejected with
|
|
# 4404). It is distinct from CLAUDE_NATIVE_TERMINAL_ROLE,
|
|
# so the pane's activity still does not drive the
|
|
# session's working status.
|
|
assert resource_role == OMNIGENT_REPL_TERMINAL_ROLE
|
|
launched_specs.append(spec)
|
|
return SessionResourceView(
|
|
id="terminal_tui_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="tui",
|
|
)
|
|
|
|
class _PatchRecordingServerClient:
|
|
"""Server client that records label PATCHes from the helper."""
|
|
|
|
def __init__(self) -> None:
|
|
""":returns: None."""
|
|
self.patches: list[_RecordedPatch] = []
|
|
|
|
async def patch(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
"""
|
|
Record the PATCH and return a 200.
|
|
|
|
:param url: Request path, e.g. ``"/v1/sessions/conv_repl"``.
|
|
:param kwargs: Request keyword arguments carrying ``json``.
|
|
:returns: HTTP 200 response.
|
|
"""
|
|
self.patches.append(_RecordedPatch(url=url, json=kwargs.get("json") or {}))
|
|
return httpx.Response(200, json={}, request=httpx.Request("PATCH", url))
|
|
|
|
published_events: list[dict[str, Any]] = []
|
|
server_client = _PatchRecordingServerClient()
|
|
|
|
terminal_view = await _auto_create_repl_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, event: published_events.append(event),
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
assert terminal_view.id == "terminal_tui_main"
|
|
assert len(launched_specs) == 1
|
|
launched = launched_specs[0]
|
|
# The hosted TUI is the framework's own REPL joining THIS session on
|
|
# THIS server. A wrong interpreter/module means the pane dies at
|
|
# first attach; a wrong session id or --server URL attaches the REPL
|
|
# to the wrong place.
|
|
assert launched.command == sys.executable
|
|
assert launched.args == [
|
|
"-m",
|
|
"omnigent",
|
|
"attach",
|
|
session_id,
|
|
"--server",
|
|
"http://ap.example",
|
|
]
|
|
# Deferred start: the REPL process must not run until the first web
|
|
# client attaches — never-opened terminals stay an idle tmux pane,
|
|
# and the session is fully live by first attach.
|
|
assert launched.tmux_start_on_attach is True
|
|
# cwd pins to the runner workspace (same convention as the
|
|
# claude-native terminal); a wrong cwd drops the REPL into $HOME.
|
|
assert launched.os_env.cwd == str(workspace)
|
|
# The presentation label gates the web Chat/Terminal pill
|
|
# (TerminalFirstContext); without this PATCH the embedded terminal
|
|
# is unreachable from the UI.
|
|
assert server_client.patches == [
|
|
_RecordedPatch(
|
|
url=f"/v1/sessions/{session_id}",
|
|
json={"labels": {UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE}},
|
|
)
|
|
]
|
|
# The live resource event enables the toggle without a refresh
|
|
# (snapshot-on-connect only covers clients that connect later).
|
|
assert published_events[0]["type"] == "session.resource.created"
|
|
assert published_events[0]["resource"]["id"] == "terminal_tui_main"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_repl_terminal_inherits_agent_sandbox(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The REPL terminal honours the agent's ``os_env.sandbox``.
|
|
|
|
Regression for the same sandbox-override bug #175 fixed on the
|
|
codex/claude auto-create paths but missed on the REPL path: the
|
|
REPL auto-create built a fresh ``TerminalEnvSpec`` whose ``os_env``
|
|
carried no ``sandbox`` and passed no ``parent_os_env``, so
|
|
``launch_terminal`` fell back to ``_default_sandbox_for_platform``
|
|
(``linux_bwrap`` / ``darwin_seatbelt``) and ignored the agent YAML.
|
|
An SDK-harness agent that declares ``os_env.sandbox.type: none``
|
|
(relying on the outer container/VM as the boundary) was wrongly
|
|
forced into bwrap, which then failed to start in a hardened
|
|
container with ``native_terminal_start_failed``.
|
|
|
|
Asserts the launched terminal spec's ``os_env.sandbox`` is the
|
|
agent's ``none`` sandbox (not the platform default) and that the
|
|
agent's ``os_env`` is threaded through as the launch
|
|
``parent_os_env`` so the rest of the policy is inherited too.
|
|
|
|
:param tmp_path: Temporary directory for the fake runner workspace.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
|
|
session_id = "conv_repl_sandbox_none"
|
|
workspace = tmp_path / "workspace"
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(workspace))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Captures the launched REPL terminal spec and parent os_env."""
|
|
|
|
async def launch_auxiliary_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Record the spec + parent_os_env and return a resource view."""
|
|
del terminal_name, session_key, resource_role
|
|
captured["spec"] = spec
|
|
captured["parent_os_env"] = parent_os_env
|
|
return SessionResourceView(
|
|
id="terminal_tui_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="tui",
|
|
)
|
|
|
|
class _PatchRecordingServerClient:
|
|
"""Server client that absorbs the label PATCH from the helper."""
|
|
|
|
async def patch(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
"""Return a 200 for the presentation-label PATCH."""
|
|
del kwargs
|
|
return httpx.Response(200, json={}, request=httpx.Request("PATCH", url))
|
|
|
|
# An agent that declares sandbox: none (runs unconfined; the outer
|
|
# container/VM is the boundary).
|
|
agent_os_env = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd=".",
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
)
|
|
agent_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="sdk_worker",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "openai-agents", "model": "claude-default"},
|
|
),
|
|
os_env=agent_os_env,
|
|
)
|
|
|
|
await _auto_create_repl_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, _evt: None,
|
|
server_client=_PatchRecordingServerClient(), # type: ignore[arg-type]
|
|
agent_spec=agent_spec,
|
|
)
|
|
|
|
launched_sandbox = captured["spec"].os_env.sandbox
|
|
assert launched_sandbox is not None, (
|
|
"REPL auto-create dropped the agent's sandbox; launch_terminal will "
|
|
"fall back to _default_sandbox_for_platform (bwrap), overriding "
|
|
"sandbox: none"
|
|
)
|
|
assert launched_sandbox.type == "none"
|
|
# The whole os_env is threaded through as the inheritance parent.
|
|
assert captured["parent_os_env"] is agent_os_env
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("harness", "sub_agent_name", "expect_created"),
|
|
[
|
|
# SDK harness, top-level → REPL terminal auto-creates.
|
|
("openai-agents", None, True),
|
|
# Sub-agent sessions surface through the parent transcript — no
|
|
# REPL pane of their own.
|
|
("openai-agents", "worker", False),
|
|
# Native harnesses own a dedicated terminal (the vendor TUI); the
|
|
# REPL pane must not double up next to it.
|
|
("codex-native", None, False),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_repl_terminal_dispatch(
|
|
harness: str,
|
|
sub_agent_name: str | None,
|
|
expect_created: bool,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``POST /v1/sessions`` auto-creates the REPL terminal for SDK sessions only.
|
|
|
|
Exercises the route-level dispatch condition: non-native harness AND
|
|
top-level session AND a terminal registry present. If the condition
|
|
regresses, either SDK sessions lose their embedded web TUI (no
|
|
create) or native / sub-agent sessions grow a spurious second
|
|
terminal (over-create).
|
|
|
|
:param harness: Harness id resolved from the agent spec,
|
|
e.g. ``"openai-agents"``.
|
|
:param sub_agent_name: ``sub_agent_name`` in the POST body, or
|
|
``None`` for a top-level session.
|
|
:param expect_created: Whether the REPL auto-create must fire.
|
|
:param tmp_path: Temporary directory isolating bridge state.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
# Keep the codex-native branch's bridge writes inside tmp_path.
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="dispatch-agent",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": harness}),
|
|
)
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the parametrized spec for any agent id."""
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
created_sessions: list[str] = []
|
|
|
|
async def _fake_auto_create_repl(
|
|
session_id: str,
|
|
resource_registry: Any,
|
|
publish_event: Any,
|
|
*,
|
|
server_client: Any,
|
|
agent_spec: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Record the dispatch instead of launching a real tmux pane."""
|
|
del resource_registry, publish_event, server_client, agent_spec
|
|
created_sessions.append(session_id)
|
|
return SessionResourceView(
|
|
id="terminal_tui_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="tui",
|
|
)
|
|
|
|
monkeypatch.setattr(runner_app_mod, "_auto_create_repl_terminal", _fake_auto_create_repl)
|
|
|
|
async def _fake_codex_needs(server_client: Any, session_id: str) -> bool:
|
|
"""Neutralize the codex-native terminal branch (out of scope here)."""
|
|
del server_client, session_id
|
|
return False
|
|
|
|
monkeypatch.setattr(runner_app_mod, "_codex_session_needs_runner_terminal", _fake_codex_needs)
|
|
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
# A real (empty) registry: the dispatch gate requires one, and
|
|
# ``get()`` on it reports no existing REPL terminal.
|
|
terminal_registry=TerminalRegistry(),
|
|
)
|
|
|
|
body: dict[str, Any] = {"session_id": "conv_dispatch", "agent_id": "ag_dispatch"}
|
|
if sub_agent_name is not None:
|
|
body["sub_agent_name"] = sub_agent_name
|
|
async with _runner_client(app) as client:
|
|
resp = await client.post("/v1/sessions", json=body)
|
|
|
|
assert resp.status_code == 201, resp.text
|
|
# Dispatch fired exactly for the SDK top-level case. An unexpected
|
|
# entry here means natives/sub-agents grew a REPL pane; a missing
|
|
# one means SDK sessions lost the embedded web TUI.
|
|
assert created_sessions == (["conv_dispatch"] if expect_created else [])
|
|
|
|
|
|
# ── Sub-agent wake-POST status check + bounded retry ──────────────────
|
|
|
|
|
|
@dataclass
|
|
class _WakePost:
|
|
"""
|
|
A single recorded POST made by ``_QueuedResponseServerClient``.
|
|
|
|
:param url: The path the wake notice was POSTed to, e.g.
|
|
``"/v1/sessions/conv_parent123/events"``.
|
|
:param notice: The injected notice text pulled out of the request body.
|
|
"""
|
|
|
|
url: str
|
|
notice: str
|
|
|
|
|
|
class _QueuedResponseServerClient:
|
|
"""
|
|
Omnigent HTTP client stub that returns a fixed queue of real responses.
|
|
|
|
A real stub (NOT ``MagicMock``) so that an unexpected attribute access or
|
|
an extra POST beyond the queue fails the test loudly instead of silently
|
|
returning a truthy mock. Each ``post`` pops the next pre-built
|
|
:class:`httpx.Response` (so ``raise_for_status`` runs its real logic —
|
|
a 503 raises, a 200 does not) and records the call for assertions.
|
|
|
|
:param responses: Responses to return in order, one per ``post`` call,
|
|
e.g. ``[httpx.Response(503, ...), httpx.Response(200, ...)]``.
|
|
"""
|
|
|
|
def __init__(self, responses: list[httpx.Response]) -> None:
|
|
"""
|
|
Store the response queue and an empty call log.
|
|
|
|
:param responses: Responses to return in order, one per POST.
|
|
:returns: None.
|
|
"""
|
|
self._responses = list(responses)
|
|
self.calls: list[_WakePost] = []
|
|
|
|
async def post(self, url: str, *, json: dict[str, Any], timeout: float) -> httpx.Response:
|
|
"""
|
|
Record the POST and return the next queued response.
|
|
|
|
:param url: Target path, e.g. ``"/v1/sessions/conv_p/events"``.
|
|
:param json: Wake-notice request body in the ingest message shape.
|
|
:param timeout: Per-request timeout (recorded only, not enforced).
|
|
:returns: The next pre-built response from the queue.
|
|
:raises AssertionError: If more POSTs are made than responses queued.
|
|
"""
|
|
notice = json["data"]["content"][0]["text"]
|
|
self.calls.append(_WakePost(url=url, notice=notice))
|
|
assert self._responses, (
|
|
f"Wake POST made {len(self.calls)} call(s) but only "
|
|
f"{len(self.calls) - 1} response(s) were queued — the retry "
|
|
f"loop exceeded its bound."
|
|
)
|
|
return self._responses.pop(0)
|
|
|
|
|
|
def _wake_response(status_code: int, parent_id: str) -> httpx.Response:
|
|
"""
|
|
Build a real ``httpx.Response`` for a wake POST to ``parent_id``.
|
|
|
|
A request is attached so ``raise_for_status`` can construct a proper
|
|
``HTTPStatusError`` on non-2xx, matching what httpx does in production.
|
|
|
|
:param status_code: HTTP status to simulate, e.g. ``503``.
|
|
:param parent_id: Parent session id used to build the request URL.
|
|
:returns: A response carrying a representative JSON body.
|
|
"""
|
|
request = httpx.Request("POST", f"http://test/v1/sessions/{parent_id}/events")
|
|
body = (
|
|
{"error": {"code": "RUNNER_UNAVAILABLE", "message": "runner reconnecting"}}
|
|
if status_code >= 400
|
|
else {"id": "evt_1", "object": "session.event"}
|
|
)
|
|
return httpx.Response(status_code, request=request, json=body)
|
|
|
|
|
|
@pytest.fixture
|
|
def _no_wake_backoff(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
|
"""
|
|
Replace the wake retry sleep with a deterministic recorder.
|
|
|
|
Patches the module-level ``_wake_retry_sleep`` indirection helper (NOT
|
|
the global ``asyncio.sleep``, which the ``no-global-asyncio-patch`` hook
|
|
bans) so retries do not actually wait, and exposes the requested backoff
|
|
delays so a test can assert how many retries occurred.
|
|
|
|
:param monkeypatch: pytest monkeypatch fixture.
|
|
:returns: A list that accumulates the backoff delays requested, in order.
|
|
"""
|
|
recorded: list[float] = []
|
|
|
|
async def _record(seconds: float) -> None:
|
|
recorded.append(seconds)
|
|
|
|
monkeypatch.setattr("omnigent.runner.app._wake_retry_sleep", _record)
|
|
return recorded
|
|
|
|
|
|
async def test_wake_post_retries_transient_503_then_succeeds(
|
|
_no_wake_backoff: list[float],
|
|
) -> None:
|
|
"""
|
|
A transient 503 wake response is retried and the next 200 succeeds.
|
|
|
|
Guards the core bug: Omnigent returns a genuine 503 ``RUNNER_UNAVAILABLE``
|
|
*response* (not a transport exception) while the parent's runner tunnel
|
|
reconnects. The wake POST must treat that as a failure and retry, not
|
|
accept it as delivered.
|
|
"""
|
|
parent_id = "conv_parent_503_then_ok"
|
|
client = _QueuedResponseServerClient(
|
|
[_wake_response(503, parent_id), _wake_response(200, parent_id)]
|
|
)
|
|
|
|
delivered = await _deliver_subagent_wake_post(
|
|
client, # type: ignore[arg-type]
|
|
parent_id,
|
|
"[System: worker completed]",
|
|
)
|
|
|
|
# Returns True only because the retry re-POSTed after the 503 and got a
|
|
# 200. If the status check were missing, the first 503 would be treated
|
|
# as success and there would be exactly one call with delivered already
|
|
# True — so both the count and the value below pin the fix.
|
|
assert delivered is True
|
|
# Exactly two POSTs: the 503 attempt + the 200 retry. One call would mean
|
|
# the 503 was silently accepted; three would mean it retried past success.
|
|
assert len(client.calls) == 2, (
|
|
f"Expected 2 wake POSTs (503 then 200 retry), got {len(client.calls)}."
|
|
)
|
|
# Both POSTs targeted the parent's events endpoint with the same notice.
|
|
assert client.calls[0].url == f"/v1/sessions/{parent_id}/events"
|
|
assert client.calls[1].notice == "[System: worker completed]"
|
|
# Exactly one backoff slept (between the two attempts) — proves the retry
|
|
# path ran rather than the call being retried zero or two+ times.
|
|
assert len(_no_wake_backoff) == 1, (
|
|
f"Expected one backoff before the single retry, got {_no_wake_backoff}."
|
|
)
|
|
|
|
|
|
async def test_wake_post_persistent_503_returns_failure(
|
|
_no_wake_backoff: list[float],
|
|
) -> None:
|
|
"""
|
|
A 503 on every attempt exhausts the retry budget and reports failure.
|
|
|
|
This is the regression guard for the silent-strand bug: a 503 must be
|
|
surfaced as a delivery failure (so the caller releases the debounce flag
|
|
and logs), never swallowed as a success.
|
|
"""
|
|
parent_id = "conv_parent_always_503"
|
|
client = _QueuedResponseServerClient(
|
|
[_wake_response(503, parent_id) for _ in range(_WAKE_POST_MAX_ATTEMPTS)]
|
|
)
|
|
|
|
delivered = await _deliver_subagent_wake_post(
|
|
client, # type: ignore[arg-type]
|
|
parent_id,
|
|
"[System: worker completed]",
|
|
)
|
|
|
|
# False = the non-2xx response was treated as a failure. Before the fix
|
|
# this returned (implicitly) success and the wake was considered delivered.
|
|
assert delivered is False
|
|
# Attempted exactly the bounded budget — not once (no retry) and not
|
|
# unbounded. The stub would have asserted on a call past the queue.
|
|
assert len(client.calls) == _WAKE_POST_MAX_ATTEMPTS, (
|
|
f"Expected {_WAKE_POST_MAX_ATTEMPTS} attempts on persistent 503, got {len(client.calls)}."
|
|
)
|
|
# One backoff fewer than attempts: we don't sleep after the final attempt.
|
|
assert len(_no_wake_backoff) == _WAKE_POST_MAX_ATTEMPTS - 1, (
|
|
f"Expected {_WAKE_POST_MAX_ATTEMPTS - 1} backoffs between "
|
|
f"{_WAKE_POST_MAX_ATTEMPTS} attempts, got {_no_wake_backoff}."
|
|
)
|
|
|
|
|
|
async def test_wake_post_permanent_4xx_not_retried(
|
|
_no_wake_backoff: list[float],
|
|
) -> None:
|
|
"""
|
|
A permanent 4xx wake rejection fails immediately without retrying.
|
|
|
|
A 400 is a client-side rejection that retrying cannot fix, so the loop
|
|
must give up after one attempt rather than burn the whole budget.
|
|
"""
|
|
parent_id = "conv_parent_400"
|
|
client = _QueuedResponseServerClient([_wake_response(400, parent_id)])
|
|
|
|
delivered = await _deliver_subagent_wake_post(
|
|
client, # type: ignore[arg-type]
|
|
parent_id,
|
|
"[System: worker completed]",
|
|
)
|
|
|
|
# Permanent rejection => failure, no delivery.
|
|
assert delivered is False
|
|
# Exactly one attempt: a permanent 4xx is not retried. Two+ would mean the
|
|
# classifier wrongly treated 400 as transient.
|
|
assert len(client.calls) == 1, (
|
|
f"Expected a single attempt on permanent 400, got {len(client.calls)}."
|
|
)
|
|
# No backoff at all — the loop exited before any sleep.
|
|
assert _no_wake_backoff == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"status_code,expected_retryable",
|
|
[
|
|
(503, True), # RUNNER_UNAVAILABLE — the routine reconnect case
|
|
(500, True), # generic server error
|
|
(429, True), # rate limit — explicitly transient
|
|
(409, True), # conflict — explicitly transient
|
|
(400, False), # bad request — permanent
|
|
(404, False), # not found — permanent
|
|
],
|
|
)
|
|
def test_wake_post_is_retryable_status_classification(
|
|
status_code: int, expected_retryable: bool
|
|
) -> None:
|
|
"""
|
|
The status classifier retries 5xx + transient 4xx, not permanent 4xx.
|
|
|
|
:param status_code: Simulated HTTP status on the wake response.
|
|
:param expected_retryable: Whether that status should be retried.
|
|
"""
|
|
request = httpx.Request("POST", "http://test/v1/sessions/p/events")
|
|
exc = httpx.HTTPStatusError(
|
|
"wake rejected",
|
|
request=request,
|
|
response=httpx.Response(status_code, request=request),
|
|
)
|
|
# Pins which statuses cost a retry vs. fail fast; a wrong verdict here
|
|
# would either waste the budget on permanent errors or give up on a 503.
|
|
assert _wake_post_is_retryable(exc) is expected_retryable
|
|
|
|
|
|
def test_wake_post_transport_error_is_retryable() -> None:
|
|
"""
|
|
A transport-level error (no response) is always retryable.
|
|
|
|
A ``ConnectError`` carries no HTTP response — the POST may never have
|
|
reached Omnigent — so the wake should be retried.
|
|
"""
|
|
request = httpx.Request("POST", "http://test/v1/sessions/p/events")
|
|
exc = httpx.ConnectError("connection refused", request=request)
|
|
# True because a transport failure is not a definitive server rejection.
|
|
assert _wake_post_is_retryable(exc) is True
|
|
|
|
|
|
# ── Per-session transcript-forwarder registry (double-mirror regression) ──
|
|
|
|
|
|
@dataclass
|
|
class _ForwarderRun:
|
|
"""
|
|
One spawned transcript-forwarder stub run.
|
|
|
|
:param task: The asyncio task executing this run, captured via
|
|
``asyncio.current_task()`` when the stub body starts. Used for
|
|
registry-independent cleanup.
|
|
:param cancelled: ``True`` once the parked run observed
|
|
:class:`asyncio.CancelledError`.
|
|
"""
|
|
|
|
task: asyncio.Task[Any] | None = None
|
|
cancelled: bool = False
|
|
|
|
|
|
async def _drain_forwarder_runs(runs: list[_ForwarderRun]) -> None:
|
|
"""
|
|
Cancel and await any still-parked forwarder stub runs.
|
|
|
|
Test cleanup helper so a failed assertion never leaks a parked task
|
|
(or a registry entry) into the next test.
|
|
|
|
:param runs: Stub runs recorded by a parking forwarder fake.
|
|
:returns: None.
|
|
"""
|
|
leftovers = [run.task for run in runs if run.task is not None and not run.task.done()]
|
|
for task in leftovers:
|
|
task.cancel()
|
|
if leftovers:
|
|
await asyncio.wait(leftovers)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_auto_forwarder_task_cancels_and_awaits_registered_task() -> None:
|
|
"""
|
|
Cancelling a session's registered forwarder awaits its completion.
|
|
|
|
This is the ordering guarantee the claude re-create path relies on:
|
|
``_cancel_auto_forwarder_task`` must not return while the old task can
|
|
still post items (it runs right before the bridge's forward-cursor
|
|
state is wiped).
|
|
"""
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
session_id = "conv_fwd_cancel_awaits"
|
|
run = _ForwarderRun()
|
|
|
|
async def _parked() -> None:
|
|
"""Park forever like the restart-forever supervisor."""
|
|
run.task = asyncio.current_task()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
run.cancelled = True
|
|
raise
|
|
|
|
try:
|
|
task = asyncio.create_task(_parked())
|
|
runner_app_mod._register_auto_forwarder_task(session_id, task)
|
|
# Yield so the coroutine body starts (a never-started task would be
|
|
# dropped without ever entering the except branch).
|
|
await asyncio.sleep(0)
|
|
assert not task.done()
|
|
|
|
await runner_app_mod._cancel_auto_forwarder_task(session_id)
|
|
|
|
# cancelled() is only True for a FINISHED cancelled task, proving
|
|
# the helper awaited completion rather than fire-and-forgetting.
|
|
assert task.cancelled(), (
|
|
"Registered forwarder task must be finished-cancelled after "
|
|
"_cancel_auto_forwarder_task returns; a live task here means the "
|
|
"helper did not await the cancellation."
|
|
)
|
|
# The coroutine body observed the cancel — the parked await was
|
|
# actually interrupted, not skipped.
|
|
assert run.cancelled is True
|
|
# The slot is freed for the successor registration.
|
|
assert session_id not in runner_app_mod._AUTO_FORWARDER_TASKS
|
|
# Idempotent: a second cancel with no registered task is a no-op.
|
|
await runner_app_mod._cancel_auto_forwarder_task(session_id)
|
|
finally:
|
|
runner_app_mod._AUTO_FORWARDER_TASKS.pop(session_id, None)
|
|
await _drain_forwarder_runs([run])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_auto_forwarder_task_replaces_incumbent_and_survives_stale_evict() -> None:
|
|
"""
|
|
Re-registration cancels the incumbent; its done-callback can't evict the successor.
|
|
|
|
Two claims:
|
|
|
|
1. Registering task B for a session that already holds live task A
|
|
cancels A (no session ever runs two forwarders).
|
|
2. A's done-callback fires AFTER B occupies the slot; the eviction is
|
|
identity-checked, so the stale callback must leave B registered.
|
|
Without the identity check, B would lose its strong reference and
|
|
the registry would report no forwarder for a session that has one.
|
|
"""
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
session_id = "conv_fwd_stale_evict"
|
|
run_a = _ForwarderRun()
|
|
run_b = _ForwarderRun()
|
|
|
|
async def _parked(run: _ForwarderRun) -> None:
|
|
"""Park forever; record cancellation on the given run."""
|
|
run.task = asyncio.current_task()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
run.cancelled = True
|
|
raise
|
|
|
|
try:
|
|
task_a = asyncio.create_task(_parked(run_a))
|
|
runner_app_mod._register_auto_forwarder_task(session_id, task_a)
|
|
await asyncio.sleep(0)
|
|
|
|
task_b = asyncio.create_task(_parked(run_b))
|
|
runner_app_mod._register_auto_forwarder_task(session_id, task_b)
|
|
|
|
# Claim 1: the incumbent was cancelled by the replacement.
|
|
await asyncio.wait({task_a})
|
|
assert run_a.cancelled is True, (
|
|
"Registering a successor must cancel the live incumbent; a "
|
|
"surviving incumbent is exactly the double-mirror bug."
|
|
)
|
|
# Let task_a's done-callback (the stale evict) run.
|
|
await asyncio.sleep(0)
|
|
await asyncio.sleep(0)
|
|
|
|
# Claim 2: the stale callback did not evict the successor.
|
|
assert runner_app_mod._AUTO_FORWARDER_TASKS.get(session_id) is task_b, (
|
|
"Task A's done-callback evicted task B — eviction must be "
|
|
"identity-checked so a predecessor's completion cannot drop the "
|
|
"live successor's registration."
|
|
)
|
|
# The successor must still be running — done here means A's cancel hit B.
|
|
assert not task_b.done()
|
|
finally:
|
|
runner_app_mod._AUTO_FORWARDER_TASKS.pop(session_id, None)
|
|
await _drain_forwarder_runs([run_a, run_b])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_forwarder_registry_isolates_sessions_and_evicts_completed() -> None:
|
|
"""
|
|
Per-session keying: cancelling one session leaves another's forwarder running.
|
|
|
|
Also pins the natural-completion eviction: a registered task that
|
|
finishes on its own removes its entry (the dict must not leak entries
|
|
the way the old set relied on ``discard`` for).
|
|
"""
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
run_a = _ForwarderRun()
|
|
run_b = _ForwarderRun()
|
|
|
|
async def _parked(run: _ForwarderRun) -> None:
|
|
"""Park forever; record cancellation on the given run."""
|
|
run.task = asyncio.current_task()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
run.cancelled = True
|
|
raise
|
|
|
|
try:
|
|
task_a = asyncio.create_task(_parked(run_a))
|
|
task_b = asyncio.create_task(_parked(run_b))
|
|
runner_app_mod._register_auto_forwarder_task("conv_fwd_sess_a", task_a)
|
|
runner_app_mod._register_auto_forwarder_task("conv_fwd_sess_b", task_b)
|
|
await asyncio.sleep(0)
|
|
|
|
await runner_app_mod._cancel_auto_forwarder_task("conv_fwd_sess_a")
|
|
|
|
assert run_a.cancelled is True
|
|
# Session B's forwarder is untouched by session A's cancel — keying
|
|
# by session id must not regress to whole-registry cancellation.
|
|
assert run_b.cancelled is False
|
|
assert not task_b.done()
|
|
assert runner_app_mod._AUTO_FORWARDER_TASKS.get("conv_fwd_sess_b") is task_b
|
|
|
|
# Natural completion evicts the entry (no leak for finished tasks).
|
|
task_b.cancel()
|
|
await asyncio.wait({task_b})
|
|
await asyncio.sleep(0)
|
|
assert "conv_fwd_sess_b" not in runner_app_mod._AUTO_FORWARDER_TASKS
|
|
finally:
|
|
runner_app_mod._AUTO_FORWARDER_TASKS.pop("conv_fwd_sess_a", None)
|
|
runner_app_mod._AUTO_FORWARDER_TASKS.pop("conv_fwd_sess_b", None)
|
|
await _drain_forwarder_runs([run_a, run_b])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_claude_terminal_recreate_cancels_prior_forwarder(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Re-running claude terminal auto-create leaves exactly one live forwarder.
|
|
|
|
Regression for the recovery path: ``create_session_terminal``'s
|
|
ensure branch re-runs ``_auto_create_claude_terminal`` after a bridge
|
|
closure, but the prior ``supervise_forwarder`` task is restart-forever
|
|
and survives pane death (it re-resolves the transcript path each loop).
|
|
Before the per-session registry, the second create wiped the shared
|
|
forward cursor and spawned a second forwarder, so both tasks mirrored
|
|
every post-recovery transcript record into the session — each item
|
|
persisted twice (the server has no external-item dedup).
|
|
|
|
:param tmp_path: Pytest-provided temporary directory.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
|
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
|
|
|
session_id = "conv_fwd_recreate"
|
|
runs: list[_ForwarderRun] = []
|
|
|
|
async def _parking_forwarder(**kwargs: Any) -> None:
|
|
"""Park forever like the real restart-forever supervisor."""
|
|
del kwargs
|
|
run = _ForwarderRun(task=asyncio.current_task())
|
|
runs.append(run)
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
run.cancelled = True
|
|
raise
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.claude_native_forwarder.supervise_forwarder",
|
|
_parking_forwarder,
|
|
)
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Captures terminal launches; no live terminal registry."""
|
|
|
|
terminal_registry = None
|
|
|
|
async def launch_required_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Return a terminal resource view without launching tmux."""
|
|
del terminal_name, session_key, spec, resource_role
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="claude:main",
|
|
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
|
)
|
|
|
|
try:
|
|
await _auto_create_claude_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, _evt: None,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
# Let forwarder A start and park — in production the recovery
|
|
# re-create fires long after the original create's task is running.
|
|
await asyncio.sleep(0)
|
|
|
|
# The recovery path: terminal resource gone, ensure re-creates.
|
|
await _auto_create_claude_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, _evt: None,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
await asyncio.sleep(0)
|
|
|
|
# Both creates spawned a forwarder; the recovery one is the survivor.
|
|
assert len(runs) == 2, (
|
|
f"Expected 2 forwarder spawns (one per auto-create), got {len(runs)}."
|
|
)
|
|
# The first forwarder was cancelled by the re-create — a False here
|
|
# is the production bug: two live tasks double-posting every record.
|
|
assert runs[0].cancelled is True, (
|
|
"Re-creating the claude terminal must cancel the prior session "
|
|
"forwarder; it survived, so every post-recovery transcript "
|
|
"record would be mirrored twice."
|
|
)
|
|
# The recovery's own forwarder survives — cancelled here means the
|
|
# re-create killed its replacement and the session mirrors nothing.
|
|
assert runs[1].cancelled is False
|
|
live_runs = [run for run in runs if not run.cancelled]
|
|
# Exactly one live forwarder mirrors the transcript for the session.
|
|
assert len(live_runs) == 1
|
|
# The registry holds exactly the live task for this session, keyed
|
|
# by session id — this is the strong reference that keeps it alive.
|
|
registered = runner_app_mod._AUTO_FORWARDER_TASKS.get(session_id)
|
|
assert registered is live_runs[0].task
|
|
# Still running: a done survivor would leave the session unmirrored.
|
|
assert not registered.done()
|
|
finally:
|
|
runner_app_mod._AUTO_FORWARDER_TASKS.pop(session_id, None)
|
|
await _drain_forwarder_runs(runs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_create_codex_terminal_recreate_cancels_prior_forwarder(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Re-running codex terminal auto-create leaves exactly one live forwarder.
|
|
|
|
Codex flavor of the claude double-mirror regression: the codex spawn
|
|
registered its forwarder task in the same unkeyed set, so an ensure
|
|
re-create for an existing session leaked the prior known-thread
|
|
forwarder alongside the new one.
|
|
|
|
:param tmp_path: Temporary directory for isolated bridge state.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
import omnigent.codex_native_app_server as codex_app_mod
|
|
import omnigent.runner.app as runner_app_mod
|
|
|
|
session_id = "conv_codex_fwd_recreate"
|
|
thread_id = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
monkeypatch.setenv("OMNIGENT_RUNNER_WORKSPACE", str(tmp_path / "workspace"))
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", "http://ap.example")
|
|
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
|
monkeypatch.setattr("omnigent.runner._entry._make_auth_token_factory", lambda: None)
|
|
|
|
class _SnapshotServerClient:
|
|
"""Server client returning a persisted resume thread + one item."""
|
|
|
|
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
"""
|
|
Return the session snapshot / items consumed by the helper.
|
|
|
|
:param url: Request path, e.g. ``"/v1/sessions/conv_..."``.
|
|
:param kwargs: Request keyword arguments (ignored).
|
|
:returns: HTTP 200 response carrying launch config or items.
|
|
"""
|
|
del kwargs
|
|
if url == f"/v1/sessions/{session_id}/items":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"data": [
|
|
{
|
|
"id": "msg_user_1",
|
|
"response_id": "codex_turn_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "remember this"}],
|
|
}
|
|
],
|
|
"has_more": False,
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
json={"external_session_id": thread_id},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
|
|
class _FakeCodexAppServer:
|
|
"""Minimal app-server object used by ``codex_terminal_env``."""
|
|
|
|
codex_path = "/opt/codex/bin/codex"
|
|
|
|
def __init__(self) -> None:
|
|
""":returns: None."""
|
|
self.env = {"OPENAI_API_KEY": "sk-test"}
|
|
self.codex_home = tmp_path / "unconfigured-codex-home"
|
|
self.listen_url: str | None = None
|
|
self.config_overrides: list[str] = []
|
|
|
|
async def start(self) -> None:
|
|
""":returns: None."""
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
def _fake_build_codex_native_server(**kwargs: Any) -> _FakeCodexAppServer:
|
|
"""
|
|
Build a fresh fake app-server per create call.
|
|
|
|
:param kwargs: Keyword arguments passed by the runner helper.
|
|
:returns: Fake app-server bound to the requested CODEX_HOME.
|
|
"""
|
|
app_server = _FakeCodexAppServer()
|
|
app_server.codex_home = kwargs["codex_home"]
|
|
return app_server
|
|
|
|
class _UnexpectedDiscoveryClient:
|
|
"""App-server client that must not connect on a known-thread resume."""
|
|
|
|
def __init__(self, *, ws_url: str, client_name: str) -> None:
|
|
"""
|
|
:param ws_url: App-server WebSocket URL.
|
|
:param client_name: JSON-RPC client name.
|
|
"""
|
|
self.ws_url = ws_url
|
|
self.client_name = client_name
|
|
|
|
async def connect(self) -> None:
|
|
"""Fail if the resume path tries to discover a fresh thread."""
|
|
raise AssertionError("resume path must not connect discovery client")
|
|
|
|
async def close(self) -> None:
|
|
""":returns: None."""
|
|
|
|
async def _fake_preload_thread(transport: str, loaded_thread_id: str) -> None:
|
|
"""
|
|
No-op thread preload.
|
|
|
|
:param transport: App-server transport URL.
|
|
:param loaded_thread_id: Thread id passed to ``thread/resume``.
|
|
:returns: None.
|
|
"""
|
|
del transport, loaded_thread_id
|
|
|
|
runs: list[_ForwarderRun] = []
|
|
|
|
async def _parking_forward_known_thread(**kwargs: Any) -> None:
|
|
"""Park forever in place of the known-thread forwarder."""
|
|
del kwargs
|
|
run = _ForwarderRun(task=asyncio.current_task())
|
|
runs.append(run)
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
run.cancelled = True
|
|
raise
|
|
|
|
class _FakeResourceRegistry:
|
|
"""Returns a terminal resource view without launching tmux."""
|
|
|
|
async def launch_auxiliary_terminal(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
spec: Any,
|
|
resource_role: str | None = None,
|
|
parent_os_env: Any = None,
|
|
) -> SessionResourceView:
|
|
"""Return a terminal resource view for the codex TUI."""
|
|
del terminal_name, session_key, spec, resource_role
|
|
return SessionResourceView(
|
|
id="terminal_codex_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="Codex",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
codex_app_mod,
|
|
"build_codex_native_server",
|
|
_fake_build_codex_native_server,
|
|
)
|
|
monkeypatch.setattr(codex_app_mod, "CodexAppServerClient", _UnexpectedDiscoveryClient)
|
|
monkeypatch.setattr(codex_app_mod, "preload_codex_thread_for_resume", _fake_preload_thread)
|
|
monkeypatch.setattr(
|
|
runner_app_mod,
|
|
"_codex_forward_known_thread",
|
|
_parking_forward_known_thread,
|
|
)
|
|
|
|
agent_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="codex",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5-default"},
|
|
),
|
|
)
|
|
|
|
try:
|
|
await _auto_create_codex_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, _evt: None,
|
|
agent_spec=agent_spec,
|
|
server_client=_SnapshotServerClient(), # type: ignore[arg-type]
|
|
)
|
|
await asyncio.sleep(0)
|
|
|
|
await _auto_create_codex_terminal(
|
|
session_id,
|
|
_FakeResourceRegistry(), # type: ignore[arg-type]
|
|
lambda _sid, _evt: None,
|
|
agent_spec=agent_spec,
|
|
server_client=_SnapshotServerClient(), # type: ignore[arg-type]
|
|
)
|
|
await asyncio.sleep(0)
|
|
|
|
assert len(runs) == 2, (
|
|
f"Expected 2 forwarder spawns (one per auto-create), got {len(runs)}."
|
|
)
|
|
# The first forwarder was cancelled by the re-create — a False here
|
|
# means two live tasks mirror the same codex thread into the session.
|
|
assert runs[0].cancelled is True, (
|
|
"Re-creating the codex terminal must cancel the prior session "
|
|
"forwarder; it survived, so transcript records would be "
|
|
"double-posted."
|
|
)
|
|
# The recovery's own forwarder survives — cancelled here means the
|
|
# re-create killed its replacement and the session mirrors nothing.
|
|
assert runs[1].cancelled is False
|
|
live_runs = [run for run in runs if not run.cancelled]
|
|
# Exactly one live forwarder mirrors the thread for the session.
|
|
assert len(live_runs) == 1
|
|
registered = runner_app_mod._AUTO_FORWARDER_TASKS.get(session_id)
|
|
assert registered is live_runs[0].task
|
|
# Still running: a done survivor would leave the session unmirrored.
|
|
assert not registered.done()
|
|
finally:
|
|
runner_app_mod._AUTO_FORWARDER_TASKS.pop(session_id, None)
|
|
runner_app_mod._AUTO_CODEX_APP_SERVERS.pop(session_id, None)
|
|
await _drain_forwarder_runs(runs)
|
|
|
|
|
|
async def test_events_interrupt_on_kiro_native_routes_to_escape(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""interrupt on a kiro-native session sends Escape via the kiro bridge.
|
|
|
|
Regression for #1137: kiro-native had no entry in the interrupt dispatch
|
|
ladder, so the web Stop button fell through to the in-process cancel floor —
|
|
a no-op for a TUI turn the harness task already returned from — and silently
|
|
did nothing. This pins that the dispatch routes kiro-native to
|
|
``kiro_native_bridge.inject_interrupt`` with the snappy 1.0s timeout.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[Any] = []
|
|
monkeypatch.setattr(
|
|
kiro_native_bridge,
|
|
"inject_interrupt",
|
|
lambda bridge_dir, *, timeout_s: captured.append((bridge_dir, timeout_s)),
|
|
)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_kiro_int", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
int_resp = await client.post(
|
|
"/v1/sessions/conv_kiro_int/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
assert int_resp.status_code == 204, int_resp.text
|
|
# 0 = the dispatch fell through to the generic path (the silent no-op bug);
|
|
# 2+ = the handler ran twice.
|
|
assert len(captured) == 1, (
|
|
f"Expected one inject_interrupt call, got {len(captured)}. If 0, the "
|
|
f"kiro-native interrupt dispatch entry is missing."
|
|
)
|
|
bridge_dir, timeout_s = captured[0]
|
|
assert bridge_dir == kiro_native_bridge.bridge_dir_for_session_id("conv_kiro_int")
|
|
assert timeout_s == 1.0
|
|
|
|
|
|
async def test_events_stop_session_on_kiro_native_kills_tmux_and_publishes_idle(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""stop_session on a kiro-native session kills the tmux pane and clears the spinner.
|
|
|
|
Mirrors the goose/claude-native stop path: route to
|
|
``kiro_native_bridge.kill_session`` and enqueue exactly one
|
|
``session.status: idle`` (kiro-cli has no Stop hook on a hard kill).
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[Any] = []
|
|
monkeypatch.setattr(
|
|
kiro_native_bridge,
|
|
"kill_session",
|
|
lambda bridge_dir, *, timeout_s: captured.append((bridge_dir, timeout_s)),
|
|
)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_kiro_stop", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
stop_resp = await client.post(
|
|
"/v1/sessions/conv_kiro_stop/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
queue = _session_event_queues_ref.get("conv_kiro_stop")
|
|
queued_events: list[dict[str, Any]] = []
|
|
while queue is not None and not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
assert len(captured) == 1, (
|
|
f"Expected one kill_session call, got {len(captured)}. If 0, the "
|
|
f"kiro-native stop dispatch entry is missing."
|
|
)
|
|
bridge_dir, timeout_s = captured[0]
|
|
assert bridge_dir == kiro_native_bridge.bridge_dir_for_session_id("conv_kiro_stop")
|
|
assert timeout_s == 1.0
|
|
idle_events = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert len(idle_events) == 1, f"stop must publish exactly one idle; got {queued_events!r}"
|
|
|
|
|
|
async def test_events_interrupt_on_kiro_native_503_skips_idle_when_inject_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""interrupt returns 503 and publishes no idle when Escape can't reach tmux.
|
|
|
|
Failure-path parity with the sibling harnesses (e.g.
|
|
``..._interrupt_on_native_session_503_skips_cleanup_when_inject_fails``): if
|
|
the bridge can't deliver Escape (pane gone, bridge dir not advertised), the
|
|
runner must surface a 503 and must NOT publish ``session.status: idle`` — idle
|
|
would clear the web-UI spinner while the kiro turn keeps generating. Guards
|
|
against a reorder that moves the idle publish ahead of the ``try``.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(kiro_native_bridge, "inject_interrupt", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_kiro_int_fail", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
int_resp = await client.post(
|
|
"/v1/sessions/conv_kiro_int_fail/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
queue = _session_event_queues_ref.get("conv_kiro_int_fail")
|
|
queued_events: list[dict[str, Any]] = []
|
|
while queue is not None and not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert int_resp.status_code == 503, (
|
|
f"kiro-native interrupt with inject_interrupt failure must return 503; "
|
|
f"got {int_resp.status_code}: {int_resp.text}"
|
|
)
|
|
body = int_resp.json()
|
|
assert body.get("error") == "kiro_native_interrupt_failed", (
|
|
f"503 body must carry the bridge-failure error code; got {body!r}"
|
|
)
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert status_idle == [], (
|
|
f"No session.status: idle should be enqueued when Escape injection "
|
|
f"failed; got {status_idle!r}."
|
|
)
|
|
|
|
|
|
async def test_events_stop_session_on_kiro_native_503_when_kill_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""stop_session returns 503 and publishes no idle when the kill can't reach tmux.
|
|
|
|
Failure-path parity with ``..._stop_session_on_native_returns_503_when_kill_fails``:
|
|
a failed kill must surface 503 rather than lie to the web UI with 204 + idle
|
|
while the ``kiro-cli`` process may still be alive.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(kiro_native_bridge, "kill_session", _fake_kill)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": "conv_kiro_stop_fail", "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
stop_resp = await client.post(
|
|
"/v1/sessions/conv_kiro_stop_fail/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
queue = _session_event_queues_ref.get("conv_kiro_stop_fail")
|
|
queued_events: list[dict[str, Any]] = []
|
|
while queue is not None and not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert stop_resp.status_code == 503, (
|
|
f"kiro-native stop_session with kill failure must return 503; "
|
|
f"got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
body = stop_resp.json()
|
|
assert body.get("error") == "kiro_native_stop_failed", (
|
|
f"503 body must carry the stop-failure error code; got {body!r}"
|
|
)
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert status_idle == [], (
|
|
f"No session.status: idle should be enqueued when kill_session failed; "
|
|
f"got {status_idle!r}."
|
|
)
|