"""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 ``, 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 `` (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 ``. 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 (``/agy-home/.gemini/config``), NOT the user's real ``~/.gemini`` — the config-scoping footgun the design avoids; * the launch args carry ``--gemini_dir=`` 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= 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 `` 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 `` — 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 --server `` (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}." )