9691 lines
340 KiB
Python
9691 lines
340 KiB
Python
"""Tests for the native Codex wrapper helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import click
|
|
import httpx
|
|
import pytest
|
|
import yaml
|
|
|
|
from omnigent import codex_native, codex_native_app_server, codex_native_forwarder
|
|
from omnigent._runner_startup import RunnerStartupProgress
|
|
from omnigent.codex_native_bridge import (
|
|
CodexNativeBridgeState,
|
|
clear_bridge_state,
|
|
read_bridge_state,
|
|
write_bridge_state,
|
|
)
|
|
from omnigent.codex_native_elicitation import codex_elicitation_id
|
|
from omnigent.spec import load
|
|
|
|
|
|
def _write_codex_auth(path: Path, payload: object) -> None:
|
|
"""Write a test Codex auth.json payload."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
|
|
def _point_codex_auth_check_at(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
auth_path: Path,
|
|
*,
|
|
binary_present: bool,
|
|
launch: Any | None = None,
|
|
) -> None:
|
|
"""Redirect Codex availability checks away from the real machine state.
|
|
|
|
``launch`` pins what :func:`resolve_native_codex_launch` returns; the default
|
|
is the defer-to-Codex-login shape (``profile=None``, ``model_provider`` not
|
|
set → resolves to ``"openai"``), which is exactly the case where
|
|
``auth.json`` is the credential that decides availability. Provider-routed
|
|
tests pass an explicit launch.
|
|
"""
|
|
if launch is None:
|
|
launch = codex_native_app_server.NativeCodexLaunch(
|
|
config_overrides=[], model=None, profile=None
|
|
)
|
|
monkeypatch.setattr(codex_native, "resolve_native_codex_launch", lambda model=None: launch)
|
|
monkeypatch.setattr(
|
|
codex_native,
|
|
"_resolve_codex_auth_source",
|
|
lambda: codex_native._CodexAuthSource(auth_path=auth_path),
|
|
)
|
|
monkeypatch.setattr(
|
|
codex_native.shutil,
|
|
"which",
|
|
lambda name: f"/tmp/{name}" if binary_present else None,
|
|
)
|
|
|
|
|
|
def test_codex_auth_unavailable_reason_binary_missing(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""Missing codex binary reports binary-missing before reading auth.json."""
|
|
auth_path = tmp_path / "codex-home" / "auth.json"
|
|
_point_codex_auth_check_at(monkeypatch, auth_path, binary_present=False)
|
|
|
|
assert codex_native._codex_auth_unavailable_reason() == "binary-missing"
|
|
|
|
|
|
def test_codex_auth_unavailable_reason_absent_auth_json_needs_auth(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""Installed codex without auth.json reports needs-auth."""
|
|
auth_path = tmp_path / "codex-home" / "auth.json"
|
|
_point_codex_auth_check_at(monkeypatch, auth_path, binary_present=True)
|
|
|
|
assert codex_native._codex_auth_unavailable_reason() == "needs-auth"
|
|
|
|
|
|
def test_codex_auth_unavailable_reason_chatgpt_tokens_available(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A real ChatGPT/OAuth auth.json (tokens block) is available.
|
|
|
|
Mirrors the openai/codex ``AuthDotJson`` shape: ``auth_mode=chatgpt`` with a
|
|
``tokens`` object. There is no top-level expiry field — access-token expiry
|
|
lives in the JWT and is refreshed via ``refresh_token`` — so presence of the
|
|
tokens is what marks the credential configured.
|
|
"""
|
|
auth_path = tmp_path / "codex-home" / "auth.json"
|
|
_point_codex_auth_check_at(monkeypatch, auth_path, binary_present=True)
|
|
_write_codex_auth(
|
|
auth_path,
|
|
{
|
|
"auth_mode": "chatgpt",
|
|
"tokens": {
|
|
"id_token": "header.payload.sig",
|
|
"access_token": "header.payload.sig",
|
|
"refresh_token": "opaque-refresh",
|
|
"account_id": "org_test",
|
|
},
|
|
"last_refresh": "2026-06-25T15:04:05Z",
|
|
},
|
|
)
|
|
|
|
assert codex_native._codex_auth_unavailable_reason() is None
|
|
|
|
|
|
def test_codex_auth_unavailable_reason_api_key_available(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A real API-key auth.json (``auth_mode=api``) is available."""
|
|
auth_path = tmp_path / "codex-home" / "auth.json"
|
|
_point_codex_auth_check_at(monkeypatch, auth_path, binary_present=True)
|
|
_write_codex_auth(auth_path, {"auth_mode": "api", "OPENAI_API_KEY": "sk-test"})
|
|
|
|
assert codex_native._codex_auth_unavailable_reason() is None
|
|
|
|
|
|
def test_codex_auth_unavailable_reason_no_credential_needs_auth(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A parseable auth.json with no credential field reports needs-auth.
|
|
|
|
e.g. a stub that records ``auth_mode`` but carries neither an
|
|
``OPENAI_API_KEY`` nor a ``tokens`` block — there is nothing to authenticate
|
|
with, so the picker should warn rather than show Codex as ready.
|
|
"""
|
|
auth_path = tmp_path / "codex-home" / "auth.json"
|
|
_point_codex_auth_check_at(monkeypatch, auth_path, binary_present=True)
|
|
_write_codex_auth(auth_path, {"auth_mode": "chatgpt"})
|
|
|
|
assert codex_native._codex_auth_unavailable_reason() == "needs-auth"
|
|
|
|
|
|
def test_codex_auth_unavailable_reason_malformed_auth_needs_auth(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""Installed codex with malformed auth.json reports needs-auth."""
|
|
auth_path = tmp_path / "codex-home" / "auth.json"
|
|
_point_codex_auth_check_at(monkeypatch, auth_path, binary_present=True)
|
|
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
auth_path.write_text("{not json", encoding="utf-8")
|
|
|
|
assert codex_native._codex_auth_unavailable_reason() == "needs-auth"
|
|
|
|
|
|
def test_codex_auth_unavailable_reason_databricks_profile_available(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A Databricks-profile launch is available even with an EMPTY auth.json.
|
|
|
|
The reported bug: the launch mints its bearer via ``databricks auth token``
|
|
and never reads ``auth.json``, so gating on it is a false negative. auth.json
|
|
is deliberately absent here — availability must come from the launch.
|
|
"""
|
|
auth_path = tmp_path / "codex-home" / "auth.json" # never created
|
|
launch = codex_native_app_server.NativeCodexLaunch(
|
|
config_overrides=[], model=None, profile="my-profile"
|
|
)
|
|
_point_codex_auth_check_at(monkeypatch, auth_path, binary_present=True, launch=launch)
|
|
|
|
assert codex_native._codex_auth_unavailable_reason() is None
|
|
|
|
|
|
def test_codex_auth_unavailable_reason_provider_override_available(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A launch pinning a non-openai model_provider is available sans auth.json."""
|
|
auth_path = tmp_path / "codex-home" / "auth.json" # never created
|
|
launch = codex_native_app_server.NativeCodexLaunch(
|
|
config_overrides=['model_provider="omnigent_databricks"'], model=None, profile=None
|
|
)
|
|
_point_codex_auth_check_at(monkeypatch, auth_path, binary_present=True, launch=launch)
|
|
|
|
assert codex_native._codex_auth_unavailable_reason() is None
|
|
|
|
|
|
def test_codex_auth_unavailable_reason_resolver_failure_falls_back_to_auth_json(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A resolver blow-up fails safe onto the auth.json check (never raises)."""
|
|
auth_path = tmp_path / "codex-home" / "auth.json"
|
|
_point_codex_auth_check_at(monkeypatch, auth_path, binary_present=True)
|
|
|
|
def _boom(model: object = None) -> object:
|
|
raise RuntimeError("corrupt config")
|
|
|
|
monkeypatch.setattr(codex_native, "resolve_native_codex_launch", _boom)
|
|
|
|
# No auth.json → falls through to needs-auth rather than propagating.
|
|
assert codex_native._codex_auth_unavailable_reason() == "needs-auth"
|
|
|
|
|
|
class _FakeTerminalClient:
|
|
"""
|
|
Minimal async client for terminal-launch helper tests.
|
|
|
|
:param response: HTTP response returned from ``post``.
|
|
"""
|
|
|
|
def __init__(self, response: httpx.Response) -> None:
|
|
self.response = response
|
|
self.posts: list[tuple[str, dict[str, Any], float | None]] = []
|
|
|
|
async def post(
|
|
self,
|
|
url: str,
|
|
*,
|
|
json: dict[str, Any],
|
|
timeout: float | None = None,
|
|
) -> httpx.Response:
|
|
"""
|
|
Capture a terminal-launch request.
|
|
|
|
:param url: Request URL.
|
|
:param json: JSON request body.
|
|
:param timeout: Request timeout.
|
|
:returns: Canned response.
|
|
"""
|
|
self.posts.append((url, json, timeout))
|
|
return self.response
|
|
|
|
|
|
class _FakeCodexWebSocket:
|
|
"""
|
|
Minimal websocket for Codex app-server handshake tests.
|
|
|
|
It immediately responds to the ``initialize`` request and records
|
|
every outbound payload.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.sent: list[str] = []
|
|
self.closed = False
|
|
self.responses: asyncio.Queue[str] = asyncio.Queue()
|
|
|
|
async def send(self, payload: str) -> None:
|
|
"""
|
|
Capture an outbound websocket text frame.
|
|
|
|
:param payload: JSON-RPC text frame.
|
|
:returns: None.
|
|
"""
|
|
self.sent.append(payload)
|
|
message = json.loads(payload)
|
|
if message.get("method") == "initialize":
|
|
await self.responses.put(json.dumps({"id": message["id"], "result": {}}))
|
|
|
|
def __aiter__(self) -> _FakeCodexWebSocket:
|
|
"""
|
|
Return the async iterator used by the client reader task.
|
|
|
|
:returns: This websocket.
|
|
"""
|
|
return self
|
|
|
|
async def __anext__(self) -> str:
|
|
"""
|
|
Yield the next queued inbound websocket text frame.
|
|
|
|
:returns: JSON-RPC text frame.
|
|
"""
|
|
return await self.responses.get()
|
|
|
|
async def close(self) -> None:
|
|
"""
|
|
Mark the fake websocket closed.
|
|
|
|
:returns: None.
|
|
"""
|
|
self.closed = True
|
|
|
|
|
|
class _FakeCodexAppServerClient:
|
|
"""
|
|
Test double for ``CodexAppServerClient``.
|
|
|
|
:param response: JSON-RPC response returned from ``request``.
|
|
:param error: Optional exception raised from ``request``.
|
|
:param events: Optional events yielded from ``iter_events``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
response: dict[str, Any] | None = None,
|
|
error: Exception | None = None,
|
|
events: list[dict[str, Any]] | None = None,
|
|
) -> None:
|
|
self.response = response or {"result": {"thread": {"id": "thread_123"}}}
|
|
self.error = error
|
|
self.events = events or []
|
|
self.connected = False
|
|
self.closed = False
|
|
self.requests: list[tuple[str, dict[str, Any]]] = []
|
|
self.responses: list[tuple[int | str, 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 one JSON-RPC request.
|
|
|
|
:param method: JSON-RPC method.
|
|
:param params: JSON-RPC params.
|
|
:returns: Canned JSON-RPC response.
|
|
"""
|
|
self.requests.append((method, params))
|
|
if self.error is not None:
|
|
raise self.error
|
|
return self.response
|
|
|
|
async def iter_events(self) -> Any:
|
|
"""
|
|
Return an empty event stream.
|
|
|
|
:returns: Async iterator with no events.
|
|
"""
|
|
for event in self.events:
|
|
yield event
|
|
|
|
async def respond(self, request_id: int | str, result: dict[str, Any]) -> None:
|
|
"""
|
|
Capture one JSON-RPC response sent to the fake app-server.
|
|
|
|
:param request_id: JSON-RPC request id.
|
|
:param result: JSON-RPC result payload.
|
|
:returns: None.
|
|
"""
|
|
self.responses.append((request_id, result))
|
|
|
|
async def close(self) -> None:
|
|
"""
|
|
Mark the fake client closed.
|
|
|
|
:returns: None.
|
|
"""
|
|
self.closed = True
|
|
|
|
|
|
def test_clear_bridge_state_removes_stale_runtime_state(tmp_path: Path) -> None:
|
|
"""
|
|
Clearing bridge state removes the stale runtime pointer.
|
|
|
|
New app-server launches reuse the same bridge directory, so a leftover
|
|
``state.json`` must disappear before web-message forwarding can read
|
|
it. A regression that leaves the old state in place would make
|
|
``read_bridge_state`` return the stale thread id here.
|
|
|
|
:param tmp_path: Temporary bridge directory.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir = tmp_path / "bridge"
|
|
write_bridge_state(
|
|
bridge_dir,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path="ws://127.0.0.1:1234",
|
|
thread_id="019e96aa-0be2-7343-8d3b-6f914d60936b",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
),
|
|
)
|
|
|
|
clear_bridge_state(bridge_dir)
|
|
|
|
assert read_bridge_state(bridge_dir) is None
|
|
|
|
|
|
def test_preload_codex_thread_for_resume_resumes_and_closes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Preloading uses Codex ``thread/resume`` before bridge state is exposed.
|
|
|
|
This helper is the guard against a web turn racing ahead of the TUI
|
|
and hitting ``turn/start`` on an app-server that has not loaded the
|
|
persisted thread yet. If the request method or params regress, this
|
|
test fails on the captured fake client request.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
|
|
def fake_client_factory(*_args: Any, **_kwargs: Any) -> _FakeCodexAppServerClient:
|
|
"""
|
|
Return the fake app-server client.
|
|
|
|
:returns: Fake client.
|
|
"""
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.codex_native_app_server.CodexAppServerClient",
|
|
fake_client_factory,
|
|
)
|
|
|
|
asyncio.run(
|
|
codex_native_app_server.preload_codex_thread_for_resume(
|
|
"ws://127.0.0.1:1234",
|
|
"019e96aa-0be2-7343-8d3b-6f914d60936b",
|
|
)
|
|
)
|
|
|
|
assert fake_client.connected is True
|
|
assert fake_client.requests == [
|
|
(
|
|
"thread/resume",
|
|
{
|
|
"threadId": "019e96aa-0be2-7343-8d3b-6f914d60936b",
|
|
"excludeTurns": True,
|
|
},
|
|
)
|
|
]
|
|
assert fake_client.closed is True
|
|
|
|
|
|
def _started_event(turn_id: str) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex ``turn/started`` notification.
|
|
|
|
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {"method": "turn/started", "params": {"turn": {"id": turn_id}}}
|
|
|
|
|
|
def _thread_started_event(thread_id: str) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex ``thread/started`` notification.
|
|
|
|
:param thread_id: Codex thread id, e.g. ``"thread_123"``.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {"method": "thread/started", "params": {"thread": {"id": thread_id}}}
|
|
|
|
|
|
def _completed_event(turn_id: str | None, *, thread_id: str | None = None) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex ``turn/completed`` notification.
|
|
|
|
:param turn_id: Codex turn id, e.g. ``"turn_123"``, or ``None``
|
|
when testing legacy or malformed terminal events.
|
|
:param thread_id: Optional Codex thread id, e.g. ``"thread_123"``.
|
|
:returns: App-server event payload.
|
|
"""
|
|
params: dict[str, Any] = {}
|
|
if turn_id is not None:
|
|
params["turnId"] = turn_id
|
|
if thread_id is not None:
|
|
params["threadId"] = thread_id
|
|
return {"method": "turn/completed", "params": params}
|
|
|
|
|
|
def _failed_event(turn_id: str | None, *, thread_id: str | None = None) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex ``turn/failed`` notification.
|
|
|
|
:param turn_id: Codex turn id, e.g. ``"turn_123"``, or ``None``
|
|
when testing legacy or malformed terminal events.
|
|
:param thread_id: Optional Codex thread id, e.g. ``"thread_123"``.
|
|
:returns: App-server event payload.
|
|
"""
|
|
params: dict[str, Any] = {}
|
|
if turn_id is not None:
|
|
params["turnId"] = turn_id
|
|
if thread_id is not None:
|
|
params["threadId"] = thread_id
|
|
return {"method": "turn/failed", "params": params}
|
|
|
|
|
|
def _agent_message_event(
|
|
turn_id: str,
|
|
item_id: str,
|
|
text: str,
|
|
*,
|
|
thread_id: str = "thread_123",
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex completed assistant-message notification.
|
|
|
|
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
|
|
:param item_id: Codex item id, e.g. ``"item_123"``.
|
|
:param text: Assistant text payload, e.g. ``"done"``.
|
|
:param thread_id: Codex thread id, e.g. ``"thread_123"``.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": thread_id,
|
|
"turnId": turn_id,
|
|
"item": {
|
|
"type": "agentMessage",
|
|
"id": item_id,
|
|
"text": text,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _agent_message_delta_event(turn_id: str, item_id: str, delta: object) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex assistant-message delta notification.
|
|
|
|
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
|
|
:param item_id: Codex item id, e.g. ``"item_123"``.
|
|
:param delta: Delta payload to include in the event, e.g. ``"hi"``.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {
|
|
"method": "item/agentMessage/delta",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": turn_id,
|
|
"itemId": item_id,
|
|
"delta": delta,
|
|
},
|
|
}
|
|
|
|
|
|
def _plan_delta_event(turn_id: str, item_id: str, delta: object) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex plan delta notification.
|
|
|
|
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
|
|
:param item_id: Codex plan item id, e.g. ``"item_plan"``.
|
|
:param delta: Delta payload to include in the event, e.g.
|
|
``"1. Inspect"``.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {
|
|
"method": "item/plan/delta",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": turn_id,
|
|
"itemId": item_id,
|
|
"delta": delta,
|
|
},
|
|
}
|
|
|
|
|
|
def _expected_delta_data(
|
|
delta: str,
|
|
turn_id: str,
|
|
item_id: str,
|
|
*,
|
|
item_type: str = "agentMessage",
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Build the Omnigent event data expected for one Codex native text delta.
|
|
|
|
:param delta: Coalesced text fragment, e.g. ``"hello"``.
|
|
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
|
|
:param item_id: Codex item id, e.g. ``"item_agent"``.
|
|
:param item_type: Codex item type, e.g. ``"agentMessage"``.
|
|
:returns: Expected ``external_output_text_delta`` data payload.
|
|
"""
|
|
return {
|
|
"delta": delta,
|
|
"message_id": f"codex:thread_123:{turn_id}:{item_type}:{item_id}",
|
|
"index": 0,
|
|
"final": False,
|
|
}
|
|
|
|
|
|
def _expected_status_data(status: str, turn_id: str) -> dict[str, Any]:
|
|
"""
|
|
Build the Omnigent event data expected for one Codex native status edge.
|
|
|
|
:param status: Omnigent session status, e.g. ``"running"``.
|
|
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
|
|
:returns: Expected ``external_session_status`` data payload.
|
|
"""
|
|
return {"status": status, "response_id": f"codex_{turn_id}"}
|
|
|
|
|
|
def _usage_event(input_tokens: int, context_window: int = 200_000) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex token-usage update event.
|
|
|
|
:param input_tokens: Context token count, e.g. ``1234``.
|
|
:param context_window: Context window size, e.g. ``200000``.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {
|
|
"method": "thread/tokenUsage/updated",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"tokenUsage": {
|
|
"total": {
|
|
"inputTokens": input_tokens,
|
|
"contextWindow": context_window,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _usage_coalescer(
|
|
client: httpx.AsyncClient,
|
|
session_id: str = "conv_123",
|
|
) -> codex_native_forwarder._SessionUsageCoalescer:
|
|
"""
|
|
Build the required Codex usage coalescer for direct handler tests.
|
|
|
|
:param client: HTTP client used by the coalescer.
|
|
:param session_id: Omnigent session id, e.g. ``"conv_123"``.
|
|
:returns: Usage coalescer bound to ``session_id``.
|
|
"""
|
|
return codex_native_forwarder._SessionUsageCoalescer(client, session_id)
|
|
|
|
|
|
def _elicitation_tracker() -> codex_native_forwarder._CodexElicitationTaskTracker:
|
|
"""
|
|
Build the required Codex elicitation tracker for direct handler tests.
|
|
|
|
:returns: Fresh tracker with no pending hook tasks.
|
|
"""
|
|
return codex_native_forwarder._CodexElicitationTaskTracker()
|
|
|
|
|
|
def test_materialize_codex_agent_spec_uses_codex_native_harness(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""
|
|
The generated wrapper spec is self-contained and selects the
|
|
isolated ``codex-native`` harness rather than the existing
|
|
non-TUI ``codex`` harness.
|
|
"""
|
|
# Pin the host shells so the declared terminals are deterministic
|
|
# ($SHELL=bash → the default/first terminal is ``bash``).
|
|
monkeypatch.setattr("shutil.which", lambda name: f"/usr/bin/{name}")
|
|
monkeypatch.setenv("SHELL", "/bin/bash")
|
|
spec_path = codex_native._materialize_codex_agent_spec(
|
|
tmp_path,
|
|
model="gpt-test",
|
|
)
|
|
|
|
raw = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
|
|
|
|
assert raw["name"] == "codex-native-ui"
|
|
# Exact executor block: the spec must NOT carry a profile key —
|
|
# the --profile CLI flag was removed, so routing is resolved at
|
|
# launch time (provider config / global auth / ambient detection).
|
|
assert raw["executor"] == {
|
|
"harness": "codex-native",
|
|
"model": "gpt-test",
|
|
}
|
|
|
|
|
|
def test_materialized_codex_agent_spec_loads_as_valid_omnigent_yaml(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
The generated wrapper spec passes Omnigent YAML validation.
|
|
|
|
This guards the session-create path, which registers the generated
|
|
spec bundle and fails before Codex starts if ``codex-native`` is not
|
|
accepted by the spec adapter.
|
|
"""
|
|
spec_path = codex_native._materialize_codex_agent_spec(
|
|
tmp_path,
|
|
model="gpt-test",
|
|
)
|
|
|
|
spec = load(spec_path)
|
|
|
|
assert spec.executor.config["harness"] == "codex-native"
|
|
# The native wrapper opts into the spawn-write surface so the
|
|
# wrapped codex can author agent configs and launch them as child
|
|
# sessions; the bridge relay derives its tool set from this spec
|
|
# via ToolManager, so a dropped flag silently removes
|
|
# sys_session_create/send/close from the native CLI.
|
|
assert spec.spawn is True
|
|
# The native wrapper declares one terminal per installed shell so the
|
|
# relay advertises the sys_terminal_* family to the wrapped codex (the
|
|
# relay gate is a non-empty ``terminals:`` block on this spec); a
|
|
# dropped block silently removes the terminal tools from the native CLI.
|
|
assert spec.terminals is not None
|
|
assert spec.terminals["bash"].command == "bash"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("codex_args", "thread_id", "remote_url", "expected"),
|
|
[
|
|
# Fresh thread over a Unix socket (local ``omnigent codex``
|
|
# cold start): no ``resume``/thread id, transport passed verbatim.
|
|
(
|
|
(),
|
|
None,
|
|
"unix:///tmp/app-server.sock",
|
|
["--remote", "unix:///tmp/app-server.sock"],
|
|
),
|
|
# Resume an existing thread over a Unix socket (local reattach).
|
|
(
|
|
(),
|
|
"thread_local",
|
|
"unix:///tmp/app-server.sock",
|
|
["resume", "--remote", "unix:///tmp/app-server.sock", "thread_local"],
|
|
),
|
|
# Fresh thread over a loopback ws endpoint.
|
|
(
|
|
(),
|
|
None,
|
|
"ws://127.0.0.1:9876",
|
|
["--remote", "ws://127.0.0.1:9876"],
|
|
),
|
|
# Resume an existing thread over a loopback ws endpoint: the
|
|
# host-spawned runner path. The app-server listens on ws:// there
|
|
# (the codex CLI lacked unix:// listen support), so
|
|
# the auto-created terminal must attach over that same ws URL. A
|
|
# regression hardcoding ``unix://`` would break exactly this case.
|
|
(
|
|
(),
|
|
"thread_host",
|
|
"ws://127.0.0.1:9876",
|
|
["resume", "--remote", "ws://127.0.0.1:9876", "thread_host"],
|
|
),
|
|
# Leading codex args are preserved ahead of the attach flags.
|
|
(
|
|
("--model", "gpt-5.4-mini"),
|
|
"thread_x",
|
|
"ws://127.0.0.1:9876",
|
|
[
|
|
"--model",
|
|
"gpt-5.4-mini",
|
|
"resume",
|
|
"--remote",
|
|
"ws://127.0.0.1:9876",
|
|
"thread_x",
|
|
],
|
|
),
|
|
],
|
|
)
|
|
def test_build_codex_remote_args_passes_transport_verbatim(
|
|
codex_args: tuple[str, ...],
|
|
thread_id: str | None,
|
|
remote_url: str,
|
|
expected: list[str],
|
|
) -> None:
|
|
"""
|
|
``build_codex_remote_args`` emits the TUI ``--remote`` attach argv
|
|
for both transports and both thread states.
|
|
|
|
The transport URL is passed through verbatim so one builder serves
|
|
both the local Unix-socket path and the host-spawned ``ws://`` path,
|
|
and ``resume <thread_id>`` is appended iff a thread id is supplied.
|
|
If this regressed to a hardcoded ``unix://`` prefix, the ws cases
|
|
would fail and the host-spawned Codex terminal could not reach its
|
|
TCP app-server (no terminal would render in the web UI).
|
|
"""
|
|
assert (
|
|
codex_native_app_server.build_codex_remote_args(
|
|
codex_args=codex_args,
|
|
thread_id=thread_id,
|
|
remote_url=remote_url,
|
|
)
|
|
== expected
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("thread_id", "expected"),
|
|
[
|
|
# Fresh thread: -c overrides precede the bare --remote attach.
|
|
(
|
|
None,
|
|
[
|
|
"-c",
|
|
'model="databricks-gpt-5-5"',
|
|
"-c",
|
|
'model_provider="omnigent_databricks"',
|
|
"--remote",
|
|
"ws://127.0.0.1:9876",
|
|
],
|
|
),
|
|
# Resume: -c overrides are global flags and MUST precede the
|
|
# ``resume`` subcommand (codex rejects globals placed after it).
|
|
(
|
|
"thread_host",
|
|
[
|
|
"-c",
|
|
'model="databricks-gpt-5-5"',
|
|
"-c",
|
|
'model_provider="omnigent_databricks"',
|
|
"resume",
|
|
"--remote",
|
|
"ws://127.0.0.1:9876",
|
|
"thread_host",
|
|
],
|
|
),
|
|
],
|
|
)
|
|
def test_build_codex_remote_args_emits_config_overrides_before_subcommand(
|
|
thread_id: str | None,
|
|
expected: list[str],
|
|
) -> None:
|
|
"""
|
|
``build_codex_remote_args`` emits each ``config_overrides`` entry as a
|
|
``-c <value>`` global flag ahead of the attach flags.
|
|
|
|
The ``--remote`` TUI is a separate process that does not inherit the
|
|
app-server's ``-c`` flags; without these the TUI falls back to the
|
|
OpenAI built-in provider (``requires_openai_auth = true``), renders
|
|
the first-run login onboarding screen, and never creates a thread —
|
|
so a host-spawned session hangs in ``running`` with no response.
|
|
Asserting the exact argv (not just membership) guards two things at
|
|
once: that the overrides are forwarded at all, and that they land
|
|
*before* the ``resume`` subcommand — codex treats ``-c`` as a global
|
|
option and rejects it when placed after a subcommand, which would
|
|
abort TUI startup and reintroduce the hang.
|
|
"""
|
|
assert (
|
|
codex_native_app_server.build_codex_remote_args(
|
|
codex_args=(),
|
|
thread_id=thread_id,
|
|
remote_url="ws://127.0.0.1:9876",
|
|
config_overrides=(
|
|
'model="databricks-gpt-5-5"',
|
|
'model_provider="omnigent_databricks"',
|
|
),
|
|
)
|
|
== expected
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("codex_args", "expected"),
|
|
[
|
|
# ``--flag value`` pair: both dropped.
|
|
(("--sandbox", "read-only"), []),
|
|
(("--ask-for-approval", "on-request"), []),
|
|
# Option-adjacent: the next token is ANOTHER flag, not this flag's
|
|
# value, so it must survive (the over-match bug dropped --model).
|
|
(("--sandbox", "--model", "gpt"), ["--model", "gpt"]),
|
|
# ``--flag=value`` single token: dropped whole, consumes nothing after.
|
|
(("--ask-for-approval=on-failure",), []),
|
|
(("--sandbox=read-only", "--model", "gpt"), ["--model", "gpt"]),
|
|
# Short aliases: ``-a`` (== --ask-for-approval) triggers the SAME codex
|
|
# startup abort as the long form, so it must be stripped too; ``-s``
|
|
# (== --sandbox) is harmless but dropped for consistency. Both spellings
|
|
# (space-separated and ``=value``-joined) are handled.
|
|
(("-a", "never"), []),
|
|
(("-a=never",), []),
|
|
(("-s", "read-only"), []),
|
|
(("-s=read-only", "--model", "gpt"), ["--model", "gpt"]),
|
|
# Short alias option-adjacent to another flag: the next flag survives.
|
|
(("-a", "--model", "gpt"), ["--model", "gpt"]),
|
|
# Trailing flag at end-of-list: dropped cleanly, no value to consume.
|
|
(("--model", "gpt", "--sandbox"), ["--model", "gpt"]),
|
|
# Unrelated arg next to a stripped pair is preserved.
|
|
(
|
|
("--model", "gpt", "--sandbox", "read-only", "--cwd", "/x"),
|
|
["--model", "gpt", "--cwd", "/x"],
|
|
),
|
|
# A pre-existing bypass flag is de-duped (the caller re-adds one copy).
|
|
(("--dangerously-bypass-approvals-and-sandbox", "--model", "gpt"), ["--model", "gpt"]),
|
|
# No conflicting flags: everything passes through untouched.
|
|
(("--model", "gpt-5.4-mini"), ["--model", "gpt-5.4-mini"]),
|
|
],
|
|
)
|
|
def test_strip_approval_sandbox_flags_only_consumes_real_values(
|
|
codex_args: tuple[str, ...],
|
|
expected: list[str],
|
|
) -> None:
|
|
"""
|
|
``_strip_approval_sandbox_flags`` drops the conflicting flags without
|
|
over-matching the token that follows them.
|
|
|
|
A ``--sandbox`` / ``--ask-for-approval`` flag consumes the next token as
|
|
its value ONLY when that token is a real value (does not start with
|
|
``-``); a following flag or end-of-list consumes nothing, so unrelated
|
|
args like ``--model gpt`` are never swallowed. The ``--flag=value``
|
|
single-token spelling is dropped whole.
|
|
"""
|
|
assert codex_native_app_server._strip_approval_sandbox_flags(codex_args) == expected
|
|
|
|
|
|
def test_build_codex_remote_args_default_keeps_approval_flags_no_bypass() -> None:
|
|
"""
|
|
Default (``bypass_sandbox=False``) emits NO bypass flag and preserves the
|
|
approval/sandbox flags the approval-mode presets pass through.
|
|
|
|
The web "Full access" / "Read only" presets are sent as
|
|
``--sandbox`` / ``--ask-for-approval`` pairs inside ``codex_args``. With
|
|
bypass off those must reach the TUI verbatim and the dangerous bypass
|
|
flag must never appear — a regression here would either drop a user's
|
|
chosen approval preset or silently escalate to full bypass.
|
|
"""
|
|
args = codex_native_app_server.build_codex_remote_args(
|
|
codex_args=("--sandbox", "read-only", "--ask-for-approval", "on-request"),
|
|
thread_id=None,
|
|
remote_url="ws://127.0.0.1:9876",
|
|
)
|
|
|
|
assert "--dangerously-bypass-approvals-and-sandbox" not in args
|
|
assert args == [
|
|
"--sandbox",
|
|
"read-only",
|
|
"--ask-for-approval",
|
|
"on-request",
|
|
"--remote",
|
|
"ws://127.0.0.1:9876",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("codex_args", "thread_id", "expected"),
|
|
[
|
|
# Fresh thread, no conflicting flags: a single bypass flag is prepended.
|
|
(
|
|
(),
|
|
None,
|
|
[
|
|
"--dangerously-bypass-approvals-and-sandbox",
|
|
"--remote",
|
|
"ws://127.0.0.1:9876",
|
|
],
|
|
),
|
|
# Conflicting approval-preset flags are stripped (flag + its value),
|
|
# unrelated args (model) survive, and the bypass flag is added once.
|
|
# codex aborts if the bypass flag is combined with --sandbox /
|
|
# --ask-for-approval, so leaving them in would break TUI startup.
|
|
(
|
|
("--sandbox", "danger-full-access", "--ask-for-approval", "never", "--model", "gpt"),
|
|
None,
|
|
[
|
|
"--dangerously-bypass-approvals-and-sandbox",
|
|
"--model",
|
|
"gpt",
|
|
"--remote",
|
|
"ws://127.0.0.1:9876",
|
|
],
|
|
),
|
|
# Resume path: the bypass flag is a global flag and MUST precede the
|
|
# ``resume`` subcommand, and a pre-existing bypass flag is de-duped.
|
|
(
|
|
("--dangerously-bypass-approvals-and-sandbox", "--sandbox", "read-only"),
|
|
"thread_x",
|
|
[
|
|
"--dangerously-bypass-approvals-and-sandbox",
|
|
"resume",
|
|
"--remote",
|
|
"ws://127.0.0.1:9876",
|
|
"thread_x",
|
|
],
|
|
),
|
|
],
|
|
)
|
|
def test_build_codex_remote_args_bypass_emits_flag_and_strips_conflicts(
|
|
codex_args: tuple[str, ...],
|
|
thread_id: str | None,
|
|
expected: list[str],
|
|
) -> None:
|
|
"""
|
|
``bypass_sandbox=True`` emits one ``--dangerously-bypass-approvals-and-
|
|
sandbox`` and strips the conflicting ``--sandbox`` / ``--ask-for-approval``
|
|
pairs.
|
|
|
|
See :func:`omnigent.codex_native_app_server._strip_approval_sandbox_flags`.
|
|
Asserting the exact argv guards three things: the bypass flag is present
|
|
exactly once, the conflicting flag pairs are removed (with their values),
|
|
and the bypass flag lands before any ``resume`` subcommand (codex rejects
|
|
a global flag placed after a subcommand).
|
|
"""
|
|
assert (
|
|
codex_native_app_server.build_codex_remote_args(
|
|
codex_args=codex_args,
|
|
thread_id=thread_id,
|
|
remote_url="ws://127.0.0.1:9876",
|
|
bypass_sandbox=True,
|
|
)
|
|
== expected
|
|
)
|
|
|
|
|
|
def test_codex_app_server_client_uses_codex_remote_handshake(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
The Python client matches Codex's Unix-socket websocket transport
|
|
and completes the initialize/initialized handshake.
|
|
"""
|
|
fake_websocket = _FakeCodexWebSocket()
|
|
captured_kwargs: dict[str, Any] = {}
|
|
|
|
async def fake_unix_connect(**kwargs: Any) -> _FakeCodexWebSocket:
|
|
"""
|
|
Capture the websocket connection arguments.
|
|
|
|
:param kwargs: ``websockets.unix_connect`` keyword arguments.
|
|
:returns: Fake websocket.
|
|
"""
|
|
captured_kwargs.update(kwargs)
|
|
return fake_websocket
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server.websockets,
|
|
"unix_connect",
|
|
fake_unix_connect,
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Open and close one Codex app-server client.
|
|
|
|
:returns: None.
|
|
"""
|
|
client = codex_native_app_server.CodexAppServerClient(
|
|
tmp_path / "app-server.sock",
|
|
client_name="test-client",
|
|
)
|
|
await client.connect()
|
|
await client.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert captured_kwargs == {
|
|
"path": str(tmp_path / "app-server.sock"),
|
|
"uri": "ws://localhost/rpc",
|
|
"max_size": 128 << 20,
|
|
"compression": None,
|
|
}
|
|
assert [json.loads(payload) for payload in fake_websocket.sent] == [
|
|
{
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"clientInfo": {"name": "test-client", "version": "0.1"},
|
|
"capabilities": {"experimentalApi": True},
|
|
},
|
|
},
|
|
{"method": "initialized"},
|
|
]
|
|
assert fake_websocket.closed
|
|
|
|
|
|
def test_codex_app_server_client_responds_to_server_requests(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
The Codex websocket client can answer server-to-client requests.
|
|
|
|
Native Codex elicitations arrive as JSON-RPC requests from the
|
|
app-server to the Omnigent client. After AP/web resolves the
|
|
prompt, the forwarder must send a result envelope with the same
|
|
request id; otherwise Codex never observes the answer.
|
|
"""
|
|
fake_websocket = _FakeCodexWebSocket()
|
|
|
|
async def fake_unix_connect(**_kwargs: Any) -> _FakeCodexWebSocket:
|
|
"""
|
|
Return the fake websocket.
|
|
|
|
:returns: Fake websocket.
|
|
"""
|
|
return fake_websocket
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server.websockets,
|
|
"unix_connect",
|
|
fake_unix_connect,
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Connect, send one server-request response, and close.
|
|
|
|
:returns: None.
|
|
"""
|
|
client = codex_native_app_server.CodexAppServerClient(
|
|
tmp_path / "app-server.sock",
|
|
client_name="test-client",
|
|
)
|
|
await client.connect()
|
|
await client.respond("req_7", {"answers": {"framework": {"answers": ["React"]}}})
|
|
await client.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert json.loads(fake_websocket.sent[-1]) == {
|
|
"id": "req_7",
|
|
"result": {"answers": {"framework": {"answers": ["React"]}}},
|
|
}
|
|
assert fake_websocket.closed
|
|
|
|
|
|
def test_thread_id_from_started_event_ignores_unrelated_events() -> None:
|
|
"""
|
|
Thread discovery only accepts well-formed Codex ``thread/started``
|
|
notifications.
|
|
"""
|
|
assert (
|
|
codex_native_forwarder._thread_id_from_started_event(
|
|
{"method": "remoteControl/status/changed"}
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
codex_native_forwarder._thread_id_from_started_event(
|
|
{"method": "thread/started", "params": {"thread": {}}}
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
codex_native_forwarder._thread_id_from_started_event(
|
|
{"method": "thread/started", "params": {"thread": {"id": "thread_123"}}}
|
|
)
|
|
== "thread_123"
|
|
)
|
|
|
|
|
|
def test_wait_for_thread_started_uses_tui_created_thread() -> None:
|
|
"""
|
|
Fresh Codex sessions let the TUI create the remote app-server
|
|
thread, then discover that id from the broadcast ``thread/started``
|
|
notification.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient(
|
|
events=[
|
|
{"method": "remoteControl/status/changed", "params": {"status": "disabled"}},
|
|
{"method": "thread/started", "params": {"thread": {"id": "thread_123"}}},
|
|
]
|
|
)
|
|
|
|
thread_id = asyncio.run(codex_native._wait_for_thread_started(fake_client)) # type: ignore[arg-type]
|
|
|
|
assert thread_id == "thread_123"
|
|
assert fake_client.requests == []
|
|
|
|
|
|
def test_wait_for_thread_started_fails_when_stream_ends() -> None:
|
|
"""
|
|
A Codex TUI that exits before creating a thread fails loudly instead
|
|
of leaving the Omnigent session without a bridge state.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient(events=[])
|
|
|
|
with pytest.raises(click.ClickException, match="event stream ended"):
|
|
asyncio.run(codex_native._wait_for_thread_started(fake_client)) # type: ignore[arg-type]
|
|
|
|
|
|
def test_wait_for_thread_started_times_out_when_no_thread_event() -> None:
|
|
"""
|
|
A Codex TUI that connects but never emits ``thread/started`` makes
|
|
discovery time out rather than hang forever.
|
|
|
|
The host-spawned runner runs ``wait_for_thread_started`` in a background
|
|
task; without the timeout a TUI that starts its remote connection but
|
|
never creates a thread would wedge that task (and leak the listener)
|
|
indefinitely. A regression removing the ``asyncio.timeout`` guard would
|
|
hang this test instead of raising.
|
|
"""
|
|
|
|
class _NeverEmitsClient:
|
|
"""App-server client whose event stream blocks without ever yielding."""
|
|
|
|
async def iter_events(self) -> Any:
|
|
await asyncio.sleep(3600)
|
|
yield {} # pragma: no cover - unreachable; the await blocks first
|
|
|
|
with pytest.raises(TimeoutError):
|
|
asyncio.run(
|
|
codex_native_forwarder.wait_for_thread_started(
|
|
_NeverEmitsClient(), # type: ignore[arg-type]
|
|
timeout=0.05,
|
|
)
|
|
)
|
|
|
|
|
|
def test_supervise_forwarder_subscribes_existing_client_after_thread_discovery(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Fresh Codex sessions pass the listener used to discover
|
|
``thread/started``. Once the thread id is known, the forwarder
|
|
subscribes that connection so TUI-originated turn/item events are
|
|
mirrored into the web session.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Run the forwarder against an empty event stream.
|
|
|
|
:returns: None.
|
|
"""
|
|
await codex_native_forwarder.supervise_forwarder(
|
|
base_url="http://127.0.0.1:1",
|
|
headers={},
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
app_server_url=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.requests == [
|
|
("thread/resume", {"threadId": "thread_123", "excludeTurns": True})
|
|
]
|
|
assert fake_client.closed
|
|
|
|
|
|
def test_supervise_forwarder_resumes_when_it_opens_client(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Resume paths open a new app-server client and subscribe via
|
|
``thread/resume``.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
|
|
def fake_client_factory(*_args: Any, **_kwargs: Any) -> _FakeCodexAppServerClient:
|
|
"""
|
|
Return the fake app-server client.
|
|
|
|
:returns: Fake client.
|
|
"""
|
|
return fake_client
|
|
|
|
# Patch at the source: the forwarder builds its fallback client via
|
|
# client_for_transport, which constructs the app_server module's class.
|
|
monkeypatch.setattr(
|
|
"omnigent.codex_native_app_server.CodexAppServerClient", fake_client_factory
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Run the forwarder against an empty event stream.
|
|
|
|
:returns: None.
|
|
"""
|
|
await codex_native_forwarder.supervise_forwarder(
|
|
base_url="http://127.0.0.1:1",
|
|
headers={},
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
app_server_url=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.connected
|
|
assert fake_client.requests == [
|
|
("thread/resume", {"threadId": "thread_123", "excludeTurns": True})
|
|
]
|
|
assert fake_client.closed
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("message", "expected"),
|
|
[
|
|
# Rollout file missing — the first transient fresh-thread state.
|
|
("{'code': -32600, 'message': 'no rollout found for thread id thread_123'}", True),
|
|
# Rollout file present but EMPTY — the second transient state the
|
|
# fresh host-spawned TUI exposes. Previously treated as fatal, which
|
|
# made the forwarder give up subscribing and stop syncing chat.
|
|
(
|
|
"{'code': -32603, 'message': 'failed to read thread: thread-store "
|
|
"internal error: failed to read thread /x/rollout.jsonl: rollout at "
|
|
"/x/rollout.jsonl is empty'}",
|
|
True,
|
|
),
|
|
# A real app-server error must stay fatal (not retried forever).
|
|
("{'code': -32000, 'message': 'permission denied'}", False),
|
|
],
|
|
)
|
|
def test_is_thread_not_ready_error_matches_no_rollout_and_empty_rollout(
|
|
message: str, expected: bool
|
|
) -> None:
|
|
"""
|
|
``_is_thread_not_ready_error`` treats BOTH fresh-thread not-ready states
|
|
(missing rollout, present-but-empty rollout) as retryable, and leaves
|
|
unrelated errors fatal. If the empty-rollout case regressed to fatal, the
|
|
host-spawned forwarder would give up subscribing and chat would not sync.
|
|
"""
|
|
assert codex_native_forwarder._is_thread_not_ready_error(RuntimeError(message)) is expected
|
|
|
|
|
|
def test_subscribe_until_ready_retries_no_rollout_and_replays_messages(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Fresh TUI threads can reject ``thread/resume`` until the first
|
|
rollout exists; the forwarder retries and backfills resume items.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient(
|
|
response={
|
|
"result": {
|
|
"thread": {
|
|
"turns": [
|
|
{
|
|
"id": "turn_123",
|
|
"items": [
|
|
{
|
|
"type": "userMessage",
|
|
"id": "item_user",
|
|
"content": [{"type": "text", "text": "first"}],
|
|
},
|
|
{
|
|
"type": "agentMessage",
|
|
"id": "item_agent",
|
|
"text": "reply",
|
|
},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
error=RuntimeError(
|
|
"{'code': -32600, 'message': 'no rollout found for thread id thread_123'}"
|
|
),
|
|
)
|
|
calls = 0
|
|
|
|
async def fake_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
nonlocal calls
|
|
fake_client.requests.append((method, params))
|
|
calls += 1
|
|
if calls == 1:
|
|
raise RuntimeError(
|
|
"{'code': -32600, 'message': 'no rollout found for thread id thread_123'}"
|
|
)
|
|
return fake_client.response
|
|
|
|
async def fake_sleep(_delay: float) -> None:
|
|
return None
|
|
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
monkeypatch.setattr(fake_client, "request", fake_request)
|
|
monkeypatch.setattr(codex_native_forwarder, "_sleep", fake_sleep)
|
|
|
|
async def run() -> None:
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._subscribe_until_ready(
|
|
fake_client, # type: ignore[arg-type]
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
thread_id="thread_123",
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.requests == [
|
|
("thread/resume", {"threadId": "thread_123", "excludeTurns": True}),
|
|
("thread/resume", {"threadId": "thread_123"}),
|
|
]
|
|
assert [payload["data"]["item_data"]["role"] for payload in posted] == [
|
|
"user",
|
|
"assistant",
|
|
]
|
|
|
|
|
|
def test_subscribe_until_ready_replays_completed_turn_status(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Resume replay closes a completed turn when live terminal events were missed.
|
|
|
|
Host-spawned codex-native suppresses the runner's injection-task ``idle``
|
|
edge, so a reconnect that misses both ``turn/started`` and
|
|
``turn/completed`` must recover the terminal status from explicit resume
|
|
turn state instead of leaving the Omnigent session running forever.
|
|
|
|
:param tmp_path: Temporary bridge directory.
|
|
:returns: None.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
fake_client = _FakeCodexAppServerClient(
|
|
response={
|
|
"result": {
|
|
"thread": {
|
|
"id": "thread_123",
|
|
"turns": [
|
|
{
|
|
"id": "turn_123",
|
|
"status": "completed",
|
|
"items": [
|
|
{
|
|
"type": "userMessage",
|
|
"id": "item_user",
|
|
"content": [{"type": "text", "text": "first"}],
|
|
},
|
|
{
|
|
"type": "agentMessage",
|
|
"id": "item_agent",
|
|
"text": "reply",
|
|
},
|
|
],
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Subscribe and replay a completed turn.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._subscribe_until_ready(
|
|
fake_client, # type: ignore[arg-type]
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
thread_id="thread_123",
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert [payload["type"] for payload in posted] == [
|
|
"external_conversation_item",
|
|
"external_conversation_item",
|
|
"external_session_status",
|
|
]
|
|
assert [payload["data"]["item_data"]["role"] for payload in posted[:2]] == [
|
|
"user",
|
|
"assistant",
|
|
]
|
|
assert posted[2]["data"] == _expected_status_data("idle", "turn_123")
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.active_turn_id is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"event,expected",
|
|
[
|
|
({"method": "turn/started", "params": {}}, True),
|
|
({"method": "item/agentMessage/delta", "params": {}}, True),
|
|
(
|
|
{"method": "thread/status/changed", "params": {"status": {"type": "active"}}},
|
|
True,
|
|
),
|
|
# Idle status / fresh-thread announce / control noise do NOT imply a
|
|
# rollout exists yet — they must NOT release the parked subscribe.
|
|
(
|
|
{"method": "thread/status/changed", "params": {"status": {"type": "idle"}}},
|
|
False,
|
|
),
|
|
({"method": "thread/started", "params": {"thread": {"id": "t1"}}}, False),
|
|
({"method": "remoteControl/status/changed", "params": {"status": "disabled"}}, False),
|
|
({"result": {}, "id": 1}, False),
|
|
],
|
|
)
|
|
def test_event_indicates_thread_active(event: dict[str, Any], expected: bool) -> None:
|
|
"""Only turn/item/active-status events imply the thread's rollout exists.
|
|
|
|
This predicate gates releasing the deferred subscription. A false
|
|
positive (e.g. on the ``idle`` status codex emits at thread creation)
|
|
would resume too early and reintroduce the no-rollout retry churn; a
|
|
false negative would leave the forwarder parked through a real turn.
|
|
"""
|
|
assert codex_native_forwarder._event_indicates_thread_active(event) is expected
|
|
|
|
|
|
def test_subscribe_until_ready_parks_until_signal_then_resumes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A fresh, not-ready thread parks on the signal instead of polling.
|
|
|
|
Reproduces the fresh-session path: ``thread/resume`` fails with ``no
|
|
rollout found`` until the thread becomes active. With a ``ready_signal``
|
|
provided, the subscribe must NOT busy-poll — it waits for the signal
|
|
(set by the caller when the live stream shows the thread active), then
|
|
retries and succeeds. ``_sleep`` is stubbed to raise so a regression
|
|
back to blind-polling turns the task red instead of silently hammering
|
|
the app-server.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient(response={"result": {"thread": {"turns": []}}})
|
|
not_ready = RuntimeError(
|
|
"{'code': -32600, 'message': 'no rollout found for thread id thread_123'}"
|
|
)
|
|
|
|
async def run() -> int:
|
|
ready = asyncio.Event()
|
|
attempts = 0
|
|
|
|
async def fake_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
nonlocal attempts
|
|
attempts += 1
|
|
# Not-ready until the thread is "active" (signal set) — mirrors
|
|
# codex deferring rollout materialization until the first turn.
|
|
if not ready.is_set():
|
|
raise not_ready
|
|
return fake_client.response
|
|
|
|
async def fake_sleep(_delay: float) -> None:
|
|
raise AssertionError(
|
|
"subscribe must park on ready_signal for a not-ready thread, not poll"
|
|
)
|
|
|
|
monkeypatch.setattr(fake_client, "request", fake_request)
|
|
monkeypatch.setattr(codex_native_forwarder, "_sleep", fake_sleep)
|
|
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(lambda _req: httpx.Response(202, json={})),
|
|
) as client:
|
|
task = asyncio.create_task(
|
|
codex_native_forwarder._subscribe_until_ready(
|
|
fake_client, # type: ignore[arg-type]
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
thread_id="thread_123",
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
ready_signal=ready,
|
|
)
|
|
)
|
|
# Let the task make its first resume attempt and park. If it had
|
|
# polled instead, fake_sleep would have raised and the task would
|
|
# be done with an exception — so an un-done task proves it parked.
|
|
for _ in range(8):
|
|
await asyncio.sleep(0)
|
|
assert not task.done(), "subscribe should still be parked on the signal"
|
|
assert attempts >= 1
|
|
|
|
# Caller observed the thread go active → release the wait.
|
|
ready.set()
|
|
await task
|
|
return attempts
|
|
|
|
attempts = asyncio.run(run())
|
|
# ≥2: the initial not-ready attempt plus at least one post-signal retry
|
|
# that succeeds. If it were still polling we'd never reach here (fake_sleep
|
|
# raises); if it never retried after the signal it would hang on await task.
|
|
assert attempts >= 2, attempts
|
|
|
|
|
|
def test_forwarder_ignores_thread_started_for_current_codex_thread(tmp_path: Path) -> None:
|
|
"""
|
|
A duplicate ``thread/started`` notification does not rotate Omnigent sessions.
|
|
|
|
Codex can broadcast the current thread after the forwarder has
|
|
already bound it. This fails if the rotation detector treats every
|
|
``thread/started`` as a clear-session boundary.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_old",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_old",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
),
|
|
)
|
|
|
|
async def run() -> bool:
|
|
"""
|
|
Drive the duplicate notification through the rotation detector.
|
|
|
|
:returns: Whether a rotation occurred.
|
|
"""
|
|
async with httpx.AsyncClient(base_url="http://127.0.0.1:8000") as client:
|
|
target = codex_native_forwarder._ForwarderTarget(
|
|
session_id="conv_old",
|
|
thread_id="thread_old",
|
|
delta_coalescer=codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_old",
|
|
),
|
|
usage_coalescer=codex_native_forwarder._SessionUsageCoalescer(
|
|
client,
|
|
"conv_old",
|
|
),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
)
|
|
return await codex_native_forwarder._maybe_rotate_session_on_thread_started(
|
|
ap_client=client,
|
|
target=target,
|
|
bridge_dir=tmp_path,
|
|
app_server_url=str(tmp_path / "app-server.sock"),
|
|
event=_thread_started_event("thread_old"),
|
|
)
|
|
|
|
assert asyncio.run(run()) is False
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.session_id == "conv_old"
|
|
assert state.thread_id == "thread_old"
|
|
|
|
|
|
def test_forwarder_rotates_session_on_new_codex_thread_and_posts_to_new_session(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Native Codex thread switches create a replacement Omnigent session.
|
|
|
|
This is the ``/clear`` regression shape: Codex keeps the terminal
|
|
alive but starts a new app-server thread. The forwarder must move
|
|
terminal ownership, update bridge state, resubscribe to the new
|
|
thread, and send subsequent status/history events to the new AP
|
|
session.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_old",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_old",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
),
|
|
)
|
|
fake_client = _FakeCodexAppServerClient()
|
|
requests: list[tuple[str, str, dict[str, Any] | None]] = []
|
|
posted_events: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Serve Omnigent calls made during Codex session rotation.
|
|
|
|
:param request: HTTP request from the forwarder.
|
|
:returns: Fake Omnigent response.
|
|
"""
|
|
body = json.loads(request.content) if request.content else None
|
|
requests.append((request.method, request.url.path, body))
|
|
if request.method == "GET" and request.url.path == "/v1/sessions/conv_old":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "conv_old",
|
|
"agent_id": "ag_codex",
|
|
"runner_id": "runner_123",
|
|
"labels": {
|
|
"omnigent.wrapper": "codex-native-ui",
|
|
"omnigent.codex_native.bridge_id": "bridge_shared",
|
|
},
|
|
},
|
|
)
|
|
if request.method == "POST" and request.url.path == "/v1/sessions":
|
|
return httpx.Response(200, json={"id": "conv_new"})
|
|
if request.method == "PATCH" and request.url.path in {
|
|
"/v1/sessions/conv_new",
|
|
"/v1/sessions/conv_old",
|
|
}:
|
|
return httpx.Response(200, json={"id": request.url.path.rsplit("/", 1)[-1]})
|
|
if request.method == "POST" and request.url.path == (
|
|
"/v1/sessions/conv_old/resources/terminals/terminal_codex_main/transfer"
|
|
):
|
|
return httpx.Response(200, json={"id": "terminal_codex_main"})
|
|
if request.method == "POST" and request.url.path == "/v1/sessions/conv_new/events":
|
|
assert isinstance(body, dict)
|
|
posted_events.append(("conv_new", body))
|
|
return httpx.Response(202, json={"queued": False})
|
|
return httpx.Response(
|
|
500,
|
|
json={"error": f"unexpected {request.method} {request.url.path}"},
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive the real rotation and event handlers.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as ap_client:
|
|
target = codex_native_forwarder._ForwarderTarget(
|
|
session_id="conv_old",
|
|
thread_id="thread_old",
|
|
delta_coalescer=codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
ap_client,
|
|
"conv_old",
|
|
),
|
|
usage_coalescer=codex_native_forwarder._SessionUsageCoalescer(
|
|
ap_client,
|
|
"conv_old",
|
|
),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
)
|
|
await codex_native_forwarder._subscribe_until_ready(
|
|
fake_client, # type: ignore[arg-type]
|
|
ap_client,
|
|
session_id=target.session_id,
|
|
bridge_dir=tmp_path,
|
|
thread_id=target.thread_id,
|
|
usage_coalescer=target.usage_coalescer,
|
|
elicitation_tracker=target.elicitation_tracker,
|
|
)
|
|
rotated = await codex_native_forwarder._maybe_rotate_session_on_thread_started(
|
|
ap_client=ap_client,
|
|
target=target,
|
|
bridge_dir=tmp_path,
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
event=_thread_started_event("thread_new"),
|
|
)
|
|
assert rotated
|
|
await codex_native_forwarder._subscribe_until_ready(
|
|
fake_client, # type: ignore[arg-type]
|
|
ap_client,
|
|
session_id=target.session_id,
|
|
bridge_dir=tmp_path,
|
|
thread_id=target.thread_id,
|
|
usage_coalescer=target.usage_coalescer,
|
|
elicitation_tracker=target.elicitation_tracker,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
ap_client,
|
|
session_id=target.session_id,
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=target.usage_coalescer,
|
|
elicitation_tracker=target.elicitation_tracker,
|
|
event=_started_event("turn_new"),
|
|
delta_coalescer=target.delta_coalescer,
|
|
expected_thread_id=target.thread_id,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
ap_client,
|
|
session_id=target.session_id,
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=target.usage_coalescer,
|
|
elicitation_tracker=target.elicitation_tracker,
|
|
event=_agent_message_event(
|
|
"turn_new",
|
|
"item_new",
|
|
"after clear",
|
|
thread_id="thread_new",
|
|
),
|
|
delta_coalescer=target.delta_coalescer,
|
|
expected_thread_id=target.thread_id,
|
|
)
|
|
await target.delta_coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.session_id == "conv_new"
|
|
assert state.thread_id == "thread_new"
|
|
# Rotation must re-persist the ws:// transport it was given, not a
|
|
# clobbered unix path — otherwise the executor would dial a dead unix
|
|
# socket after /clear and steering/interrupt would silently fail.
|
|
assert state.socket_path == "ws://127.0.0.1:9876"
|
|
assert fake_client.requests == [
|
|
("thread/resume", {"threadId": "thread_old", "excludeTurns": True}),
|
|
("thread/resume", {"threadId": "thread_new", "excludeTurns": True}),
|
|
]
|
|
assert (
|
|
"POST",
|
|
"/v1/sessions",
|
|
{
|
|
"agent_id": "ag_codex",
|
|
"labels": {
|
|
"omnigent.wrapper": "codex-native-ui",
|
|
"omnigent.codex_native.bridge_id": "bridge_shared",
|
|
},
|
|
},
|
|
) in requests
|
|
assert (
|
|
"POST",
|
|
"/v1/sessions/conv_old/resources/terminals/terminal_codex_main/transfer",
|
|
{"target_session_id": "conv_new"},
|
|
) in requests
|
|
assert [
|
|
payload["data"]["status"]
|
|
for _, payload in posted_events
|
|
if payload["type"] == "external_session_status"
|
|
] == ["running"]
|
|
assert [
|
|
payload["data"]["item_data"]["content"][0]["text"]
|
|
for _, payload in posted_events
|
|
if payload["type"] == "external_conversation_item"
|
|
] == ["after clear"]
|
|
|
|
|
|
def test_forwarder_rotation_failure_preserves_old_target(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Failed Codex thread rotation leaves the old forwarding target usable.
|
|
|
|
If Omnigent rejects replacement-session creation, the forwarder logs the
|
|
event-handler failure and continues. The old target must remain
|
|
intact; closing its coalescer before the Omnigent move succeeds would
|
|
leave later old-thread streaming in a half-rotated state.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary bridge directory.
|
|
:returns: None.
|
|
"""
|
|
|
|
class _FakeCoalescer:
|
|
"""
|
|
Test coalescer that records lifecycle calls.
|
|
|
|
:param session_id: Omnigent session id represented by this fake,
|
|
e.g. ``"conv_old"``.
|
|
"""
|
|
|
|
def __init__(self, session_id: str) -> None:
|
|
"""
|
|
Initialize the fake coalescer.
|
|
|
|
:param session_id: Omnigent session id represented by this fake,
|
|
e.g. ``"conv_old"``.
|
|
:returns: None.
|
|
"""
|
|
self.session_id = session_id
|
|
self.flushed = False
|
|
self.closed = False
|
|
|
|
async def flush(self) -> None:
|
|
"""
|
|
Record a flush request.
|
|
|
|
:returns: None.
|
|
"""
|
|
self.flushed = True
|
|
|
|
async def close(self) -> None:
|
|
"""
|
|
Record a close request.
|
|
|
|
:returns: None.
|
|
"""
|
|
self.closed = True
|
|
|
|
async def fail_create_thread_replacement_session(**_kwargs: object) -> str:
|
|
"""
|
|
Simulate Omnigent rejecting the replacement-session operation.
|
|
|
|
:returns: Never returns successfully.
|
|
:raises RuntimeError: Always raised to model Omnigent failure.
|
|
"""
|
|
raise RuntimeError("replacement failed")
|
|
|
|
fake_delta_coalescer = _FakeCoalescer("conv_old")
|
|
fake_usage_coalescer = _FakeCoalescer("conv_old")
|
|
monkeypatch.setattr(
|
|
codex_native_forwarder,
|
|
"_create_thread_replacement_session",
|
|
fail_create_thread_replacement_session,
|
|
)
|
|
|
|
async def run() -> codex_native_forwarder._ForwarderTarget:
|
|
"""
|
|
Drive a failed new-thread rotation through the real detector.
|
|
|
|
:returns: Forwarder target after the failed rotation attempt.
|
|
"""
|
|
async with httpx.AsyncClient(base_url="http://127.0.0.1:8000") as client:
|
|
target = codex_native_forwarder._ForwarderTarget(
|
|
session_id="conv_old",
|
|
thread_id="thread_old",
|
|
delta_coalescer=fake_delta_coalescer, # type: ignore[arg-type]
|
|
usage_coalescer=fake_usage_coalescer, # type: ignore[arg-type]
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
)
|
|
with pytest.raises(RuntimeError, match="replacement failed"):
|
|
await codex_native_forwarder._maybe_rotate_session_on_thread_started(
|
|
ap_client=client,
|
|
target=target,
|
|
bridge_dir=tmp_path,
|
|
app_server_url=str(tmp_path / "app-server.sock"),
|
|
event=_thread_started_event("thread_new"),
|
|
)
|
|
return target
|
|
|
|
target = asyncio.run(run())
|
|
|
|
assert target.session_id == "conv_old"
|
|
assert target.thread_id == "thread_old"
|
|
assert target.delta_coalescer is fake_delta_coalescer
|
|
assert target.usage_coalescer is fake_usage_coalescer
|
|
assert fake_delta_coalescer.flushed
|
|
assert fake_usage_coalescer.flushed
|
|
assert not fake_delta_coalescer.closed
|
|
assert not fake_usage_coalescer.closed
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("initial_active_turn_id", "events", "expected_active_turn_id", "expected_statuses"),
|
|
[
|
|
(None, [_started_event("turn_old")], "turn_old", ["running"]),
|
|
(
|
|
None,
|
|
[_started_event("turn_old"), _completed_event("turn_old")],
|
|
None,
|
|
["running", "idle"],
|
|
),
|
|
(
|
|
None,
|
|
[_started_event("turn_old"), _failed_event("turn_old")],
|
|
None,
|
|
["running", "failed"],
|
|
),
|
|
(
|
|
None,
|
|
[_completed_event("turn_early", thread_id="thread_123")],
|
|
None,
|
|
["idle"],
|
|
),
|
|
(
|
|
None,
|
|
[_failed_event("turn_early", thread_id="thread_123")],
|
|
None,
|
|
["failed"],
|
|
),
|
|
(
|
|
None,
|
|
[_completed_event("turn_early", thread_id="thread_other")],
|
|
None,
|
|
[],
|
|
),
|
|
(
|
|
None,
|
|
[_started_event("turn_old"), _started_event("turn_new"), _completed_event("turn_old")],
|
|
"turn_new",
|
|
["running", "running"],
|
|
),
|
|
(
|
|
None,
|
|
[_started_event("turn_old"), _started_event("turn_new"), _failed_event("turn_old")],
|
|
"turn_new",
|
|
["running", "running"],
|
|
),
|
|
(
|
|
None,
|
|
[_started_event("turn_old"), _started_event("turn_new"), _completed_event("turn_new")],
|
|
None,
|
|
["running", "running", "idle"],
|
|
),
|
|
# A no-id terminal event while turn_old is live is ambiguous: it is
|
|
# ignored, so the active turn stays and no premature idle is posted
|
|
# (the bug that hid the "working" spinner mid-turn).
|
|
(
|
|
None,
|
|
[_started_event("turn_old"), _completed_event(None)],
|
|
"turn_old",
|
|
["running"],
|
|
),
|
|
("turn_new", [_completed_event("turn_old")], "turn_new", []),
|
|
("turn_new", [_failed_event("turn_old")], "turn_new", []),
|
|
("turn_new", [_completed_event("turn_new")], None, ["idle"]),
|
|
],
|
|
)
|
|
def test_forwarder_tracks_active_turn_across_terminal_event_sequences(
|
|
initial_active_turn_id: str | None,
|
|
events: list[dict[str, Any]],
|
|
expected_active_turn_id: str | None,
|
|
expected_statuses: list[str],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Forwarder turn lifecycle handling is ordered by terminal turn id.
|
|
|
|
Rapid web sends can update the active Codex turn before an older
|
|
terminal notification arrives. Matching terminal notifications must
|
|
mark the session idle or failed, while stale terminal notifications
|
|
must leave the newer active turn and session status untouched.
|
|
|
|
:param initial_active_turn_id: Active turn id before replaying the
|
|
sequence, e.g. ``"turn_new"``.
|
|
:param events: Codex app-server notifications to replay.
|
|
:param expected_active_turn_id: Expected bridge state after the
|
|
sequence.
|
|
:param expected_statuses: Expected external session status posts.
|
|
:param tmp_path: Temporary bridge directory.
|
|
:returns: None.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=initial_active_turn_id,
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay the Codex event sequence through the real handler.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
for event in events:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.active_turn_id == expected_active_turn_id
|
|
assert [
|
|
payload["data"]["status"]
|
|
for payload in posted
|
|
if payload["type"] == "external_session_status"
|
|
] == expected_statuses
|
|
|
|
|
|
def test_forwarder_posts_agent_item_after_stale_terminal_event(tmp_path: Path) -> None:
|
|
"""
|
|
Stale turn completion does not block newer Codex response mirroring.
|
|
|
|
This is the rapid-send failure shape: the web inject path has
|
|
already advanced to ``turn_new`` when a delayed terminal event for
|
|
``turn_old`` arrives. The stale terminal event must not mark the
|
|
session idle or prevent the newer assistant item from syncing.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay the stale-terminal/new-item sequence.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
for event in [
|
|
_started_event("turn_old"),
|
|
_started_event("turn_new"),
|
|
_completed_event("turn_old"),
|
|
_agent_message_event("turn_new", "item_agent", "new response"),
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.active_turn_id == "turn_new"
|
|
assert [
|
|
payload["data"]["status"]
|
|
for payload in posted
|
|
if payload["type"] == "external_session_status"
|
|
] == ["running", "running"]
|
|
assert [
|
|
payload["data"]["item_data"]["content"][0]["text"]
|
|
for payload in posted
|
|
if payload["type"] == "external_conversation_item"
|
|
] == ["new response"]
|
|
|
|
|
|
def test_forwarder_posts_active_codex_agent_message_delta(tmp_path: Path) -> None:
|
|
"""
|
|
Codex assistant deltas are forwarded as transient Omnigent text deltas.
|
|
|
|
Breaking the ``item/agentMessage/delta`` branch would leave the
|
|
web stream silent until Codex posts its completed ``agentMessage``
|
|
item, so this asserts on the exact Omnigent event envelope.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay one active assistant-delta event.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=_agent_message_delta_event("turn_123", "item_agent", "hel"),
|
|
delta_coalescer=coalescer,
|
|
)
|
|
await coalescer.flush()
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data("hel", "turn_123", "item_agent"),
|
|
}
|
|
]
|
|
|
|
|
|
def test_forwarder_persists_interrupted_codex_partial_agent_message(tmp_path: Path) -> None:
|
|
"""
|
|
Interrupted Codex turns persist the visible partial assistant text.
|
|
|
|
Codex interruption terminates the turn with ``turn/completed`` status
|
|
``interrupted`` and may never emit a completed ``agentMessage`` item.
|
|
Without this fallback, Omnigent Web shows the streamed text live but loses it
|
|
from durable history as soon as the turn ends.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState()
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay assistant deltas followed by an interrupted terminal boundary.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
for event in [
|
|
_agent_message_delta_event("turn_123", "item_agent", "partial "),
|
|
_agent_message_delta_event("turn_123", "item_agent", "answer"),
|
|
{
|
|
"method": "turn/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turn": {"id": "turn_123", "status": "interrupted"},
|
|
},
|
|
},
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
delta_coalescer=coalescer,
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data("partial answer", "turn_123", "item_agent"),
|
|
},
|
|
{
|
|
"type": "external_session_interrupted",
|
|
"data": {"response_id": "codex_turn_123"},
|
|
},
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "message",
|
|
"item_data": {
|
|
"role": "assistant",
|
|
"agent": "codex-native-ui",
|
|
"interrupted": True,
|
|
"content": [{"type": "output_text", "text": "partial answer"}],
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
{
|
|
"type": "external_session_status",
|
|
"data": _expected_status_data("idle", "turn_123"),
|
|
},
|
|
]
|
|
assert forwarder_state.partial_text_by_turn == {}
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.active_turn_id is None
|
|
|
|
|
|
def test_forwarder_posts_active_codex_plan_delta(tmp_path: Path) -> None:
|
|
"""
|
|
Codex plan deltas are forwarded as transient Omnigent text deltas.
|
|
|
|
Plan mode uses ``item/plan/delta`` while rendering the visible
|
|
plan. If this branch is missing, Omnigent web stays blank even though the
|
|
Codex TUI is already showing the plan.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay one active plan-delta event.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=_plan_delta_event("turn_123", "item_plan", "1. Inspect"),
|
|
delta_coalescer=coalescer,
|
|
)
|
|
await coalescer.flush()
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data(
|
|
"1. Inspect",
|
|
"turn_123",
|
|
"item_plan",
|
|
item_type="plan",
|
|
),
|
|
}
|
|
]
|
|
|
|
|
|
def test_forwarder_recovers_active_turn_from_codex_plan_delta(tmp_path: Path) -> None:
|
|
"""
|
|
Plan deltas mark the session running when ``turn/started`` was missed.
|
|
|
|
Fresh TUI turns can begin before the forwarder finishes subscribing
|
|
via ``thread/resume``. The delta itself carries both ``threadId``
|
|
and ``turnId``; when no active turn is recorded yet and the thread
|
|
matches bridge state, the forwarder adopts that turn, publishes the
|
|
missing ``running`` status edge, and streams the plan instead of
|
|
dropping the first visible content.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay a plan delta before any observed turn-start edge.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=_plan_delta_event("turn_early", "item_plan", "Draft plan"),
|
|
delta_coalescer=coalescer,
|
|
)
|
|
await coalescer.flush()
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_session_status",
|
|
"data": _expected_status_data("running", "turn_early"),
|
|
},
|
|
{
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data(
|
|
"Draft plan",
|
|
"turn_early",
|
|
"item_plan",
|
|
item_type="plan",
|
|
),
|
|
},
|
|
]
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.active_turn_id == "turn_early"
|
|
|
|
|
|
def test_forwarder_recovers_active_turn_from_codex_agent_message_delta(tmp_path: Path) -> None:
|
|
"""
|
|
Assistant deltas mark the session running when ``turn/started`` was missed.
|
|
|
|
Some Codex turns begin before the forwarder has subscribed to the
|
|
app-server event stream. If the first observed event is already an
|
|
``item/agentMessage/delta`` for the current thread, the forwarder must
|
|
adopt that turn and publish ``external_session_status: running``; otherwise
|
|
the web thread's ``Working...`` indicator stays idle until text happens to
|
|
render.
|
|
|
|
:param tmp_path: Temporary bridge directory.
|
|
:returns: None.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay an assistant delta before any observed turn-start edge.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=_agent_message_delta_event("turn_early", "item_agent", "Hello"),
|
|
delta_coalescer=coalescer,
|
|
)
|
|
await coalescer.flush()
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_session_status",
|
|
"data": _expected_status_data("running", "turn_early"),
|
|
},
|
|
{
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data("Hello", "turn_early", "item_agent"),
|
|
},
|
|
]
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.active_turn_id == "turn_early"
|
|
|
|
|
|
def test_forwarder_recovers_user_before_recovered_agent_message_delta(tmp_path: Path) -> None:
|
|
"""
|
|
Recovered assistant deltas do not stream above a missed user message.
|
|
|
|
If the observer misses ``turn/started``, ``userMessage``, and
|
|
``item/started``, the first visible event may be an assistant delta.
|
|
Adopting the turn is correct, but the forwarder must first recover and
|
|
post the user item so the web transcript order remains user then
|
|
assistant.
|
|
|
|
:param tmp_path: Temporary bridge directory.
|
|
:returns: None.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
resume_response = {
|
|
"result": {
|
|
"thread": {
|
|
"id": "thread_123",
|
|
"turns": [
|
|
{
|
|
"id": "turn_early",
|
|
"items": [
|
|
{
|
|
"type": "userMessage",
|
|
"id": "item_user",
|
|
"content": [{"type": "text", "text": "hello"}],
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
fake_client = _FakeCodexAppServerClient(response=resume_response)
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_123",
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay an assistant delta before all earlier turn events.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=_agent_message_delta_event("turn_early", "item_agent", "Hello"),
|
|
delta_coalescer=coalescer,
|
|
expected_thread_id="thread_123",
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
await coalescer.flush()
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert [payload["type"] for payload in posted] == [
|
|
"external_conversation_item",
|
|
"external_session_status",
|
|
"external_output_text_delta",
|
|
]
|
|
assert posted[0]["data"]["item_data"]["role"] == "user"
|
|
assert posted[0]["data"]["item_data"]["content"][0]["text"] == "hello"
|
|
assert posted[1]["data"] == _expected_status_data("running", "turn_early")
|
|
assert posted[2]["data"] == _expected_delta_data("Hello", "turn_early", "item_agent")
|
|
assert [method for method, _ in fake_client.requests] == ["thread/resume"]
|
|
assert forwarder_state.has_posted_user_message("turn_early")
|
|
|
|
|
|
def test_forwarder_drops_stale_and_malformed_codex_agent_message_deltas(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex assistant deltas only stream for the active turn.
|
|
|
|
This prevents a delayed delta from an older rapid-send turn from
|
|
appearing in the current web bubble. Non-string deltas are dropped
|
|
before they can reach AP's strict ``data.delta`` validation.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_new",
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay stale, malformed, and valid delta notifications.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
for event in [
|
|
_agent_message_delta_event("turn_old", "item_old", "stale"),
|
|
_agent_message_delta_event("turn_new", "item_new", {"text": "bad"}),
|
|
_agent_message_delta_event("turn_new", "item_new", "fresh"),
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
delta_coalescer=coalescer,
|
|
)
|
|
await coalescer.flush()
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data("fresh", "turn_new", "item_new"),
|
|
}
|
|
]
|
|
|
|
|
|
def test_forwarder_coalesces_codex_agent_message_deltas(tmp_path: Path) -> None:
|
|
"""
|
|
Native Codex streaming does not post one Omnigent event per tiny delta.
|
|
|
|
Breaking the coalescer would recreate the slow-drain failure where
|
|
Codex finishes locally while the Omnigent SSE stream is still serialized
|
|
behind many per-token HTTP POSTs. Stale and malformed deltas must
|
|
still be filtered before text enters the coalesced buffer.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_new",
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay multiple delta notifications through the coalescer.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
for event in [
|
|
_agent_message_delta_event("turn_old", "item_old", "stale"),
|
|
_agent_message_delta_event("turn_new", "item_new", {"text": "bad"}),
|
|
_agent_message_delta_event("turn_new", "item_new", "hel"),
|
|
_agent_message_delta_event("turn_new", "item_new", "lo"),
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
delta_coalescer=coalescer,
|
|
)
|
|
await coalescer.flush()
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data("hello", "turn_new", "item_new"),
|
|
}
|
|
]
|
|
|
|
|
|
def test_forwarder_posts_codex_usage_live_per_frame(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex usage posts live (per frame) so the web UI cost badge updates
|
|
mid-turn, not only at the turn boundary.
|
|
|
|
Codex emits ``thread/tokenUsage/updated`` every few seconds; the forwarder
|
|
flushes the coalescer right after recording each frame so the server can
|
|
price and broadcast cost immediately. (Previously usage was deferred to the
|
|
turn boundary, leaving the cost badge stuck until the turn ended.) The
|
|
sparse cadence means the per-frame post does not block the high-frequency
|
|
text-delta path, which has its own coalescer.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
posts_after_usage_updates: list[dict[str, Any]] = []
|
|
posts_after_text_flush: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from both coalescers.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay usage updates, then text, then terminal completion.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
delta_coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
usage_coalescer = codex_native_forwarder._SessionUsageCoalescer(
|
|
client,
|
|
"conv_123",
|
|
)
|
|
elicitation_tracker = _elicitation_tracker()
|
|
for event in [_usage_event(100), _usage_event(150)]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
event=event,
|
|
delta_coalescer=delta_coalescer,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
)
|
|
posts_after_usage_updates.extend(posted)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
event=_agent_message_delta_event("turn_123", "item_agent", "visible text"),
|
|
delta_coalescer=delta_coalescer,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
)
|
|
await delta_coalescer.flush()
|
|
posts_after_text_flush.extend(posted)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
event=_completed_event("turn_123"),
|
|
delta_coalescer=delta_coalescer,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
)
|
|
await delta_coalescer.close()
|
|
await usage_coalescer.close()
|
|
await elicitation_tracker.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
# Usage now posts LIVE on each frame (so the cost badge moves mid-turn),
|
|
# latest-only via the coalescer's dedup: 100 then 150. A value of ``[]``
|
|
# here would mean usage was deferred again and the badge would stay stuck
|
|
# until the turn boundary (the regression this guards).
|
|
assert [p["type"] for p in posts_after_usage_updates] == [
|
|
"external_session_usage",
|
|
"external_session_usage",
|
|
]
|
|
# The first frame posts every value (nothing posted yet); the second posts
|
|
# only the CHANGED keys — context_window was unchanged, so the coalescer's
|
|
# dedup drops it (proving latest-only diffing, not blind re-posting).
|
|
assert posts_after_usage_updates[0]["data"] == {
|
|
"context_tokens": 100,
|
|
"context_window": 200_000,
|
|
"cumulative_input_tokens": 100,
|
|
}
|
|
assert posts_after_usage_updates[1]["data"] == {
|
|
"context_tokens": 150,
|
|
"cumulative_input_tokens": 150,
|
|
}
|
|
# Text still streams via its own coalescer — the per-frame usage posts
|
|
# neither swallowed nor blocked it.
|
|
assert {
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data("visible text", "turn_123", "item_agent"),
|
|
} in posts_after_text_flush
|
|
# Exactly two usage posts overall: the turn-boundary flush is a no-op
|
|
# because 150 was already posted (dedup), so no duplicate lands at the end.
|
|
assert [p["type"] for p in posted].count("external_session_usage") == 2
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("deltas", "flush_interval_seconds", "flush_char_threshold", "expected_delta"),
|
|
[
|
|
(["timed"], 0.001, 1000, "timed"),
|
|
(["abc", "de"], 60.0, 5, "abcde"),
|
|
(["line\n"], 60.0, 1000, "line\n"),
|
|
],
|
|
)
|
|
def test_output_text_delta_coalescer_auto_flushes(
|
|
deltas: list[str],
|
|
flush_interval_seconds: float,
|
|
flush_char_threshold: int,
|
|
expected_delta: str,
|
|
) -> None:
|
|
"""
|
|
The coalescer flushes without an explicit flush barrier.
|
|
|
|
Each parametrized case isolates one automatic trigger: timer
|
|
expiry, character threshold, and newline. The test waits for the
|
|
Omnigent post directly instead of calling ``flush()``, so removing any
|
|
trigger leaves that case stuck until ``wait_for`` fails.
|
|
|
|
:param deltas: Text fragments appended to the coalescer, e.g.
|
|
``["abc", "de"]``.
|
|
:param flush_interval_seconds: Timer budget for the first buffered
|
|
delta, e.g. ``0.001``.
|
|
:param flush_char_threshold: Buffered character threshold that
|
|
triggers a flush, e.g. ``5``.
|
|
:param expected_delta: Coalesced Omnigent delta payload.
|
|
:returns: None.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Append deltas and wait for the automatic Omnigent post.
|
|
|
|
:returns: None.
|
|
"""
|
|
posted_event = asyncio.Event()
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the coalescer.
|
|
|
|
:param request: HTTP request sent by the coalescer.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
posted_event.set()
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=flush_interval_seconds,
|
|
flush_char_threshold=flush_char_threshold,
|
|
)
|
|
for delta in deltas:
|
|
await coalescer.append(delta)
|
|
await asyncio.wait_for(posted_event.wait(), timeout=1.0)
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_output_text_delta",
|
|
"data": {"delta": expected_delta},
|
|
}
|
|
]
|
|
|
|
|
|
def test_forwarder_flushes_coalesced_deltas_before_completed_agent_item(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Completed Codex items cannot overtake buffered text deltas.
|
|
|
|
The web stream should receive the live text tail before the durable
|
|
completed ``agentMessage`` item for the same turn.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay buffered deltas followed by the completed assistant item.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
for event in [
|
|
_agent_message_delta_event("turn_123", "item_agent", "partial "),
|
|
_agent_message_delta_event("turn_123", "item_agent", "tail"),
|
|
_agent_message_event("turn_123", "item_agent", "complete response"),
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
delta_coalescer=coalescer,
|
|
)
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert [payload["type"] for payload in posted] == [
|
|
"external_output_text_delta",
|
|
"external_conversation_item",
|
|
]
|
|
assert posted[0]["data"] == _expected_delta_data("partial tail", "turn_123", "item_agent")
|
|
assert posted[1]["data"]["item_data"]["content"][0]["text"] == "complete response"
|
|
|
|
|
|
def test_supervise_forwarder_continues_after_event_handler_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
One malformed Codex notification must not stop transcript mirroring.
|
|
|
|
The native forwarder is long-lived. If one event raises during
|
|
handling, later events from the same app-server stream still need
|
|
to be processed or Codex responses stop syncing back to AP.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient(
|
|
events=[
|
|
{"method": "turn/started", "params": {"turn": {"id": "turn_bad"}}},
|
|
{"method": "turn/started", "params": {"turn": {"id": "turn_after"}}},
|
|
]
|
|
)
|
|
handled: list[str] = []
|
|
|
|
async def fake_handle_event(
|
|
_client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
event: dict[str, Any],
|
|
delta_coalescer: codex_native_forwarder._OutputTextDeltaCoalescer | None = None,
|
|
usage_coalescer: codex_native_forwarder._SessionUsageCoalescer | None = None,
|
|
elicitation_tracker: codex_native_forwarder._CodexElicitationTaskTracker | None = None,
|
|
expected_thread_id: str | None = None,
|
|
codex_client: codex_native_app_server.CodexAppServerClient | None = None,
|
|
forwarder_state: codex_native_forwarder._CodexForwarderState | None = None,
|
|
) -> None:
|
|
"""
|
|
Fail the first event and record subsequent events.
|
|
|
|
:param _client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session id, e.g. ``"conv_123"``.
|
|
:param bridge_dir: Native Codex bridge directory.
|
|
:param event: Codex event payload.
|
|
:param delta_coalescer: Optional text-delta coalescer.
|
|
:param usage_coalescer: Optional usage coalescer.
|
|
:param elicitation_tracker: Optional elicitation tracker.
|
|
:param expected_thread_id: Active Codex thread id.
|
|
:param codex_client: Optional Codex app-server client.
|
|
:param forwarder_state: Optional forwarder state.
|
|
:returns: None.
|
|
"""
|
|
del (
|
|
session_id,
|
|
bridge_dir,
|
|
delta_coalescer,
|
|
usage_coalescer,
|
|
elicitation_tracker,
|
|
expected_thread_id,
|
|
codex_client,
|
|
forwarder_state,
|
|
)
|
|
turn_id = event["params"]["turn"]["id"]
|
|
if turn_id == "turn_bad":
|
|
raise RuntimeError("bad event")
|
|
handled.append(turn_id)
|
|
|
|
monkeypatch.setattr(codex_native_forwarder, "_handle_event", fake_handle_event)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Run the supervisor against a two-event stream.
|
|
|
|
:returns: None.
|
|
"""
|
|
await codex_native_forwarder.supervise_forwarder(
|
|
base_url="http://127.0.0.1:1",
|
|
headers={},
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
app_server_url=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert handled == ["turn_after"]
|
|
assert "Codex forwarder event handling failed" in caplog.text
|
|
|
|
|
|
def test_forwarder_sends_codex_mcp_elicitation_response_to_app_server(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex MCP elicitation requests are forwarded to Omnigent and the Omnigent hook
|
|
result is sent back to the app-server with the original JSON-RPC id.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
requests: list[httpx.Request] = []
|
|
codex_event = {
|
|
"id": 3,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"serverName": "booking",
|
|
"mode": "form",
|
|
"message": "Pick a date",
|
|
"requestedSchema": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture the Omnigent hook request and return an accepted MCP result.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent hook response.
|
|
"""
|
|
requests.append(request)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"action": "accept",
|
|
"content": {"date": "tomorrow"},
|
|
"_meta": None,
|
|
},
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive one app-server request through the forwarder.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.drain()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert [request.url.path for request in requests] == [
|
|
"/v1/sessions/conv_123/hooks/codex-elicitation-request"
|
|
]
|
|
assert json.loads(requests[0].content) == codex_event
|
|
assert fake_client.responses == [
|
|
(
|
|
3,
|
|
{
|
|
"action": "accept",
|
|
"content": {"date": "tomorrow"},
|
|
"_meta": None,
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
def test_forwarder_keeps_streaming_when_native_tui_answers_codex_elicitation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Native TUI approval must not park the Omnigent web mirror.
|
|
|
|
The Omnigent hook remains pending when a separate native Codex client
|
|
answers the prompt first. Codex app-server emits
|
|
``serverRequest/resolved`` with the original request id; the
|
|
forwarder must mirror that exact resolution to Omnigent and still mirror
|
|
later transcript events.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
hook_started = asyncio.Event()
|
|
hook_cancelled = asyncio.Event()
|
|
posted_events: list[dict[str, Any]] = []
|
|
codex_event = {
|
|
"id": 3,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"serverName": "booking",
|
|
"mode": "form",
|
|
"message": "Pick a date",
|
|
"requestedSchema": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Hold the hook open and capture subsequent Omnigent event posts.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent event response for non-hook posts.
|
|
"""
|
|
if request.url.path.endswith("/hooks/codex-elicitation-request"):
|
|
hook_started.set()
|
|
try:
|
|
await asyncio.Future()
|
|
except asyncio.CancelledError:
|
|
hook_cancelled.set()
|
|
raise
|
|
posted_events.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive a pending elicitation followed by native-side progress.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
usage_coalescer = _usage_coalescer(client)
|
|
await asyncio.wait_for(
|
|
codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
),
|
|
timeout=0.2,
|
|
)
|
|
await asyncio.wait_for(hook_started.wait(), timeout=1.0)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event={
|
|
"method": "serverRequest/resolved",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"requestId": 3,
|
|
},
|
|
},
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=_agent_message_event("turn_123", "item_agent", "after approval"),
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.close()
|
|
await asyncio.wait_for(hook_cancelled.wait(), timeout=1.0)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.responses == []
|
|
assert posted_events == [
|
|
{
|
|
"type": "external_elicitation_resolved",
|
|
"data": {
|
|
"elicitation_id": codex_elicitation_id(
|
|
"conv_123",
|
|
"mcpServer/elicitation/request",
|
|
3,
|
|
),
|
|
},
|
|
},
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "message",
|
|
"item_data": {
|
|
"role": "assistant",
|
|
"agent": "codex-native-ui",
|
|
"content": [{"type": "output_text", "text": "after approval"}],
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def test_forwarder_ignores_resolution_for_different_codex_request_id(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex resolution must match the pending JSON-RPC request id.
|
|
|
|
Same-thread activity is not enough: a different
|
|
``serverRequest/resolved.requestId`` may belong to another
|
|
server-to-client request, so forwarding it would clear the wrong web
|
|
approval card.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
hook_started = asyncio.Event()
|
|
posted_events: list[dict[str, Any]] = []
|
|
codex_event = {
|
|
"id": 3,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"serverName": "booking",
|
|
"mode": "form",
|
|
"message": "Pick a date",
|
|
"requestedSchema": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Hold the hook open and capture subsequent Omnigent event posts.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent event response for non-hook posts.
|
|
"""
|
|
if request.url.path.endswith("/hooks/codex-elicitation-request"):
|
|
hook_started.set()
|
|
await asyncio.Future()
|
|
posted_events.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive a pending elicitation followed by an unrelated resolution.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
usage_coalescer = _usage_coalescer(client)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await asyncio.wait_for(hook_started.wait(), timeout=1.0)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event={
|
|
"method": "serverRequest/resolved",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"requestId": 999,
|
|
},
|
|
},
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=_agent_message_event("turn_123", "item_agent", "after approval"),
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert [event["type"] for event in posted_events] == ["external_conversation_item"]
|
|
assert posted_events[0]["data"]["item_data"]["content"][0]["text"] == "after approval"
|
|
|
|
|
|
def test_forwarder_falls_back_to_terminal_turn_for_missed_resolution(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
A terminal Codex turn clears a matching pending web prompt.
|
|
|
|
``serverRequest/resolved`` is the exact signal for native-side
|
|
approval, but if the forwarder misses that notification then an
|
|
accepted ``turn/completed`` for the same turn is the next safe
|
|
lifecycle boundary proving Codex is no longer waiting on the
|
|
server-to-client request.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
hook_started = asyncio.Event()
|
|
hook_cancelled = asyncio.Event()
|
|
posted_events: list[dict[str, Any]] = []
|
|
codex_event = {
|
|
"id": 3,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"serverName": "booking",
|
|
"mode": "form",
|
|
"message": "Pick a date",
|
|
"requestedSchema": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Hold the hook open and capture subsequent Omnigent event posts.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent event response for non-hook posts.
|
|
"""
|
|
if request.url.path.endswith("/hooks/codex-elicitation-request"):
|
|
hook_started.set()
|
|
try:
|
|
await asyncio.Future()
|
|
except asyncio.CancelledError:
|
|
hook_cancelled.set()
|
|
raise
|
|
posted_events.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive a pending elicitation followed by terminal-turn cleanup.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
usage_coalescer = _usage_coalescer(client)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await asyncio.wait_for(hook_started.wait(), timeout=1.0)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event={
|
|
"method": "turn/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turn": {
|
|
"id": "turn_123",
|
|
"status": "completed",
|
|
"items": [],
|
|
},
|
|
},
|
|
},
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.close()
|
|
await asyncio.wait_for(hook_cancelled.wait(), timeout=1.0)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.responses == []
|
|
assert [event["type"] for event in posted_events] == [
|
|
"external_session_status",
|
|
"external_elicitation_resolved",
|
|
]
|
|
assert posted_events[1]["data"]["elicitation_id"] == codex_elicitation_id(
|
|
"conv_123",
|
|
"mcpServer/elicitation/request",
|
|
3,
|
|
)
|
|
|
|
|
|
def test_forwarder_does_not_clear_pending_elicitation_for_stale_terminal_turn(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Stale terminal turn events cannot clear a newer pending prompt.
|
|
|
|
The fallback must run only after the active-turn guard accepts the
|
|
terminal event. Otherwise a delayed completion from an older Codex
|
|
turn could dismiss an approval card that belongs to the current
|
|
native turn.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
hook_started = asyncio.Event()
|
|
posted_events: list[dict[str, Any]] = []
|
|
codex_event = {
|
|
"id": 3,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_new",
|
|
"serverName": "booking",
|
|
"mode": "form",
|
|
"message": "Pick a date",
|
|
"requestedSchema": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_new",
|
|
),
|
|
)
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Hold the hook open and capture subsequent Omnigent event posts.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent event response for non-hook posts.
|
|
"""
|
|
if request.url.path.endswith("/hooks/codex-elicitation-request"):
|
|
hook_started.set()
|
|
await asyncio.Future()
|
|
posted_events.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive a pending elicitation followed by stale turn completion.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
usage_coalescer = _usage_coalescer(client)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await asyncio.wait_for(hook_started.wait(), timeout=1.0)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event={
|
|
"method": "turn/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turn": {
|
|
"id": "turn_old",
|
|
"status": "completed",
|
|
"items": [],
|
|
},
|
|
},
|
|
},
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.responses == []
|
|
assert posted_events == []
|
|
|
|
|
|
def test_forwarder_sends_codex_request_user_input_response_to_app_server(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex requestUserInput frames use the same Omnigent hook path and relay
|
|
its ``answers`` result back to app-server.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
codex_event = {
|
|
"id": "req_9",
|
|
"method": "item/tool/requestUserInput",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_123",
|
|
"questions": [{"id": "framework", "question": "Which framework?"}],
|
|
},
|
|
}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Return a requestUserInput result from the Omnigent hook.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent hook response.
|
|
"""
|
|
assert request.url.path == "/v1/sessions/conv_123/hooks/codex-elicitation-request"
|
|
assert json.loads(request.content) == codex_event
|
|
return httpx.Response(
|
|
200,
|
|
json={"answers": {"framework": {"answers": ["React"]}}},
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive one requestUserInput frame through the forwarder.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.drain()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.responses == [("req_9", {"answers": {"framework": {"answers": ["React"]}}})]
|
|
|
|
|
|
def test_forwarder_flushes_plan_text_before_codex_request_user_input(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Buffered plan deltas reach Omnigent before the final plan prompt.
|
|
|
|
Codex can emit ``item/plan/delta`` and immediately send
|
|
``item/tool/requestUserInput`` for "Implement this plan?". The
|
|
forwarder coalesces text deltas, so it must flush the buffer before
|
|
posting the long-poll elicitation hook; otherwise web sees the
|
|
prompt before the plan content, or the plan stays buffered while
|
|
the hook waits for a user answer.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
fake_client = _FakeCodexAppServerClient()
|
|
request_bodies: list[dict[str, Any]] = []
|
|
request_paths: list[str] = []
|
|
codex_event = {
|
|
"id": "plan_prompt",
|
|
"method": "item/tool/requestUserInput",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_plan_prompt",
|
|
"questions": [
|
|
{
|
|
"id": "plan_decision",
|
|
"header": "Plan",
|
|
"question": "Implement this plan?",
|
|
"isOther": False,
|
|
"isSecret": False,
|
|
"options": [{"label": "Yes, implement this plan"}],
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent posts in arrival order.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent response appropriate to the endpoint.
|
|
"""
|
|
request_paths.append(request.url.path)
|
|
request_bodies.append(json.loads(request.content))
|
|
if request.url.path.endswith("/events"):
|
|
return httpx.Response(202, json={"queued": False})
|
|
return httpx.Response(
|
|
200,
|
|
json={"answers": {"plan_decision": {"answers": ["Yes, implement this plan"]}}},
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay a plan delta followed by the plan-mode final prompt.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
elicitation_tracker = _elicitation_tracker()
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=_plan_delta_event("turn_123", "item_plan", "1. Inspect existing flow"),
|
|
delta_coalescer=coalescer,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
delta_coalescer=coalescer,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.drain()
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert request_paths == [
|
|
"/v1/sessions/conv_123/events",
|
|
"/v1/sessions/conv_123/hooks/codex-elicitation-request",
|
|
]
|
|
assert request_bodies[0] == {
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data(
|
|
"1. Inspect existing flow",
|
|
"turn_123",
|
|
"item_plan",
|
|
item_type="plan",
|
|
),
|
|
}
|
|
assert request_bodies[1] == codex_event
|
|
assert fake_client.responses == [
|
|
(
|
|
"plan_prompt",
|
|
{"answers": {"plan_decision": {"answers": ["Yes, implement this plan"]}}},
|
|
)
|
|
]
|
|
|
|
|
|
def test_forwarder_synthesizes_plan_implementation_prompt_after_completed_plan_turn(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Completed Plan-mode turns surface the final implementation prompt in Omnigent Web.
|
|
|
|
Codex's terminal TUI owns the ``Implement this plan?`` picker
|
|
locally, so the app-server does not emit a native
|
|
``item/tool/requestUserInput`` request. The forwarder must bridge
|
|
that terminal-only prompt through the existing Codex elicitation
|
|
hook after the completed plan item and terminal turn event arrive.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
fake_client = _FakeCodexAppServerClient()
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState(model="mock-model")
|
|
request_paths: list[str] = []
|
|
request_bodies: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent posts and decline the synthesized prompt.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent response appropriate to the endpoint.
|
|
"""
|
|
request_paths.append(request.url.path)
|
|
request_bodies.append(json.loads(request.content))
|
|
if request.url.path.endswith("/events"):
|
|
return httpx.Response(202, json={"queued": False})
|
|
return httpx.Response(
|
|
200,
|
|
json={"answers": {"plan_implementation": {"answers": ["No, stay in Plan mode"]}}},
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay a streamed plan, completed plan item, and terminal completion.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
client,
|
|
"conv_123",
|
|
flush_interval_seconds=60.0,
|
|
flush_char_threshold=1000,
|
|
)
|
|
for event in [
|
|
_plan_delta_event("turn_123", "item_plan", "1. Inspect existing flow"),
|
|
{
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {
|
|
"type": "plan",
|
|
"id": "item_plan",
|
|
"text": "1. Inspect existing flow",
|
|
},
|
|
},
|
|
},
|
|
_completed_event("turn_123"),
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
delta_coalescer=coalescer,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
await coalescer.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert request_paths == [
|
|
"/v1/sessions/conv_123/events",
|
|
"/v1/sessions/conv_123/events",
|
|
"/v1/sessions/conv_123/events",
|
|
"/v1/sessions/conv_123/hooks/codex-elicitation-request",
|
|
]
|
|
assert request_bodies[0] == {
|
|
"type": "external_output_text_delta",
|
|
"data": _expected_delta_data(
|
|
"1. Inspect existing flow",
|
|
"turn_123",
|
|
"item_plan",
|
|
item_type="plan",
|
|
),
|
|
}
|
|
assert request_bodies[3] == {
|
|
"id": "plan_implementation:turn_123",
|
|
"method": "item/tool/requestUserInput",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "turn_123:plan_implementation",
|
|
"questions": [
|
|
{
|
|
"id": "plan_implementation",
|
|
"header": "Plan",
|
|
"question": "Implement this plan?",
|
|
"isOther": False,
|
|
"isSecret": False,
|
|
"options": [
|
|
{
|
|
"label": "Yes, implement this plan",
|
|
"description": "Switch to Default and start coding.",
|
|
},
|
|
{
|
|
"label": "Yes, clear context and implement",
|
|
"description": "Fresh thread with this plan.",
|
|
},
|
|
{
|
|
"label": "No, stay in Plan mode",
|
|
"description": "Continue planning with the model.",
|
|
},
|
|
],
|
|
}
|
|
],
|
|
},
|
|
}
|
|
assert fake_client.requests == []
|
|
|
|
|
|
def test_forwarder_starts_default_turn_from_plan_implementation_prompt(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Accepting the synthesized Plan prompt starts a Default-mode Codex turn.
|
|
|
|
If the forwarder only displayed the web prompt without translating
|
|
the answer back into Codex app-server actions, Omnigent Web would look
|
|
interactive but selecting ``Yes, implement this plan`` would do
|
|
nothing.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
fake_client = _FakeCodexAppServerClient()
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState(model="mock-model")
|
|
|
|
async def fake_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Capture Codex app-server requests and return a started turn.
|
|
|
|
:param method: JSON-RPC method.
|
|
:param params: JSON-RPC params.
|
|
:returns: JSON-RPC response envelope.
|
|
"""
|
|
fake_client.requests.append((method, params))
|
|
return {"result": {"turn": {"id": "turn_impl"}}}
|
|
|
|
fake_client.request = fake_request # type: ignore[method-assign]
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Accept the synthesized Plan implementation prompt.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent hook or event response.
|
|
"""
|
|
if request.url.path.endswith("/events"):
|
|
return httpx.Response(202, json={"queued": False})
|
|
return httpx.Response(
|
|
200,
|
|
json={"answers": {"plan_implementation": {"answers": ["Yes, implement this plan"]}}},
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay a completed plan turn and answer its web prompt.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
for event in [
|
|
{
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {
|
|
"type": "plan",
|
|
"id": "item_plan",
|
|
"text": "1. Implement",
|
|
},
|
|
},
|
|
},
|
|
_completed_event("turn_123"),
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.requests == [
|
|
(
|
|
"turn/start",
|
|
{
|
|
"threadId": "thread_123",
|
|
"input": [{"type": "text", "text": "Implement the plan."}],
|
|
"collaborationMode": {
|
|
"mode": "default",
|
|
"settings": {
|
|
"model": "mock-model",
|
|
"reasoning_effort": None,
|
|
"developer_instructions": None,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
]
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.active_turn_id == "turn_impl"
|
|
|
|
|
|
def test_forwarder_starts_fresh_thread_from_clear_context_plan_prompt(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
The clear-context Plan prompt choice creates a fresh Codex thread.
|
|
|
|
This mirrors the terminal TUI action closely enough for Omnigent Web:
|
|
the bridge switches to the new thread, sends the clear-context
|
|
implementation prompt, and records the new active turn.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
fake_client = _FakeCodexAppServerClient()
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState(model="mock-model")
|
|
|
|
async def fake_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Capture Codex requests for fresh-thread plan implementation.
|
|
|
|
:param method: JSON-RPC method.
|
|
:param params: JSON-RPC params.
|
|
:returns: JSON-RPC response envelope for the method.
|
|
"""
|
|
fake_client.requests.append((method, params))
|
|
if method == "thread/start":
|
|
return {"result": {"thread": {"id": "thread_fresh"}}}
|
|
return {"result": {"turn": {"id": "turn_fresh"}}}
|
|
|
|
fake_client.request = fake_request # type: ignore[method-assign]
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Select the clear-context implementation option.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent hook or event response.
|
|
"""
|
|
if request.url.path.endswith("/events"):
|
|
return httpx.Response(202, json={"queued": False})
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"answers": {
|
|
"plan_implementation": {"answers": ["Yes, clear context and implement"]}
|
|
}
|
|
},
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay a completed plan turn and answer with clear-context.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
for event in [
|
|
{
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {
|
|
"type": "plan",
|
|
"id": "item_plan",
|
|
"text": "- do the work",
|
|
},
|
|
},
|
|
},
|
|
_completed_event("turn_123"),
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.requests[0] == (
|
|
"thread/start",
|
|
{"model": "mock-model", "sessionStartSource": "clear"},
|
|
)
|
|
assert fake_client.requests[1][0] == "turn/start"
|
|
assert fake_client.requests[1][1]["threadId"] == "thread_fresh"
|
|
assert (
|
|
fake_client.requests[1][1]["input"][0]["text"]
|
|
== "A previous agent produced the plan below to accomplish the user's task. "
|
|
"Implement the plan in a fresh context. Treat the plan as the source of "
|
|
"user intent, re-read files as needed, and carry the work through "
|
|
"implementation and verification.\n\n- do the work"
|
|
)
|
|
state = read_bridge_state(tmp_path)
|
|
assert state is not None
|
|
assert state.thread_id == "thread_fresh"
|
|
assert state.active_turn_id == "turn_fresh"
|
|
|
|
|
|
def test_forwarder_sends_codex_command_approval_response_to_app_server(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex command-approval request frames use the Omnigent hook path and
|
|
relay its decision result back to app-server.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
codex_event = {
|
|
"id": 14,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_cmd",
|
|
"startedAtMs": 1,
|
|
"command": "date",
|
|
"cwd": "/tmp/workspace",
|
|
},
|
|
}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Return a command approval result from the Omnigent hook.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent hook response.
|
|
"""
|
|
assert request.url.path == "/v1/sessions/conv_123/hooks/codex-elicitation-request"
|
|
assert json.loads(request.content) == codex_event
|
|
return httpx.Response(200, json={"decision": "accept"})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive one command approval frame through the forwarder.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.drain()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.responses == [(14, {"decision": "accept"})]
|
|
|
|
|
|
def test_forwarder_routes_unregistered_child_command_approval_to_parent(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Command approvals from an unregistered child thread are not dropped.
|
|
|
|
Child thread registration can race behind server-to-client request
|
|
frames. For ordinary transcript events an unknown non-parent
|
|
``threadId`` is stale and should be ignored, but an approval request
|
|
must remain actionable. Until the child AP session is known, the
|
|
forwarder posts the hook to the parent session so Nessie can answer it.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
posted: list[tuple[str, dict[str, Any]]] = []
|
|
state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_parent",
|
|
)
|
|
child_started_event = {
|
|
"method": "thread/started",
|
|
"params": {
|
|
"thread": {
|
|
"id": "thread_child_unregistered",
|
|
"source": {"subAgent": {"thread_spawn": {"parent_thread_id": "thread_parent"}}},
|
|
}
|
|
},
|
|
}
|
|
codex_event = {
|
|
"id": 15,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_child_unregistered",
|
|
"turnId": "turn_child",
|
|
"itemId": "item_cmd",
|
|
"command": "date",
|
|
},
|
|
}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture the AP hook request and accept the command.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: AP hook response.
|
|
"""
|
|
posted.append((request.url.path, json.loads(request.content)))
|
|
return httpx.Response(200, json={"decision": "accept"})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive one unregistered child-thread approval through the forwarder.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client, "conv_parent"),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=child_started_event,
|
|
expected_thread_id="thread_parent",
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=state,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client, "conv_parent"),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
expected_thread_id="thread_parent",
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=state,
|
|
)
|
|
await elicitation_tracker.drain()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
(
|
|
"/v1/sessions/conv_parent/hooks/codex-elicitation-request",
|
|
codex_event,
|
|
)
|
|
]
|
|
assert fake_client.responses == [(15, {"decision": "accept"})]
|
|
|
|
|
|
def test_forwarder_drops_unknown_thread_command_approval(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Command approvals from unproven non-parent threads stay stale-dropped.
|
|
|
|
The child-registration race exemption only applies after Codex has
|
|
announced a child via ``thread/started`` metadata. A random stale
|
|
thread id must not surface an approval card in the current parent
|
|
session.
|
|
|
|
:param tmp_path: Pytest temporary directory.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
posted: list[tuple[str, dict[str, Any]]] = []
|
|
state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_parent",
|
|
)
|
|
codex_event = {
|
|
"id": 16,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_stale_unknown",
|
|
"turnId": "turn_stale",
|
|
"itemId": "item_cmd",
|
|
"command": "date",
|
|
},
|
|
}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Record any unexpected AP hook request.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: AP hook response.
|
|
"""
|
|
posted.append((request.url.path, json.loads(request.content)))
|
|
return httpx.Response(200, json={"decision": "accept"})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive one stale-thread approval through the forwarder.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client, "conv_parent"),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
expected_thread_id="thread_parent",
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=state,
|
|
)
|
|
await elicitation_tracker.drain()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == []
|
|
assert fake_client.responses == []
|
|
|
|
|
|
def test_forwarder_drops_old_parent_child_command_approval(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Announced child approvals must still match the active parent thread.
|
|
|
|
A pending child marker from an old parent thread should not make a
|
|
later stale approval actionable after the parent Codex thread has
|
|
rotated.
|
|
|
|
:param tmp_path: Pytest temporary directory.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
posted: list[tuple[str, dict[str, Any]]] = []
|
|
state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_parent",
|
|
)
|
|
child_started_event = {
|
|
"method": "thread/started",
|
|
"params": {
|
|
"thread": {
|
|
"id": "thread_child_old_parent",
|
|
"source": {"subAgent": {"thread_spawn": {"parent_thread_id": "thread_old"}}},
|
|
}
|
|
},
|
|
}
|
|
codex_event = {
|
|
"id": 17,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_child_old_parent",
|
|
"turnId": "turn_child",
|
|
"itemId": "item_cmd",
|
|
"command": "date",
|
|
},
|
|
}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Record any unexpected AP hook request.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: AP hook response.
|
|
"""
|
|
posted.append((request.url.path, json.loads(request.content)))
|
|
return httpx.Response(200, json={"decision": "accept"})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive one old-parent child approval through the forwarder.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client, "conv_parent"),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=child_started_event,
|
|
expected_thread_id="thread_old",
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=state,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client, "conv_parent"),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
expected_thread_id="thread_new",
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=state,
|
|
)
|
|
await elicitation_tracker.drain()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == []
|
|
assert fake_client.responses == []
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _CapturedSessionEvent:
|
|
"""
|
|
Captured AP session event from a Codex forwarder test.
|
|
|
|
:param session_id: AP session id parsed from the request path,
|
|
e.g. ``"conv_new"``.
|
|
:param body: Decoded session event body.
|
|
"""
|
|
|
|
session_id: str
|
|
body: dict[str, Any]
|
|
|
|
|
|
def test_supervise_forwarder_rotation_clears_unparented_pending_child_threads(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Parent thread rotation clears unregistered child-thread markers.
|
|
|
|
Codex may omit ``parent_thread_id`` on a child ``thread/started``
|
|
notification. That gap marker is intentionally permissive while the
|
|
parent thread is current, but it must not survive a parent ``/clear``
|
|
rotation and make stale child approvals actionable in the new session.
|
|
|
|
:param tmp_path: Pytest temporary directory.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_old",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_old",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
),
|
|
)
|
|
unparented_child_started = {
|
|
"method": "thread/started",
|
|
"params": {
|
|
"thread": {
|
|
"id": "thread_child_without_parent",
|
|
"source": {"subAgent": {"thread_spawn": {}}},
|
|
}
|
|
},
|
|
}
|
|
stale_child_approval = {
|
|
"id": 18,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_child_without_parent",
|
|
"turnId": "turn_child",
|
|
"itemId": "item_cmd",
|
|
"command": "date",
|
|
},
|
|
}
|
|
fake_client = _FakeCodexAppServerClient(
|
|
events=[
|
|
unparented_child_started,
|
|
_thread_started_event("thread_new"),
|
|
_started_event("turn_new"),
|
|
stale_child_approval,
|
|
]
|
|
)
|
|
session_events: list[_CapturedSessionEvent] = []
|
|
hook_posts: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Serve AP calls made while the supervise loop rotates sessions.
|
|
|
|
:param request: HTTP request from the forwarder.
|
|
:returns: Fake AP response.
|
|
"""
|
|
body = json.loads(request.content) if request.content else None
|
|
if request.method == "GET" and request.url.path == "/v1/sessions/conv_old":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "conv_old",
|
|
"agent_id": "ag_codex",
|
|
"runner_id": "runner_123",
|
|
"labels": {},
|
|
},
|
|
)
|
|
if request.method == "POST" and request.url.path == "/v1/sessions":
|
|
return httpx.Response(200, json={"id": "conv_new"})
|
|
if request.method == "PATCH" and request.url.path in {
|
|
"/v1/sessions/conv_new",
|
|
"/v1/sessions/conv_old",
|
|
}:
|
|
return httpx.Response(200, json={"id": request.url.path.rsplit("/", 1)[-1]})
|
|
if request.method == "POST" and request.url.path == (
|
|
"/v1/sessions/conv_old/resources/terminals/terminal_codex_main/transfer"
|
|
):
|
|
return httpx.Response(200, json={"id": "terminal_codex_main"})
|
|
if request.url.path.endswith("/hooks/codex-elicitation-request"):
|
|
assert isinstance(body, dict)
|
|
hook_posts.append(body)
|
|
return httpx.Response(200, json={"decision": "accept"})
|
|
if request.url.path.endswith("/events"):
|
|
assert isinstance(body, dict)
|
|
session_id = request.url.path.split("/")[3]
|
|
session_events.append(_CapturedSessionEvent(session_id=session_id, body=body))
|
|
return httpx.Response(202, json={"queued": False})
|
|
return httpx.Response(
|
|
500,
|
|
json={"error": f"unexpected {request.method} {request.url.path}"},
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Run the supervise loop over rotation and stale child events.
|
|
|
|
:returns: None.
|
|
"""
|
|
await codex_native_forwarder.supervise_forwarder(
|
|
base_url="http://127.0.0.1:8000",
|
|
headers={},
|
|
session_id="conv_old",
|
|
bridge_dir=tmp_path,
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
thread_id="thread_old",
|
|
client=fake_client, # type: ignore[arg-type]
|
|
ap_transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert [
|
|
event.body["data"]["status"]
|
|
for event in session_events
|
|
if event.session_id == "conv_new" and event.body["type"] == "external_session_status"
|
|
] == ["running"]
|
|
assert [event for event in session_events if event.session_id == "conv_old"] == []
|
|
assert hook_posts == []
|
|
assert fake_client.responses == []
|
|
|
|
|
|
def test_forwarder_sends_codex_permissions_response_to_app_server(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex permission-profile request frames are relayed through Omnigent and
|
|
answered with the hook's permission-grant result.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
codex_event = {
|
|
"id": 15,
|
|
"method": "item/permissions/requestApproval",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "item_permissions",
|
|
"startedAtMs": 1,
|
|
"cwd": "/tmp/workspace",
|
|
"reason": "need network",
|
|
"permissions": {"network": {"enabled": True}, "fileSystem": None},
|
|
},
|
|
}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Return a permissions approval result from the Omnigent hook.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent hook response.
|
|
"""
|
|
assert request.url.path == "/v1/sessions/conv_123/hooks/codex-elicitation-request"
|
|
assert json.loads(request.content) == codex_event
|
|
return httpx.Response(
|
|
200,
|
|
json={"permissions": {"network": {"enabled": True}}, "scope": "turn"},
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive one permissions request frame through the forwarder.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=codex_event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.drain()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.responses == [
|
|
(15, {"permissions": {"network": {"enabled": True}}, "scope": "turn"})
|
|
]
|
|
|
|
|
|
def test_forwarder_logs_unsupported_codex_server_request(
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
Unsupported Codex server requests are visible in forwarder logs.
|
|
|
|
Without this diagnostic, a new app-server request method can be
|
|
delivered on the observer connection and disappear with no clue
|
|
about which protocol adapter is missing.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Fail if the unsupported request is accidentally posted to AP.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Never returns.
|
|
"""
|
|
raise AssertionError(f"unexpected Omnigent request: {request.method} {request.url}")
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive one unsupported server request through the forwarder.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"id": "unsupported_1",
|
|
"method": "item/tool/call",
|
|
"params": {"threadId": "thread_123"},
|
|
},
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.responses == []
|
|
assert (
|
|
"Codex forwarder ignored unsupported server request: method=item/tool/call" in caplog.text
|
|
)
|
|
|
|
|
|
def test_forwarder_leaves_codex_elicitation_pending_on_empty_hook_body(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Empty Omnigent hook responses represent timeout/disconnect fallback, not
|
|
an approval. The forwarder must not synthesize an accept/decline
|
|
result back to Codex.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Return the Omnigent hook's fail-ask shape.
|
|
|
|
:param _request: HTTP request sent by the forwarder.
|
|
:returns: Empty successful response.
|
|
"""
|
|
return httpx.Response(200)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive one timed-out elicitation request through the forwarder.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=elicitation_tracker,
|
|
event={
|
|
"id": 4,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"mode": "form",
|
|
"message": "Pick a value",
|
|
"requestedSchema": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
await elicitation_tracker.drain()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert fake_client.responses == []
|
|
|
|
|
|
def test_forwarder_posts_user_message_on_assistant_item_started(tmp_path: Path) -> None:
|
|
"""
|
|
The user message is recovered when the assistant STARTS, not finishes.
|
|
|
|
On a fresh thread the live ``userMessage`` event can be missed. If
|
|
recovery waited for the assistant's ``item/completed``, the assistant
|
|
text deltas would already have streamed into a bubble rendered ABOVE
|
|
the still-pending user bubble. Recovering at the assistant's
|
|
``item/started`` — which fires before any delta — commits the user
|
|
message first, so the web UI renders the question above the reply.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
resume_response = {
|
|
"result": {
|
|
"thread": {
|
|
"id": "thread_123",
|
|
"turns": [
|
|
{
|
|
"id": "turn_123",
|
|
"items": [
|
|
{
|
|
"type": "userMessage",
|
|
"id": "item-1",
|
|
"content": [{"type": "text", "text": "hello codex"}],
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
fake_client = _FakeCodexAppServerClient(response=resume_response)
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_123",
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Deliver the assistant's ``item/started`` with the user missed live.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "item/started",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {"type": "agentMessage", "id": "msg_live"},
|
|
},
|
|
},
|
|
expected_thread_id="thread_123",
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
items = [p for p in posted if p["type"] == "external_conversation_item"]
|
|
# The user message is posted at assistant-start (before any delta),
|
|
# so it lands first and the assistant streams below it.
|
|
assert [p["data"]["item_data"]["role"] for p in items] == ["user"]
|
|
assert items[0]["data"]["item_data"]["content"][0]["text"] == "hello codex"
|
|
# The recovery issued exactly one resume to fetch the user message, and
|
|
# the turn is now marked so the item/completed backstop won't re-post.
|
|
assert [method for method, _ in fake_client.requests] == ["thread/resume"]
|
|
assert forwarder_state.has_posted_user_message("turn_123")
|
|
|
|
|
|
def test_forwarder_posts_codex_user_and_agent_messages(tmp_path: Path) -> None:
|
|
"""
|
|
Codex app-server completed message items are translated into
|
|
external conversation items for the Omnigent session stream.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {
|
|
"type": "userMessage",
|
|
"id": "item_user",
|
|
"content": [{"type": "text", "text": "hello codex"}],
|
|
},
|
|
"completedAtMs": 1,
|
|
},
|
|
},
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {
|
|
"type": "agentMessage",
|
|
"id": "item_agent",
|
|
"text": "hello from codex",
|
|
"phase": None,
|
|
"memoryCitation": None,
|
|
},
|
|
"completedAtMs": 2,
|
|
},
|
|
},
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "message",
|
|
"item_data": {
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "hello codex"}],
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "message",
|
|
"item_data": {
|
|
"role": "assistant",
|
|
"agent": "codex-native-ui",
|
|
"content": [{"type": "output_text", "text": "hello from codex"}],
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def test_forwarder_recovers_missed_user_message_before_assistant(tmp_path: Path) -> None:
|
|
"""
|
|
A missed live ``userMessage`` is recovered before the assistant reply.
|
|
|
|
On a fresh thread the forwarder can subscribe after ``turn/start``, so
|
|
the early ``userMessage`` event streams past before the subscription
|
|
lands and only the ``agentMessage`` arrives live. Without recovery the
|
|
assistant reply would be posted first and the resume backfill would
|
|
add the user message after it, inverting the web bubbles. The
|
|
forwarder must resume to recover the turn's user message and post it
|
|
BEFORE the reply so Omnigent assigns it the earlier position.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
# Resume returns the full turn (user then assistant) with Codex's
|
|
# positional resume ids; the recovery reads the userMessage from it.
|
|
resume_response = {
|
|
"result": {
|
|
"thread": {
|
|
"id": "thread_123",
|
|
"turns": [
|
|
{
|
|
"id": "turn_123",
|
|
"items": [
|
|
{
|
|
"type": "userMessage",
|
|
"id": "item-1",
|
|
"content": [{"type": "text", "text": "hello codex"}],
|
|
},
|
|
{"type": "agentMessage", "id": "item-2", "text": "hello from codex"},
|
|
],
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
fake_client = _FakeCodexAppServerClient(response=resume_response)
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_123",
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Deliver only the assistant message live (user message missed).
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=_agent_message_event("turn_123", "msg_live", "hello from codex"),
|
|
expected_thread_id="thread_123",
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
items = [p for p in posted if p["type"] == "external_conversation_item"]
|
|
roles = [p["data"]["item_data"]["role"] for p in items]
|
|
# User recovered and posted first, then the assistant reply.
|
|
assert roles == ["user", "assistant"]
|
|
assert items[0]["data"]["item_data"]["content"][0]["text"] == "hello codex"
|
|
assert items[1]["data"]["item_data"]["content"][0]["text"] == "hello from codex"
|
|
# The recovery issued exactly one resume to fetch the user message.
|
|
assert [method for method, _ in fake_client.requests] == ["thread/resume"]
|
|
|
|
|
|
def test_forwarder_skips_user_recovery_when_user_seen_live(tmp_path: Path) -> None:
|
|
"""
|
|
No recovery resume fires when the user message arrived live.
|
|
|
|
On the happy path the live stream delivers ``userMessage`` before
|
|
``agentMessage``, so the forwarder must NOT issue a spurious
|
|
``thread/resume`` when the reply arrives — the turn is already known
|
|
to have a posted user message.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
fake_client = _FakeCodexAppServerClient()
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_123",
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Deliver the user message then the assistant message live.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {
|
|
"type": "userMessage",
|
|
"id": "msg_user_live",
|
|
"content": [{"type": "text", "text": "hello codex"}],
|
|
},
|
|
},
|
|
},
|
|
expected_thread_id="thread_123",
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=_agent_message_event("turn_123", "msg_live", "hello from codex"),
|
|
expected_thread_id="thread_123",
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
roles = [
|
|
p["data"]["item_data"]["role"] for p in posted if p["type"] == "external_conversation_item"
|
|
]
|
|
assert roles == ["user", "assistant"]
|
|
# The live user message satisfied the ordering guarantee; no resume.
|
|
assert fake_client.requests == []
|
|
|
|
|
|
def test_forwarder_posts_codex_turn_plan_update(tmp_path: Path) -> None:
|
|
"""
|
|
Codex ``turn/plan/updated`` notifications are visible in Omnigent web.
|
|
|
|
Plan mode emits plan state through a dedicated app-server
|
|
notification rather than assistant text. If the forwarder ignores
|
|
it, the terminal shows the plan while the web transcript appears to
|
|
skip straight to the final prompt.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay one plan update notification.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "turn/plan/updated",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"explanation": None,
|
|
"plan": [
|
|
{"step": "Inspect Codex plan events", "status": "completed"},
|
|
{"step": "Mirror plans to web", "status": "inProgress"},
|
|
{"step": "Run checks", "status": "pending"},
|
|
],
|
|
},
|
|
},
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert len(posted) == 1
|
|
assert posted[0]["type"] == "external_conversation_item"
|
|
data = posted[0]["data"]
|
|
assert data["item_type"] == "message"
|
|
assert data["response_id"] == "codex_turn_123"
|
|
assert data["item_data"] == {
|
|
"role": "assistant",
|
|
"agent": "codex-native-ui",
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": (
|
|
"Plan:\n"
|
|
"- [x] Inspect Codex plan events\n"
|
|
"- [~] Mirror plans to web\n"
|
|
"- [ ] Run checks"
|
|
),
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def test_forwarder_posts_completed_codex_plan_item() -> None:
|
|
"""
|
|
Completed Codex ``plan`` thread items are mirrored into Omnigent history.
|
|
|
|
This covers resume/replay and final transcript state, where the
|
|
plan arrives as a completed thread item rather than a live
|
|
``turn/plan/updated`` notification.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "plan",
|
|
"id": "plan_123",
|
|
"text": "1. Inspect\n2. Implement\n3. Verify",
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "message",
|
|
"item_data": {
|
|
"role": "assistant",
|
|
"agent": "codex-native-ui",
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": "1. Inspect\n2. Implement\n3. Verify",
|
|
}
|
|
],
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def _capture_handler(posted: list[dict[str, Any]]) -> Callable[[httpx.Request], httpx.Response]:
|
|
"""
|
|
Build a MockTransport handler that records forwarder event posts.
|
|
|
|
:param posted: List to append each decoded request body to.
|
|
:returns: Handler that records the body and returns ``202``.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture one Omnigent event post from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
return handler
|
|
|
|
|
|
async def _replay_completed_item(
|
|
item: dict[str, Any], handler: Callable[..., httpx.Response]
|
|
) -> None:
|
|
"""
|
|
Drive one Codex ``item/completed`` notification through the forwarder.
|
|
|
|
:param item: Codex item payload, e.g. a ``commandExecution`` item.
|
|
:param handler: MockTransport handler capturing the Omnigent posts.
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=Path("/tmp"),
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": item,
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
def test_forwarder_posts_codex_command_execution_tool_call() -> None:
|
|
"""
|
|
A completed Codex ``commandExecution`` becomes a function-call pair.
|
|
|
|
Native Codex sessions run Codex's own shell tool, so the single
|
|
``item/completed`` (which carries both the command and its
|
|
aggregated output) must be mirrored as the Omnigent ``function_call`` /
|
|
``function_call_output`` pair the web UI renders. The item shape
|
|
here matches a real app-server capture.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "commandExecution",
|
|
"id": "call_abc123",
|
|
"command": "/bin/zsh -lc 'cat hello.txt'",
|
|
"cwd": "/repo",
|
|
"status": "completed",
|
|
"aggregatedOutput": "hello world\n",
|
|
"exitCode": 0,
|
|
"durationMs": 0,
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
# Both items must be posted: a function_call carrying the command as
|
|
# arguments, then a function_call_output carrying the shell output.
|
|
# A single post would mean the result was dropped; the call_id must
|
|
# match across both so the UI pairs them into one tool card.
|
|
assert posted == [
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "function_call",
|
|
"item_data": {
|
|
"agent": "codex-native-ui",
|
|
"name": "shell",
|
|
"arguments": '{"command": "/bin/zsh -lc \'cat hello.txt\'", "cwd": "/repo"}',
|
|
"call_id": "call_abc123",
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "function_call_output",
|
|
"item_data": {
|
|
"call_id": "call_abc123",
|
|
"output": "hello world\n",
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def test_forwarder_surfaces_failed_command_exit_code() -> None:
|
|
"""
|
|
A non-zero command exit is surfaced in the mirrored output.
|
|
|
|
Codex reports ``exitCode`` separately from ``aggregatedOutput``, so a
|
|
failed command would look successful in the UI unless the forwarder
|
|
folds the exit code into the output text.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "commandExecution",
|
|
"id": "call_fail",
|
|
"command": "/bin/zsh -lc 'exit 3'",
|
|
"cwd": "/repo",
|
|
"status": "failed",
|
|
"aggregatedOutput": "boom\n",
|
|
"exitCode": 3,
|
|
"durationMs": 1,
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
outputs = [
|
|
p["data"]["item_data"]["output"]
|
|
for p in posted
|
|
if p["data"]["item_type"] == "function_call_output"
|
|
]
|
|
# The exit code (3, from the replayed item above) must appear appended
|
|
# to the captured stderr/stdout. If the suffix were missing the output
|
|
# would be just "boom\n" — a failed command indistinguishable from a
|
|
# successful one in the UI.
|
|
assert outputs == ["boom\n\n[exit code: 3]"]
|
|
|
|
|
|
def test_forwarder_posts_codex_file_change_tool_call() -> None:
|
|
"""
|
|
A completed Codex ``fileChange`` becomes an apply_patch tool card.
|
|
|
|
The item shape (``changes`` with ``path`` / ``kind`` / ``diff``)
|
|
matches a real app-server capture; the forwarder must pass the
|
|
changes through as arguments and summarize them as the output.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "fileChange",
|
|
"id": "call_patch",
|
|
"changes": [
|
|
{
|
|
"path": "/repo/greeting.py",
|
|
"kind": {"type": "add"},
|
|
"diff": "print('hi')\n",
|
|
}
|
|
],
|
|
"status": "completed",
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
assert [p["data"]["item_type"] for p in posted] == [
|
|
"function_call",
|
|
"function_call_output",
|
|
]
|
|
call = posted[0]["data"]["item_data"]
|
|
assert call["name"] == "apply_patch"
|
|
# Arguments carry the raw changes so the diff is recoverable in the UI.
|
|
assert json.loads(call["arguments"]) == {
|
|
"changes": [
|
|
{"path": "/repo/greeting.py", "kind": {"type": "add"}, "diff": "print('hi')\n"}
|
|
]
|
|
}
|
|
# Output summarizes each change as "<kind> <path>" from real fields.
|
|
assert posted[1]["data"]["item_data"]["output"] == "add /repo/greeting.py"
|
|
|
|
|
|
def test_forwarder_posts_codex_web_search_tool_call() -> None:
|
|
"""
|
|
A completed Codex ``webSearch`` becomes a web_search tool card.
|
|
|
|
Codex does not surface search results, so the queries it ran are the
|
|
only result data; the forwarder uses them as the output rather than
|
|
inventing a result. The item shape matches a real app-server capture.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "webSearch",
|
|
"id": "ws_123",
|
|
"query": "python latest stable version",
|
|
"action": {
|
|
"type": "search",
|
|
"query": "python latest stable version",
|
|
"queries": ["python latest stable version"],
|
|
},
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
call = posted[0]["data"]["item_data"]
|
|
assert call["name"] == "web_search"
|
|
assert json.loads(call["arguments"]) == {"query": "python latest stable version"}
|
|
assert posted[1]["data"]["item_data"]["output"] == "python latest stable version"
|
|
|
|
|
|
def test_forwarder_drops_codex_tool_item_missing_required_field(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
A malformed tool item is dropped, not mirrored with invented fields.
|
|
|
|
A ``commandExecution`` with no ``command`` must not be posted as a
|
|
tool call with an empty/placeholder command, because that would
|
|
create a misleading tool card. The forwarder logs and skips it.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "commandExecution",
|
|
"id": "call_bad",
|
|
"cwd": "/repo",
|
|
"status": "completed",
|
|
"aggregatedOutput": "",
|
|
"exitCode": 0,
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
# Nothing is posted: a malformed item is dropped rather than mirrored
|
|
# with a fabricated command, and the drop is logged for diagnosis.
|
|
assert posted == []
|
|
assert "Codex commandExecution missing command" in caplog.text
|
|
|
|
|
|
def test_forwarder_posts_codex_image_view_tool_call() -> None:
|
|
"""
|
|
A completed Codex ``imageView`` becomes a view_image tool card.
|
|
|
|
Codex emits an ``imageView`` item when the model opens a local image
|
|
to look at it. Before this was handled the item was silently dropped,
|
|
so the web transcript skipped a step the native TUI shows. The only
|
|
datum is the path, so it is both the argument and the output (the web
|
|
UI cannot fetch a runner-local path).
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "imageView",
|
|
"id": "img_view_1",
|
|
"path": "/repo/screenshot.png",
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "function_call",
|
|
"item_data": {
|
|
"agent": "codex-native-ui",
|
|
"name": "view_image",
|
|
"arguments": '{"path": "/repo/screenshot.png"}',
|
|
"call_id": "img_view_1",
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "function_call_output",
|
|
"item_data": {
|
|
"call_id": "img_view_1",
|
|
"output": "/repo/screenshot.png",
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def test_forwarder_posts_codex_image_generation_tool_call() -> None:
|
|
"""
|
|
A completed Codex ``imageGeneration`` becomes a generate_image tool card.
|
|
|
|
The raw ``result`` (base64 image bytes) is deliberately NOT mirrored —
|
|
the web UI has no assistant-side image rendering and the base64 blob
|
|
would only bloat the transcript. The card carries the revised prompt as
|
|
the argument and the status plus on-disk save path as the output.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "imageGeneration",
|
|
"id": "img_gen_1",
|
|
"status": "completed",
|
|
"revisedPrompt": "a red bicycle on a beach",
|
|
"result": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB",
|
|
"savedPath": "/repo/out.png",
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
assert [p["data"]["item_type"] for p in posted] == [
|
|
"function_call",
|
|
"function_call_output",
|
|
]
|
|
call = posted[0]["data"]["item_data"]
|
|
assert call["name"] == "generate_image"
|
|
assert call["call_id"] == "img_gen_1"
|
|
# The revised prompt is surfaced; the base64 result is never echoed.
|
|
assert json.loads(call["arguments"]) == {"revised_prompt": "a red bicycle on a beach"}
|
|
assert "iVBORw0KGgo" not in call["arguments"]
|
|
output = posted[1]["data"]["item_data"]["output"]
|
|
assert output == "status: completed\nsaved to /repo/out.png"
|
|
assert "iVBORw0KGgo" not in output
|
|
|
|
|
|
def test_forwarder_drops_codex_image_generation_missing_status(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
An ``imageGeneration`` with no status is dropped, not mirrored blank.
|
|
|
|
``status`` is a required protocol field; its absence means a malformed
|
|
item, which is logged and skipped rather than mirrored as an empty card.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "imageGeneration",
|
|
"id": "img_gen_bad",
|
|
"revisedPrompt": "x",
|
|
"result": "y",
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
assert posted == []
|
|
assert "Codex imageGeneration missing status" in caplog.text
|
|
|
|
|
|
def test_forwarder_posts_codex_entered_review_mode_marker() -> None:
|
|
"""
|
|
Codex ``enteredReviewMode`` surfaces a visible review-mode marker.
|
|
|
|
Codex ``/review`` brackets a turn with enter/exit thread items. The web
|
|
UI has no review affordance, so the transition is mirrored as a short
|
|
assistant-message marker (the same rail used for plan updates), carrying
|
|
the review subject so the web user sees what is under review.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "enteredReviewMode",
|
|
"id": "review_1",
|
|
"review": "review the auth changes",
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "message",
|
|
"item_data": {
|
|
"role": "assistant",
|
|
"agent": "codex-native-ui",
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": "Entered review mode: review the auth changes",
|
|
}
|
|
],
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_forwarder_posts_codex_exited_review_mode_marker() -> None:
|
|
"""
|
|
Codex ``exitedReviewMode`` surfaces a visible exit marker.
|
|
|
|
With no review subject the marker is just the header — a terse divider
|
|
that tells the web user the session left review mode.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
asyncio.run(
|
|
_replay_completed_item(
|
|
{
|
|
"type": "exitedReviewMode",
|
|
"id": "review_2",
|
|
"review": "",
|
|
},
|
|
_capture_handler(posted),
|
|
)
|
|
)
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "message",
|
|
"item_data": {
|
|
"role": "assistant",
|
|
"agent": "codex-native-ui",
|
|
"content": [{"type": "output_text", "text": "Exited review mode"}],
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def _turn_diff_event(turn_id: str, diff: str, *, thread_id: str = "thread_123") -> dict[str, Any]:
|
|
"""
|
|
Build a Codex ``turn/diff/updated`` notification.
|
|
|
|
:param turn_id: Codex turn id, e.g. ``"turn_123"``.
|
|
:param diff: Aggregated unified diff for the turn so far.
|
|
:param thread_id: Codex thread id, e.g. ``"thread_123"``.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {
|
|
"method": "turn/diff/updated",
|
|
"params": {"threadId": thread_id, "turnId": turn_id, "diff": diff},
|
|
}
|
|
|
|
|
|
def test_forwarder_coalesces_and_flushes_turn_diff(tmp_path: Path) -> None:
|
|
"""
|
|
``turn/diff/updated`` is coalesced and flushed once at turn end.
|
|
|
|
Codex streams the aggregated working-tree diff repeatedly as edits land.
|
|
Posting each update would spam the transcript with a growing diff, so the
|
|
forwarder stores only the newest diff and mirrors it once, at the
|
|
terminal boundary, as a single ``turn_diff`` function-call pair — after
|
|
the turn's other items and before the idle status edge.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState()
|
|
latest_diff = "--- a/x.py\n+++ b/x.py\n@@\n-old\n+new\n"
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay two diff updates followed by the terminal boundary.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(_capture_handler(posted)),
|
|
) as client:
|
|
for event in [
|
|
_turn_diff_event("turn_123", "--- a/x.py\n+++ b/x.py\n@@\n-old\n"),
|
|
_turn_diff_event("turn_123", latest_diff),
|
|
_completed_event("turn_123", thread_id="thread_123"),
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
# The two diff updates post nothing; only the terminal boundary flushes a
|
|
# single turn_diff pair (carrying the LATEST diff), then the idle edge.
|
|
assert posted == [
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "function_call",
|
|
"item_data": {
|
|
"agent": "codex-native-ui",
|
|
"name": "turn_diff",
|
|
"arguments": "{}",
|
|
"call_id": "codex_turn_diff_turn_123",
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
{
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "function_call_output",
|
|
"item_data": {
|
|
"call_id": "codex_turn_diff_turn_123",
|
|
"output": latest_diff,
|
|
},
|
|
"response_id": "codex_turn_123",
|
|
},
|
|
},
|
|
{
|
|
"type": "external_session_status",
|
|
"data": _expected_status_data("idle", "turn_123"),
|
|
},
|
|
]
|
|
# The stored diff is consumed on flush — no leak into the next turn.
|
|
assert forwarder_state.turn_diff_by_turn == {}
|
|
|
|
|
|
def test_forwarder_skips_turn_diff_when_none_captured(tmp_path: Path) -> None:
|
|
"""
|
|
A turn with no ``turn/diff/updated`` posts no turn_diff card.
|
|
|
|
Read-only turns never receive a diff notification, so the terminal
|
|
boundary must emit only the idle status edge — no empty diff artifact.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
posted: list[dict[str, Any]] = []
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState()
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay a terminal boundary with no preceding diff update.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(_capture_handler(posted)),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=_completed_event("turn_123", thread_id="thread_123"),
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert posted == [
|
|
{
|
|
"type": "external_session_status",
|
|
"data": _expected_status_data("idle", "turn_123"),
|
|
},
|
|
]
|
|
|
|
|
|
def test_forwarder_skips_item_retry_on_ambiguous_transport_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
An ambiguous item-POST failure stops retries instead of re-posting.
|
|
|
|
A lost response (e.g. read timeout) after the server already
|
|
appended the item and published ``session.input.consumed`` is
|
|
indistinguishable from a failed send. External items are not deduped
|
|
server-side, so a retry would persist a second copy — the duplicate
|
|
message bug in the web UI. The forwarder must give up after
|
|
the first ambiguous attempt.
|
|
|
|
A failure here (more than one POST attempt) is exactly the
|
|
duplicate-item regression this guards against.
|
|
"""
|
|
attempts: list[str] = []
|
|
sleep_delays: list[float] = []
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
"""
|
|
Record retry delays without slowing the test.
|
|
|
|
:param seconds: Delay requested by the forwarder.
|
|
:returns: None.
|
|
"""
|
|
sleep_delays.append(seconds)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Fail every item POST with a read timeout (response lost).
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Never returns.
|
|
:raises httpx.ReadTimeout: For every attempt, simulating the
|
|
server committing the item but the response being lost.
|
|
"""
|
|
attempts.append(json.loads(request.content)["type"])
|
|
raise httpx.ReadTimeout("response lost", request=request)
|
|
|
|
monkeypatch.setattr(codex_native_forwarder, "_sleep", fake_sleep)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Post one mirrored Codex item against the failing transport.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._post_external_item(
|
|
client,
|
|
"conv_123",
|
|
item_type="message",
|
|
item_data={"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
|
|
response_id="codex_turn_123",
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
# Exactly one POST attempt: the ambiguous failure must not be
|
|
# retried. Two or three attempts would mean the forwarder re-posted
|
|
# a possibly-committed item — the duplicate-bubble bug.
|
|
assert attempts == ["external_conversation_item"]
|
|
# Returned before any backoff: no retry was even scheduled.
|
|
assert sleep_delays == []
|
|
|
|
|
|
def test_forwarder_retries_item_on_connect_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A provably-undelivered item POST keeps its full retry budget.
|
|
|
|
A connection error proves no request bytes reached the server, so
|
|
the item cannot have been committed — retrying is safe and dropping
|
|
early would lose messages whenever the server is briefly
|
|
unreachable. The complement to the ambiguous-skip test, guarding
|
|
the duplicate fix from turning into a message-loss bug.
|
|
"""
|
|
attempts: list[str] = []
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
"""
|
|
Skip retry backoff to keep the test fast.
|
|
|
|
:param seconds: Delay requested by the forwarder.
|
|
:returns: None.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Fail every item POST with a connection error (never delivered).
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Never returns.
|
|
:raises httpx.ConnectError: For every attempt, simulating an
|
|
unreachable server.
|
|
"""
|
|
attempts.append(json.loads(request.content)["type"])
|
|
raise httpx.ConnectError("connection refused", request=request)
|
|
|
|
monkeypatch.setattr(codex_native_forwarder, "_sleep", fake_sleep)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Post one mirrored Codex item against the unreachable transport.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._post_external_item(
|
|
client,
|
|
"conv_123",
|
|
item_type="message",
|
|
item_data={"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
|
|
response_id="codex_turn_123",
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
# The full retry budget was spent: connect errors are safe to retry,
|
|
# so the forwarder must not give up after the first attempt. Fewer
|
|
# attempts would mean the ambiguous-skip is over-broad (message loss).
|
|
assert attempts == ["external_conversation_item"] * codex_native_forwarder._POST_MAX_ATTEMPTS
|
|
|
|
|
|
def test_forwarder_still_retries_status_on_ambiguous_transport_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The ambiguous-failure skip applies only to conversation items.
|
|
|
|
Status events are idempotent (last-write-wins), so re-posting one
|
|
that may already have landed is harmless — and keeping the retries
|
|
preserves delivery of the running/idle badge. A failure here means
|
|
the skip leaked beyond ``external_conversation_item`` and transient
|
|
events lost their retry budget.
|
|
"""
|
|
attempts: list[str] = []
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
"""
|
|
Skip retry backoff to keep the test fast.
|
|
|
|
:param seconds: Delay requested by the forwarder.
|
|
:returns: None.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Fail every status POST with a read timeout.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Never returns.
|
|
:raises httpx.ReadTimeout: For every attempt.
|
|
"""
|
|
attempts.append(json.loads(request.content)["type"])
|
|
raise httpx.ReadTimeout("response lost", request=request)
|
|
|
|
monkeypatch.setattr(codex_native_forwarder, "_sleep", fake_sleep)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Post one status edge against the failing transport.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._post_status(client, "conv_123", "running")
|
|
|
|
asyncio.run(run())
|
|
|
|
# All attempts spent: status posts keep retrying through ambiguous
|
|
# failures because re-delivery is harmless and dropping early would
|
|
# strand the session badge.
|
|
assert attempts == ["external_session_status"] * codex_native_forwarder._POST_MAX_ATTEMPTS
|
|
|
|
|
|
def test_forwarder_retries_transient_external_item_rejection(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Transient Omnigent failures do not drop the mirrored Codex item.
|
|
|
|
This test fails if ``_post_external_item`` gives up after the first
|
|
retryable HTTP status instead of retrying the same item post.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
sleep_delays: list[float] = []
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
"""
|
|
Record retry delays without slowing the test.
|
|
|
|
:param seconds: Delay requested by the forwarder.
|
|
:returns: None.
|
|
"""
|
|
sleep_delays.append(seconds)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Reject the first item post transiently, then accept the retry.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: HTTP response for this attempt.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
if len(posted) == 1:
|
|
return httpx.Response(503, text="starting")
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
monkeypatch.setattr(codex_native_forwarder, "_sleep", fake_sleep)
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Post one mirrored Codex item through the real handler.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {
|
|
"type": "agentMessage",
|
|
"id": "item_agent",
|
|
"text": "retry me",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
# Exactly two posts proves the first transient rejection was retried
|
|
# once and then accepted; one would mean a dropped item, while more
|
|
# would mean the forwarder retried after success.
|
|
assert len(posted) == 2
|
|
assert posted[0] == posted[1]
|
|
assert posted[1]["data"]["item_data"]["content"][0]["text"] == "retry me"
|
|
assert sleep_delays == [0.1]
|
|
|
|
|
|
def test_forwarder_logs_rejected_external_item(
|
|
caplog: pytest.LogCaptureFixture,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Omnigent 4xx responses are logged so mirror failures are diagnosable.
|
|
"""
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(400, text="bad payload")
|
|
|
|
async def run() -> None:
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {
|
|
"type": "userMessage",
|
|
"id": "item_user",
|
|
"content": [{"type": "text", "text": "hello codex"}],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert "failed to post Codex conversation item: status=400 body=bad payload" in caplog.text
|
|
|
|
|
|
def test_forwarder_marks_codex_skill_user_message_as_meta(tmp_path: Path) -> None:
|
|
"""
|
|
Codex ``<skill>`` user messages are hidden durable context.
|
|
|
|
Codex persists skill bodies as user messages wrapped in
|
|
``<skill>...</skill>``. The forwarder must preserve that message
|
|
for Omnigent resume/history replay while tagging it ``is_meta`` so UI
|
|
clients can hide it.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent event posts from the forwarder.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Replay one normal and one Codex skill user-message item.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_normal",
|
|
"item": {
|
|
"type": "userMessage",
|
|
"id": "item_user_normal",
|
|
"content": [{"type": "text", "text": "hello"}],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event={
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_skill",
|
|
"item": {
|
|
"type": "userMessage",
|
|
"id": "item_user_skill",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": (
|
|
"<skill>\n<name>grill-me</name>\nAsk questions.\n</skill>"
|
|
),
|
|
}
|
|
],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
assert len(posted) == 2
|
|
normal_data = posted[0]["data"]["item_data"]
|
|
skill_data = posted[1]["data"]["item_data"]
|
|
assert "is_meta" not in normal_data
|
|
assert skill_data["is_meta"] is True
|
|
assert skill_data["content"][0]["text"].startswith("<skill>")
|
|
|
|
|
|
def test_local_run_prints_resume_hint_after_attach(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""
|
|
Local ``omnigent codex`` prints a copyable resume hint on exit.
|
|
|
|
This exercises the run-level call site, not just the formatter:
|
|
a regression that prepares and attaches successfully but forgets
|
|
to echo the final ``--resume`` prompt fails on the captured stderr.
|
|
"""
|
|
spec_path = tmp_path / "codex.yaml"
|
|
spec_path.write_text("name: codex-native-ui\nprompt: hi\n", encoding="utf-8")
|
|
opened: list[tuple[str, str, bool]] = []
|
|
|
|
class _Proc:
|
|
"""Stub for the local server subprocess."""
|
|
|
|
def poll(self) -> None:
|
|
"""
|
|
Pretend the fake local server is alive.
|
|
|
|
:returns: None.
|
|
"""
|
|
|
|
def fake_start_server(*args: object, **kwargs: object) -> Any:
|
|
"""
|
|
Return a minimal server handle without starting a process.
|
|
|
|
:param args: Positional startup arguments.
|
|
:param kwargs: Keyword startup arguments.
|
|
:returns: Fake local server handle.
|
|
"""
|
|
del args, kwargs
|
|
return SimpleNamespace(proc=_Proc(), runner_id="runner_local", log_path=None)
|
|
|
|
async def fake_prepare(**kwargs: object) -> codex_native.PreparedCodexTerminal:
|
|
"""
|
|
Return prepared Codex terminal details without launching Codex.
|
|
|
|
:param kwargs: Terminal preparation keyword arguments.
|
|
:returns: Prepared fake terminal.
|
|
"""
|
|
del kwargs
|
|
return codex_native.PreparedCodexTerminal(
|
|
session_id="conv_codex_fresh",
|
|
terminal_id=codex_native.codex_terminal_resource_id(),
|
|
tmux_socket=None,
|
|
tmux_target=None,
|
|
bridge_dir=tmp_path / "bridge",
|
|
thread_id="thread_123",
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
app_server=None,
|
|
event_client=None,
|
|
reattached=False,
|
|
)
|
|
|
|
async def fake_attach_with_forwarder(**kwargs: object) -> None:
|
|
"""
|
|
Simulate a completed Codex attach session.
|
|
|
|
:param kwargs: Attach keyword arguments.
|
|
:returns: None.
|
|
"""
|
|
del kwargs
|
|
|
|
monkeypatch.setattr("omnigent.chat._find_free_port", lambda: 23456)
|
|
monkeypatch.setattr("omnigent.chat._start_local_server", fake_start_server)
|
|
monkeypatch.setattr("omnigent.chat._stop_local_server", lambda server: None)
|
|
monkeypatch.setattr("omnigent.chat._wait_for_server", lambda *a, **k: None)
|
|
monkeypatch.setattr("omnigent.chat._bundle_agent", lambda path: b"bundle")
|
|
monkeypatch.setattr(codex_native, "_prepare_codex_terminal", fake_prepare)
|
|
monkeypatch.setattr(codex_native, "_attach_with_forwarder", fake_attach_with_forwarder)
|
|
monkeypatch.setattr(
|
|
codex_native,
|
|
"open_conversation_link_if_enabled",
|
|
lambda **kwargs: opened.append(
|
|
(
|
|
kwargs["base_url"],
|
|
kwargs["conversation_id"],
|
|
kwargs["enabled"],
|
|
)
|
|
),
|
|
)
|
|
|
|
codex_native._run_with_local_server(
|
|
spec_path,
|
|
session_id=None,
|
|
resume_picker=False,
|
|
codex_args=(),
|
|
command="codex",
|
|
model=None,
|
|
prompt=None,
|
|
auto_open_conversation=True,
|
|
)
|
|
|
|
captured = capsys.readouterr()
|
|
web_ui = "Web UI: http://127.0.0.1:23456/c/conv_codex_fresh"
|
|
resume_hint = "Resume with: omnigent codex --resume conv_codex_fresh"
|
|
assert web_ui in captured.err
|
|
assert resume_hint in captured.err
|
|
assert captured.err.index(web_ui) < captured.err.index(resume_hint)
|
|
assert opened == [("http://127.0.0.1:23456", "conv_codex_fresh", True)]
|
|
|
|
|
|
def test_local_resume_does_not_print_redundant_resume_hint(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""
|
|
``omnigent codex --resume`` does not echo another resume prompt.
|
|
|
|
This prevents the hint from becoming always-on noise after a user
|
|
has already chosen the conversation id to resume.
|
|
"""
|
|
spec_path = tmp_path / "codex.yaml"
|
|
spec_path.write_text("name: codex-native-ui\nprompt: hi\n", encoding="utf-8")
|
|
|
|
class _Proc:
|
|
"""Stub for the local server subprocess."""
|
|
|
|
def poll(self) -> None:
|
|
"""
|
|
Pretend the fake local server is alive.
|
|
|
|
:returns: None.
|
|
"""
|
|
|
|
def fake_start_server(*args: object, **kwargs: object) -> Any:
|
|
"""
|
|
Return a minimal server handle without starting a process.
|
|
|
|
:param args: Positional startup arguments.
|
|
:param kwargs: Keyword startup arguments.
|
|
:returns: Fake local server handle.
|
|
"""
|
|
del args, kwargs
|
|
return SimpleNamespace(proc=_Proc(), runner_id="runner_local", log_path=None)
|
|
|
|
async def fake_prepare(**kwargs: object) -> codex_native.PreparedCodexTerminal:
|
|
"""
|
|
Return prepared Codex terminal details without launching Codex.
|
|
|
|
:param kwargs: Terminal preparation keyword arguments.
|
|
:returns: Prepared fake terminal.
|
|
"""
|
|
del kwargs
|
|
return codex_native.PreparedCodexTerminal(
|
|
session_id="conv_codex_existing",
|
|
terminal_id=codex_native.codex_terminal_resource_id(),
|
|
tmux_socket=None,
|
|
tmux_target=None,
|
|
bridge_dir=tmp_path / "bridge",
|
|
thread_id="thread_123",
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
app_server=None,
|
|
event_client=None,
|
|
reattached=False,
|
|
)
|
|
|
|
async def fake_attach_with_forwarder(**kwargs: object) -> None:
|
|
"""
|
|
Simulate a completed Codex attach session.
|
|
|
|
:param kwargs: Attach keyword arguments.
|
|
:returns: None.
|
|
"""
|
|
del kwargs
|
|
|
|
monkeypatch.setattr("omnigent.chat._find_free_port", lambda: 23457)
|
|
monkeypatch.setattr("omnigent.chat._start_local_server", fake_start_server)
|
|
monkeypatch.setattr("omnigent.chat._stop_local_server", lambda server: None)
|
|
monkeypatch.setattr("omnigent.chat._wait_for_server", lambda *a, **k: None)
|
|
monkeypatch.setattr(codex_native, "_prepare_codex_terminal", fake_prepare)
|
|
monkeypatch.setattr(codex_native, "_attach_with_forwarder", fake_attach_with_forwarder)
|
|
|
|
codex_native._run_with_local_server(
|
|
spec_path,
|
|
session_id="conv_codex_existing",
|
|
resume_picker=False,
|
|
codex_args=(),
|
|
command="codex",
|
|
model=None,
|
|
prompt=None,
|
|
)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Web UI: http://127.0.0.1:23457/c/conv_codex_existing" in captured.err
|
|
assert "Resume with:" not in captured.err
|
|
|
|
|
|
def test_run_codex_native_does_not_require_local_codex_binary(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The wrapper no longer owns the Codex process.
|
|
|
|
A missing local ``codex`` binary must not fail preflight because the
|
|
daemon-spawned runner resolves and starts Codex. If the old local
|
|
preflight comes back, this test raises before ``fake_remote`` records
|
|
the command.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
remote_called = False
|
|
|
|
def fake_which(command: str) -> str | None:
|
|
"""
|
|
Return fake executable paths.
|
|
|
|
:param command: Command name passed to ``shutil.which``.
|
|
:returns: Fake absolute path for tmux, otherwise ``None``.
|
|
"""
|
|
if command == "tmux":
|
|
return "/usr/bin/tmux"
|
|
return None
|
|
|
|
def fake_remote(
|
|
base_url: str,
|
|
spec_path: Path,
|
|
*,
|
|
session_id: str | None,
|
|
resume_picker: bool,
|
|
codex_args: tuple[str, ...],
|
|
model: str | None,
|
|
prompt: str | None,
|
|
auto_open_conversation: bool,
|
|
) -> None:
|
|
"""
|
|
Record that the remote daemon path was selected.
|
|
|
|
:returns: None.
|
|
"""
|
|
nonlocal remote_called
|
|
del (
|
|
base_url,
|
|
spec_path,
|
|
session_id,
|
|
resume_picker,
|
|
codex_args,
|
|
model,
|
|
prompt,
|
|
auto_open_conversation,
|
|
)
|
|
remote_called = True
|
|
|
|
monkeypatch.setattr(codex_native.shutil, "which", fake_which)
|
|
monkeypatch.setattr(codex_native, "_run_with_remote_server", fake_remote)
|
|
|
|
codex_native.run_codex_native(
|
|
server="http://localhost:8000",
|
|
session_id=None,
|
|
codex_args=(),
|
|
command="codex",
|
|
)
|
|
|
|
assert remote_called is True
|
|
|
|
|
|
def test_record_launch_for_fresh_session_persists_current_cwd(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Fresh Codex sessions record the cwd used for future resumes.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary workspace and state root.
|
|
:returns: None.
|
|
"""
|
|
from omnigent.codex_native_state import read_launch_state
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
monkeypatch.chdir(workspace)
|
|
monkeypatch.setenv("OMNIGENT_CODEX_NATIVE_STATE_DIR", str(tmp_path / "state"))
|
|
|
|
codex_native._record_launch_for_fresh_session("conv_abc")
|
|
|
|
state = read_launch_state("conv_abc")
|
|
assert state is not None
|
|
assert state.working_directory == str(workspace.resolve())
|
|
|
|
|
|
def test_align_working_directory_with_session_matching_cwd_is_noop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Resume from the recorded cwd must not prompt or mutate cwd.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary workspace and state root.
|
|
:returns: None.
|
|
"""
|
|
from omnigent.codex_native_state import write_launch_state
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("OMNIGENT_CODEX_NATIVE_STATE_DIR", str(tmp_path / "state"))
|
|
write_launch_state("conv_abc", str(tmp_path.resolve()))
|
|
|
|
def fail_prompt(**_kwargs: object) -> str:
|
|
"""
|
|
Fail if a prompt is shown for a matching cwd.
|
|
|
|
:returns: Never returns.
|
|
"""
|
|
raise AssertionError("matching cwd should not prompt")
|
|
|
|
monkeypatch.setattr(codex_native, "_prompt_codex_resume_workspace_action", fail_prompt)
|
|
|
|
codex_native._align_working_directory_with_session("conv_abc")
|
|
|
|
assert Path.cwd().resolve() == tmp_path.resolve()
|
|
|
|
|
|
def test_align_working_directory_with_session_switches_to_recorded_cwd(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Choosing ``switch`` changes cwd before Codex resume continues.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary workspace and state root.
|
|
:returns: None.
|
|
"""
|
|
from omnigent.codex_native_state import write_launch_state
|
|
|
|
recorded = tmp_path / "recorded"
|
|
current = tmp_path / "current"
|
|
recorded.mkdir()
|
|
current.mkdir()
|
|
monkeypatch.chdir(current)
|
|
monkeypatch.setenv("OMNIGENT_CODEX_NATIVE_STATE_DIR", str(tmp_path / "state"))
|
|
write_launch_state("conv_abc", str(recorded.resolve()))
|
|
monkeypatch.setattr(
|
|
codex_native,
|
|
"_prompt_codex_resume_workspace_action",
|
|
lambda **_kwargs: "switch",
|
|
)
|
|
|
|
codex_native._align_working_directory_with_session("conv_abc")
|
|
|
|
assert Path.cwd().resolve() == recorded.resolve()
|
|
|
|
|
|
def test_align_working_directory_with_session_missing_recorded_cwd_raises(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Recorded-but-missing cwd fails loud instead of starting Codex wrong.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary workspace and state root.
|
|
:returns: None.
|
|
"""
|
|
from omnigent.codex_native_state import write_launch_state
|
|
|
|
current = tmp_path / "current"
|
|
missing = tmp_path / "missing"
|
|
current.mkdir()
|
|
monkeypatch.chdir(current)
|
|
monkeypatch.setenv("OMNIGENT_CODEX_NATIVE_STATE_DIR", str(tmp_path / "state"))
|
|
write_launch_state("conv_abc", str(missing))
|
|
|
|
with pytest.raises(click.ClickException) as excinfo:
|
|
codex_native._align_working_directory_with_session("conv_abc")
|
|
|
|
assert "conv_abc" in excinfo.value.message
|
|
assert str(missing) in excinfo.value.message
|
|
|
|
|
|
def test_codex_resume_workspace_options_name_cancel_action(tmp_path: Path) -> None:
|
|
"""
|
|
The cwd mismatch prompt names cancellation explicitly.
|
|
|
|
A generic ``leave`` option is ambiguous because Codex does not
|
|
continue from the current cwd; the caller cancels resume.
|
|
|
|
:param tmp_path: Temporary recorded workspace path.
|
|
:returns: None.
|
|
"""
|
|
options = codex_native._codex_resume_workspace_action_options(
|
|
recorded_path=tmp_path,
|
|
)
|
|
|
|
assert [(option.action, option.label) for option in options] == [
|
|
("switch", f"Switch working directory to {tmp_path}"),
|
|
("cancel", "Cancel resume"),
|
|
]
|
|
|
|
|
|
def test_run_with_remote_server_aligns_cwd_before_daemon_prepare(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Remote resume aligns cwd before daemon runner preparation samples it.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary paths for prepared Codex details.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.chat as chat_mod
|
|
import omnigent.cli as cli_mod
|
|
import omnigent.host.identity as identity_mod
|
|
|
|
order: list[str] = []
|
|
|
|
def fake_ensure_host_daemon(*_args: object, **_kwargs: object) -> None:
|
|
"""
|
|
Record daemon startup order.
|
|
|
|
:returns: None.
|
|
"""
|
|
order.append("ensure-daemon")
|
|
|
|
async def fake_prepare_codex_terminal_via_daemon(
|
|
**kwargs: object,
|
|
) -> codex_native.PreparedCodexTerminal:
|
|
"""
|
|
Record terminal preparation order.
|
|
|
|
:param kwargs: Preparation keyword arguments.
|
|
:returns: Prepared reattached Codex terminal.
|
|
"""
|
|
assert kwargs["host_id"] == "host_local"
|
|
assert kwargs["session_id"] == "conv_abc"
|
|
assert kwargs["workspace"] == str(aligned_dir.resolve())
|
|
assert isinstance(kwargs["startup_progress"], RunnerStartupProgress)
|
|
order.append("prepare")
|
|
return codex_native.PreparedCodexTerminal(
|
|
session_id="conv_abc",
|
|
terminal_id="terminal_codex_main",
|
|
tmux_socket=None,
|
|
tmux_target=None,
|
|
bridge_dir=tmp_path / "bridge",
|
|
thread_id="thread_123",
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
app_server=None,
|
|
event_client=None,
|
|
reattached=True,
|
|
)
|
|
|
|
async def fake_attach_terminal_resource(**_kwargs: object) -> None:
|
|
"""
|
|
Record attach order.
|
|
|
|
:returns: None.
|
|
"""
|
|
order.append("attach")
|
|
|
|
monkeypatch.setattr(chat_mod, "_remote_headers", lambda *_args, **_kwargs: {})
|
|
monkeypatch.setattr(chat_mod, "_server_auth", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(cli_mod, "_ensure_host_daemon", fake_ensure_host_daemon)
|
|
monkeypatch.setattr(
|
|
identity_mod,
|
|
"load_or_create_host_identity",
|
|
lambda: SimpleNamespace(host_id="host_local"),
|
|
)
|
|
start_dir = tmp_path / "start"
|
|
aligned_dir = tmp_path / "aligned"
|
|
start_dir.mkdir()
|
|
aligned_dir.mkdir()
|
|
monkeypatch.chdir(start_dir)
|
|
|
|
monkeypatch.setattr(
|
|
codex_native,
|
|
"_resolve_session_id_for_resume",
|
|
lambda **_kwargs: "conv_abc",
|
|
)
|
|
|
|
def fake_align_working_directory(_session_id: str) -> None:
|
|
"""
|
|
Simulate resume alignment changing the process cwd.
|
|
|
|
:param _session_id: Session id being aligned.
|
|
:returns: None.
|
|
"""
|
|
order.append("align")
|
|
os.chdir(aligned_dir)
|
|
|
|
monkeypatch.setattr(
|
|
codex_native,
|
|
"_align_working_directory_with_session",
|
|
fake_align_working_directory,
|
|
)
|
|
monkeypatch.setattr(
|
|
codex_native,
|
|
"_prepare_codex_terminal_via_daemon",
|
|
fake_prepare_codex_terminal_via_daemon,
|
|
)
|
|
monkeypatch.setattr(codex_native, "_attach_terminal_resource", fake_attach_terminal_resource)
|
|
|
|
codex_native._run_with_remote_server(
|
|
"https://example.com",
|
|
tmp_path / "codex.yaml",
|
|
session_id="conv_abc",
|
|
resume_picker=False,
|
|
codex_args=(),
|
|
model=None,
|
|
prompt=None,
|
|
)
|
|
|
|
assert order == ["align", "ensure-daemon", "prepare", "attach"]
|
|
|
|
|
|
def test_run_with_local_server_records_fresh_session_before_attach(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Local fresh sessions persist launch cwd before terminal attach.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary paths for fake server and Codex details.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.chat as chat_mod
|
|
|
|
order: list[str] = []
|
|
|
|
class _FakeServer:
|
|
"""
|
|
Minimal local server handle.
|
|
|
|
:param proc: Fake server process.
|
|
:param runner_id: Stable runner id.
|
|
"""
|
|
|
|
proc = object()
|
|
runner_id = "runner_local"
|
|
|
|
async def fake_prepare_codex_terminal(**_kwargs: object) -> codex_native.PreparedCodexTerminal:
|
|
"""
|
|
Return a freshly prepared Codex terminal.
|
|
|
|
:returns: Prepared Codex terminal.
|
|
"""
|
|
order.append("prepare")
|
|
return codex_native.PreparedCodexTerminal(
|
|
session_id="conv_fresh",
|
|
terminal_id="terminal_codex_main",
|
|
tmux_socket=None,
|
|
tmux_target=None,
|
|
bridge_dir=tmp_path / "bridge",
|
|
thread_id="thread_123",
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
app_server=None,
|
|
event_client=None,
|
|
reattached=False,
|
|
)
|
|
|
|
async def fake_attach_with_forwarder(**_kwargs: object) -> None:
|
|
"""
|
|
Record attach after launch-state recording.
|
|
|
|
:returns: None.
|
|
"""
|
|
order.append("attach")
|
|
|
|
monkeypatch.setattr(chat_mod, "_find_free_port", lambda: 9876)
|
|
monkeypatch.setattr(chat_mod, "_start_local_server", lambda *_args, **_kwargs: _FakeServer())
|
|
monkeypatch.setattr(chat_mod, "_wait_for_server", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(chat_mod, "_stop_local_server", lambda _server: None)
|
|
monkeypatch.setattr(chat_mod, "_bundle_agent", lambda _path: b"bundle")
|
|
monkeypatch.setattr(codex_native, "_resolve_session_id_for_resume", lambda **_kwargs: None)
|
|
monkeypatch.setattr(codex_native, "_prepare_codex_terminal", fake_prepare_codex_terminal)
|
|
monkeypatch.setattr(codex_native, "_attach_with_forwarder", fake_attach_with_forwarder)
|
|
monkeypatch.setattr(
|
|
codex_native,
|
|
"_record_launch_for_fresh_session",
|
|
lambda session_id: order.append(f"record:{session_id}"),
|
|
)
|
|
|
|
codex_native._run_with_local_server(
|
|
tmp_path / "codex.yaml",
|
|
session_id=None,
|
|
resume_picker=False,
|
|
codex_args=(),
|
|
command="/opt/codex/bin/codex",
|
|
model=None,
|
|
prompt=None,
|
|
)
|
|
|
|
assert order == ["prepare", "record:conv_fresh", "attach"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prepare_codex_terminal_via_daemon_creates_runner_and_ensures_terminal(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Daemon preparation owns session create, runner launch, and terminal ensure.
|
|
|
|
This exercises the real ``_prepare_codex_terminal_via_daemon`` orchestration
|
|
against an ``httpx.MockTransport`` Omnigent server. Removing terminal launch arg
|
|
persistence, daemon runner launch, the runner re-bind (which clears
|
|
``omnigent.stopped`` on resume), the ``ensure_native_terminal``
|
|
request, or terminal metadata decoding turns this test red.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: None.
|
|
"""
|
|
original_async_client = httpx.AsyncClient
|
|
calls: list[tuple[str, str, object]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Route Omnigent requests issued by daemon preparation.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Mock Omnigent response.
|
|
"""
|
|
path = request.url.path
|
|
body: object = None
|
|
if request.content:
|
|
content_type = request.headers.get("content-type", "")
|
|
body = (
|
|
json.loads(request.content)
|
|
if content_type.startswith("application/json")
|
|
else request.content
|
|
)
|
|
calls.append((request.method, path, body))
|
|
if request.method == "POST" and path == "/v1/sessions":
|
|
return httpx.Response(201, json={"session_id": "conv_new"})
|
|
if request.method == "PATCH" and path == "/v1/sessions/conv_new":
|
|
return httpx.Response(200, json={})
|
|
if request.method == "GET" and path == "/v1/hosts/host_local":
|
|
return httpx.Response(200, json={"status": "online"})
|
|
if request.method == "GET" and path == "/v1/sessions/conv_new":
|
|
return httpx.Response(200, json={})
|
|
if request.method == "POST" and path == "/v1/hosts/host_local/runners":
|
|
return httpx.Response(200, json={"runner_id": "runner_new"})
|
|
if request.method == "GET" and path == "/v1/runners/runner_new/status":
|
|
return httpx.Response(200, json={"online": True})
|
|
if request.method == "POST" and path.endswith("/resources/terminals"):
|
|
return httpx.Response(200, json={"id": "terminal_codex_main"})
|
|
if request.method == "GET" and path.endswith("/resources/terminals/terminal_codex_main"):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "terminal_codex_main",
|
|
"metadata": {
|
|
"tmux_socket": "/tmp/codex.sock",
|
|
"tmux_target": "codex:main",
|
|
},
|
|
},
|
|
)
|
|
return httpx.Response(404, json={"error": {"message": path}})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
|
|
def client_factory(*args: object, **kwargs: object) -> httpx.AsyncClient:
|
|
"""
|
|
Inject the mock Omnigent transport into clients created by the helper.
|
|
|
|
:param args: Positional ``httpx.AsyncClient`` args.
|
|
:param kwargs: Keyword ``httpx.AsyncClient`` args.
|
|
:returns: Async client using the mock transport.
|
|
"""
|
|
kwargs["transport"] = transport
|
|
return original_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(codex_native.httpx, "AsyncClient", client_factory)
|
|
progress_updates: list[str] = []
|
|
|
|
prepared = await codex_native._prepare_codex_terminal_via_daemon(
|
|
base_url="https://example.com",
|
|
headers={},
|
|
session_id=None,
|
|
session_bundle=b"bundle",
|
|
codex_args=("--config", "approval_policy=on-request"),
|
|
model="gpt-5.4-mini",
|
|
host_id="host_local",
|
|
workspace="/repo",
|
|
startup_progress=RunnerStartupProgress(update=progress_updates.append),
|
|
)
|
|
|
|
assert prepared.session_id == "conv_new"
|
|
assert prepared.terminal_id == "terminal_codex_main"
|
|
assert prepared.tmux_socket == Path("/tmp/codex.sock")
|
|
assert prepared.tmux_target == "codex:main"
|
|
assert prepared.app_server_url is None
|
|
assert prepared.app_server is None
|
|
assert prepared.event_client is None
|
|
assert prepared.reattached is False
|
|
create_body = next(
|
|
body for method, path, body in calls if method == "POST" and path == "/v1/sessions"
|
|
)
|
|
assert isinstance(create_body, bytes)
|
|
assert b'"terminal_launch_args"' in create_body
|
|
assert b"approval_policy=on-request" in create_body
|
|
assert (
|
|
"POST",
|
|
"/v1/hosts/host_local/runners",
|
|
{"session_id": "conv_new", "workspace": "/repo"},
|
|
) in calls
|
|
# Runner re-bind clears omnigent.stopped on resume.
|
|
assert ("PATCH", "/v1/sessions/conv_new", {"runner_id": "runner_new"}) in calls
|
|
assert (
|
|
"POST",
|
|
"/v1/sessions/conv_new/resources/terminals",
|
|
{"terminal": "codex", "session_key": "main", "ensure_native_terminal": True},
|
|
) in calls
|
|
assert progress_updates == [
|
|
"Creating Codex session...",
|
|
"Starting runner...",
|
|
"Waiting for runner...",
|
|
"Starting Codex terminal...",
|
|
"Codex terminal ready.",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prepare_codex_terminal_via_daemon_live_resume_skips_config_patch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Warm reattach leaves live terminal state alone.
|
|
|
|
If a Codex terminal is already running, updating ``terminal_launch_args`` or
|
|
``model_override`` would only change the database for a later cold start and
|
|
silently mislead the user. The helper must return the live terminal and warn
|
|
instead of PATCHing the session. It also must not rewrite the live Codex
|
|
rollout from Omnigent history while the app-server may be appending to it.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param capsys: Pytest capture fixture.
|
|
:returns: None.
|
|
"""
|
|
original_async_client = httpx.AsyncClient
|
|
calls: list[tuple[str, str, object]] = []
|
|
thread_id = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "bridges")
|
|
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id, codex_home_for_bridge_dir
|
|
|
|
live_rollout = _write_source_rollout(
|
|
codex_home=codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_live")),
|
|
thread_id=thread_id,
|
|
source_cwd="/repo",
|
|
)
|
|
before = live_rollout.read_bytes()
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Route Omnigent requests for a live resume.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Mock Omnigent response.
|
|
"""
|
|
path = request.url.path
|
|
body: object = json.loads(request.content) if request.content else None
|
|
calls.append((request.method, path, body))
|
|
if request.method == "GET" and path == "/v1/sessions/conv_live":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"labels": {"omnigent.wrapper": "codex-native-ui"},
|
|
"external_session_id": thread_id,
|
|
},
|
|
)
|
|
if request.method == "GET" and path.endswith("/resources/terminals/terminal_codex_main"):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "terminal_codex_main",
|
|
"metadata": {
|
|
"tmux_socket": "/tmp/live.sock",
|
|
"tmux_target": "live:main",
|
|
},
|
|
},
|
|
)
|
|
if request.method == "GET" and path == "/v1/sessions/conv_live/items":
|
|
return httpx.Response(500, json={"error": {"message": "unexpected items fetch"}})
|
|
if request.method == "PATCH":
|
|
return httpx.Response(500, json={"error": {"message": "unexpected patch"}})
|
|
return httpx.Response(404, json={"error": {"message": path}})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
|
|
def client_factory(*args: object, **kwargs: object) -> httpx.AsyncClient:
|
|
"""
|
|
Inject the mock Omnigent transport into clients created by the helper.
|
|
|
|
:param args: Positional ``httpx.AsyncClient`` args.
|
|
:param kwargs: Keyword ``httpx.AsyncClient`` args.
|
|
:returns: Async client using the mock transport.
|
|
"""
|
|
kwargs["transport"] = transport
|
|
return original_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(codex_native.httpx, "AsyncClient", client_factory)
|
|
|
|
prepared = await codex_native._prepare_codex_terminal_via_daemon(
|
|
base_url="https://example.com",
|
|
headers={},
|
|
session_id="conv_live",
|
|
session_bundle=None,
|
|
codex_args=("--model", "gpt-5.4-mini"),
|
|
model="gpt-5.4-mini",
|
|
host_id="host_local",
|
|
workspace="/repo",
|
|
)
|
|
|
|
assert prepared.session_id == "conv_live"
|
|
assert prepared.terminal_id == "terminal_codex_main"
|
|
assert prepared.tmux_socket == Path("/tmp/live.sock")
|
|
assert prepared.tmux_target == "live:main"
|
|
assert prepared.app_server_url is None
|
|
assert prepared.thread_id == thread_id
|
|
assert prepared.reattached is True
|
|
assert ("GET", "/v1/sessions/conv_live/items", None) not in calls
|
|
assert not any(method == "PATCH" for method, _path, _body in calls)
|
|
assert live_rollout.read_bytes() == before
|
|
assert "Ignoring Codex launch args/model" in capsys.readouterr().err
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prepare_codex_terminal_hot_resume_does_not_rewrite_rollout(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Local hot reattach leaves live Codex rollout state alone.
|
|
|
|
``_prepare_codex_terminal`` has an early return when the terminal
|
|
resource is already running. That hot path must not synthesize or
|
|
rewrite rollout files from Omnigent history because Codex may be appending
|
|
to the same JSONL file concurrently.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary directory for isolated bridge state.
|
|
:returns: None.
|
|
"""
|
|
original_async_client = httpx.AsyncClient
|
|
session_id = "conv_hot_codex"
|
|
thread_id = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
bridge_id = "bridge_hot_codex"
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
monkeypatch.chdir(workspace)
|
|
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "bridges")
|
|
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id, codex_home_for_bridge_dir
|
|
|
|
live_rollout = _write_source_rollout(
|
|
codex_home=codex_home_for_bridge_dir(bridge_dir_for_bridge_id(bridge_id)),
|
|
thread_id=thread_id,
|
|
source_cwd=str(workspace),
|
|
)
|
|
before = live_rollout.read_bytes()
|
|
calls: list[tuple[str, str]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Serve a codex-native session, live terminal, and Omnigent item history.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Mock Omnigent response.
|
|
"""
|
|
path = request.url.path
|
|
calls.append((request.method, path))
|
|
if request.method == "GET" and path == f"/v1/sessions/{session_id}":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"labels": {
|
|
"omnigent.wrapper": "codex-native-ui",
|
|
"omnigent.codex_native.bridge_id": bridge_id,
|
|
},
|
|
"external_session_id": thread_id,
|
|
},
|
|
)
|
|
if (
|
|
request.method == "GET"
|
|
and path == f"/v1/sessions/{session_id}/resources/terminals/terminal_codex_main"
|
|
):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "terminal_codex_main",
|
|
"metadata": {
|
|
"tmux_socket": "/tmp/live-codex.sock",
|
|
"tmux_target": "codex-live:main",
|
|
},
|
|
},
|
|
)
|
|
if request.method == "GET" and path == f"/v1/sessions/{session_id}/items":
|
|
return httpx.Response(500, json={"error": {"message": "unexpected items fetch"}})
|
|
return httpx.Response(404, json={"error": {"message": path}})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
|
|
def client_factory(*args: object, **kwargs: object) -> httpx.AsyncClient:
|
|
"""
|
|
Inject the mock Omnigent transport into clients created by the helper.
|
|
|
|
:param args: Positional ``httpx.AsyncClient`` args.
|
|
:param kwargs: Keyword ``httpx.AsyncClient`` args.
|
|
:returns: Async client using the mock transport.
|
|
"""
|
|
kwargs["transport"] = transport
|
|
return original_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(codex_native.httpx, "AsyncClient", client_factory)
|
|
|
|
prepared = await codex_native._prepare_codex_terminal(
|
|
base_url="https://example.com",
|
|
headers={},
|
|
session_id=session_id,
|
|
runner_id="runner_local",
|
|
session_bundle=None,
|
|
codex_args=(),
|
|
command="/opt/codex/bin/codex",
|
|
model=None,
|
|
)
|
|
|
|
assert prepared.reattached is True
|
|
assert prepared.session_id == session_id
|
|
assert prepared.thread_id == thread_id
|
|
assert prepared.tmux_socket == Path("/tmp/live-codex.sock")
|
|
assert prepared.tmux_target == "codex-live:main"
|
|
assert ("GET", f"/v1/sessions/{session_id}/items") not in calls
|
|
assert live_rollout.read_bytes() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("status_code", "body"),
|
|
[
|
|
(404, {"error": {"code": "not_found", "message": "Resource not found"}}),
|
|
(
|
|
409,
|
|
{
|
|
"error": {
|
|
"code": "conflict",
|
|
"message": (
|
|
"conversation 'conv_abc' is not bound to a runner; "
|
|
"resume the session to bind a registered runner"
|
|
),
|
|
}
|
|
},
|
|
),
|
|
(
|
|
503,
|
|
{
|
|
"error": {
|
|
"code": "runner_unavailable",
|
|
"message": (
|
|
"runner 'runner_token_dead' is offline for conversation 'conv_abc'"
|
|
),
|
|
}
|
|
},
|
|
),
|
|
],
|
|
)
|
|
async def test_find_running_codex_terminal_known_misses_relaunch(
|
|
status_code: int,
|
|
body: dict[str, Any],
|
|
) -> None:
|
|
"""
|
|
Missing terminals or unavailable prior runners relaunch cleanly.
|
|
|
|
These are the explicit reattach-miss shapes: absent terminal,
|
|
unbound conversation, and stale runner. They let resume bind the
|
|
current runner and launch ``codex resume``.
|
|
|
|
:param status_code: HTTP status returned by the Omnigent resource lookup.
|
|
:param body: Structured Omnigent error body for the lookup.
|
|
:returns: None.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Return a reattach miss response.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Mock Omnigent response.
|
|
"""
|
|
assert request.url.path.endswith("/resources/terminals/terminal_codex_main")
|
|
return httpx.Response(status_code, json=body)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://example.com") as client:
|
|
found = await codex_native._find_running_codex_terminal(client, "conv_abc")
|
|
|
|
assert found is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_running_codex_terminal_unexpected_error_still_raises() -> None:
|
|
"""
|
|
Non-reattach failures still fail loud.
|
|
|
|
:returns: None.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Return an unexpected server error.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Mock Omnigent response.
|
|
"""
|
|
del request
|
|
return httpx.Response(500, text="database unavailable")
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://example.com") as client:
|
|
with pytest.raises(click.ClickException) as excinfo:
|
|
await codex_native._find_running_codex_terminal(client, "conv_abc")
|
|
|
|
assert "Failed to fetch Codex terminal (500)" in excinfo.value.message
|
|
assert "database unavailable" in excinfo.value.message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status_code", [409, 502, 503])
|
|
async def test_find_running_codex_terminal_generic_errors_still_raise(
|
|
status_code: int,
|
|
) -> None:
|
|
"""
|
|
Generic infra failures are not treated as "no terminal".
|
|
|
|
:param status_code: HTTP status returned by the Omnigent resource lookup.
|
|
:returns: None.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Return a non-reattach failure response.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Mock Omnigent response.
|
|
"""
|
|
del request
|
|
return httpx.Response(
|
|
status_code,
|
|
json={"error": {"code": "internal_error", "message": "database unavailable"}},
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://example.com") as client:
|
|
with pytest.raises(click.ClickException) as excinfo:
|
|
await codex_native._find_running_codex_terminal(client, "conv_abc")
|
|
|
|
assert f"Failed to fetch Codex terminal ({status_code})" in excinfo.value.message
|
|
assert "database unavailable" in excinfo.value.message
|
|
|
|
|
|
def test_launch_codex_terminal_starts_fresh_remote_tui() -> None:
|
|
"""
|
|
Fresh sessions let the Codex TUI create the remote app-server
|
|
thread instead of resuming a pre-created rollout-less thread.
|
|
|
|
:returns: None.
|
|
"""
|
|
client = _FakeTerminalClient(httpx.Response(200, json={"id": "terminal_codex_main"}))
|
|
|
|
launched = asyncio.run(
|
|
codex_native._launch_codex_terminal(
|
|
client, # type: ignore[arg-type]
|
|
"conv_abc",
|
|
codex_args=("-c", "approval_policy=on-request"),
|
|
command="/opt/codex/bin/codex",
|
|
thread_id=None,
|
|
remote_url="ws://127.0.0.1:9876",
|
|
env={"CODEX_HOME": "/tmp/codex-home"},
|
|
)
|
|
)
|
|
|
|
assert launched.terminal_id == "terminal_codex_main"
|
|
assert launched.tmux_socket is None
|
|
assert launched.tmux_target is None
|
|
assert client.posts[0][1]["spec"]["args"] == [
|
|
"-c",
|
|
"approval_policy=on-request",
|
|
"--remote",
|
|
"ws://127.0.0.1:9876",
|
|
]
|
|
assert client.posts[0][1]["spec"]["command"] == "/opt/codex/bin/codex"
|
|
assert client.posts[0][1]["spec"]["tmux_allow_passthrough"] is True
|
|
assert client.posts[0][1]["spec"]["tmux_start_on_attach"] is True
|
|
|
|
|
|
def test_launch_codex_terminal_uses_remote_resume_order() -> None:
|
|
"""
|
|
Terminal launch uses the Codex resume subcommand with ``--remote``
|
|
before the thread id, matching Codex CLI parsing coverage.
|
|
|
|
:returns: None.
|
|
"""
|
|
client = _FakeTerminalClient(httpx.Response(200, json={"id": "terminal_codex_main"}))
|
|
|
|
launched = asyncio.run(
|
|
codex_native._launch_codex_terminal(
|
|
client, # type: ignore[arg-type]
|
|
"conv_abc",
|
|
codex_args=("-c", "approval_policy=on-request"),
|
|
command="/opt/codex/bin/codex",
|
|
thread_id="thread_123",
|
|
remote_url="ws://127.0.0.1:9876",
|
|
env={"CODEX_HOME": "/tmp/codex-home"},
|
|
)
|
|
)
|
|
|
|
assert launched.terminal_id == "terminal_codex_main"
|
|
assert client.posts == [
|
|
(
|
|
"/v1/sessions/conv_abc/resources/terminals",
|
|
{
|
|
"terminal": "codex",
|
|
"session_key": "main",
|
|
"spec": {
|
|
"command": "/opt/codex/bin/codex",
|
|
"args": [
|
|
"-c",
|
|
"approval_policy=on-request",
|
|
"resume",
|
|
"--remote",
|
|
"ws://127.0.0.1:9876",
|
|
"thread_123",
|
|
],
|
|
"os_env_type": "caller_process",
|
|
"cwd": str(Path.cwd()),
|
|
"env": {"CODEX_HOME": "/tmp/codex-home"},
|
|
"scrollback": 100_000,
|
|
"tmux_allow_passthrough": True,
|
|
"tmux_start_on_attach": True,
|
|
},
|
|
},
|
|
30.0,
|
|
)
|
|
]
|
|
|
|
|
|
def test_launch_codex_terminal_extracts_tmux_attach_metadata(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Terminal launch returns the runner tmux coordinates needed for
|
|
direct local attach.
|
|
|
|
This fails if the Codex wrapper only keeps the terminal id and is
|
|
forced back through the WebSocket terminal bridge even when the
|
|
runner exposed a local tmux socket.
|
|
|
|
:param tmp_path: Temporary directory used for fake socket paths.
|
|
:returns: None.
|
|
"""
|
|
socket_path = tmp_path / "tmux.sock"
|
|
client = _FakeTerminalClient(
|
|
httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "terminal_codex_main",
|
|
"metadata": {
|
|
"tmux_socket": str(socket_path),
|
|
"tmux_target": "main",
|
|
},
|
|
},
|
|
)
|
|
)
|
|
|
|
launched = asyncio.run(
|
|
codex_native._launch_codex_terminal(
|
|
client, # type: ignore[arg-type]
|
|
"conv_abc",
|
|
codex_args=(),
|
|
command="/opt/codex/bin/codex",
|
|
thread_id="thread_123",
|
|
remote_url="ws://127.0.0.1:9876",
|
|
env={},
|
|
)
|
|
)
|
|
|
|
assert launched.terminal_id == "terminal_codex_main"
|
|
assert launched.tmux_socket == socket_path
|
|
assert launched.tmux_target == "main"
|
|
|
|
|
|
def test_attach_with_forwarder_uses_direct_tmux_when_socket_is_local(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
A local runner tmux socket bypasses the WebSocket terminal bridge.
|
|
|
|
Breaking the direct attach branch would make this call invoke
|
|
``_attach_with_reconnect`` and fail the test before any fake tmux
|
|
attach is recorded.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary directory used for fake socket paths.
|
|
:returns: None.
|
|
"""
|
|
socket_path = tmp_path / "tmux.sock"
|
|
socket_path.touch()
|
|
attached: list[tuple[Path, str]] = []
|
|
|
|
async def fake_attach_direct_tmux(path: Path, target: str) -> None:
|
|
"""
|
|
Record the direct tmux attach request.
|
|
|
|
:param path: Tmux socket path.
|
|
:param target: Tmux target.
|
|
:returns: None.
|
|
"""
|
|
attached.append((path, target))
|
|
|
|
async def fail_attach_with_reconnect(**_kwargs: object) -> None:
|
|
"""
|
|
Fail if the WebSocket bridge path is used.
|
|
|
|
:returns: None.
|
|
"""
|
|
raise AssertionError("WebSocket attach path should not be used")
|
|
|
|
monkeypatch.setattr("omnigent.codex_native.shutil.which", lambda _name: "/usr/bin/tmux")
|
|
monkeypatch.setattr(codex_native, "_attach_direct_tmux", fake_attach_direct_tmux)
|
|
monkeypatch.setattr(codex_native, "_attach_with_reconnect", fail_attach_with_reconnect)
|
|
|
|
asyncio.run(
|
|
codex_native._attach_with_forwarder(
|
|
base_url="http://127.0.0.1:8000",
|
|
headers={},
|
|
prepared=codex_native.PreparedCodexTerminal(
|
|
session_id="conv_abc",
|
|
terminal_id="terminal_codex_main",
|
|
tmux_socket=socket_path,
|
|
tmux_target="main",
|
|
bridge_dir=tmp_path / "bridge",
|
|
thread_id="thread_123",
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
app_server=None,
|
|
event_client=None,
|
|
reattached=True,
|
|
),
|
|
prompt=None,
|
|
)
|
|
)
|
|
|
|
assert attached == [(socket_path, "main")]
|
|
|
|
|
|
def test_attach_with_forwarder_attaches_before_waiting_for_fresh_thread(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Fresh tmux Codex sessions attach before waiting for ``thread/started``.
|
|
|
|
The Codex pane waits for the first tmux client before starting, so
|
|
waiting for the app-server thread before attaching would deadlock
|
|
and would also force Codex to query terminal colors while detached.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary directory used for fake socket paths.
|
|
:returns: None.
|
|
"""
|
|
attach_started = asyncio.Event()
|
|
allow_attach_exit = asyncio.Event()
|
|
forwarded_threads: list[str | None] = []
|
|
|
|
class _FakeAppServer:
|
|
"""Minimal app-server handle for attach cleanup."""
|
|
|
|
async def close(self) -> None:
|
|
"""
|
|
Close the fake app-server.
|
|
|
|
:returns: None.
|
|
"""
|
|
|
|
async def fake_attach_terminal_resource(**_kwargs: object) -> None:
|
|
"""
|
|
Record that the terminal attach started and wait to exit.
|
|
|
|
:returns: None.
|
|
"""
|
|
attach_started.set()
|
|
await allow_attach_exit.wait()
|
|
|
|
async def fake_initialize_fresh_terminal_thread(**kwargs: object) -> str:
|
|
"""
|
|
Assert initialization runs only after the attach has started.
|
|
|
|
:param kwargs: Initialization keyword arguments.
|
|
:returns: Fake Codex thread id.
|
|
"""
|
|
del kwargs
|
|
assert attach_started.is_set()
|
|
allow_attach_exit.set()
|
|
return "thread_123"
|
|
|
|
def fake_start_codex_forwarder(**kwargs: object) -> asyncio.Task[None]:
|
|
"""
|
|
Record the thread id used for the forwarder.
|
|
|
|
:param kwargs: Forwarder keyword arguments.
|
|
:returns: Cancellable no-op task.
|
|
"""
|
|
prepared = kwargs["prepared"]
|
|
assert isinstance(prepared, codex_native.PreparedCodexTerminal)
|
|
forwarded_threads.append(prepared.thread_id)
|
|
return asyncio.create_task(asyncio.sleep(3600))
|
|
|
|
async def fake_start_initial_turn(_socket_path: Path, _thread_id: str, _prompt: str) -> None:
|
|
"""
|
|
Fail if an initial prompt is unexpectedly sent.
|
|
|
|
:returns: None.
|
|
"""
|
|
raise AssertionError("no initial prompt expected")
|
|
|
|
monkeypatch.setattr(codex_native, "_attach_terminal_resource", fake_attach_terminal_resource)
|
|
monkeypatch.setattr(
|
|
codex_native,
|
|
"_initialize_fresh_terminal_thread",
|
|
fake_initialize_fresh_terminal_thread,
|
|
)
|
|
monkeypatch.setattr(codex_native, "_start_codex_forwarder", fake_start_codex_forwarder)
|
|
monkeypatch.setattr(codex_native, "_start_initial_turn", fake_start_initial_turn)
|
|
|
|
asyncio.run(
|
|
codex_native._attach_with_forwarder(
|
|
base_url="http://127.0.0.1:8000",
|
|
headers={},
|
|
prepared=codex_native.PreparedCodexTerminal(
|
|
session_id="conv_abc",
|
|
terminal_id="terminal_codex_main",
|
|
tmux_socket=tmp_path / "tmux.sock",
|
|
tmux_target="main",
|
|
bridge_dir=tmp_path / "bridge",
|
|
thread_id=None,
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
app_server=_FakeAppServer(), # type: ignore[arg-type]
|
|
event_client=object(), # type: ignore[arg-type]
|
|
reattached=False,
|
|
),
|
|
prompt=None,
|
|
)
|
|
)
|
|
|
|
assert forwarded_threads == ["thread_123"]
|
|
|
|
|
|
def test_attach_with_forwarder_closes_active_rotated_session_terminal(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Wrapper exit closes the terminal on the active rotated Omnigent session.
|
|
|
|
Codex ``/clear`` transfers the terminal resource to a replacement
|
|
session. Shutdown must follow the bridge state written by the
|
|
forwarder; closing the original session would leave the transferred
|
|
terminal live.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary directory used for fake bridge state.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir = tmp_path / "bridge"
|
|
closed_terminals: list[tuple[str, str]] = []
|
|
app_server_closed = False
|
|
|
|
class _FakeAppServer:
|
|
"""Minimal app-server handle for attach cleanup."""
|
|
|
|
async def close(self) -> None:
|
|
"""
|
|
Record app-server cleanup.
|
|
|
|
:returns: None.
|
|
"""
|
|
nonlocal app_server_closed
|
|
app_server_closed = True
|
|
|
|
async def fake_attach_terminal_resource(**_kwargs: object) -> None:
|
|
"""
|
|
Simulate ``/clear`` rotating Omnigent ownership during attach.
|
|
|
|
:returns: None.
|
|
"""
|
|
write_bridge_state(
|
|
bridge_dir,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_rotated",
|
|
socket_path=str(tmp_path / "codex.sock"),
|
|
thread_id="thread_after_clear",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
),
|
|
)
|
|
|
|
def fake_start_codex_forwarder(**_kwargs: object) -> asyncio.Task[None]:
|
|
"""
|
|
Return a cancellable no-op forwarder task.
|
|
|
|
:returns: Running task that never completes on its own.
|
|
"""
|
|
return asyncio.create_task(asyncio.sleep(3600))
|
|
|
|
async def fake_close_codex_terminal(**kwargs: object) -> None:
|
|
"""
|
|
Record the terminal close target.
|
|
|
|
:param kwargs: Close helper keyword arguments.
|
|
:returns: None.
|
|
"""
|
|
closed_terminals.append(
|
|
(
|
|
str(kwargs["session_id"]),
|
|
str(kwargs["terminal_id"]),
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr(codex_native, "_attach_terminal_resource", fake_attach_terminal_resource)
|
|
monkeypatch.setattr(codex_native, "_start_codex_forwarder", fake_start_codex_forwarder)
|
|
monkeypatch.setattr(codex_native, "_close_codex_terminal", fake_close_codex_terminal)
|
|
|
|
asyncio.run(
|
|
codex_native._attach_with_forwarder(
|
|
base_url="http://127.0.0.1:8000",
|
|
headers={},
|
|
prepared=codex_native.PreparedCodexTerminal(
|
|
session_id="conv_original",
|
|
terminal_id="terminal_codex_main",
|
|
tmux_socket=tmp_path / "tmux.sock",
|
|
tmux_target="main",
|
|
bridge_dir=bridge_dir,
|
|
thread_id="thread_before_clear",
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
app_server=_FakeAppServer(), # type: ignore[arg-type]
|
|
event_client=None,
|
|
reattached=False,
|
|
),
|
|
prompt=None,
|
|
)
|
|
)
|
|
|
|
assert closed_terminals == [("conv_rotated", "terminal_codex_main")]
|
|
assert app_server_closed
|
|
|
|
|
|
def test_attach_terminal_resource_runner_owned_missing_socket_fails_loud(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Runner-owned Codex terminals do not fall back to WebSocket attach.
|
|
|
|
The CLI should only attach to the runner's tmux socket for this
|
|
shape; if the socket metadata is stale or non-local, falling back to
|
|
the Omnigent terminal WebSocket would reintroduce CLI-owned terminal IO.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary directory used for fake socket paths.
|
|
:returns: None.
|
|
"""
|
|
|
|
async def fail_attach_with_reconnect(**_kwargs: object) -> None:
|
|
"""
|
|
Fail if the WebSocket bridge path is used.
|
|
|
|
:returns: None.
|
|
"""
|
|
raise AssertionError("Runner-owned Codex attach must not use WebSocket")
|
|
|
|
monkeypatch.setattr("omnigent.codex_native.shutil.which", lambda _name: "/usr/bin/tmux")
|
|
monkeypatch.setattr(codex_native, "_attach_with_reconnect", fail_attach_with_reconnect)
|
|
|
|
with pytest.raises(click.ClickException) as exc_info:
|
|
asyncio.run(
|
|
codex_native._attach_terminal_resource(
|
|
base_url="http://127.0.0.1:8000",
|
|
headers={},
|
|
prepared=codex_native.PreparedCodexTerminal(
|
|
session_id="conv_abc",
|
|
terminal_id="terminal_codex_main",
|
|
tmux_socket=tmp_path / "missing.sock",
|
|
tmux_target="main",
|
|
bridge_dir=tmp_path / "bridge",
|
|
thread_id="thread_123",
|
|
app_server_url=None,
|
|
app_server=None,
|
|
event_client=None,
|
|
reattached=True,
|
|
),
|
|
recover=None,
|
|
)
|
|
)
|
|
|
|
message = str(exc_info.value)
|
|
assert "Runner-owned Codex terminal requires direct tmux attach" in message
|
|
assert str(tmp_path / "missing.sock") in message
|
|
assert "WebSocket" not in message
|
|
|
|
|
|
def test_attach_with_forwarder_falls_back_when_tmux_socket_is_not_local(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Non-local runner sockets keep using the Omnigent terminal attach bridge.
|
|
|
|
This is the remote-runner case: the resource may advertise a socket
|
|
path from another host, but the CLI can only direct-attach when that
|
|
path exists locally.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Temporary directory used for fake socket paths.
|
|
:returns: None.
|
|
"""
|
|
websocket_attaches: list[str] = []
|
|
bridge_dir = tmp_path / "bridge"
|
|
write_bridge_state(
|
|
bridge_dir,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_rotated",
|
|
socket_path=str(tmp_path / "codex.sock"),
|
|
thread_id="thread_456",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
),
|
|
)
|
|
|
|
async def fail_attach_direct_tmux(_path: Path, _target: str) -> None:
|
|
"""
|
|
Fail if direct tmux attach is attempted.
|
|
|
|
:returns: None.
|
|
"""
|
|
raise AssertionError("Direct tmux attach should not be used")
|
|
|
|
async def fake_attach_with_reconnect(**kwargs: object) -> None:
|
|
"""
|
|
Record the WebSocket attach URL.
|
|
|
|
:param kwargs: Attach loop keyword arguments.
|
|
:returns: None.
|
|
"""
|
|
attach_url = kwargs["attach_url"]
|
|
assert isinstance(attach_url, str)
|
|
active_session_id_reader = kwargs["active_session_id_reader"]
|
|
assert callable(active_session_id_reader)
|
|
assert active_session_id_reader() == "conv_rotated"
|
|
websocket_attaches.append(attach_url)
|
|
|
|
monkeypatch.setattr("omnigent.codex_native.shutil.which", lambda _name: "/usr/bin/tmux")
|
|
monkeypatch.setattr(codex_native, "_attach_direct_tmux", fail_attach_direct_tmux)
|
|
monkeypatch.setattr(codex_native, "_attach_with_reconnect", fake_attach_with_reconnect)
|
|
|
|
asyncio.run(
|
|
codex_native._attach_with_forwarder(
|
|
base_url="http://127.0.0.1:8000",
|
|
headers={},
|
|
prepared=codex_native.PreparedCodexTerminal(
|
|
session_id="conv_abc",
|
|
terminal_id="terminal_codex_main",
|
|
tmux_socket=tmp_path / "missing.sock",
|
|
tmux_target="main",
|
|
bridge_dir=bridge_dir,
|
|
thread_id="thread_123",
|
|
app_server_url="ws://127.0.0.1:9876",
|
|
app_server=None,
|
|
event_client=None,
|
|
reattached=True,
|
|
),
|
|
prompt=None,
|
|
)
|
|
)
|
|
|
|
assert websocket_attaches == [
|
|
"ws://127.0.0.1:8000/v1/sessions/conv_abc/resources/terminals/terminal_codex_main/attach"
|
|
]
|
|
|
|
|
|
def test_session_usage_data_extracts_cumulative_tokens() -> None:
|
|
"""
|
|
``_session_usage_data_from_params`` surfaces Codex's cumulative input /
|
|
output tokens as ``cumulative_*`` fields (for server-side cost pricing).
|
|
|
|
Codex's ``tokenUsage.total`` is cumulative across the thread, so these are
|
|
the session totals; without them codex-native ``session_usage.total_cost_usd``
|
|
stays 0 (codex produces no ``response.completed`` for the Omnigent relay).
|
|
"""
|
|
params = {
|
|
"threadId": "thread_123",
|
|
"tokenUsage": {
|
|
"total": {
|
|
"inputTokens": 1000,
|
|
"outputTokens": 250,
|
|
"contextWindow": 200000,
|
|
},
|
|
},
|
|
}
|
|
data = codex_native_forwarder._session_usage_data_from_params(params)
|
|
assert data is not None
|
|
assert data["cumulative_input_tokens"] == 1000
|
|
assert data["cumulative_output_tokens"] == 250
|
|
# Existing context-ring fields still flow.
|
|
assert data["context_tokens"] == 1000
|
|
assert data["context_window"] == 200000
|
|
# No ``cachedInputTokens`` in the frame ⇒ no cache field forwarded. A
|
|
# failure here would mean the server splits a phantom cache bucket out of
|
|
# the input total, under-counting non-cached input.
|
|
assert "cumulative_cache_read_input_tokens" not in data
|
|
|
|
|
|
def test_session_usage_data_forwards_cached_input_tokens() -> None:
|
|
"""
|
|
``cachedInputTokens`` is forwarded as ``cumulative_cache_read_input_tokens``
|
|
while ``cumulative_input_tokens`` stays the FULL input total.
|
|
|
|
Codex's ``inputTokens`` is inclusive of cached tokens (codex-rs
|
|
``non_cached_input = input_tokens - cached_input_tokens``). The forwarder
|
|
must report both faithfully so the server can split the cheaper cache-read
|
|
portion out before pricing — otherwise cached tokens are billed at the full
|
|
input rate (the cost over-report this fix targets).
|
|
"""
|
|
params = {
|
|
"threadId": "thread_123",
|
|
"tokenUsage": {
|
|
"total": {
|
|
"inputTokens": 1000,
|
|
"cachedInputTokens": 800,
|
|
"outputTokens": 250,
|
|
"contextWindow": 200000,
|
|
},
|
|
},
|
|
}
|
|
data = codex_native_forwarder._session_usage_data_from_params(params)
|
|
assert data is not None
|
|
# Full input total preserved (NOT pre-subtracted) — the server owns the
|
|
# split. If this were 200, the forwarder would have double-applied the
|
|
# subtraction the server also does.
|
|
assert data["cumulative_input_tokens"] == 1000
|
|
# Cached count surfaced for the server to price at the cache-read rate.
|
|
assert data["cumulative_cache_read_input_tokens"] == 800
|
|
|
|
|
|
def test_session_usage_data_without_output_tokens_omits_cumulative_output() -> None:
|
|
"""
|
|
A usage notification lacking ``outputTokens`` yields no
|
|
``cumulative_output_tokens`` — the server then prices input only rather
|
|
than treating a missing field as zero output.
|
|
"""
|
|
params = {"tokenUsage": {"total": {"inputTokens": 500, "contextWindow": 200000}}}
|
|
data = codex_native_forwarder._session_usage_data_from_params(params)
|
|
assert data is not None
|
|
assert data["cumulative_input_tokens"] == 500
|
|
assert "cumulative_output_tokens" not in data
|
|
|
|
|
|
def test_session_usage_data_context_tokens_uses_last_turn_input() -> None:
|
|
"""
|
|
``context_tokens`` (the context-ring value) should reflect the LAST
|
|
turn's input — how much of the window the latest request occupied —
|
|
not the cumulative total across the whole thread.
|
|
|
|
When ``tokenUsage.last`` is present, ``context_tokens`` comes from
|
|
``last.inputTokens``; ``cumulative_input_tokens`` still uses
|
|
``total.inputTokens`` for cost pricing.
|
|
"""
|
|
params = {
|
|
"tokenUsage": {
|
|
"total": {
|
|
"inputTokens": 4_800_000,
|
|
"outputTokens": 200_000,
|
|
"contextWindow": 1_178_000,
|
|
},
|
|
"last": {
|
|
"inputTokens": 950_000,
|
|
"outputTokens": 12_000,
|
|
},
|
|
},
|
|
}
|
|
data = codex_native_forwarder._session_usage_data_from_params(params)
|
|
assert data is not None
|
|
# Ring shows current context occupancy from the last turn.
|
|
assert data["context_tokens"] == 950_000
|
|
# Cost pricing uses cumulative totals.
|
|
assert data["cumulative_input_tokens"] == 4_800_000
|
|
assert data["cumulative_output_tokens"] == 200_000
|
|
assert data["context_window"] == 1_178_000
|
|
|
|
|
|
def test_session_usage_data_context_tokens_falls_back_without_last() -> None:
|
|
"""
|
|
When ``tokenUsage.last`` is absent (e.g. first frame before a turn
|
|
completes), ``context_tokens`` falls back to ``total.inputTokens``.
|
|
"""
|
|
params = {
|
|
"tokenUsage": {
|
|
"total": {
|
|
"inputTokens": 1000,
|
|
"outputTokens": 250,
|
|
"contextWindow": 200_000,
|
|
},
|
|
},
|
|
}
|
|
data = codex_native_forwarder._session_usage_data_from_params(params)
|
|
assert data is not None
|
|
assert data["context_tokens"] == 1000
|
|
assert data["cumulative_input_tokens"] == 1000
|
|
|
|
|
|
def test_session_usage_data_context_tokens_falls_back_when_last_missing_input() -> None:
|
|
"""
|
|
When ``tokenUsage.last`` is present but lacks a usable ``inputTokens``,
|
|
``context_tokens`` falls back to ``total.inputTokens`` rather than being
|
|
omitted (which would leave the UI ring stuck on a stale value from a
|
|
previous coalescer frame).
|
|
"""
|
|
params = {
|
|
"tokenUsage": {
|
|
"total": {
|
|
"inputTokens": 3000,
|
|
"outputTokens": 500,
|
|
"contextWindow": 200_000,
|
|
},
|
|
"last": {
|
|
"outputTokens": 100,
|
|
# inputTokens intentionally absent
|
|
},
|
|
},
|
|
}
|
|
data = codex_native_forwarder._session_usage_data_from_params(params)
|
|
assert data is not None
|
|
assert data["context_tokens"] == 3000
|
|
|
|
|
|
def test_usage_coalescer_flush_attaches_model_to_every_post() -> None:
|
|
"""
|
|
``flush`` attaches the recorded model to each token-bearing post.
|
|
|
|
The server prices cumulative codex tokens into ``total_cost_usd`` only
|
|
when the post carries a ``model`` (codex-native sessions have no
|
|
``llm.model`` to fall back on). Codex sends settings (model) and usage
|
|
in separate frames, so the coalescer must remember the model and stamp
|
|
it on every flush — including the second turn's post, where only the
|
|
token counts changed and the dedup would otherwise omit the model.
|
|
"""
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture Omnigent usage posts from the coalescer.
|
|
|
|
:param request: HTTP request sent by the coalescer.
|
|
:returns: Accepted response.
|
|
"""
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Record two usage frames (model known) and flush each.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
coalescer = _usage_coalescer(client)
|
|
coalescer.record(
|
|
{"tokenUsage": {"total": {"inputTokens": 1000, "outputTokens": 250}}},
|
|
model="gpt-5.1-codex",
|
|
)
|
|
await coalescer.flush()
|
|
# A later turn: only token counts change, model arrives as None
|
|
# on the usage frame, yet the post must still carry the model.
|
|
coalescer.record(
|
|
{"tokenUsage": {"total": {"inputTokens": 2000, "outputTokens": 600}}},
|
|
)
|
|
await coalescer.flush()
|
|
|
|
asyncio.run(run())
|
|
|
|
assert len(posted) == 2
|
|
assert posted[0]["data"]["model"] == "gpt-5.1-codex"
|
|
assert posted[0]["data"]["cumulative_input_tokens"] == 1000
|
|
assert posted[0]["data"]["cumulative_output_tokens"] == 250
|
|
# Second post still carries the model even though only tokens changed.
|
|
assert posted[1]["data"]["model"] == "gpt-5.1-codex"
|
|
assert posted[1]["data"]["cumulative_input_tokens"] == 2000
|
|
assert posted[1]["data"]["cumulative_output_tokens"] == 600
|
|
|
|
|
|
# ── Codex subagent tracking and dedup ────────────────────────────────────────
|
|
|
|
|
|
def _collab_item_completed_event(
|
|
*,
|
|
parent_thread_id: str = "thread_parent",
|
|
child_thread_id: str = "thread_child",
|
|
item_id: str = "collab_1",
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex ``collabAgentToolCall`` ``item/completed`` notification.
|
|
|
|
:param parent_thread_id: Codex parent thread id.
|
|
:param child_thread_id: Codex child thread id.
|
|
:param item_id: Codex item id for the collab item.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": parent_thread_id,
|
|
"turnId": "turn_parent",
|
|
"item": {
|
|
"type": "collabAgentToolCall",
|
|
"id": item_id,
|
|
"tool": "spawnAgent",
|
|
"senderThreadId": parent_thread_id,
|
|
"receiverThreadIds": [child_thread_id],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _collab_item_started_event(
|
|
*,
|
|
parent_thread_id: str = "thread_parent",
|
|
child_thread_id: str = "thread_child",
|
|
item_id: str = "collab_1",
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex ``collabAgentToolCall`` ``item/started`` notification.
|
|
|
|
:param parent_thread_id: Codex parent thread id.
|
|
:param child_thread_id: Codex child thread id.
|
|
:param item_id: Codex item id for the collab item.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {
|
|
"method": "item/started",
|
|
"params": {
|
|
"threadId": parent_thread_id,
|
|
"turnId": "turn_parent",
|
|
"item": {
|
|
"type": "collabAgentToolCall",
|
|
"id": item_id,
|
|
"tool": "spawnAgent",
|
|
"senderThreadId": parent_thread_id,
|
|
"receiverThreadIds": [child_thread_id],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _child_agent_message_event(
|
|
*,
|
|
child_thread_id: str = "thread_child",
|
|
turn_id: str = "turn_child",
|
|
item_id: str = "child_msg",
|
|
text: str = "child output",
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex ``agentMessage`` notification from a child thread.
|
|
|
|
:param child_thread_id: Codex child thread id.
|
|
:param turn_id: Codex turn id.
|
|
:param item_id: Codex item id.
|
|
:param text: Assistant text content.
|
|
:returns: App-server event payload.
|
|
"""
|
|
return {
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": child_thread_id,
|
|
"turnId": turn_id,
|
|
"item": {
|
|
"type": "agentMessage",
|
|
"id": item_id,
|
|
"text": text,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _child_resume_response(
|
|
*,
|
|
child_thread_id: str = "thread_child",
|
|
parent_thread_id: str = "thread_parent",
|
|
turn_id: str = "turn_child",
|
|
item_id: str = "child_msg",
|
|
text: str = "child output",
|
|
agent_nickname: str = "Euclid",
|
|
agent_role: str = "explorer",
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Build a Codex ``thread/resume`` response for a child thread.
|
|
|
|
:param child_thread_id: Codex child thread id.
|
|
:param parent_thread_id: Codex parent thread id in the spawn source.
|
|
:param turn_id: Turn id for the replayed item.
|
|
:param item_id: Item id for the replayed item.
|
|
:param text: Text content of the replayed item.
|
|
:param agent_nickname: Codex-assigned agent nickname.
|
|
:param agent_role: Codex-assigned agent role.
|
|
:returns: JSON-RPC ``thread/resume`` response.
|
|
"""
|
|
return {
|
|
"result": {
|
|
"thread": {
|
|
"id": child_thread_id,
|
|
"agentNickname": agent_nickname,
|
|
"agentRole": agent_role,
|
|
"source": {
|
|
"subAgent": {
|
|
"thread_spawn": {
|
|
"parent_thread_id": parent_thread_id,
|
|
}
|
|
}
|
|
},
|
|
"turns": [
|
|
{
|
|
"id": turn_id,
|
|
"items": [{"type": "agentMessage", "id": item_id, "text": text}],
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class _PerThreadFakeCodexClient(_FakeCodexAppServerClient):
|
|
"""
|
|
Test double that returns per-thread ``thread/resume`` responses.
|
|
|
|
The base ``_FakeCodexAppServerClient.request`` returns the same canned
|
|
response for every call. This subclass dispatches by the requested
|
|
``threadId`` so tests can give child threads a different resume payload
|
|
from the parent.
|
|
|
|
:param thread_responses: Mapping from Codex thread id to JSON-RPC
|
|
response payload, e.g.
|
|
``{"thread_child": {"result": {...}}}``.
|
|
:param default_response: Fallback response for thread ids that have no
|
|
explicit entry.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
thread_responses: dict[str, dict[str, Any]],
|
|
default_response: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""
|
|
Initialise with per-thread responses.
|
|
|
|
:param thread_responses: Per-thread response map.
|
|
:param default_response: Fallback response when the requested
|
|
thread id is not in ``thread_responses``.
|
|
:returns: None.
|
|
"""
|
|
super().__init__(response=default_response or {"result": {"thread": None}})
|
|
self.thread_responses = thread_responses
|
|
|
|
async def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Dispatch to a per-thread response or the default.
|
|
|
|
:param method: JSON-RPC method, e.g. ``"thread/resume"``.
|
|
:param params: JSON-RPC params.
|
|
:returns: Per-thread or default JSON-RPC response.
|
|
"""
|
|
self.requests.append((method, params))
|
|
thread_id = params.get("threadId")
|
|
if isinstance(thread_id, str) and thread_id in self.thread_responses:
|
|
return self.thread_responses[thread_id]
|
|
return self.response
|
|
|
|
|
|
def _transcript_posts(
|
|
posted: list[tuple[str, dict[str, Any]]],
|
|
session_id: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Filter Omnigent posts to the ``external_conversation_item`` events for one session.
|
|
|
|
:param posted: All captured Omnigent posts as ``(path, body)`` tuples.
|
|
:param session_id: Omnigent session id to filter for.
|
|
:returns: List of ``external_conversation_item`` body dicts.
|
|
"""
|
|
return [
|
|
body
|
|
for path, body in posted
|
|
if path == f"/v1/sessions/{session_id}/events"
|
|
and body["type"] == "external_conversation_item"
|
|
]
|
|
|
|
|
|
def _registration_posts(
|
|
posted: list[tuple[str, dict[str, Any]]],
|
|
parent_session_id: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Filter Omnigent posts to the ``external_codex_subagent_start`` events for a parent.
|
|
|
|
:param posted: All captured Omnigent posts as ``(path, body)`` tuples.
|
|
:param parent_session_id: Parent Omnigent session id to filter for.
|
|
:returns: List of ``external_codex_subagent_start`` body dicts.
|
|
"""
|
|
return [
|
|
body
|
|
for path, body in posted
|
|
if path == f"/v1/sessions/{parent_session_id}/events"
|
|
and body["type"] == "external_codex_subagent_start"
|
|
]
|
|
|
|
|
|
def _make_omnigent_handler(
|
|
posted: list[tuple[str, dict[str, Any]]],
|
|
child_session_id: str = "conv_child",
|
|
) -> Callable[[httpx.Request], httpx.Response]:
|
|
"""
|
|
Build an Omnigent ``MockTransport`` handler that registers a child session on demand.
|
|
|
|
:param posted: Mutable list collecting all captured Omnigent requests.
|
|
:param child_session_id: Omnigent child session id to return for
|
|
``external_codex_subagent_start`` events.
|
|
:returns: Request handler for ``httpx.MockTransport``.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Capture the request and return the appropriate mock response.
|
|
|
|
:param request: Incoming HTTP request.
|
|
:returns: Mock Omnigent response.
|
|
"""
|
|
body = json.loads(request.content)
|
|
posted.append((request.url.path, body))
|
|
if body.get("type") == "external_codex_subagent_start":
|
|
return httpx.Response(
|
|
202, json={"queued": False, "child_session_id": child_session_id}
|
|
)
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
return handler
|
|
|
|
|
|
def test_forwarder_dedupes_replay_and_live_child_item(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
A child transcript item written during backfill replay is not rewritten
|
|
by the matching live ``item/completed`` event.
|
|
|
|
This is the primary regression test for the duplicate-write bug. The
|
|
forwarder discovers a child thread via a ``collabAgentToolCall`` and
|
|
replays its backlog. Seconds later the same item arrives live on the
|
|
event stream. With a correct total dedup key (``threadId:turnId:item.id``
|
|
derived identically in both paths) the second delivery should be
|
|
silently dropped. The test fails when either path produces a key of
|
|
``None`` or a key derived from different field values.
|
|
|
|
The critical assertion:
|
|
|
|
- Exactly **one** ``external_conversation_item`` to ``conv_child``
|
|
proves dedup fired on the live delivery.
|
|
- If it were two, the total-key derivation differs between paths or
|
|
``claim_item_key`` was bypassed.
|
|
"""
|
|
posted: list[tuple[str, dict[str, Any]]] = []
|
|
codex_client = _PerThreadFakeCodexClient(
|
|
thread_responses={"thread_child": _child_resume_response()}
|
|
)
|
|
events = [
|
|
# Parent turn — causes child registration + backfill replay.
|
|
_collab_item_completed_event(),
|
|
# Live delivery of the SAME child item already written during replay.
|
|
_child_agent_message_event(text="child output"),
|
|
]
|
|
codex_client.events = events
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Run supervise_forwarder against the event sequence.
|
|
|
|
:returns: None.
|
|
"""
|
|
await codex_native_forwarder.supervise_forwarder(
|
|
base_url="http://127.0.0.1:8000",
|
|
headers={},
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
app_server_url=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_parent",
|
|
client=codex_client, # type: ignore[arg-type]
|
|
ap_transport=httpx.MockTransport(_make_omnigent_handler(posted)),
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
child_posts = _transcript_posts(posted, "conv_child")
|
|
# Exactly one item posted: the replay delivery.
|
|
# Two would mean the live delivery was not deduped against the replay.
|
|
assert len(child_posts) == 1, (
|
|
f"Expected exactly 1 transcript post to conv_child (replay only); "
|
|
f"got {len(child_posts)}. Duplicate write survived dedup."
|
|
)
|
|
assert child_posts[0]["data"]["item_data"]["content"][0]["text"] == "child output"
|
|
|
|
|
|
def test_forwarder_does_not_double_write_stable_id_item_delivered_twice(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
A child item with a stable ``id`` posted twice in the same turn is
|
|
written to Omnigent only once.
|
|
|
|
This is the primary stable-id dedup case: the same ``item/completed``
|
|
event may arrive once from the backfill replay and once live. The
|
|
dedup key ``threadId:turnId:item.id`` must be identical both times so
|
|
the second delivery is dropped by ``claim_item_key``.
|
|
|
|
Anonymous items (no ``id``) cannot be reliably deduped across replay
|
|
and live because there is no stable identity to key on. Only items with
|
|
Codex-assigned ``id`` fields are guaranteed to deduplicate correctly.
|
|
"""
|
|
posted: list[tuple[str, dict[str, Any]]] = []
|
|
state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_parent",
|
|
)
|
|
state.note_child_thread("thread_child", "conv_child")
|
|
|
|
stable_event = {
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "thread_child",
|
|
"turnId": "turn_child",
|
|
# Stable item id — dedup must key on this.
|
|
"item": {"type": "agentMessage", "id": "msg_abc123", "text": "stable output"},
|
|
},
|
|
}
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Deliver the same stable-id child event twice with a shared state.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(_make_omnigent_handler(posted)),
|
|
) as client:
|
|
uc = codex_native_forwarder._SessionUsageCoalescer(client, "conv_parent")
|
|
for _ in range(2):
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
event=stable_event,
|
|
usage_coalescer=uc,
|
|
expected_thread_id="thread_parent",
|
|
forwarder_state=state,
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
child_posts = _transcript_posts(posted, "conv_child")
|
|
# Exactly one post: the stable id is claimed on first delivery and
|
|
# claim_item_key returns False on the second, preventing a duplicate.
|
|
assert len(child_posts) == 1, (
|
|
f"Expected 1 transcript post for stable-id item; "
|
|
f"got {len(child_posts)}. Stable-id items are not deduped correctly."
|
|
)
|
|
assert child_posts[0]["data"]["item_data"]["content"][0]["text"] == "stable output"
|
|
|
|
|
|
def test_forwarder_child_thread_started_does_not_rotate_parent_session(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
A ``thread/started`` event from a Codex AgentControl child does not
|
|
rotate the parent Omnigent session.
|
|
|
|
Native ``/clear`` starts a new top-level thread and must rotate.
|
|
Child threads also emit ``thread/started`` when they begin — those
|
|
events carry ``source.subAgent.thread_spawn`` and must be ignored
|
|
by the rotation check, otherwise the parent's Omnigent session would be
|
|
replaced every time a child starts.
|
|
|
|
The test fails if ``_maybe_rotate_session_on_thread_started`` returns
|
|
``True`` for a child ``thread/started`` event.
|
|
"""
|
|
codex_write_bridge_state = write_bridge_state # noqa: F841 — alias for clarity.
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_parent",
|
|
socket_path=str(tmp_path / "sock"),
|
|
thread_id="thread_parent",
|
|
codex_home=str(tmp_path / "home"),
|
|
),
|
|
)
|
|
|
|
child_thread_started_event: dict[str, Any] = {
|
|
"method": "thread/started",
|
|
"params": {
|
|
"thread": {
|
|
"id": "thread_child",
|
|
"source": {"subAgent": {"thread_spawn": {"parent_thread_id": "thread_parent"}}},
|
|
}
|
|
},
|
|
}
|
|
ap_posts: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
async def run() -> bool:
|
|
"""
|
|
Drive the child thread-started event through the rotation check.
|
|
|
|
:returns: Whether a session rotation occurred.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(_make_omnigent_handler(ap_posts)),
|
|
) as ap_client:
|
|
target = codex_native_forwarder._ForwarderTarget(
|
|
session_id="conv_parent",
|
|
thread_id="thread_parent",
|
|
delta_coalescer=codex_native_forwarder._OutputTextDeltaCoalescer(
|
|
ap_client, "conv_parent"
|
|
),
|
|
usage_coalescer=codex_native_forwarder._SessionUsageCoalescer(
|
|
ap_client, "conv_parent"
|
|
),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
)
|
|
return await codex_native_forwarder._maybe_rotate_session_on_thread_started(
|
|
ap_client=ap_client,
|
|
target=target,
|
|
bridge_dir=tmp_path,
|
|
app_server_url=str(tmp_path / "sock"),
|
|
event=child_thread_started_event,
|
|
)
|
|
|
|
rotated = asyncio.run(run())
|
|
assert rotated is False, (
|
|
"Expected child thread/started to NOT rotate the parent session; "
|
|
"it rotated. _thread_started_is_subagent guard is missing or broken."
|
|
)
|
|
# No Omnigent calls should have been made during rotation detection.
|
|
assert ap_posts == []
|
|
|
|
|
|
def test_forwarder_routes_live_child_items_to_child_session(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Live ``item/completed`` events for a known child thread are routed
|
|
to the child Omnigent session, not the parent.
|
|
|
|
Once a child thread is registered in ``forwarder_state.subagents_by_thread``,
|
|
the routing layer must direct any event carrying the child's ``threadId``
|
|
to the child session. The test fails if live child items land on the
|
|
parent session or are dropped silently.
|
|
"""
|
|
posted: list[tuple[str, dict[str, Any]]] = []
|
|
state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_parent",
|
|
)
|
|
state.note_child_thread("thread_child", "conv_child")
|
|
|
|
live_child_event = _child_agent_message_event(text="live child message")
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Deliver one live child event through _handle_event with a registered child.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(_make_omnigent_handler(posted)),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
event=live_child_event,
|
|
usage_coalescer=codex_native_forwarder._SessionUsageCoalescer(
|
|
client, "conv_parent"
|
|
),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
expected_thread_id="thread_parent",
|
|
forwarder_state=state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
child_posts = _transcript_posts(posted, "conv_child")
|
|
parent_posts = _transcript_posts(posted, "conv_parent")
|
|
|
|
assert len(child_posts) == 1, (
|
|
f"Expected 1 transcript post to conv_child; got {len(child_posts)}. "
|
|
"Live child items are not being routed to the child session."
|
|
)
|
|
assert child_posts[0]["data"]["item_data"]["content"][0]["text"] == "live child message"
|
|
assert parent_posts == [], (
|
|
f"Got {len(parent_posts)} transcript post(s) to conv_parent; "
|
|
"child items must not route to the parent session."
|
|
)
|
|
|
|
|
|
def test_forwarder_collab_item_started_registers_child_before_completed(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
``item/started`` for a collab-agent spawn registers the child session
|
|
so live child events can be routed immediately.
|
|
|
|
Codex emits both ``item/started`` and ``item/completed`` for a
|
|
``collabAgentToolCall``. Registration at ``item/started`` lets the
|
|
forwarder route child events that arrive before ``item/completed``.
|
|
The test checks that after ``item/started`` is processed, the child
|
|
thread is already in ``forwarder_state.subagents_by_thread``.
|
|
"""
|
|
posted: list[tuple[str, dict[str, Any]]] = []
|
|
state = codex_native_forwarder._CodexForwarderState(
|
|
parent_session_id="conv_parent",
|
|
)
|
|
|
|
started_event = _collab_item_started_event()
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive the collab item/started through _handle_event.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(_make_omnigent_handler(posted)),
|
|
) as client:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
event=started_event,
|
|
usage_coalescer=codex_native_forwarder._SessionUsageCoalescer(
|
|
client, "conv_parent"
|
|
),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
expected_thread_id="thread_parent",
|
|
forwarder_state=state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
regs = _registration_posts(posted, "conv_parent")
|
|
assert len(regs) == 1, (
|
|
f"Expected 1 child registration after item/started; got {len(regs)}. "
|
|
"collab-agent children must be registered at item/started."
|
|
)
|
|
assert state.session_for_child_thread("thread_child") == "conv_child", (
|
|
"Expected thread_child to be registered in forwarder_state after "
|
|
"item/started, but it was not found."
|
|
)
|
|
|
|
|
|
def test_completed_item_key_is_total_never_empty() -> None:
|
|
"""
|
|
``_completed_item_key`` returns a non-empty string for every item shape.
|
|
|
|
The dedup key must be total: if it returned ``None`` or an empty string
|
|
for any item, that item would bypass the dedup gate and could be written
|
|
twice. This test covers three shapes:
|
|
- item with a stable ``id`` (normal case)
|
|
- item missing ``id`` (positional fallback via ``peek_anon_item_key``)
|
|
- item missing ``id`` *and* ``turnId`` (worst-case envelope)
|
|
"""
|
|
state = codex_native_forwarder._CodexForwarderState()
|
|
|
|
# Case 1: stable item id — the normal production path.
|
|
key1, is_anon1 = codex_native_forwarder._completed_item_key(
|
|
{"threadId": "t", "turnId": "u"},
|
|
{"id": "item-abc", "type": "agentMessage"},
|
|
state,
|
|
)
|
|
assert isinstance(key1, str) and key1, (
|
|
f"Key for stable item id must be a non-empty string; got {key1!r}"
|
|
)
|
|
assert not is_anon1, "Stable-id item must not be flagged anonymous"
|
|
|
|
# Case 2: no item id — must produce a positional fallback key.
|
|
key2, is_anon2 = codex_native_forwarder._completed_item_key(
|
|
{"threadId": "t", "turnId": "u"},
|
|
{"type": "agentMessage"}, # no id
|
|
state,
|
|
)
|
|
assert isinstance(key2, str) and key2, (
|
|
f"Key for anonymous item must be a non-empty string; got {key2!r}"
|
|
)
|
|
assert is_anon2, "Anonymous item must be flagged is_anon=True"
|
|
|
|
# Case 3: no item id AND no turnId — must still produce a non-empty string.
|
|
key3, _ = codex_native_forwarder._completed_item_key(
|
|
{"threadId": "t"}, # no turnId
|
|
{"type": "agentMessage"}, # no id
|
|
state,
|
|
)
|
|
assert isinstance(key3, str) and key3, (
|
|
f"Key without turnId must be a non-empty string; got {key3!r}"
|
|
)
|
|
|
|
|
|
def test_completed_item_key_two_anon_items_same_turn_distinct() -> None:
|
|
"""
|
|
Two anonymous items in the same (thread, turn) get distinct dedup keys
|
|
when the counter is advanced between them.
|
|
|
|
The positional counter is peeked (not advanced) during key derivation.
|
|
Only after ``advance_anon_counter`` does the next peek return a new slot.
|
|
This test simulates the correct claim-and-advance cycle for two sequential
|
|
anonymous items in the same turn.
|
|
"""
|
|
state = codex_native_forwarder._CodexForwarderState()
|
|
params = {"threadId": "t", "turnId": "u"}
|
|
item = {"type": "agentMessage"} # no id
|
|
|
|
# First item: peek key, claim, advance.
|
|
key_a, _ = codex_native_forwarder._completed_item_key(params, item, state)
|
|
assert state.claim_item_key(key_a)
|
|
state.advance_anon_counter("t", "u")
|
|
|
|
# Second item: peek key after counter advanced — must be different.
|
|
key_b, _ = codex_native_forwarder._completed_item_key(params, item, state)
|
|
|
|
assert key_a != key_b, (
|
|
f"Two sequential anonymous items must get distinct keys after advancing "
|
|
f"the counter; both got {key_a!r}. advance_anon_counter is not working."
|
|
)
|
|
|
|
|
|
def test_completed_item_key_claimed_anon_slot_rejected_on_reclaim() -> None:
|
|
"""
|
|
Once an anonymous item key is claimed, a second claim for the same key
|
|
returns ``False``.
|
|
|
|
The anonymous counter is only advanced after a successful claim, so
|
|
peeking before advancing gives the same slot. This tests the dedup gate
|
|
directly: after claim+advance the slot anon-0 is in ``synced_item_keys``
|
|
and a re-claim must be rejected.
|
|
|
|
Note: anonymous-item dedup across replay vs live is only reliable when
|
|
both paths see the counter at the same value — which holds when they both
|
|
call ``_handle_completed_item`` sequentially on the same connection (the
|
|
counter advances after each successful claim). The primary dedup mechanism
|
|
for production items is the stable ``item.id`` path, not this fallback.
|
|
"""
|
|
state = codex_native_forwarder._CodexForwarderState()
|
|
|
|
# Simulate the first delivery: peek the key, claim it, advance the counter.
|
|
key_a, _ = codex_native_forwarder._completed_item_key(
|
|
{"threadId": "t", "turnId": "u"}, {"type": "agentMessage"}, state
|
|
)
|
|
assert state.claim_item_key(key_a)
|
|
state.advance_anon_counter("t", "u")
|
|
|
|
# anon-0 is now in synced_item_keys — a direct re-claim must be rejected.
|
|
assert not state.claim_item_key("t:u:anon-0"), (
|
|
"anon-0 must be rejected after the first claim; the slot is already in synced_item_keys."
|
|
)
|
|
|
|
|
|
def test_forwarder_resolves_child_thread_elicitation_on_child_session(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
A child-thread elicitation resolves on the child session, not the parent.
|
|
|
|
When a collab child thread raises an elicitation, the approval card is
|
|
published on the child Omnigent session (``route_session_id``). Codex's
|
|
``serverRequest/resolved`` must clear it on that same child session;
|
|
resolving on the parent leaves the child's card stuck for any web user
|
|
watching the child.
|
|
"""
|
|
fake_client = _FakeCodexAppServerClient()
|
|
hook_started = asyncio.Event()
|
|
posted_paths: list[str] = []
|
|
state = codex_native_forwarder._CodexForwarderState(parent_session_id="conv_parent")
|
|
state.note_child_thread("thread_child", "conv_child")
|
|
request_event = {
|
|
"id": 7,
|
|
"method": "mcpServer/elicitation/request",
|
|
"params": {
|
|
"threadId": "thread_child",
|
|
"turnId": "turn_child",
|
|
"serverName": "booking",
|
|
"mode": "form",
|
|
"message": "Pick a date",
|
|
"requestedSchema": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Hold the child hook open; record the session path other events post to.
|
|
|
|
:param request: HTTP request sent by the forwarder.
|
|
:returns: Omnigent event response for non-hook posts.
|
|
"""
|
|
if request.url.path.endswith("/hooks/codex-elicitation-request"):
|
|
hook_started.set()
|
|
await asyncio.Future() # pending approval — never resolves natively
|
|
posted_paths.append(request.url.path)
|
|
return httpx.Response(202, json={"queued": False})
|
|
|
|
async def run() -> None:
|
|
"""
|
|
Drive a child-thread elicitation request, then its resolution.
|
|
|
|
:returns: None.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
elicitation_tracker = _elicitation_tracker()
|
|
usage_coalescer = _usage_coalescer(client)
|
|
# Routes to the child thread → the approval card is published on
|
|
# conv_child (the hook POST blocks, simulating a pending prompt).
|
|
await asyncio.wait_for(
|
|
codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event=request_event,
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=state,
|
|
),
|
|
timeout=0.2,
|
|
)
|
|
await asyncio.wait_for(hook_started.wait(), timeout=1.0)
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_parent",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=usage_coalescer,
|
|
elicitation_tracker=elicitation_tracker,
|
|
event={
|
|
"method": "serverRequest/resolved",
|
|
"params": {"threadId": "thread_child", "requestId": 7},
|
|
},
|
|
codex_client=fake_client, # type: ignore[arg-type]
|
|
forwarder_state=state,
|
|
)
|
|
await elicitation_tracker.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
# The resolution must post to the CHILD session's events. With the old
|
|
# session_id=session_id (parent), it posts to conv_parent and the child's
|
|
# approval card never clears for a web user watching the child.
|
|
assert "/v1/sessions/conv_child/events" in posted_paths, (
|
|
f"resolution should target the child session; got {posted_paths}"
|
|
)
|
|
assert "/v1/sessions/conv_parent/events" not in posted_paths, (
|
|
f"resolution must not target the parent session; got {posted_paths}"
|
|
)
|
|
|
|
|
|
def _write_source_rollout(*, codex_home: Path, thread_id: str, source_cwd: str) -> Path:
|
|
"""
|
|
Write a realistic source Codex rollout for clone tests.
|
|
|
|
Builds the on-disk shape Codex produces: a date-partitioned
|
|
``sessions/YYYY/MM/DD/rollout-<ts>-<thread_id>.jsonl`` whose first
|
|
line is ``session_meta`` (carrying the thread ``id`` and ``cwd``),
|
|
followed by a ``turn_context`` (structural ``cwd``) and two
|
|
*historical* records that mention *source_cwd* inside their bodies —
|
|
a developer message and a function_call_output. The historical
|
|
mentions exist to prove the clone leaves them untouched.
|
|
|
|
:param codex_home: The ``CODEX_HOME`` to write under, e.g.
|
|
``Path("/tmp/.../codex-home")``.
|
|
:param thread_id: Thread id / rollout stem, e.g. ``"019e96aa-...."``.
|
|
:param source_cwd: Working directory recorded in the source, e.g.
|
|
``"/repo/worktree-a"``.
|
|
:returns: Path to the written rollout.
|
|
"""
|
|
rollout_dir = codex_home / "sessions" / "2026" / "06" / "05"
|
|
rollout_dir.mkdir(parents=True, exist_ok=True)
|
|
rollout = rollout_dir / f"rollout-2026-06-05T15-23-07-{thread_id}.jsonl"
|
|
records = [
|
|
{
|
|
"timestamp": "2026-06-05T07:23:34.547Z",
|
|
"type": "session_meta",
|
|
"payload": {"id": thread_id, "cwd": source_cwd, "originator": "test"},
|
|
},
|
|
{
|
|
"timestamp": "2026-06-05T07:23:34.549Z",
|
|
"type": "turn_context",
|
|
"payload": {"turn_id": "turn_1", "cwd": source_cwd, "approval_policy": "on-request"},
|
|
},
|
|
{
|
|
"timestamp": "2026-06-05T07:23:34.554Z",
|
|
"type": "response_item",
|
|
"payload": {
|
|
"type": "message",
|
|
"role": "developer",
|
|
"content": [{"type": "input_text", "text": f"<env> cwd: {source_cwd} </env>"}],
|
|
},
|
|
},
|
|
{
|
|
"timestamp": "2026-06-05T07:23:40.000Z",
|
|
"type": "response_item",
|
|
"payload": {
|
|
"type": "function_call_output",
|
|
"output": f"{source_cwd}/tests/foo.py:42: AssertionError",
|
|
},
|
|
},
|
|
]
|
|
with rollout.open("w", encoding="utf-8") as handle:
|
|
for record in records:
|
|
handle.write(json.dumps(record) + "\n")
|
|
return rollout
|
|
|
|
|
|
def test_clone_codex_rollout_rewrites_id_and_structural_cwd_into_clone_home(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
Cloning rewrites only the thread id and structural cwd in the clone home.
|
|
|
|
Proves the surgical rewrite: ``session_meta.id`` becomes the target
|
|
id, ``session_meta.cwd`` and ``turn_context.cwd`` become the clone
|
|
workspace, the rollout lands in the CLONE's CODEX_HOME under the
|
|
target id, and record order is preserved.
|
|
"""
|
|
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id, codex_home_for_bridge_dir
|
|
|
|
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "bridges")
|
|
source_thread = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
target_thread = "019eaa11-1111-7222-8333-444455556666"
|
|
source_cwd = "/repo/worktree-source"
|
|
clone_cwd = tmp_path / "worktree-clone"
|
|
|
|
source_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_source"))
|
|
_write_source_rollout(codex_home=source_home, thread_id=source_thread, source_cwd=source_cwd)
|
|
clone_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_clone"))
|
|
|
|
result = codex_native._clone_codex_rollout(
|
|
source_session_id="conv_source",
|
|
source_thread_id=source_thread,
|
|
target_thread_id=target_thread,
|
|
clone_codex_home=clone_home,
|
|
clone_workspace=clone_cwd,
|
|
)
|
|
|
|
assert result is not None
|
|
# Lands in the CLONE's home, under the target id, preserving the
|
|
# source's date-partitioned layout.
|
|
assert clone_home in result.parents, f"{result} not under clone home {clone_home}"
|
|
assert source_home not in result.parents
|
|
assert result.name == f"rollout-2026-06-05T15-23-07-{target_thread}.jsonl"
|
|
|
|
records = [json.loads(line) for line in result.read_text().splitlines()]
|
|
# Order preserved: session_meta, turn_context, message, function_call_output.
|
|
assert [r["type"] for r in records] == [
|
|
"session_meta",
|
|
"turn_context",
|
|
"response_item",
|
|
"response_item",
|
|
]
|
|
meta = records[0]["payload"]
|
|
assert meta["id"] == target_thread, "session_meta.id must be rewritten to the target thread id"
|
|
assert meta["cwd"] == str(clone_cwd), (
|
|
"session_meta.cwd must be rewritten to the clone workspace"
|
|
)
|
|
assert records[1]["payload"]["cwd"] == str(clone_cwd), "turn_context.cwd must be rewritten"
|
|
|
|
|
|
def test_clone_codex_rollout_leaves_historical_cwd_untouched(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
Cloning never rewrites cwd inside message / tool-output bodies.
|
|
|
|
The historical ``cwd`` mentions (a developer message and a
|
|
function_call_output) record what actually happened in the source
|
|
workspace; rewriting them would fabricate history. Only the two
|
|
structural fields move.
|
|
"""
|
|
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id, codex_home_for_bridge_dir
|
|
|
|
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "bridges")
|
|
source_thread = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
target_thread = "019eaa11-1111-7222-8333-444455556666"
|
|
source_cwd = "/repo/worktree-source"
|
|
clone_cwd = tmp_path / "worktree-clone"
|
|
|
|
source_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_source"))
|
|
source_rollout = _write_source_rollout(
|
|
codex_home=source_home, thread_id=source_thread, source_cwd=source_cwd
|
|
)
|
|
clone_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_clone"))
|
|
|
|
result = codex_native._clone_codex_rollout(
|
|
source_session_id="conv_source",
|
|
source_thread_id=source_thread,
|
|
target_thread_id=target_thread,
|
|
clone_codex_home=clone_home,
|
|
clone_workspace=clone_cwd,
|
|
)
|
|
|
|
assert result is not None
|
|
records = [json.loads(line) for line in result.read_text().splitlines()]
|
|
developer_text = records[2]["payload"]["content"][0]["text"]
|
|
tool_output = records[3]["payload"]["output"]
|
|
# Both historical bodies still reference the SOURCE cwd verbatim.
|
|
assert source_cwd in developer_text, "historical message body must be preserved"
|
|
assert source_cwd in tool_output, "historical tool output must be preserved"
|
|
assert str(clone_cwd) not in developer_text, "clone cwd must not leak into message history"
|
|
assert str(clone_cwd) not in tool_output, "clone cwd must not leak into tool output"
|
|
# Historical lines must be copied byte-for-byte — not re-serialized —
|
|
# so whitespace / key order / escaping are preserved exactly. The
|
|
# source fixture writes records with default (spaced) JSON separators;
|
|
# if the cloner re-serialized them they would lose those spaces and
|
|
# this byte comparison would fail. Indices 2 and 3 are the developer
|
|
# message and the function_call_output (both historical).
|
|
source_lines = source_rollout.read_text().splitlines()
|
|
clone_lines = result.read_text().splitlines()
|
|
assert clone_lines[2] == source_lines[2], "historical message line must be byte-identical"
|
|
assert clone_lines[3] == source_lines[3], "historical tool-output line must be byte-identical"
|
|
|
|
|
|
def test_clone_codex_rollout_leaves_source_untouched(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The source rollout is read-only — cloning never mutates it."""
|
|
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id, codex_home_for_bridge_dir
|
|
|
|
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "bridges")
|
|
source_thread = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
source_cwd = "/repo/worktree-source"
|
|
|
|
source_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_source"))
|
|
source_rollout = _write_source_rollout(
|
|
codex_home=source_home, thread_id=source_thread, source_cwd=source_cwd
|
|
)
|
|
before = source_rollout.read_bytes()
|
|
clone_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_clone"))
|
|
|
|
codex_native._clone_codex_rollout(
|
|
source_session_id="conv_source",
|
|
source_thread_id=source_thread,
|
|
target_thread_id="019eaa11-1111-7222-8333-444455556666",
|
|
clone_codex_home=clone_home,
|
|
clone_workspace=tmp_path / "clone",
|
|
)
|
|
|
|
assert source_rollout.read_bytes() == before, (
|
|
"source rollout must be byte-identical after clone"
|
|
)
|
|
|
|
|
|
def test_clone_codex_rollout_returns_none_when_source_missing(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
Cloning returns None when the source rollout isn't on this host.
|
|
|
|
The caller treats None as "launch fresh" — a fork to a host without
|
|
the source rollout must not strand the clone pointing at a missing
|
|
thread.
|
|
"""
|
|
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id, codex_home_for_bridge_dir
|
|
|
|
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "bridges")
|
|
clone_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_clone"))
|
|
|
|
result = codex_native._clone_codex_rollout(
|
|
source_session_id="conv_missing",
|
|
source_thread_id="019e96aa-0be2-7343-8d3b-6f914d60936b",
|
|
target_thread_id="019eaa11-1111-7222-8333-444455556666",
|
|
clone_codex_home=clone_home,
|
|
clone_workspace=tmp_path / "clone",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_clone_codex_rollout_returns_none_for_unsafe_target_id(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
An unsafe target id is rejected before any filesystem write.
|
|
|
|
Guards against path traversal via the minted id being interpolated
|
|
into the rollout filename.
|
|
"""
|
|
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id, codex_home_for_bridge_dir
|
|
|
|
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "bridges")
|
|
source_thread = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
source_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_source"))
|
|
_write_source_rollout(codex_home=source_home, thread_id=source_thread, source_cwd="/repo/src")
|
|
clone_home = codex_home_for_bridge_dir(bridge_dir_for_bridge_id("conv_clone"))
|
|
|
|
result = codex_native._clone_codex_rollout(
|
|
source_session_id="conv_source",
|
|
source_thread_id=source_thread,
|
|
target_thread_id="../../etc/passwd",
|
|
clone_codex_home=clone_home,
|
|
clone_workspace=tmp_path / "clone",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_local_codex_resume_rollout_synthesizes_omnigent_history(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Cross-machine Codex resume rebuilds a local rollout from Omnigent history.
|
|
|
|
The server can know the Omnigent conversation and Codex thread id while the
|
|
current host has no ``$CODEX_HOME/sessions/.../rollout-*-<thread>.jsonl``.
|
|
This helper must fetch committed Omnigent items, follow pagination, and write
|
|
the response items before ``codex resume <thread>`` launches. If it only
|
|
checked for local rollout state, this test would leave no file to read.
|
|
|
|
:param tmp_path: Temporary directory for isolated ``CODEX_HOME`` and cwd.
|
|
"""
|
|
thread_id = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
codex_home = tmp_path / "codex-home"
|
|
requested_urls: list[str] = []
|
|
first_page = [
|
|
{
|
|
"id": "msg_user_1",
|
|
"response_id": "codex_turn_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "open TODO.md"}],
|
|
},
|
|
{
|
|
"id": "fc_shell_1",
|
|
"response_id": "codex_turn_1",
|
|
"type": "function_call",
|
|
"name": "shell",
|
|
"arguments": '{"command":"cat TODO.md"}',
|
|
"call_id": "call_shell_1",
|
|
},
|
|
]
|
|
second_page = [
|
|
{
|
|
"id": "fco_shell_1",
|
|
"response_id": "codex_turn_1",
|
|
"type": "function_call_output",
|
|
"call_id": "call_shell_1",
|
|
"output": "contents",
|
|
},
|
|
{
|
|
"id": "msg_assistant_1",
|
|
"response_id": "codex_turn_1",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "TODO.md says contents"}],
|
|
},
|
|
{
|
|
"id": "msg_user_interrupted",
|
|
"response_id": "codex_turn_interrupted",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "apply risky change"}],
|
|
},
|
|
{
|
|
"id": "msg_assistant_interrupted",
|
|
"response_id": "codex_turn_interrupted",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"interrupted": True,
|
|
"content": [{"type": "output_text", "text": "partially applied"}],
|
|
},
|
|
]
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Serve two chronological item pages.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Mock Omnigent response.
|
|
"""
|
|
requested_urls.append(str(request.url))
|
|
assert request.url.path == "/v1/sessions/conv_codex/items"
|
|
after = request.url.params.get("after")
|
|
if after is None:
|
|
return httpx.Response(
|
|
200,
|
|
json={"data": first_page, "has_more": True, "last_id": "fc_shell_1"},
|
|
)
|
|
assert after == "fc_shell_1"
|
|
return httpx.Response(200, json={"data": second_page, "has_more": False})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
rollout = await codex_native._ensure_local_codex_resume_rollout(
|
|
client,
|
|
session_id="conv_codex",
|
|
external_session_id=thread_id,
|
|
codex_home=codex_home,
|
|
workspace=workspace.resolve(),
|
|
model_provider="omnigent_databricks",
|
|
codex_path=None,
|
|
)
|
|
|
|
assert rollout is not None
|
|
assert codex_home in rollout.parents
|
|
assert rollout.name.endswith(f"-{thread_id}.jsonl")
|
|
records = [
|
|
json.loads(line)
|
|
for line in rollout.read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
assert [record["type"] for record in records] == [
|
|
"session_meta",
|
|
"turn_context",
|
|
"response_item",
|
|
"event_msg",
|
|
"response_item",
|
|
"response_item",
|
|
"response_item",
|
|
"event_msg",
|
|
]
|
|
assert records[0]["payload"]["id"] == thread_id
|
|
assert records[0]["payload"]["cwd"] == str(workspace.resolve())
|
|
# codex >= 0.133 refuses to parse a rollout whose session_meta lacks
|
|
# timestamp/cli_version, and its thread-store backfill breaks resume
|
|
# when model_provider is absent (verified against codex 0.136.0).
|
|
assert records[0]["payload"]["timestamp"] == records[0]["timestamp"]
|
|
assert records[0]["payload"]["cli_version"] == "0.0.0"
|
|
assert records[0]["payload"]["model_provider"] == "omnigent_databricks"
|
|
assert records[1]["payload"]["turn_id"] == "turn_1"
|
|
assert records[1]["payload"]["cwd"] == str(workspace.resolve())
|
|
assert records[2]["payload"] == {
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "open TODO.md"}],
|
|
"id": "msg_user_1",
|
|
}
|
|
# Visible-turn reconstruction reads event_msg mirrors, not
|
|
# response_item history: without them codex resumes an empty thread.
|
|
assert records[3]["payload"] == {
|
|
"type": "user_message",
|
|
"message": "open TODO.md",
|
|
"images": [],
|
|
"local_images": [],
|
|
"text_elements": [],
|
|
}
|
|
assert records[4]["payload"] == {
|
|
"type": "function_call",
|
|
"name": "shell",
|
|
"arguments": '{"command":"cat TODO.md"}',
|
|
"call_id": "call_shell_1",
|
|
"id": "fc_shell_1",
|
|
}
|
|
assert records[5]["payload"] == {
|
|
"type": "function_call_output",
|
|
"call_id": "call_shell_1",
|
|
"output": "contents",
|
|
"id": "fco_shell_1",
|
|
}
|
|
assert records[6]["payload"] == {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "TODO.md says contents"}],
|
|
"id": "msg_assistant_1",
|
|
}
|
|
assert records[7]["payload"] == {
|
|
"type": "agent_message",
|
|
"message": "TODO.md says contents",
|
|
"phase": "final_answer",
|
|
"memory_citation": None,
|
|
}
|
|
restored_payloads = [
|
|
record["payload"] for record in records if record["type"] == "response_item"
|
|
]
|
|
assert all(payload.get("id") != "msg_user_interrupted" for payload in restored_payloads)
|
|
assert all(payload.get("id") != "msg_assistant_interrupted" for payload in restored_payloads)
|
|
restored_text = json.dumps(restored_payloads)
|
|
assert "apply risky change" not in restored_text
|
|
assert "partially applied" not in restored_text
|
|
assert any("after=fc_shell_1" in url for url in requested_urls), (
|
|
f"history pagination was not followed; requests were {requested_urls!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_local_codex_resume_rollout_preserves_existing_rollout(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex cold resume does not rewrite an existing local rollout.
|
|
|
|
A local rollout is Codex runtime state, not a cache. If it already
|
|
exists, the helper must return it untouched instead of fetching AP
|
|
history and rewriting the file.
|
|
|
|
:param tmp_path: Temporary directory for isolated ``CODEX_HOME``.
|
|
"""
|
|
thread_id = "019e96aa-0be2-7343-8d3b-6f914d60936b"
|
|
codex_home = tmp_path / "codex-home"
|
|
existing = _write_source_rollout(
|
|
codex_home=codex_home,
|
|
thread_id=thread_id,
|
|
source_cwd="/stale/cwd",
|
|
)
|
|
before = existing.read_bytes()
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Fail if Omnigent history is fetched despite a local rollout.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Mock Omnigent response.
|
|
"""
|
|
del request
|
|
raise AssertionError("existing rollout should avoid Omnigent history fetch")
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
rollout = await codex_native._ensure_local_codex_resume_rollout(
|
|
client,
|
|
session_id="conv_codex",
|
|
external_session_id=thread_id,
|
|
codex_home=codex_home,
|
|
workspace=(tmp_path / "workspace").resolve(),
|
|
model_provider="omnigent_databricks",
|
|
codex_path=None,
|
|
)
|
|
|
|
assert rollout == existing
|
|
assert existing.read_bytes() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("bad_item", "message"),
|
|
[
|
|
(
|
|
{
|
|
"id": "fc_bad",
|
|
"response_id": "codex_turn_1",
|
|
"type": "function_call",
|
|
"name": "shell",
|
|
"call_id": "call_shell_1",
|
|
},
|
|
"function_call 'fc_bad' has non-string arguments",
|
|
),
|
|
(
|
|
{
|
|
"id": "fco_bad",
|
|
"response_id": "codex_turn_1",
|
|
"type": "function_call_output",
|
|
"call_id": "call_shell_1",
|
|
},
|
|
"function_call_output 'fco_bad' has non-string output",
|
|
),
|
|
],
|
|
)
|
|
async def test_ensure_local_codex_resume_rollout_rejects_malformed_tool_history(
|
|
tmp_path: Path,
|
|
bad_item: dict[str, Any],
|
|
message: str,
|
|
) -> None:
|
|
"""
|
|
Codex rollout synthesis fails loudly for corrupt Omnigent tool history.
|
|
|
|
Tool call ``arguments`` and tool ``output`` are required Omnigent string
|
|
fields. Missing values must not be invented as ``{}`` or ``""``,
|
|
because Codex would then resume a tool history that never happened.
|
|
|
|
:param tmp_path: Temporary directory for isolated ``CODEX_HOME``.
|
|
:param bad_item: Malformed Omnigent item to serve.
|
|
:param message: Expected diagnostic substring.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Serve malformed Omnigent history.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Mock Omnigent item page.
|
|
"""
|
|
assert request.url.path == "/v1/sessions/conv_codex/items"
|
|
return httpx.Response(200, json={"data": [bad_item], "has_more": False})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
with pytest.raises(click.ClickException, match=message):
|
|
await codex_native._ensure_local_codex_resume_rollout(
|
|
client,
|
|
session_id="conv_codex",
|
|
external_session_id="019e96aa-0be2-7343-8d3b-6f914d60936b",
|
|
codex_home=tmp_path / "codex-home",
|
|
workspace=(tmp_path / "workspace").resolve(),
|
|
model_provider="omnigent_databricks",
|
|
codex_path=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_local_codex_resume_rollout_rejects_unsafe_thread_id(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex cold resume fails loudly for an unsafe persisted thread id.
|
|
|
|
The caller relies on this helper to make ``codex resume <thread>`` viable.
|
|
Returning ``None`` would silently launch Codex without the guaranteed
|
|
rollout this path is responsible for preparing.
|
|
|
|
:param tmp_path: Temporary directory for isolated ``CODEX_HOME``.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""
|
|
Fail if Omnigent history is fetched for an unsafe thread id.
|
|
|
|
:param request: Incoming mock HTTP request.
|
|
:returns: Never returns.
|
|
"""
|
|
del request
|
|
raise AssertionError("unsafe thread id should be rejected before Omnigent fetch")
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
with pytest.raises(click.ClickException, match="not a safe Codex rollout id"):
|
|
await codex_native._ensure_local_codex_resume_rollout(
|
|
client,
|
|
session_id="conv_codex",
|
|
external_session_id="../../etc/passwd",
|
|
codex_home=tmp_path / "codex-home",
|
|
workspace=(tmp_path / "workspace").resolve(),
|
|
model_provider="omnigent_databricks",
|
|
codex_path=None,
|
|
)
|
|
|
|
|
|
def test_mint_codex_thread_id_is_uuidv7() -> None:
|
|
"""
|
|
Minted thread ids are valid UUIDv7 strings.
|
|
|
|
Codex thread ids are UUIDv7; the clone resumes via
|
|
``codex resume <minted_id>``, so the format must match what Codex
|
|
accepts (verified end-to-end by the opt-in fork e2e).
|
|
"""
|
|
import uuid as _uuid
|
|
|
|
minted = codex_native._mint_codex_thread_id()
|
|
parsed = _uuid.UUID(minted)
|
|
assert parsed.version == 7
|
|
assert codex_native._CODEX_THREAD_ID_RE.fullmatch(minted)
|
|
|
|
|
|
def test_command_execution_appends_sandbox_bypass_guidance_on_namespace_error() -> None:
|
|
"""A codex shell command that fails because codex's own command sandbox
|
|
cannot start (no unprivileged user namespaces in a hardened container) gets
|
|
actionable recovery guidance appended, instead of surfacing only the opaque
|
|
``bwrap: No permissions to create new namespace`` output (issue #657)."""
|
|
item = {
|
|
"command": "/bin/zsh -lc 'echo hi'",
|
|
"aggregatedOutput": (
|
|
"bwrap: No permissions to create new namespace, likely because the "
|
|
"kernel does not allow non-privileged user namespaces.\n"
|
|
),
|
|
"exitCode": 1,
|
|
}
|
|
tool_call = codex_native_forwarder._command_execution_tool_call("call_1", item)
|
|
assert tool_call is not None
|
|
# The raw bwrap output and the exit code are preserved verbatim...
|
|
assert "No permissions to create new namespace" in tool_call.output
|
|
assert "[exit code: 1]" in tool_call.output
|
|
# ...with actionable recovery guidance appended (the "Full access" preset
|
|
# and the config sandbox_mode workaround).
|
|
assert "Full access" in tool_call.output
|
|
assert "danger-full-access" in tool_call.output
|
|
|
|
|
|
def test_command_execution_leaves_normal_output_untouched() -> None:
|
|
"""A successful command keeps its output verbatim — the guidance only fires
|
|
on the sandbox-namespace failure, never on ordinary output (issue #657)."""
|
|
item = {"command": "pwd", "aggregatedOutput": "/repo\n", "exitCode": 0}
|
|
tool_call = codex_native_forwarder._command_execution_tool_call("call_1", item)
|
|
assert tool_call is not None
|
|
assert tool_call.output == "/repo\n"
|
|
assert "Full access" not in tool_call.output
|
|
assert "danger-full-access" not in tool_call.output
|
|
|
|
|
|
def test_forwarder_mirrors_codex_context_compaction(tmp_path: Path) -> None:
|
|
"""
|
|
Codex context-compaction surfaces as external_compaction_status (#1255).
|
|
|
|
A ``contextCompaction`` item/started shows the spinner (in_progress) and
|
|
the ``thread/compacted`` notification clears it (completed). Both signals
|
|
were previously dropped, so the web UI never indicated Codex compacted —
|
|
increasingly relevant with GPT-5.1-Codex-Max auto-compaction.
|
|
"""
|
|
write_bridge_state(
|
|
tmp_path,
|
|
CodexNativeBridgeState(
|
|
session_id="conv_123",
|
|
socket_path=str(tmp_path / "app-server.sock"),
|
|
thread_id="thread_123",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_123",
|
|
),
|
|
)
|
|
forwarder_state = codex_native_forwarder._CodexForwarderState()
|
|
posted: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""Record /events bodies; 202 for events, 200 otherwise."""
|
|
if request.url.path.endswith("/events"):
|
|
posted.append(json.loads(request.content))
|
|
return httpx.Response(202, json={"queued": False})
|
|
return httpx.Response(200, json={})
|
|
|
|
async def run() -> None:
|
|
"""Drive a compaction start item then the completion notification."""
|
|
async with httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:8000",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
for event in [
|
|
{
|
|
"method": "item/started",
|
|
"params": {
|
|
"threadId": "thread_123",
|
|
"turnId": "turn_123",
|
|
"item": {"type": "contextCompaction", "id": "item_c"},
|
|
},
|
|
},
|
|
{"method": "thread/compacted", "params": {"threadId": "thread_123"}},
|
|
]:
|
|
await codex_native_forwarder._handle_event(
|
|
client,
|
|
session_id="conv_123",
|
|
bridge_dir=tmp_path,
|
|
usage_coalescer=_usage_coalescer(client),
|
|
elicitation_tracker=_elicitation_tracker(),
|
|
event=event,
|
|
forwarder_state=forwarder_state,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
compaction = [p for p in posted if p.get("type") == "external_compaction_status"]
|
|
assert compaction == [
|
|
{"type": "external_compaction_status", "data": {"status": "in_progress"}},
|
|
{"type": "external_compaction_status", "data": {"status": "completed"}},
|
|
]
|
|
|
|
|
|
def test_rollout_records_includes_compacted_entry_from_compaction_item() -> None:
|
|
"""Compaction items emit a Compacted rollout record and discard prior items."""
|
|
items: list[dict[str, Any]] = [
|
|
{
|
|
"id": "msg_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "hello"}],
|
|
"response_id": "resp_1",
|
|
},
|
|
{
|
|
"id": "msg_2",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "hi there"}],
|
|
"response_id": "resp_1",
|
|
},
|
|
{
|
|
"id": "cmp_1",
|
|
"type": "compaction",
|
|
"summary": "compaction summary",
|
|
"compacted_messages": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "hello"}],
|
|
},
|
|
{
|
|
"type": "compaction",
|
|
"encrypted_content": "gAAAA_encrypted",
|
|
},
|
|
],
|
|
"window_id": 2,
|
|
"response_id": "compact_1",
|
|
},
|
|
{
|
|
"id": "msg_3",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "after compaction"}],
|
|
"response_id": "resp_2",
|
|
},
|
|
]
|
|
records = codex_native._codex_rollout_records_from_session_items(
|
|
items,
|
|
session_id="conv_test",
|
|
external_session_id="019f-thread",
|
|
cwd=Path("/tmp/test"),
|
|
model_provider="openai",
|
|
cli_version="0.140.0",
|
|
)
|
|
# Should have: session_meta, compacted, turn_context, response_item (msg_3), event_msg
|
|
types = [r["type"] for r in records]
|
|
assert "session_meta" in types
|
|
assert "compacted" in types
|
|
# Pre-compaction response_items should be gone
|
|
pre_compaction_items = [
|
|
r
|
|
for r in records
|
|
if r["type"] == "response_item"
|
|
and r["payload"].get("content") == [{"type": "input_text", "text": "hello"}]
|
|
]
|
|
assert len(pre_compaction_items) == 0, "Pre-compaction items should be discarded"
|
|
# The compacted record should have replacement_history and window_id
|
|
compacted_records = [r for r in records if r["type"] == "compacted"]
|
|
assert len(compacted_records) == 1
|
|
cp = compacted_records[0]["payload"]
|
|
assert cp["window_id"] == 2
|
|
assert len(cp["replacement_history"]) == 2
|
|
assert cp["replacement_history"][1]["encrypted_content"] == "gAAAA_encrypted"
|
|
# Post-compaction message should still be present
|
|
post_items = [
|
|
r
|
|
for r in records
|
|
if r["type"] == "response_item"
|
|
and r["payload"].get("content") == [{"type": "input_text", "text": "after compaction"}]
|
|
]
|
|
assert len(post_items) == 1
|
|
|
|
|
|
def test_rollout_records_without_compaction_item_has_no_compacted_entry() -> None:
|
|
"""Sessions without compaction produce no Compacted rollout record."""
|
|
items: list[dict[str, Any]] = [
|
|
{
|
|
"id": "msg_1",
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "hello"}],
|
|
"response_id": "resp_1",
|
|
},
|
|
]
|
|
records = codex_native._codex_rollout_records_from_session_items(
|
|
items,
|
|
session_id="conv_test",
|
|
external_session_id="019f-thread",
|
|
cwd=Path("/tmp/test"),
|
|
model_provider="openai",
|
|
cli_version="0.140.0",
|
|
)
|
|
types = [r["type"] for r in records]
|
|
assert "compacted" not in types
|