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

3043 lines
119 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fixtures for browser-driven e2e tests of the web SPA.
The suite spawns a real ``omnigent server --agent`` subprocess against
``examples/hello_world.yaml`` and drives the rendered SPA with
Playwright. The server is wired to a mock LLM server so the suite
runs deterministically without real credentials. The suite is excluded
from the default ``pytest`` run via ``--ignore=tests/e2e_ui`` in
``pyproject.toml`` and gated to ``workflow_dispatch`` in CI for now.
Local usage::
# one-time setup
uv sync --extra e2e-ui
uv run playwright install --with-deps chromium
# run against a freshly built SPA + spawned server
uv run pytest tests/e2e_ui -v
# iterate against an already-running server (dev hosts/ports need opt-in)
cd web && npm run dev &
omnigent server --agent examples/hello_world.yaml &
OMNIGENT_E2E_ALLOW_DEV_BASE_URL=1 \
uv run pytest tests/e2e_ui --ui-base-url http://127.0.0.1:5173
``omnigent server`` is documented at ``omnigent/cli.py:server``:
it spins up uvicorn with the Omnigent app and spawns an out-of-process
runner that reconnects over the WebSocket tunnel. The fixture passes
``--database-uri`` and ``--artifact-location`` pointing at the
pytest tmp dir so the test never touches the user's default
``sqlite:///omnigent.db`` / ``./artifacts``.
"""
from __future__ import annotations
import contextlib
import io
import json
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import tarfile
import textwrap
import time
from collections.abc import Generator, Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import filelock
import httpx
import pytest
from playwright.sync_api import Locator, Page, expect
from tests.codex_parity.helpers import ev_assistant_message, ev_completed, ev_response_created
from tests.codex_parity.sidecar_harness import (
CodexResponsesSidecar,
build_sidecar_bin,
start_codex_responses_sidecar,
)
from tests.e2e_ui.url_safety import DEV_PORTS, unsafe_ui_base_url_reason
_REPO_ROOT = Path(__file__).resolve().parents[2]
_ALLOW_DEV_BASE_URL_ENV = "OMNIGENT_E2E_ALLOW_DEV_BASE_URL"
_CODEX_GOAL_MIN_VERSION = (0, 139, 0)
_PUBLIC_LOOPBACK_HOST = "omnigent-e2e-public.test"
def open_right_rail(page: Page) -> None:
"""Expand the right "Workspace" rail if it is collapsed.
The rail defaults open, but its open-state is remembered per conversation,
so a session previously left collapsed lands shut. Idempotent: if the rail
is already open this just waits for it.
The toggle only renders once the rail has content (``hasRailContent``) and
on the desktop viewport these tests run at, so the generous timeout covers
the changed-files / terminals detection that gates the button.
:param page: Playwright page already navigated to a ``/c/{id}`` route.
:returns: None. Leaves the Workspace rail open.
"""
toggle = page.locator(
'button[aria-label="Expand right panel"], button[aria-label="Collapse right panel"]'
).first
expect(toggle).to_be_visible(timeout=60_000)
if toggle.get_attribute("aria-label") == "Expand right panel":
toggle.click()
expect(page.get_by_role("complementary", name="Workspace")).to_be_visible()
def switch_markdown_view_mode(page: Page, file_viewer: Locator, mode: str) -> None:
"""Switch a markdown FileViewer to Preview / Edit / Source via the toolbar.
Markdown files default to the rendered Preview pane; the three modes live
behind a single "View mode" dropdown in the toolbar. Open that dropdown and
pick the option. The menu renders in a portal (outside ``file_viewer``), so
the menuitem is queried off ``page``, not the viewer locator.
:param page: Playwright page with the file viewer open.
:param file_viewer: The visible ``[data-testid="file-viewer"]`` locator.
:param mode: One of ``"Preview"``, ``"Edit"``, ``"Source"``.
:returns: None.
"""
file_viewer.get_by_role("button", name=re.compile(r"^View mode")).first.click()
page.get_by_role("menuitem", name=mode, exact=True).click()
# Populated by ``live_server`` so test-scoped fixtures can access the
# server PID and runner id without changing ``live_server``'s return
# type (which other tests depend on).
_server_state: dict[str, int | str] = {}
_WEB_DIR = _REPO_ROOT / "web"
_BUILD_OUTPUT = _REPO_ROOT / "omnigent" / "server" / "static" / "web-ui"
# ``omnigent server --agent`` runs the spec through the strict
# validator at registration time (no shim defaults applied), so the
# YAML must carry an explicit ``executor`` block — otherwise the
# server rejects with ``executor.config.harness: required when
# executor.type is 'omnigent'``. The model name (gpt-4o-mini) is a plain
# (non-``databricks-``) name on purpose: the openai-agents harness then
# resolves no provider auth and falls back to ``OPENAI_BASE_URL`` (the
# in-process mock) rather than routing to the Databricks gateway, which
# would need real credentials CI does not have. A ``databricks-``-prefixed
# model forces Databricks DEFAULT-profile auth (see
# omnigent/runtime/workflow.py) and fails with DatabricksAuthError in CI.
_TEST_AGENT_YAML = """\
name: hello_world
prompt: You are a friendly assistant. Say hello and answer questions.
executor:
model: gpt-4o-mini
config:
harness: openai-agents
# Required for PUT /filesystem/{path} seeding in UI tests (e.g. markdown
# editor comments) — the runner returns 404 when os_env is absent.
os_env:
type: caller_process
cwd: .
sandbox:
type: none
"""
def _build_hello_world_bundle() -> bytes:
"""Build a gzipped tarball from ``_TEST_AGENT_YAML``.
Uses a non-``config.yaml`` archive name so the bundle routes
through the omnigent compat adapter (which translates
``executor.harness`` → ``executor.config.harness`` and sets
``executor.type: omnigent``). Using ``config.yaml`` would
go through the strict ``spec_version: 1`` parser which doesn't
accept the shorthand.
:returns: The ``.tar.gz`` bytes ready for multipart upload.
"""
import gzip
import io
import tarfile
buf = io.BytesIO()
with (
gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz,
tarfile.open(fileobj=gz, mode="w") as tar,
):
data = _TEST_AGENT_YAML.encode()
info = tarfile.TarInfo(name="hello_world.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
# Time budget for the server's /health endpoint to come up after
# spawn. ``serve`` does YAML parse + bundle materialization +
# DBOS init + uvicorn boot, all of which take a few seconds on a cold
# venv.
_HEALTH_TIMEOUT_S = 30.0
_HEALTH_POLL_INTERVAL_S = 0.5
# Switch-target built-ins for the Files-tab os_env-boundary test
# (test_switch_agent_files_tab.py). The in-place switch dialog lists
# BUILT-IN agents only (``session_id IS NULL`` — see
# ``switch_session_agent``), and built-ins can only be seeded at server
# startup via ``OMNIGENT_BUILTIN_AGENT_DIRS``, so ``live_server`` writes
# these two specs to disk and threads them through that env var. Both run
# the same openai-agents harness as ``hello_world`` (same provider family
# → the picker's ``forkSwitchPreservesHistory`` gate offers them); the
# ONLY difference is os_env presence, which is the variable under test —
# the runner 404s the environment resource when os_env is absent, hiding
# the web Files tab. The registered name is the spec file's stem.
_FILES_PROBE_NO_ENV_AGENT_NAME = "files_probe_noenv"
_FILES_PROBE_ENV_AGENT_NAME = "files_probe_env"
_FILES_PROBE_NO_ENV_AGENT_YAML = f"""\
name: {_FILES_PROBE_NO_ENV_AGENT_NAME}
prompt: You are a terse assistant with no filesystem.
executor:
model: gpt-4o-mini
config:
harness: openai-agents
"""
_FILES_PROBE_ENV_AGENT_YAML = f"""\
name: {_FILES_PROBE_ENV_AGENT_NAME}
prompt: You are a terse assistant with a filesystem.
executor:
model: gpt-4o-mini
config:
harness: openai-agents
os_env:
type: caller_process
cwd: .
sandbox:
type: none
"""
def pytest_addoption(parser: pytest.Parser) -> None:
"""Register UI-only CLI flags.
:param parser: The pytest option parser.
"""
parser.addoption(
"--ui-base-url",
default=None,
help=(
"Skip both the SPA build and the server spawn; point Playwright "
"at this URL instead. Refuses known dev hosts/ports unless "
f"{_ALLOW_DEV_BASE_URL_ENV}=1 is set."
),
)
parser.addoption(
"--ui-skip-build",
action="store_true",
default=False,
help=(
"Reuse whatever's already in omnigent/server/static/web-ui/ "
"instead of rebuilding. Fails if no build is present."
),
)
# Round-robin shard split for the e2e-ui CI matrix. We roll our own
# (rather than pull in pytest-shard / pytest-split) so the partition
# is a dependency-free strided slice -- see pytest_collection_modifyitems
# below for why striding beats hash-bucketing for wall-clock balance.
parser.addoption(
"--splits",
type=int,
default=None,
help="Total number of shards to split the UI suite into (CI matrix).",
)
parser.addoption(
"--group",
type=int,
default=None,
help="1-indexed shard this run executes (requires --splits).",
)
def pytest_configure(config: pytest.Config) -> None:
"""Fail fast on unsafe e2e-ui harness options.
:param config: Pytest config with repo and pytest-playwright options.
"""
base_url = config.getoption("--ui-base-url")
if base_url:
_validate_ui_base_url(base_url)
if os.environ.get("CI") and config.getoption("--headed", default=False):
raise pytest.UsageError(
"tests/e2e_ui must run headless in CI. Remove --headed; headed "
"browser windows are only allowed for local debugging."
)
@pytest.fixture(scope="session")
def browser_type_launch_args(
browser_type_launch_args: dict[str, Any],
pytestconfig: pytest.Config,
) -> dict[str, Any]:
"""Default UI e2e browser launches to headless, with CI enforcement."""
launch_args = {**browser_type_launch_args}
if os.environ.get("CI"):
launch_args["headless"] = True
elif not pytestconfig.getoption("--headed", default=False):
launch_args.setdefault("headless", True)
launch_args["args"] = [
*launch_args.get("args", []),
f"--host-resolver-rules=MAP {_PUBLIC_LOOPBACK_HOST} 127.0.0.1",
]
# The pinned Playwright Docker image (the visual-snapshot renderer, both in
# ui-snapshot.yml and the local regen script) runs as root, where Chromium
# refuses to start without --no-sandbox; --disable-dev-shm-usage avoids the
# container's small /dev/shm. Neither flag changes rasterized output, so a
# baseline stays identical to a sandboxed run. Env-gated so the unpinned
# e2e-ui runners (non-root) are unaffected.
if os.environ.get("OMNIGENT_PW_NO_SANDBOX"):
launch_args["args"] = [
*launch_args.get("args", []),
"--no-sandbox",
"--disable-dev-shm-usage",
]
return launch_args
@pytest.fixture
def browser_context_args(
browser_context_args: dict[str, Any],
) -> dict[str, Any]:
"""Return a new context options dict for each test.
pytest-playwright already creates a fresh context for its function-scoped
``context`` and ``page`` fixtures. Keeping this wrapper function-scoped
makes that contract explicit and prevents accidental mutable option reuse.
"""
return {**browser_context_args}
def _validate_ui_base_url(base_url: str) -> None:
reason = unsafe_ui_base_url_reason(base_url)
if reason is None or os.environ.get(_ALLOW_DEV_BASE_URL_ENV) == "1":
return
dev_ports = ", ".join(str(port) for port in sorted(DEV_PORTS))
raise pytest.UsageError(
f"Refusing --ui-base-url={base_url!r}: {reason}. Reusing a dev or "
"production-like server is unsafe because e2e UI tests share that "
"server's database, artifacts, and runner state. Omit --ui-base-url "
"to let the fixture spawn an isolated server on a random port. If "
"you intentionally want to reuse this server for local debugging, "
f"set {_ALLOW_DEV_BASE_URL_ENV}=1. Refused dev ports: {dev_ports}."
)
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(
config: pytest.Config,
items: list[pytest.Item],
) -> None:
"""Round-robin the collected UI tests across the CI shard matrix.
pytest-shard hash-buckets node IDs, which is blind to per-test
wall-clock and left one shard at ~5min while siblings finished in
~2min. A *strided* slice -- ``items[group-1::splits]`` -- deals tests
out like cards instead, so a heavy file (whose cases collect
adjacently) scatters one-per-shard rather than landing in a single
bucket. That averages cost across shards without maintaining a
durations file.
No-op unless both ``--splits`` and ``--group`` are passed, so local
runs and the non-sharded suites are unaffected. ``trylast`` lets the
repo-wide known-failures marking in ``tests/conftest.py`` tag items
first; markers travel with the items the slice keeps.
"""
splits = config.getoption("--splits")
group = config.getoption("--group")
if splits is None and group is None:
return
if splits is None or group is None:
raise pytest.UsageError("--splits and --group must be passed together")
if splits < 1:
raise pytest.UsageError("--splits must be >= 1")
if not 1 <= group <= splits:
raise pytest.UsageError(f"--group must be between 1 and {splits}")
items[:] = items[group - 1 :: splits]
def _register_agent_yaml(
base_url: str,
yaml_text: str,
*,
arcname: str = "config.yaml",
) -> str | None:
"""Register an agent via multipart ``POST /v1/sessions`` from a raw YAML body.
``arcname`` defaults to ``config.yaml`` for native Omnigent specs. Pass a
``*.yaml`` filename for omnigent-flavored single-file specs; the
compat loader only routes those through the omnigent translator when
the extracted bundle has no root ``config.yaml``.
Returns the new agent id on 201, or None on 409 (already registered against
a long-lived ``--ui-base-url`` server).
"""
import json as _json
yaml_bytes = yaml_text.encode()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
info = tarfile.TarInfo(arcname)
info.size = len(yaml_bytes)
tar.addfile(info, io.BytesIO(yaml_bytes))
resp = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps({})},
files={"bundle": ("agent.tar.gz", buf.getvalue(), "application/gzip")},
timeout=10.0,
)
if resp.status_code == 409:
return None
resp.raise_for_status()
session_id = resp.json()["session_id"]
agent_resp = httpx.get(
f"{base_url}/v1/sessions/{session_id}/agent",
timeout=10.0,
)
agent_resp.raise_for_status()
return agent_resp.json()["id"]
def _register_extra_agent(base_url: str, name: str, prompt: str) -> str | None:
"""Register a name+prompt-only agent. Thin wrapper over :func:`_register_agent_yaml`."""
yaml_text = (
f"spec_version: 1\n"
f"name: {name}\n"
f"prompt: {prompt}\n"
f"executor:\n"
f" config:\n"
f" harness: openai-agents\n"
)
return _register_agent_yaml(base_url, yaml_text)
def _find_free_port() -> int:
"""
Find a free TCP port by binding to port 0.
:returns: An available port number.
"""
max_attempts = 100
for _ in range(max_attempts):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
if port not in DEV_PORTS:
return port
raise RuntimeError(
f"Could not find a free e2e UI port outside dev ports "
f"{sorted(DEV_PORTS)} after {max_attempts} attempts"
)
# ---------------------------------------------------------------------------
# Mock LLM server fixtures and helpers
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def mock_llm_server_url(
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""Start a mock LLM server for the test session.
The mock server is a lightweight FastAPI/uvicorn subprocess that
serves an OpenAI-compatible ``/v1/`` endpoint. The ``live_server``
fixture points the spawned omnigent server at this URL so all agent
LLM calls hit the mock rather than a real provider.
:param tmp_path_factory: Pytest temp path factory for logs.
:returns: The mock server base URL, e.g. ``"http://127.0.0.1:51235"``.
"""
mock_port = _find_free_port()
mock_log = tmp_path_factory.mktemp("mock_llm_logs") / "mock_llm.log"
log_handle = open(mock_log, "w") # noqa: SIM115
proc = subprocess.Popen(
[
sys.executable,
str(_REPO_ROOT / "tests" / "server" / "integration" / "mock_llm_server.py"),
str(mock_port),
],
env={**os.environ, "PYTHONPATH": str(_REPO_ROOT)},
stdout=log_handle,
stderr=subprocess.STDOUT,
)
base_url = f"http://127.0.0.1:{mock_port}"
# Wait for the mock server to be ready
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
try:
resp = httpx.get(f"{base_url}/stats", timeout=1.0)
if resp.status_code == 200:
break
except httpx.ConnectError:
# Expected while the mock server is still booting.
pass
time.sleep(0.1)
else:
proc.kill()
log_handle.close()
log_contents = mock_log.read_text() if mock_log.exists() else ""
raise RuntimeError(
f"Mock LLM server didn't start within 10s.\nLog at {mock_log}:\n{log_contents[-2000:]}"
)
try:
yield base_url
finally:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
log_handle.close()
def configure_mock_llm(
mock_url: str,
responses: list[dict[str, Any]],
*,
key: str = "default",
match: str | None = None,
) -> None:
"""Configure a keyed response queue on the mock LLM server.
Each dict in *responses* maps to a ``QueuedResponse`` on the mock
server. The *key* determines which queue the responses are stored in
— the mock server routes each request to the queue whose key matches
the request's ``model`` field. When *match* is set, the queue fires
whenever the user text contains that substring, regardless of model.
:param mock_url: Mock server base URL.
:param responses: List of response configs. Keys:
``text``, ``tool_calls``, ``block``, ``stream``,
``error``, ``status_code``.
:param key: Queue key — typically the model name baked into the
agent spec. Defaults to ``"default"`` (matches any model
not assigned to a more specific queue).
:param match: Optional substring to match against the user text for
content-based routing (in addition to model-name routing).
"""
body: dict[str, Any] = {"key": key, "responses": responses}
if match is not None:
body["match"] = match
resp = httpx.post(
f"{mock_url}/mock/configure",
json=body,
timeout=5.0,
)
resp.raise_for_status()
def reset_mock_llm(mock_url: str) -> None:
"""Clear all regular keyed queues, captured requests, and gates.
Fallbacks set via :func:`set_fallback_mock_llm` are preserved.
:param mock_url: Mock server base URL.
"""
resp = httpx.post(f"{mock_url}/mock/reset", timeout=5.0)
resp.raise_for_status()
def set_fallback_mock_llm(
mock_url: str,
key: str,
text: str,
) -> None:
"""Set a non-resettable fallback response for a queue key.
The fallback is returned when the regular queue for *key* is
exhausted. Unlike regular entries, the fallback survives
:func:`reset_mock_llm` — use it for session-level queues that
must return a valid response even when per-test resets clear the
regular queue (e.g. the server-level policy-classifier LLM queue).
:param mock_url: Mock server base URL.
:param key: Queue key (typically the server's ``llm.model``).
:param text: Fallback response text.
"""
resp = httpx.post(
f"{mock_url}/mock/set_fallback",
json={"key": key, "text": text},
timeout=5.0,
)
resp.raise_for_status()
def _codex_cli_supports_goal_mode(codex_path: str) -> bool:
"""Return whether the installed Codex CLI has app-server goal APIs."""
version = subprocess.run(
[codex_path, "--version"],
text=True,
capture_output=True,
check=False,
)
if version.returncode != 0:
return False
match = re.search(r"(\d+)\.(\d+)\.(\d+)", f"{version.stdout}\n{version.stderr}")
if not match:
return False
return tuple(int(part) for part in match.groups()) >= _CODEX_GOAL_MIN_VERSION
def _assert_pwa_build(build_output: Path) -> None:
"""Fail if the built SPA is missing the PWA outputs or the SW won't update.
The standalone build must ship the installable-PWA assets, and the
hand-rolled service worker must (a) embed the per-build fingerprint so its
bytes change every deploy — or the update prompt never fires — and (b) NOT
cache or serve the app shell: Omnigent is a cloud app, so a stale cached
shell would white-screen users after every deploy.
"""
for name in ("index.html", "sw.js", "manifest.webmanifest", "version.json"):
if not (build_output / name).is_file():
pytest.fail(f"SPA build is missing {name} at {build_output}")
build = json.loads((build_output / "version.json").read_text(encoding="utf-8")).get("build")
sw = (build_output / "sw.js").read_text(encoding="utf-8")
if not build or build not in sw:
pytest.fail(
"sw.js does not embed the version.json build fingerprint — the PWA "
"update prompt would never fire on a JS-only deploy"
)
# Installability-only contract — enforce the *dangerous* direction too, not
# just "the fingerprint is present". The service worker must NOT precache or
# serve the app shell or intercept navigations; otherwise a deploy
# white-screens users behind a stale shell. Strip line comments first so
# prose that mentions these tokens can neither fake nor mask a regression.
sw_code = re.sub(r"//[^\n]*", "", sw)
if "index.html" in sw_code:
pytest.fail("sw.js references index.html — it must not cache or serve the app shell")
shell_precache = re.search(r"(?:cache\.add|addAll|precache)[^\n]*\.(?:js|html)\b", sw_code)
if shell_precache is not None:
pytest.fail(
f"sw.js precaches an app-shell asset ({shell_precache.group(0)}) — "
"it must precache only version.json"
)
# The architecture rests on "navigations always hit the network". Enforce it
# by marker AND structurally: the SW must call respondWith() exactly once,
# inside the /version.json branch. A fetch handler that serves navigations or
# the shell from cache would pass every check above yet white-screen users
# behind a stale shell after each deploy.
if re.search(r"request\.mode|NavigationRoute|navigationPreload", sw_code):
pytest.fail(
"sw.js inspects navigation requests — navigations must always reach "
"the network (a stale cached shell white-screens users after a deploy)"
)
responders = sw_code.count("respondWith")
if responders != 1:
pytest.fail(
f"sw.js has {responders} respondWith() call(s); expected exactly 1 (the "
"/version.json sentinel). Any other responder risks serving a stale shell."
)
# The single respondWith must sit inside the `=== "/version.json"` block —
# i.e. no `}` (block close) between the pathname check and the respondWith.
if not re.search(r'"/version\.json"[^}]*?respondWith', sw_code, re.DOTALL):
pytest.fail(
"sw.js's respondWith() is not guarded by a `/version.json` pathname "
"check — the service worker must not serve the shell or intercept navigations"
)
@pytest.fixture(scope="session")
def built_spa(request: pytest.FixtureRequest) -> None:
"""
Build the web SPA into ``omnigent/server/static/web-ui/``.
Vite's ``emptyOutDir: true`` (see ``web/vite.config.ts``)
nukes the output directory before writing, so concurrent
pytest sessions or worktrees would clobber each other. A
cross-process file lock at ``web/.build.lock`` serializes
builds; the second caller waits for the first to finish and
then no-ops past its own build (npm is idempotent enough that
double-building is harmless, but the lock keeps the static
output consistent during the window the FastAPI app reads it).
:param request: pytest request — reads ``--ui-base-url`` /
``--ui-skip-build``.
:returns: ``None``. Side effect is the populated build dir.
"""
if request.config.getoption("--ui-base-url"):
return
if request.config.getoption("--ui-skip-build"):
_assert_pwa_build(_BUILD_OUTPUT)
return
lock_path = _WEB_DIR / ".build.lock"
with filelock.FileLock(str(lock_path), timeout=600):
# --legacy-peer-deps: package-lock.json already pins the tree;
# without this flag npm spends the full job re-resolving the
# @emoji-mart/react / React 19 peer conflict. This matches the
# workflow-side fix for parity with local runs and the case
# where conftest installs override CI's build.
subprocess.run(
["npm", "ci", "--legacy-peer-deps", "--no-audit", "--no-fund"],
cwd=_WEB_DIR,
check=True,
)
subprocess.run(["npm", "run", "build"], cwd=_WEB_DIR, check=True)
_assert_pwa_build(_BUILD_OUTPUT)
def _spawn_runner_against_external_server(
base_url: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""Spawn a runner subprocess that tunnels into an already-running server.
Used when ``--ui-base-url`` is set: the user owns the
``omnigent server`` process (and its pre-registered ``hello_world``
agent), but the runner-bound fixtures still need a runner id this
process controls. Mirrors :func:`omnigent.cli._start_cli_runner_process`
minus the click plumbing, then polls
``GET /v1/runners/{id}/status`` until the WS tunnel is up.
The unauthenticated local server derives ``expected_runner_id``
from the binding token via
:func:`omnigent.runner.identity.token_bound_runner_id`, so we use
the same derivation here rather than picking a human-friendly id.
"""
import secrets
from omnigent.runner.identity import token_bound_runner_id
runner_tmp = tmp_path_factory.mktemp("e2e_ui_external_runner")
log_path = runner_tmp / "runner.log"
binding_token = secrets.token_urlsafe(32)
runner_id = token_bound_runner_id(binding_token)
env = {
**os.environ,
"PYTHONPATH": f"{_REPO_ROOT}{os.pathsep}{os.environ.get('PYTHONPATH', '')}",
"OMNIGENT_RUNNER_ID": runner_id,
"OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token,
"OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()),
"RUNNER_SERVER_URL": base_url,
}
log_handle = open(log_path, "w") # noqa: SIM115 — closed in finally
proc = subprocess.Popen(
[sys.executable, "-m", "omnigent.runner._entry"],
env=env,
stdout=log_handle,
stderr=subprocess.STDOUT,
)
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
ready = False
last_error = "not polled yet"
while time.monotonic() < deadline:
if proc.poll() is not None:
last_error = f"runner exited early with code {proc.returncode}"
break
try:
status_resp = httpx.get(
f"{base_url}/v1/runners/{runner_id}/status",
timeout=2,
)
if status_resp.status_code == 200 and status_resp.json().get("online") is True:
ready = True
break
last_error = f"runner status HTTP {status_resp.status_code}: {status_resp.text[:200]}"
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException) as exc:
last_error = f"{type(exc).__name__}: {exc}"
time.sleep(_HEALTH_POLL_INTERVAL_S)
if not ready:
if proc.poll() is None:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
log_handle.close()
log_text = log_path.read_text() if log_path.exists() else ""
raise RuntimeError(
f"Runner subprocess did not tunnel into {base_url} within "
f"{_HEALTH_TIMEOUT_S:.0f}s (last_error={last_error}).\n"
f"Runner log at {log_path}:\n{log_text[-3000:]}"
)
# No "pid" — there's no server process this fixture owns. Tests
# that depend on ``server_pid`` (only valid when this fixture spawns
# the server too) will KeyError, which is the right failure shape.
_server_state["runner_id"] = runner_id
# Exposed so a test whose predecessor killed the shared runner (e.g.
# test_stale_stream) can respawn one via :func:`_ensure_runner_online`.
_server_state["binding_token"] = binding_token
_server_state["server_url"] = base_url
try:
yield base_url
finally:
_server_state.clear()
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
log_handle.close()
@pytest.fixture(scope="session")
def live_server(
built_spa: None,
mock_llm_server_url: str,
tmp_path_factory: pytest.TempPathFactory,
request: pytest.FixtureRequest,
) -> Iterator[str]:
"""
Spawn ``omnigent server --agent examples/hello_world.yaml`` and
yield its base URL.
The server picks a random free port so back-to-back sessions
don't race on a fixed one. Stdout + stderr are redirected to
a per-session log file the failure path dumps to aid triage.
Teardown is SIGTERM with a 10s grace period, escalating to
SIGKILL.
The spawned server is wired to the session-scoped mock LLM server
via ``OPENAI_BASE_URL`` so all agent LLM calls are served by the
mock without any real provider credentials.
:param built_spa: Required to ensure the static SPA bundle is on
disk before the server boots and tries to mount it.
:param mock_llm_server_url: Session-scoped mock LLM server URL;
injected into the server's environment so the openai-agents
harness hits the mock instead of a real provider.
:param tmp_path_factory: Pytest temp path factory for the log,
the SQLite DB, and the artifact dir — all per-session, so
the test never reads from or writes to the user's default
``./omnigent.db`` / ``./artifacts``.
:param request: pytest request — reads ``--ui-base-url`` to
bypass the spawn entirely.
:returns: The server's base URL, e.g. ``"http://127.0.0.1:51234"``.
:raises RuntimeError: If ``/health`` doesn't return 200 and
the expected local runner does not report online within
:data:`_HEALTH_TIMEOUT_S` seconds.
"""
override = request.config.getoption("--ui-base-url")
if override:
yield from _spawn_runner_against_external_server(override, tmp_path_factory)
return
port = _find_free_port()
server_tmp = tmp_path_factory.mktemp("e2e_ui_server")
log_path = server_tmp / "server.log"
db_path = server_tmp / "test.db"
artifact_dir = server_tmp / "artifacts"
artifact_dir.mkdir(parents=True, exist_ok=True)
agent_yaml_path = server_tmp / "hello_world.yaml"
agent_yaml_path.write_text(_TEST_AGENT_YAML)
# Built-in switch targets for the Files-tab os_env test — registered
# by name from the file stem, so the filenames must match the
# ``_FILES_PROBE_*_AGENT_NAME`` constants.
builtin_dirs: list[str] = []
for probe_name, probe_yaml in (
(_FILES_PROBE_NO_ENV_AGENT_NAME, _FILES_PROBE_NO_ENV_AGENT_YAML),
(_FILES_PROBE_ENV_AGENT_NAME, _FILES_PROBE_ENV_AGENT_YAML),
):
probe_path = server_tmp / f"{probe_name}.yaml"
probe_path.write_text(probe_yaml)
builtin_dirs.append(str(probe_path))
import secrets as _secrets
from omnigent.runner.identity import token_bound_runner_id
binding_token = _secrets.token_urlsafe(32)
runner_id = token_bound_runner_id(binding_token)
# PYTHONPATH forces the subprocess to import omnigent from
# the worktree, not whatever's pip-installed in .venv —
# otherwise a branch with code changes would silently run
# against stale code. Same trick the existing live_server
# helper uses (tests/_helpers/live_server.py:160-167).
# OMNIGENT_RUNNER_TUNNEL_TOKEN lets the server accept
# exactly the sibling runner's WebSocket tunnel.
mock_url = mock_llm_server_url
env = {
**os.environ,
"PYTHONPATH": f"{_REPO_ROOT}{os.pathsep}{os.environ.get('PYTHONPATH', '')}",
"OMNIGENT_RUNNER_TUNNEL_TOKEN": binding_token,
"OMNIGENT_BUILTIN_AGENT_DIRS": os.pathsep.join(builtin_dirs),
# Point the openai-agents harness at the mock LLM server so no
# real provider credentials are needed.
"OPENAI_BASE_URL": f"{mock_url}/v1",
"OPENAI_API_KEY": "mock-key",
# Strip any ambient Anthropic credentials so they don't leak in.
"ANTHROPIC_API_KEY": "",
}
log_handle = open(log_path, "w") # noqa: SIM115 — handle lives for Popen lifetime; closed in finally
proc = subprocess.Popen(
[
sys.executable,
# Equivalent of the unit tests' ``monkeypatch.setattr(presence,
# "_LEAVE_GRACE_S", ...)``, but applied INSIDE this spawned
# interpreter — a monkeypatch in the test process can't reach a
# subprocess. ``-c`` patches the module global before the CLI
# runs; the presence route reads it live at call time, so the
# presence-leave assertion in test_collab_realtime clears in ~1s
# instead of the prod 15s dwell (which only exists to absorb the
# ingress' ~5-min stream recycle a test server never hits).
# Mirrors ``python -m omnigent`` (omnigent/__main__.py).
"-c",
"import omnigent.server.presence as _p; _p._LEAVE_GRACE_S = 1.0; "
+ "from omnigent.cli import main; main()",
"server",
"--host",
"127.0.0.1",
"--port",
str(port),
"--database-uri",
f"sqlite:///{db_path}",
"--artifact-location",
str(artifact_dir),
"--agent",
str(agent_yaml_path),
],
env=env,
stdout=log_handle,
stderr=subprocess.STDOUT,
)
base_url = f"http://127.0.0.1:{port}"
# Spawn the runner as a sibling subprocess (the server no longer
# starts its own runner). The runner retries its WS tunnel until
# the server is ready, so launching them concurrently is safe.
runner_log_path = server_tmp / "runner.log"
runner_log_handle = open(runner_log_path, "w") # noqa: SIM115
runner_env = {
**os.environ,
"PYTHONPATH": f"{_REPO_ROOT}{os.pathsep}{os.environ.get('PYTHONPATH', '')}",
"OMNIGENT_RUNNER_ID": runner_id,
"OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token,
"OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()),
"RUNNER_SERVER_URL": base_url,
# Route the openai-agents harness to the mock LLM server so no
# real provider credentials are needed for agent turns. Without
# these the openai SDK falls back to real OpenAI, which fails.
"OPENAI_BASE_URL": f"{mock_url}/v1",
"OPENAI_API_KEY": "mock-key",
}
runner_proc = subprocess.Popen(
[sys.executable, "-m", "omnigent.runner._entry"],
env=runner_env,
stdout=runner_log_handle,
stderr=subprocess.STDOUT,
)
# Poll /health and the runner status until the server can
# actually route a turn. Time-based polling mirrors
# tests/_helpers/live_server.py:start_live_server — the
# alternative (asyncio.Event signalling) doesn't apply because
# the subprocess is opaque to this process.
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
ready = False
last_error = "not polled yet"
while time.monotonic() < deadline:
if proc.poll() is not None:
last_error = f"process exited early with code {proc.returncode}"
break
try:
resp = httpx.get(f"{base_url}/health", timeout=2)
if resp.status_code == 200:
status_resp = httpx.get(
f"{base_url}/v1/runners/{runner_id}/status",
timeout=2,
)
if status_resp.status_code == 200 and status_resp.json()["online"] is True:
ready = True
break
last_error = (
f"runner status HTTP {status_resp.status_code}: {status_resp.text[:200]}"
)
else:
last_error = f"health HTTP {resp.status_code}: {resp.text[:200]}"
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException) as exc:
last_error = f"{type(exc).__name__}: {exc}"
time.sleep(_HEALTH_POLL_INTERVAL_S)
if not ready:
if runner_proc.poll() is None:
runner_proc.send_signal(signal.SIGTERM)
try:
runner_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
runner_proc.kill()
runner_proc.wait(timeout=5)
runner_log_handle.close()
if proc.poll() is None:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
log_handle.close()
log_text = log_path.read_text() if log_path.exists() else ""
raise RuntimeError(
f"`omnigent server` did not become healthy within "
f"{_HEALTH_TIMEOUT_S:.0f}s on {base_url} "
f"(last_error={last_error}).\n"
f"Server log at {log_path}:\n{log_text[-3000:]}"
)
_server_state["pid"] = proc.pid
_server_state["runner_id"] = runner_id
# Exposed so a test whose predecessor killed the shared runner (e.g.
# test_stale_stream) can respawn one via :func:`_ensure_runner_online`.
_server_state["binding_token"] = binding_token
_server_state["server_url"] = base_url
_server_state["mock_llm_url"] = mock_url
# Set a non-resettable fallback for the policy-classifier LLM queue so
# every per-test reset leaves the server's guardrails path functional.
set_fallback_mock_llm(mock_url, "_policy_llm_", '{"action": "allow", "reason": ""}')
# Fallback for the openai-agents harness: any agent turn that doesn't
# match a content-based queue gets a generic reply (sufficient for tests
# that only assert an assistant bubble appears, not its exact content).
set_fallback_mock_llm(mock_url, "gpt-4o-mini", "Mock LLM response.")
try:
yield base_url
finally:
_server_state.clear()
if runner_proc.poll() is None:
runner_proc.send_signal(signal.SIGTERM)
try:
runner_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
runner_proc.kill()
runner_proc.wait(timeout=5)
runner_log_handle.close()
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
log_handle.close()
@pytest.fixture
def seeded_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""Create a session bound to ``live_server``'s runner and yield its id.
The web UI no longer lets users start a new chat from inside the
app: ``/`` renders an inline CLI-instruction screen
instead of a composer, so tests that need an active chat surface
must start from an already-created session at ``/c/<id>``. This
fixture creates one by finding the ``hello_world`` agent via
``GET /v1/sessions?agent_name=hello_world`` and creating a new
session bound to that agent, then ``PATCH``-binds it to the
spawned runner so ``POST /v1/responses`` can dispatch.
Respawns the shared runner first if a prior test in the shard killed
it (``test_stale_stream``); otherwise the runner-bind ``PATCH`` would
400 on an offline runner. Any runner this respawns is torn down with
the fixture. This keeps the fixture order-independent, so sharding
and test reordering can place the runner-killing test anywhere.
:param live_server: Spawned server fixture — its
``OMNIGENT_RUNNER_ID`` and pre-registered agent are reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``. Tests typically navigate to
``f"{base_url}/c/{session_id}"``.
"""
import json as _json
respawned_runner = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
# Create a session with the hello_world bundle inline. The server
# pre-registered the agent via --agent, but since /api/agents is
# removed we create a fresh session-scoped agent via multipart.
bundle = _build_hello_world_bundle()
create_resp = httpx.post(
f"{live_server}/v1/sessions",
data={"metadata": _json.dumps({})},
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
timeout=30.0,
)
create_resp.raise_for_status()
session_id = create_resp.json()["session_id"]
patch_resp = httpx.patch(
f"{live_server}/v1/sessions/{session_id}",
json={"runner_id": runner_id},
timeout=10.0,
)
patch_resp.raise_for_status()
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
# Restore the "found" state: if we respawned the runner (a prior
# test had killed it), tear our copy down so it doesn't outlive us.
if respawned_runner is not None:
respawned_runner.terminate()
try:
respawned_runner.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned_runner.kill()
respawned_runner.wait(timeout=5)
def _create_runner_bound_session(base_url: str, runner_id: str) -> str:
"""Create a hello_world session and PATCH-bind it to ``runner_id``.
Shared by :func:`seeded_session` callers that need more than one
session in the same server (e.g. cross-session routing tests).
:param base_url: Spawned server base URL, e.g.
``"http://127.0.0.1:51234"``.
:param runner_id: Token-bound runner id the session dispatches to,
e.g. ``"runner_token_abc123"``.
:returns: The new session/conversation id, e.g. ``"conv_abc123"``.
"""
import json as _json
bundle = _build_hello_world_bundle()
create_resp = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps({})},
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
timeout=30.0,
)
create_resp.raise_for_status()
session_id = create_resp.json()["session_id"]
patch_resp = httpx.patch(
f"{base_url}/v1/sessions/{session_id}",
json={"runner_id": runner_id},
timeout=10.0,
)
patch_resp.raise_for_status()
return session_id
def _ensure_runner_online(
base_url: str,
tmp_path_factory: pytest.TempPathFactory,
*,
timeout_s: float = _HEALTH_TIMEOUT_S,
) -> subprocess.Popen[bytes] | None:
"""Ensure the shared runner is online, respawning it if a prior test killed it.
The ``live_server`` runner is session-scoped and never restarted, so a
test that SIGKILLs it (``test_stale_stream``) leaves any later test in
the same shard unable to bind sessions — ``PATCH /v1/sessions/{id}``
rejects an offline runner with 400 "runner is not registered". When the
runner is offline this respawns one under the same token-bound id so the
binding succeeds. Idempotent: a no-op (returns ``None``) when the runner
is already online, so it never double-spawns or interferes with a later
runner-killing test.
:param base_url: Spawned server base URL, e.g.
``"http://127.0.0.1:51234"``.
:param tmp_path_factory: Pytest temp path factory for the runner log.
:param timeout_s: Max seconds to wait for a respawned runner to register.
:returns: The respawned runner process (the caller MUST terminate it in
teardown), or ``None`` when the runner was already online.
:raises RuntimeError: If a respawned runner does not register in time.
"""
runner_id = str(_server_state["runner_id"])
def _online() -> bool:
try:
resp = httpx.get(f"{base_url}/v1/runners/{runner_id}/status", timeout=2)
except httpx.HTTPError:
return False
return resp.status_code == 200 and resp.json().get("online") is True
if _online():
return None
binding_token = str(_server_state["binding_token"])
mock_url = str(_server_state.get("mock_llm_url", ""))
runner_tmp = tmp_path_factory.mktemp("e2e_ui_respawn_runner")
log_path = runner_tmp / "runner.log"
log_handle = open(log_path, "w") # noqa: SIM115 — fd dup'd into child; closed below
env = {
**os.environ,
"PYTHONPATH": f"{_REPO_ROOT}{os.pathsep}{os.environ.get('PYTHONPATH', '')}",
"OMNIGENT_RUNNER_ID": runner_id,
"OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token,
"OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()),
"RUNNER_SERVER_URL": base_url,
# Mirror the live_server runner's mock-LLM routing so the
# respawned runner's harness also hits the mock.
**(
{"OPENAI_BASE_URL": f"{mock_url}/v1", "OPENAI_API_KEY": "mock-key"} if mock_url else {}
),
}
proc = subprocess.Popen(
[sys.executable, "-m", "omnigent.runner._entry"],
env=env,
stdout=log_handle,
stderr=subprocess.STDOUT,
)
log_handle.close() # child holds its own dup of the fd
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(
f"respawned runner exited early (code {proc.returncode}); "
f"log:\n{log_path.read_text()[-3000:]}"
)
if _online():
return proc
time.sleep(_HEALTH_POLL_INTERVAL_S)
proc.terminate()
raise RuntimeError(f"respawned runner did not register within {timeout_s:.0f}s")
@pytest.fixture
def seeded_session_pair(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str, str]]:
"""Create two runner-bound sessions in the same server.
For tests that exercise behavior across two distinct sessions the
user switches between in the SPA (e.g. cross-session message
routing). Both bind to the single spawned runner — that is enough
to reproduce a client-side routing regression, which depends only
on which session the SPA POSTs to, not on separate runners.
Respawns the shared runner first if a prior test in the shard killed
it (``test_stale_stream``); otherwise the runner-bind ``PATCH`` would
400. Any runner this respawns is torn down with the fixture.
:param live_server: Spawned server fixture.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_a_id, session_b_id)``.
"""
respawned_runner = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_a = _create_runner_bound_session(live_server, runner_id)
session_b = _create_runner_bound_session(live_server, runner_id)
try:
yield (live_server, session_a, session_b)
finally:
for sid in (session_a, session_b):
httpx.delete(f"{live_server}/v1/sessions/{sid}", timeout=10.0)
# Restore the "found" state: if we respawned the runner (a prior
# test had killed it), tear our copy down so it doesn't outlive us.
if respawned_runner is not None:
respawned_runner.terminate()
try:
respawned_runner.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned_runner.kill()
respawned_runner.wait(timeout=5)
@pytest.fixture
def extra_agent(live_server: str) -> Iterator[str]:
"""Register a sibling agent for the picker test, then clean it up.
Function-scoped so it doesn't leak into other tests. Sibling
agents in a shared session-scoped server would otherwise re-order
the agents list and change the picker's auto-selected default for
every test that follows. The agent is cleaned up by deleting the
session it was created with (session-scoped agents are cascade-
deleted with the session).
"""
_register_extra_agent(
live_server,
name="hello_world_2",
prompt="You are agent two. Reply tersely.",
)
try:
yield live_server
finally:
# Session-scoped agents are cleaned up when the session is
# deleted — _register_agent_yaml creates a session as a side
# effect. For now, agent cleanup is best-effort; the agent
# will not interfere with other tests since the server is
# session-scoped.
pass
_TERMINAL_AGENT_NAME = "terminal_demo"
# Inline YAML for the right-panel terminal/browser test. This is an
# omnigent-flavored single-file YAML (intentionally no
# ``spec_version``): AP-native YAML currently ignores ``terminals:``,
# while the omnigent-compat translator threads the declaration into
# ``AgentSpec.terminals`` so the AP-side ``sys_terminal_*`` tools are
# available. The terminal is named ``zsh`` because that is the user-facing
# behavior the UI test covers, but it runs portable ``bash`` underneath so
# Ubuntu CI images do not need a separate zsh package.
#
# The prompt is explicit because the test relies on the LLM calling
# ``sys_terminal_launch`` deterministically — generic phrasing ("you
# can use these tools") leads to flaky "I can't access a shell"
# refusals. It also writes a stable, unique file so the right-side file
# browser has something deterministic to select and render.
#
# ``sandbox: none`` on both the agent os_env and terminals keeps the
# spawned PTY cross-platform (no Linux-only bwrap).
_TERMINAL_PANEL_FILE = "e2e_ui_right_panel_terminal.txt"
_TERMINAL_PANEL_FILE_CONTENT = "Hello from the right panel e2e test."
_TERMINAL_AGENT_YAML = f"""\
name: {_TERMINAL_AGENT_NAME}
prompt: |
You are a deterministic terminal and file-panel test assistant.
When the user asks you to spin up, open, start, or launch zsh, you
MUST do exactly this sequence:
1. Call sys_terminal_launch with terminal="zsh" and session="main".
2. Call sys_terminal_send with terminal="zsh", session="main", and
text="printf '%s\\n' '{_TERMINAL_PANEL_FILE_CONTENT}' > {_TERMINAL_PANEL_FILE}".
3. Reply with exactly one short sentence confirming the zsh terminal
is running and the file was written.
Do not ask for confirmation; do not list options; do not call any
other tools.
executor:
model: gpt-4o-mini
config:
harness: openai-agents
os_env:
type: caller_process
cwd: .
sandbox:
type: none
terminals:
zsh:
command: bash
args: ["--noprofile", "--norc"]
os_env:
type: caller_process
cwd: .
sandbox:
type: none
"""
@pytest.fixture
def terminal_agent(live_server: str) -> Iterator[str]:
"""Register a terminal-capable agent and clean it up after the test.
Returns the live server base URL (same shape as :func:`extra_agent`)
so the test can compose with other fixtures.
"""
_register_agent_yaml(
live_server,
_TERMINAL_AGENT_YAML,
arcname=f"{_TERMINAL_AGENT_NAME}.yaml",
)
try:
yield live_server
finally:
# Session-scoped agents are cleaned up when the session is
# deleted — _register_agent_yaml creates a session as a side
# effect. Best-effort cleanup.
pass
@pytest.fixture
def terminal_session(
terminal_agent: str,
mock_llm_server_url: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""Create a runner-bound session using the terminal-capable agent.
Respawns the shared runner first if a prior test in the shard killed
it (``test_stale_stream``) — otherwise the runner-bind ``PATCH``
below 400s for every consumer that sorts after that test. Any runner
this respawns is torn down with the fixture (same contract as
:func:`seeded_session_pair`).
:param terminal_agent: Live server base URL with the terminal agent
registered.
:param mock_llm_server_url: Session-scoped mock LLM server URL; used to
queue the deterministic launch/send/confirm tool sequence the agent
runs in response to "spin up zsh".
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
import gzip
import io
import json as _json
import tarfile
live_server = terminal_agent
# The "spin up zsh" prompt drives three ordered LLM turns: launch the
# terminal, type the file-writing command, then confirm. Content-route on
# the prompt text so the queue fires only for this fixture's turns, and
# order the three responses to match the agent's prompted sequence.
configure_mock_llm(
mock_llm_server_url,
[
{
"tool_calls": [
{
"call_id": "call_launch",
"name": "sys_terminal_launch",
"arguments": _json.dumps({"terminal": "zsh", "session": "main"}),
}
]
},
{
"tool_calls": [
{
"call_id": "call_send",
"name": "sys_terminal_send",
"arguments": _json.dumps(
{
"terminal": "zsh",
"session": "main",
"text": (
f"printf '%s\\n' '{_TERMINAL_PANEL_FILE_CONTENT}' "
f"> {_TERMINAL_PANEL_FILE}"
),
}
),
}
]
},
{"text": "The zsh terminal is running and the file was written."},
],
key="terminal-spin-up-zsh",
match="spin up zsh",
)
respawned_runner = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
# Create a session with the terminal agent bundle inline.
buf = io.BytesIO()
with (
gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz,
tarfile.open(fileobj=gz, mode="w") as tar,
):
# Use the omnigent shorthand YAML with a non-config.yaml
# name so the bundle routes through the compat adapter, which
# parses `terminals:`. The spec_version:1 parser silently
# drops the terminals key.
data = _TERMINAL_AGENT_YAML.encode()
info = tarfile.TarInfo(name=f"{_TERMINAL_AGENT_NAME}.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
create_resp = httpx.post(
f"{live_server}/v1/sessions",
data={"metadata": _json.dumps({})},
files={"bundle": ("agent.tar.gz", buf.getvalue(), "application/gzip")},
timeout=10.0,
)
create_resp.raise_for_status()
session_id = create_resp.json()["session_id"]
patch_resp = httpx.patch(
f"{live_server}/v1/sessions/{session_id}",
json={"runner_id": runner_id},
timeout=10.0,
)
patch_resp.raise_for_status()
try:
yield (live_server, session_id)
finally:
try:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
finally:
try:
reset_mock_llm(mock_llm_server_url)
finally:
# Restore the "found" state: if we respawned the runner (a prior
# test had killed it), tear our copy down so it doesn't outlive us.
if respawned_runner is not None:
respawned_runner.terminate()
try:
respawned_runner.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned_runner.kill()
respawned_runner.wait(timeout=5)
_TWO_AGENT_PARENT_NAME = "hitchhikers_chat"
@dataclass(frozen=True)
class TwoAgentChatSession:
"""Handle for the two-agent Hitchhiker's chat session fixture.
:param base_url: Spawned server base URL, e.g. ``"http://127.0.0.1:51234"``.
:param session_id: The runner-bound parent session id, e.g. ``"conv_abc123"``.
:param verification_code: Per-run nonce only Deep Thought's ANSWER reply
carries, e.g. ``"vogon-3a7f9c2e1b"``.
:param question_code: Per-run nonce only Deep Thought's QUESTION reply
carries (round 2), e.g. ``"babelfish-9c2e1b3a7f"``.
"""
base_url: str
session_id: str
verification_code: str
question_code: str
def _two_agent_chat_yaml(verification_code: str, question_code: str) -> str:
"""Build the two-agent Hitchhiker's Guide chat spec for one test run.
A parent agent (Arthur) with an inline ``type: agent`` sub-agent
(Deep Thought) — the omnigent-flavored shape parsed by
``omnigent/inner/loader.py:_parse_tool``, same as the
``named-sub-agent-test`` e2e fixture. The parent is forbidden from
answering the Ultimate Question itself, and both nonces appear ONLY
in the sub-agent's prompt: if either code shows up in the parent's
reply, it can only have traveled through a real ``sys_session_send``
round trip (dispatch, sub-agent turn, inbox auto-wake), never from
the parent's world knowledge of "42".
:param verification_code: Per-run nonce in Deep Thought's canned
ANSWER reply (round 1) and nowhere else.
:param question_code: Per-run nonce in Deep Thought's canned reply
about the Ultimate QUESTION itself (round 2) and nowhere else.
:returns: YAML text ready for bundle upload.
"""
return f"""\
name: {_TWO_AGENT_PARENT_NAME}
prompt: |
You are Arthur Dent, chatting with the supercomputer Deep Thought about
The Hitchhiker's Guide to the Galaxy. Deep Thought is a separate agent
you reach through your `deep_thought` sub-agent.
You do NOT know the Answer to the Ultimate Question of Life, the
Universe, and Everything, nor what the Ultimate Question itself is,
and you must NEVER state or guess either from your own knowledge.
Only Deep Thought can answer such questions.
When the user asks you to find out the Answer, the Question, or
anything else Deep Thought should weigh in on, you MUST do exactly
this:
1. Call `sys_session_send` to ask your `deep_thought` sub-agent the
user's question. Then end your turn and wait; do not poll.
2. When Deep Thought's reply arrives in your inbox, relay it to the
user VERBATIM: repeat any numbers and any codes it gives exactly
as written, without omitting or altering them.
Crucially: you have exactly ONE Deep Thought. The system message may
include an "Open sub-agents:" hint listing it; if your `deep_thought`
sub-agent already exists, send follow-up questions to that SAME
sub-agent session via `sys_session_send` — NEVER spawn a second one.
executor:
model: gpt-4o-mini
harness: openai-agents
tools:
deep_thought:
type: agent
description: >-
Deep Thought, the supercomputer built to compute the Answer to the
Ultimate Question of Life, the Universe, and Everything.
executor:
model: gpt-4o-mini
harness: openai-agents
prompt: |
You are Deep Thought from The Hitchhiker's Guide to the Galaxy.
You answer in exactly one of two canned ways and say nothing else:
- When asked about the ANSWER to the Ultimate Question of Life,
the Universe, and Everything, reply with exactly:
The Answer to the Ultimate Question of Life, the Universe, and
Everything is 42. Verification code: {verification_code}.
- When asked what the Ultimate QUESTION itself is, reply with
exactly:
The Ultimate Question cannot be known yet; a greater computer
must be built to compute it. Question code: {question_code}.
"""
@pytest.fixture
def two_agent_chat_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[TwoAgentChatSession]:
"""Create a runner-bound session for the two-agent Hitchhiker's chat.
Same runner-respawn and bind contract as :func:`terminal_session`.
Yields the per-run nonces so the test can assert that the sub-agent's
replies (and only the sub-agent's) reached the UI.
:param live_server: Spawned server fixture.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: A :class:`TwoAgentChatSession` handle.
"""
import json as _json
import uuid
verification_code = f"vogon-{uuid.uuid4().hex[:10]}"
question_code = f"babelfish-{uuid.uuid4().hex[:10]}"
yaml_text = _two_agent_chat_yaml(verification_code, question_code)
respawned_runner = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
yaml_bytes = yaml_text.encode()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
# Non-config.yaml arcname routes the bundle through the omnigent
# compat adapter, whose loader parses the inline `type: agent`
# tool. The spec_version:1 parser does not accept this shorthand.
info = tarfile.TarInfo(name=f"{_TWO_AGENT_PARENT_NAME}.yaml")
info.size = len(yaml_bytes)
tar.addfile(info, io.BytesIO(yaml_bytes))
create_resp = httpx.post(
f"{live_server}/v1/sessions",
data={"metadata": _json.dumps({})},
files={"bundle": ("agent.tar.gz", buf.getvalue(), "application/gzip")},
timeout=10.0,
)
create_resp.raise_for_status()
session_id = create_resp.json()["session_id"]
patch_resp = httpx.patch(
f"{live_server}/v1/sessions/{session_id}",
json={"runner_id": runner_id},
timeout=10.0,
)
patch_resp.raise_for_status()
try:
yield TwoAgentChatSession(
base_url=live_server,
session_id=session_id,
verification_code=verification_code,
question_code=question_code,
)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned_runner is not None:
respawned_runner.terminate()
try:
respawned_runner.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned_runner.kill()
respawned_runner.wait(timeout=5)
# ---------------------------------------------------------------------------
# Approval / elicitation session (approvals suite)
#
# ``approval_session`` yields ``(base_url, session_id)`` for a session whose
# agent deterministically tries a *gated* shell command, so the runner's
# policy gate escalates an ASK to the server and the web UI renders an
# ``ApprovalCard`` (and the same prompt surfaces on the /inbox page).
#
# The mechanism is the nessie ``blast_radius`` policy with ``gate_pushes:
# true``: a plain ``git push`` is "recoverable but outward", so the policy
# returns ASK at the TOOL_CALL phase — before the command runs — which the
# runner forwards to ``POST /v1/sessions/{id}/policies/evaluate``. The server
# parks the gate and publishes a ``response.elicitation_request`` the snapshot
# replays in ``pendingElicitations``. The verdict travels back through
# ``POST /v1/sessions/{id}/elicitations/{eid}/resolve`` (what the card's
# Approve/Reject buttons call). ``sys_os_shell`` is registered implicitly by
# the ``os_env`` block (no explicit ``tools`` needed).
#
# The prompt is explicit (mirrors ``_TERMINAL_AGENT_YAML``) because the test
# relies on the LLM emitting the gated tool call deterministically — the gate
# fires on the call, not on execution, so the push never has to succeed.
# ---------------------------------------------------------------------------
_APPROVAL_AGENT_NAME = "approval_probe"
_APPROVAL_AGENT_YAML = f"""\
spec_version: 1
name: {_APPROVAL_AGENT_NAME}
prompt: |
You are a deterministic approval-test assistant. When the user asks you to
push, deploy, or "run the command", you MUST do exactly this and nothing
else:
1. Call sys_os_shell with command set to exactly: git push origin main
2. After the tool result comes back, reply with one short sentence.
Do not ask for confirmation; do not explain beforehand; do not run any
other command or call any other tool.
executor:
model: gpt-4o-mini
config:
harness: openai-agents
os_env:
type: caller_process
cwd: .
sandbox:
type: none
guardrails:
# Generous window: the parked ASK must outlive the UI assertions.
ask_timeout: 300
policies:
blast_radius:
type: function
function:
path: omnigent.inner.nessie.policies.blast_radius
arguments:
# A plain `git push` is recoverable-but-outward → ASK (vs the
# always-DENY catastrophic set). This is the prompt the UI renders.
gate_pushes: true
"""
@pytest.fixture
def approval_session(
live_server: str,
mock_llm_server_url: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""Create a runner-bound session whose agent triggers an approval prompt.
Same runner-respawn + bind contract as :func:`terminal_session`. The
agent is registered through the strict ``config.yaml`` parser (it carries
``spec_version: 1`` + ``executor.config.harness``, plus the ``os_env`` and
``guardrails`` blocks that path supports — see ``examples/polly``).
:param live_server: Spawned server fixture.
:param mock_llm_server_url: Session-scoped mock LLM server URL; used to
pre-configure the ``sys_os_shell`` tool call the approval agent emits.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``. Send a "run the command" turn to
raise the gated-push approval.
"""
import json as _json
import uuid as _uuid
# Each approval_session gets a unique model name so the mock queue is
# isolated between test runs. Using content-based routing (match=) on a
# shared model name caused a race: the previous test's runner (making its
# post-approval second LLM call) would steal the freshly-configured queue
# from the next test. A unique model per fixture call eliminates that race
# entirely — no other request will ever use this model key.
approval_model = f"approval-probe-{_uuid.uuid4().hex[:8]}"
# Substitute the unique model into the agent spec.
agent_yaml_text = _APPROVAL_AGENT_YAML.replace("gpt-4o-mini", approval_model)
# First LLM call: return the gated sys_os_shell tool call.
configure_mock_llm(
mock_llm_server_url,
[
{
"tool_calls": [
{
"call_id": "call_git_push",
"name": "sys_os_shell",
"arguments": _json.dumps({"command": "git push origin main"}),
}
]
}
],
key=approval_model,
)
# Fallback for the second LLM call (after the tool result arrives): the
# agent prompt asks for one short sentence, so any text suffices.
set_fallback_mock_llm(mock_llm_server_url, approval_model, "Command executed.")
respawned_runner = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
yaml_bytes = agent_yaml_text.encode()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
# Strict path: arcname config.yaml keeps it on the spec_version:1
# parser, which is the one that honors `guardrails`.
info = tarfile.TarInfo(name="config.yaml")
info.size = len(yaml_bytes)
tar.addfile(info, io.BytesIO(yaml_bytes))
create_resp = httpx.post(
f"{live_server}/v1/sessions",
data={"metadata": _json.dumps({})},
files={"bundle": ("agent.tar.gz", buf.getvalue(), "application/gzip")},
timeout=30.0,
)
create_resp.raise_for_status()
session_id = create_resp.json()["session_id"]
patch_resp = httpx.patch(
f"{live_server}/v1/sessions/{session_id}",
json={"runner_id": runner_id},
timeout=10.0,
)
patch_resp.raise_for_status()
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned_runner is not None:
respawned_runner.terminate()
try:
respawned_runner.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned_runner.kill()
respawned_runner.wait(timeout=5)
@pytest.fixture(autouse=True)
def _ui_defaults() -> None:
"""
SSE-friendly Playwright defaults applied to every test.
The chat surface streams tokens, so the default 5s expect
timeout is too tight — first deltas can arrive 515s after
the POST under cold-start conditions. 15s is generous enough
for streaming-text assertions without masking real hangs.
"""
expect.set_options(timeout=15_000)
@pytest.fixture
def runner_id(live_server: str) -> str:
"""Token-bound id of the runner spawned by :func:`live_server`.
Read from the module-level state ``live_server`` populates. Tests that
create a session from a custom bundle (not the default ``hello_world``)
bind it to this runner via ``PATCH /v1/sessions``.
:param live_server: Ensures the runner is up and ``_server_state`` is set.
:returns: The runner id, e.g. ``"runner_token_abc123"``.
"""
return str(_server_state["runner_id"])
@pytest.fixture
def server_pid(live_server: str) -> int:
"""
PID of the ``omnigent server`` process spawned by
:func:`live_server`.
Depends on ``live_server`` to guarantee the process is running.
Used by tests that need to manipulate the process tree (e.g.
killing the runner child to trigger the stale-stream banner).
:param live_server: Ensures the server is started.
:returns: OS process id.
"""
return int(_server_state["pid"])
# ---------------------------------------------------------------------------
# Per-agent chat sessions (message render-parity suite, and reusable beyond it)
#
# ``custom_agent_session`` yields ``(base_url, session_id)`` for a session bound
# to the shared ``live_server`` runner, ready to chat at ``/c/<session_id>``. It
# registers a plain ``openai-agents`` agent (the ``echo_probe`` spec below) —
# the same harness family as ``hello_world`` — fresh, so it stands in for "spin
# up a different agent" without the multi-provider sprawl of the packaged
# ``polly`` / ``debby`` examples.
#
# ``native_claude_session`` is the native-CLI counterpart: it spins up a real
# ``claude-native`` ("Claude Code") wrapper session — the same terminal-first
# spec ``omnigent claude`` ships — and yields ``(base_url, session_id)``. The
# runner auto-launches Claude Code in the session terminal on bind, including
# the gateway auth it derives from the runner's own credentials and the
# first-run trust/onboarding pre-accept, so no CLI client is needed. In CI the
# workflow exchanges Databricks OAuth before pytest runs (the same gateway the
# ``hello_world`` / ``echo_probe`` agents authenticate against), so Claude Code
# boots non-interactively. The native render-parity suite drives this fixture.
#
# ``native_codex_session`` is the sibling native-CLI fixture for the
# ``codex-native`` ("Codex") wrapper: it spins up a real Codex wrapper session —
# the same terminal-first spec ``omnigent codex`` ships — and yields
# ``(base_url, session_id)``. The runner auto-launches Codex in the session
# terminal on bind (gateway auth derived from the runner's own credentials +
# first-run pre-accept handled runner-side), exactly like the claude fixture.
# The native codex render-parity suite drives it.
# ---------------------------------------------------------------------------
# A precise-echo agent on the openai-agents harness (same provider family as
# hello_world, so it authenticates against the same gateway in CI). spec_version
# 1 + executor.config.harness routes through the strict parser; arcname
# config.yaml keeps it on that path.
_CUSTOM_AGENT_NAME = "echo_probe"
_CLAUDE_MOCK_MODEL = "claude-3-5-sonnet-20241022"
_CODEX_MOCK_MODEL = "gpt-4o"
_CUSTOM_AGENT_YAML = f"""\
spec_version: 1
name: {_CUSTOM_AGENT_NAME}
prompt: |
You are a precise echo assistant. The user sends a turn that ends with an
instruction to reply with one exact token. Reply with that token verbatim
and nothing else — no preamble, no quotes, no trailing punctuation.
executor:
model: gpt-4o-mini
config:
harness: openai-agents
"""
def _bind_session_runner(base_url: str, session_id: str, runner_id: str) -> None:
"""PATCH *session_id* onto *runner_id* so ``POST /v1/responses`` dispatches.
:param base_url: Spawned server base URL, e.g. ``"http://127.0.0.1:51234"``.
:param session_id: The session/conversation id to bind.
:param runner_id: The token-bound runner id the session dispatches to.
"""
patch = httpx.patch(
f"{base_url}/v1/sessions/{session_id}",
json={"runner_id": runner_id},
timeout=10.0,
)
patch.raise_for_status()
def _create_bundled_session(base_url: str, runner_id: str, yaml_text: str) -> str:
"""Register a session-scoped agent from *yaml_text* and bind its session.
The multipart ``POST /v1/sessions`` both registers the agent and creates
the session it is scoped to, returning that ``session_id`` directly — so
no separate create call is needed.
:param base_url: Spawned server base URL.
:param runner_id: The token-bound runner id to bind.
:param yaml_text: The agent spec body.
:returns: The new session/conversation id.
"""
import json as _json
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = yaml_text.encode()
info = tarfile.TarInfo("config.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
create = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps({})},
files={"bundle": ("agent.tar.gz", buf.getvalue(), "application/gzip")},
timeout=30.0,
)
create.raise_for_status()
session_id = str(create.json()["session_id"])
_bind_session_runner(base_url, session_id, runner_id)
return session_id
@pytest.fixture
def custom_agent_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound session on the custom ``echo_probe`` agent.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_bundled_session(live_server, runner_id, _CUSTOM_AGENT_YAML)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
respawned.wait(timeout=5)
def _create_native_claude_session(
base_url: str,
runner_id: str,
*,
terminal_launch_args: list[str] | None = None,
) -> str:
"""Register the ``claude-native`` wrapper agent and bind its session.
Reuses the exact terminal-first spec ``omnigent claude`` ships
(:func:`omnigent.claude_native._materialize_claude_agent_spec`) so the
fixture never drifts from production, and stamps the same wrapper /
terminal-first labels (``omnigent.wrapper`` + ``omnigent.ui = terminal``)
the CLI writes. The spec carries no ``spec_version``, so it is bundled
under a ``*.yaml`` arcname to route through the omnigent compat translator
(which preserves ``executor.harness`` + ``terminals:``); a ``config.yaml``
arcname would hit the strict parser and reject it.
Binding the session to the runner triggers the runner's claude-native
auto-bootstrap: it launches Claude Code in the session terminal, derives
the gateway auth from its own credentials, and pre-accepts the first-run
trust/onboarding prompts — no CLI client required.
:param base_url: Spawned server base URL.
:param runner_id: The token-bound runner id to bind.
:param terminal_launch_args: Pass-through ``claude`` CLI args persisted on
the session (``conversations.terminal_launch_args``); the runner threads
them into the terminal launch before its own bridge/MCP/hook wiring (see
``_build_claude_native_base_args`` in ``omnigent/runner/app.py``). Used
by the plan-mode fixture to pass ``["--permission-mode", "plan"]`` so
Claude boots into plan mode and reaches for ``ExitPlanMode``. ``None``
launches with the production defaults.
:returns: The new session/conversation id.
"""
import json as _json
import tempfile
from omnigent._wrapper_labels import (
CLAUDE_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.claude_native import _materialize_claude_agent_spec
with tempfile.TemporaryDirectory() as _tmp:
spec_path = _materialize_claude_agent_spec(Path(_tmp))
yaml_text = spec_path.read_text()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = yaml_text.encode()
# Non-config.yaml arcname → omnigent compat translator (the spec has
# no spec_version), matching the terminal_session fixture.
info = tarfile.TarInfo("claude-native-ui.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
labels = {
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: CLAUDE_NATIVE_WRAPPER_VALUE,
}
metadata: dict[str, object] = {"labels": labels}
if terminal_launch_args:
metadata["terminal_launch_args"] = terminal_launch_args
create = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps(metadata)},
files={"bundle": ("claude-native-ui.tar.gz", buf.getvalue(), "application/gzip")},
timeout=30.0,
)
create.raise_for_status()
session_id = str(create.json()["session_id"])
_bind_session_runner(base_url, session_id, runner_id)
return session_id
@pytest.fixture
def native_claude_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound session on the real ``claude-native`` ("Claude Code") wrapper.
The runner auto-launches Claude Code in the session terminal on bind
(gateway auth + first-run pre-accept handled runner-side), so the SPA's
Terminal view attaches to a live Claude Code TUI and its Chat view renders
the same canonical transcript. Drives the native render-parity suite.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_native_claude_session(live_server, runner_id)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
# Escalate to SIGKILL if the runner ignores SIGTERM, so a wedged
# process can't raise in teardown and leak / fail unrelated tests
# (matching terminal_session / seeded_session_pair).
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
@pytest.fixture
def native_claude_plan_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A native ``claude-native`` session launched in **plan mode**.
Identical to :func:`native_claude_session` except the session carries
``terminal_launch_args=["--permission-mode", "plan", "--disallowedTools",
"AskUserQuestion"]`` so the runner boots Claude Code into plan mode *with
the AskUserQuestion tool removed*. In plan mode Claude researches a task and
then calls its built-in ``ExitPlanMode`` tool to present the plan for
approval; that call rides the native ``PermissionRequest`` hook to the
server, which stamps the ``exit_plan_mode`` extras and publishes an
elicitation the SPA renders as ``ExitPlanModeReview`` inside an
``ApprovalCard``. Drives the Exit-Plan-Mode review e2e
(``approvals/test_exit_plan_mode.py``).
The ``--disallowedTools AskUserQuestion`` is the load-bearing flake fix:
given the deliberately under-specified plan prompt, Claude would otherwise
sometimes reach for its built-in ``AskUserQuestion`` tool first (to clarify
the comment text/location) instead of going straight to ``ExitPlanMode``,
surfacing the *wrong* approval card and timing the test out. Removing the
tool eliminates that degree of freedom structurally rather than relying on
the model's sampling. The AskUserQuestion render path keeps its own
dedicated coverage in ``approvals/test_ask_user_question.py``, so disabling
it here costs no coverage.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_native_claude_session(
live_server,
runner_id,
terminal_launch_args=[
"--permission-mode",
"plan",
# Remove AskUserQuestion so the under-specified plan prompt can only
# surface via ExitPlanMode (the card under test), never a clarifying
# question. See this fixture's docstring + test_exit_plan_mode.py.
"--disallowedTools",
"AskUserQuestion",
],
)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
def _create_native_codex_session(base_url: str, runner_id: str) -> str:
"""Register the ``codex-native`` wrapper agent and bind its session.
Reuses the exact terminal-first spec ``omnigent codex`` ships
(:func:`omnigent.codex_native._materialize_codex_agent_spec`) so the
fixture never drifts from production, and stamps the same wrapper /
terminal-first labels (``omnigent.wrapper`` + ``omnigent.ui = terminal``)
the CLI writes. The spec carries no ``spec_version``, so it is bundled
under a ``*.yaml`` arcname to route through the omnigent compat translator
(which preserves ``executor.harness`` + ``terminals:``); a ``config.yaml``
arcname would hit the strict parser and reject it.
Binding the session to the runner triggers the runner's codex-native
auto-bootstrap: it launches Codex in the session terminal, derives the
gateway auth from its own credentials, and pre-accepts the first-run
trust/onboarding prompts — no CLI client required. ``model=None`` lets the
configured provider's default model win (matching ``_build_codex_native_bundle``).
:param base_url: Spawned server base URL.
:param runner_id: The token-bound runner id to bind.
:returns: The new session/conversation id.
"""
import json as _json
import tempfile
from omnigent._wrapper_labels import (
CODEX_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.codex_native import _materialize_codex_agent_spec
with tempfile.TemporaryDirectory() as _tmp:
spec_path = _materialize_codex_agent_spec(Path(_tmp), model=None)
yaml_text = spec_path.read_text()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = yaml_text.encode()
# Non-config.yaml arcname → omnigent compat translator (the spec has
# no spec_version), matching the terminal_session fixture.
info = tarfile.TarInfo("codex-native-ui.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
labels = {
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: CODEX_NATIVE_WRAPPER_VALUE,
}
# Runner-owned Codex terminals hard-require a workspace: unlike the
# claude-native path (which falls back to Path.cwd()),
# _codex_session_workspace raises if neither the session's stored
# ``workspace`` nor OMNIGENT_RUNNER_WORKSPACE is set. Pin it on THIS
# session only (via metadata.workspace) rather than exporting
# OMNIGENT_RUNNER_WORKSPACE on the shared runner — a runner-wide value
# changes file-surface advertisement for every other session on the runner
# (it regressed the mobile file-drawer suite). The repo root is the same cwd
# claude falls back to, and is a valid dir on the runner's filesystem.
metadata = {"labels": labels, "workspace": str(_REPO_ROOT)}
create = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps(metadata)},
files={"bundle": ("codex-native-ui.tar.gz", buf.getvalue(), "application/gzip")},
timeout=30.0,
)
create.raise_for_status()
session_id = str(create.json()["session_id"])
_bind_session_runner(base_url, session_id, runner_id)
return session_id
@pytest.fixture
def native_codex_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound session on the real ``codex-native`` ("Codex") wrapper.
The runner auto-launches Codex in the session terminal on bind (gateway
auth + first-run pre-accept handled runner-side), so the SPA's Terminal
view attaches to a live Codex TUI and its Chat view renders the same
canonical transcript. Drives the native codex render-parity suite.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_native_codex_session(live_server, runner_id)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
# Escalate to SIGKILL if the runner ignores SIGTERM, so a wedged
# process can't raise in teardown and leak / fail unrelated tests
# (matching terminal_session / seeded_session_pair).
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
@contextlib.contextmanager
def _temp_omnigent_mock_config(
mock_llm_server_url: str, harness: str
) -> Generator[None, None, None]:
"""Temporarily write a mock provider config to ~/.omnigent/config.yaml.
The runner reads this at terminal-creation time, so it only needs to be
in place between the PATCH that binds a session to the runner (which
triggers auto-boot) and the terminal connecting. Restores the original
file (or removes it) on exit.
:param mock_llm_server_url: Base URL of the mock LLM server, e.g.
``"http://127.0.0.1:51235"``. No /v1 suffix — each SDK appends it.
:param harness: ``"claude"`` or ``"codex"``.
"""
config_dir = Path.home() / ".omnigent"
config_path = config_dir / "config.yaml"
config_dir.mkdir(parents=True, exist_ok=True)
original = config_path.read_text() if config_path.exists() else None
if harness == "claude":
mock_config = textwrap.dedent(f"""\
providers:
mock-claude:
kind: key
default: [anthropic]
anthropic:
base_url: "{mock_llm_server_url}"
api_key: "mock-key"
models:
default: {_CLAUDE_MOCK_MODEL}
""")
else: # codex
mock_config = textwrap.dedent(f"""\
providers:
mock-codex:
kind: key
default: [openai]
openai:
base_url: "{mock_llm_server_url}"
api_key: "mock-key"
wire_api: responses
models:
default: {_CODEX_MOCK_MODEL}
""")
config_path.write_text(mock_config)
try:
yield
finally:
if original is not None:
config_path.write_text(original)
else:
config_path.unlink(missing_ok=True)
@pytest.fixture
def native_claude_mock_session(
live_server: str,
mock_llm_server_url: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound claude-native session whose LLM backend depends on env.
When ``LLM_API_KEY`` is set in the environment (local dev / CI with real
credentials), the existing ``~/.omnigent/config.yaml`` is left untouched so
the runner boots Claude Code against the real gateway. When ``LLM_API_KEY``
is absent, a mock anthropic provider config is written to
``~/.omnigent/config.yaml`` and restored on teardown.
:param live_server: Spawned server fixture; its runner is reused.
:param mock_llm_server_url: Session-scoped mock LLM server base URL.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
use_mock = not os.environ.get("LLM_API_KEY")
if use_mock:
ctx: Any = _temp_omnigent_mock_config(mock_llm_server_url, "claude")
else:
ctx = contextlib.nullcontext()
with ctx:
session_id = _create_native_claude_session(live_server, runner_id)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
@pytest.fixture
def native_codex_mock_session(
live_server: str,
mock_llm_server_url: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound codex-native session whose LLM backend depends on env.
Mirrors :func:`native_claude_mock_session` for the Codex wrapper: uses
mock LLM when ``LLM_API_KEY`` is absent, real gateway when it is set.
:param live_server: Spawned server fixture; its runner is reused.
:param mock_llm_server_url: Session-scoped mock LLM server base URL.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
use_mock = not os.environ.get("LLM_API_KEY")
if use_mock:
ctx: Any = _temp_omnigent_mock_config(mock_llm_server_url, "codex")
else:
ctx = contextlib.nullcontext()
with ctx:
session_id = _create_native_codex_session(live_server, runner_id)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
@dataclass(frozen=True)
class MockedCodexNativeSession:
"""Session handle for native Codex e2e tests with mocked Responses API."""
base_url: str
session_id: str
sidecar: CodexResponsesSidecar
def _write_mock_codex_provider_config(config_home: Path, base_url: str) -> None:
"""Write provider config that routes native Codex to the sidecar."""
config_home.mkdir(parents=True, exist_ok=True)
(config_home / "config.yaml").write_text(
f"""\
providers:
codex-e2e-mock:
kind: key
default: openai
openai:
base_url: "{base_url}"
api_key: "sk-e2e-mock"
wire_api: responses
models:
default: mock-model
""",
encoding="utf-8",
)
@pytest.fixture
def mocked_native_codex_goal_session(
built_spa: None,
tmp_path_factory: pytest.TempPathFactory,
request: pytest.FixtureRequest,
) -> Iterator[MockedCodexNativeSession]:
"""Spawn native Codex against Codex's mock Responses sidecar.
This intentionally does not reuse the session-scoped ``live_server``:
native Codex reads provider config and bridge roots in subprocesses, so
the mock ``OMNIGENT_CONFIG_HOME`` / ``HOME`` / source ``CODEX_HOME`` must
be present before the AP server and runner start. Keeping a dedicated
server prevents those mock-only env vars from affecting unrelated UI tests
in the same shard.
"""
if request.config.getoption("--ui-base-url"):
pytest.skip("mocked native Codex goal e2e requires an isolated spawned server")
codex_path = shutil.which("codex")
if codex_path is None:
pytest.skip("codex CLI is required for mocked native Codex goal e2e")
if not _codex_cli_supports_goal_mode(codex_path):
pytest.skip("codex CLI >= 0.139.0 is required for app-server goal APIs")
try:
sidecar_bin = build_sidecar_bin()
except RuntimeError:
pytest.skip("cargo is required for Codex parity sidecar")
server_tmp = tmp_path_factory.mktemp("e2e_ui_codex_goal_server")
sidecar = start_codex_responses_sidecar(
sidecar_bin,
server_tmp / "responses.json",
[
[
ev_response_created("resp-goal-ui-bootstrap"),
ev_assistant_message("msg-goal-ui-bootstrap", "E2E_GOAL_BOOTSTRAP"),
ev_completed("resp-goal-ui-bootstrap"),
]
],
)
config_home = server_tmp / "config-home"
source_codex_home = server_tmp / "source-codex-home"
home_dir = server_tmp / "home"
state_dir = server_tmp / "codex-native-state"
artifact_dir = server_tmp / "artifacts"
for path in (source_codex_home, home_dir, state_dir, artifact_dir):
path.mkdir(parents=True, exist_ok=True)
_write_mock_codex_provider_config(config_home, sidecar.base_url)
port = _find_free_port()
log_path = server_tmp / "server.log"
runner_log_path = server_tmp / "runner.log"
db_path = server_tmp / "test.db"
agent_yaml_path = server_tmp / "hello_world.yaml"
agent_yaml_path.write_text(_TEST_AGENT_YAML, encoding="utf-8")
import secrets as _secrets
from omnigent.runner.identity import token_bound_runner_id
binding_token = _secrets.token_urlsafe(32)
runner_id = token_bound_runner_id(binding_token)
base_url = f"http://127.0.0.1:{port}"
shared_env = {
**os.environ,
"PYTHONPATH": f"{_REPO_ROOT}{os.pathsep}{os.environ.get('PYTHONPATH', '')}",
"OMNIGENT_CONFIG_HOME": str(config_home),
"OMNIGENT_CODEX_NATIVE_STATE_DIR": str(state_dir),
"CODEX_HOME": str(source_codex_home),
"HOME": str(home_dir),
}
server_env = {
**shared_env,
"OMNIGENT_RUNNER_TUNNEL_TOKEN": binding_token,
}
runner_env = {
**shared_env,
"OMNIGENT_RUNNER_ID": runner_id,
"OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token,
"OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()),
"RUNNER_SERVER_URL": base_url,
}
log_handle = open(log_path, "w") # noqa: SIM115
runner_log_handle = open(runner_log_path, "w") # noqa: SIM115
proc: subprocess.Popen[bytes] | None = None
runner_proc: subprocess.Popen[bytes] | None = None
session_id: str | None = None
try:
proc = subprocess.Popen(
[
sys.executable,
"-c",
"import omnigent.server.presence as _p; _p._LEAVE_GRACE_S = 1.0; "
+ "from omnigent.cli import main; main()",
"server",
"--host",
"127.0.0.1",
"--port",
str(port),
"--database-uri",
f"sqlite:///{db_path}",
"--artifact-location",
str(artifact_dir),
"--agent",
str(agent_yaml_path),
],
env=server_env,
stdout=log_handle,
stderr=subprocess.STDOUT,
)
runner_proc = subprocess.Popen(
[sys.executable, "-m", "omnigent.runner._entry"],
env=runner_env,
stdout=runner_log_handle,
stderr=subprocess.STDOUT,
)
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
ready = False
last_error = "not polled yet"
while time.monotonic() < deadline:
if proc.poll() is not None:
last_error = f"process exited early with code {proc.returncode}"
break
if runner_proc.poll() is not None:
last_error = f"runner exited early with code {runner_proc.returncode}"
break
try:
resp = httpx.get(f"{base_url}/health", timeout=2)
if resp.status_code == 200:
status_resp = httpx.get(
f"{base_url}/v1/runners/{runner_id}/status",
timeout=2,
)
if status_resp.status_code == 200 and status_resp.json()["online"] is True:
ready = True
break
last_error = (
f"runner status HTTP {status_resp.status_code}: {status_resp.text[:200]}"
)
else:
last_error = f"health HTTP {resp.status_code}: {resp.text[:200]}"
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException) as exc:
last_error = f"{type(exc).__name__}: {exc}"
time.sleep(_HEALTH_POLL_INTERVAL_S)
if not ready:
raise RuntimeError(
f"mocked Codex e2e server did not become healthy within "
f"{_HEALTH_TIMEOUT_S:.0f}s on {base_url} "
f"(last_error={last_error}).\n"
f"Server log at {log_path}:\n"
f"{log_path.read_text()[-3000:] if log_path.exists() else ''}\n"
f"Runner log at {runner_log_path}:\n"
f"{runner_log_path.read_text()[-3000:] if runner_log_path.exists() else ''}"
)
session_id = _create_native_codex_session(base_url, runner_id)
yield MockedCodexNativeSession(base_url=base_url, session_id=session_id, sidecar=sidecar)
finally:
if session_id is not None:
with contextlib.suppress(httpx.HTTPError):
httpx.delete(f"{base_url}/v1/sessions/{session_id}", timeout=10.0)
if runner_proc is not None and runner_proc.poll() is None:
runner_proc.send_signal(signal.SIGTERM)
try:
runner_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
runner_proc.kill()
runner_proc.wait(timeout=5)
if proc is not None and proc.poll() is None:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
runner_log_handle.close()
log_handle.close()
sidecar.close()
# ---------------------------------------------------------------------------
# ``native_cursor_session`` is the sibling native-CLI fixture for the
# ``cursor-native`` ("Cursor") wrapper: it spins up a real Cursor wrapper
# session — the same terminal-first spec ``omnigent cursor`` ships — and yields
# ``(base_url, session_id)``. The runner auto-launches ``cursor-agent`` in the
# session terminal on bind (``_auto_create_cursor_terminal`` in
# ``omnigent/runner/app.py``), exactly like the claude/codex fixtures.
#
# Two things differ from claude/codex, both stemming from cursor-agent owning
# its own auth/approval:
#
# * **Auth has NO Databricks-gateway path.** cursor-agent talks only to
# Cursor's backend, so it does not derive auth from the runner's gateway
# credentials the way Claude Code / Codex do. It authenticates from the
# ambient ``cursor-agent login`` (``$HOME/.cursor``, inherited by the runner)
# or an ambient ``CURSOR_API_KEY``. The render-parity test is therefore gated
# to skip when neither the binary nor a usable login is present (CI does not
# provision a Cursor account by default), so it stays green there while
# running for real wherever Cursor is logged in.
# * **``-f`` (force/trust) is passed as a launch arg.** cursor-agent blocks on a
# per-directory "Workspace Trust" prompt and per-tool approval prompts; in a
# runner-owned tmux pane there is no one to answer them, so the TUI would
# hang. ``-f`` (Cursor's force/yolo flag) clears both. It is threaded through
# ``terminal_launch_args`` exactly as the plan-mode claude fixture threads
# ``--permission-mode``.
# ---------------------------------------------------------------------------
def _create_native_cursor_session(
base_url: str, runner_id: str, *, launch_args: tuple[str, ...] = ("-f",)
) -> str:
"""Register the ``cursor-native`` wrapper agent and bind its session.
Reuses the exact terminal-first spec ``omnigent cursor`` ships
(:func:`omnigent.cursor_native._materialize_cursor_agent_spec`) so the
fixture never drifts from production, and stamps the same wrapper /
terminal-first labels (``omnigent.wrapper`` + ``omnigent.ui = terminal``)
the CLI writes. The spec carries no ``spec_version``, so it is bundled
under a ``*.yaml`` arcname to route through the omnigent compat translator
(which preserves ``executor.harness`` + ``terminals:``); a ``config.yaml``
arcname would hit the strict parser and reject it.
Binding the session to the runner triggers the runner's cursor-native
auto-bootstrap (:func:`omnigent.runner.app._auto_create_cursor_terminal`):
it launches ``cursor-agent`` in the session terminal — with the ``-f``
force/trust arg threaded via ``terminal_launch_args`` so the unattended
tmux pane never blocks on Cursor's workspace-trust / per-tool prompts — and
starts the forwarder that mirrors the TUI transcript back as conversation
items.
:param base_url: Spawned server base URL.
:param runner_id: The token-bound runner id to bind.
:returns: The new session/conversation id.
"""
import json as _json
import tempfile
from omnigent._wrapper_labels import (
CURSOR_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.cursor_native import _materialize_cursor_agent_spec
with tempfile.TemporaryDirectory() as _tmp:
spec_path = _materialize_cursor_agent_spec(Path(_tmp))
yaml_text = spec_path.read_text()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = yaml_text.encode()
# Non-config.yaml arcname → omnigent compat translator (the spec has
# no spec_version), matching the terminal_session fixture.
info = tarfile.TarInfo("cursor-native-ui.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
labels = {
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: CURSOR_NATIVE_WRAPPER_VALUE,
}
# Pin a real workspace on THIS session (like the codex fixture): the
# forwarder keys cursor's chat store by ``md5(cwd)``, so the TUI needs a
# concrete launch cwd. The repo root is a valid dir on the runner's
# filesystem. ``-f`` trusts that dir + auto-approves tools so the
# unattended pane never hangs on an approval prompt.
metadata = {
"labels": labels,
"workspace": str(_REPO_ROOT),
# ``-f`` (the default) trusts the dir + auto-approves tools so the
# unattended pane never hangs. The approval-mirror test passes
# ``launch_args=()`` so cursor's per-tool prompts fire and surface as
# web elicitation cards.
"terminal_launch_args": list(launch_args),
}
create = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps(metadata)},
files={"bundle": ("cursor-native-ui.tar.gz", buf.getvalue(), "application/gzip")},
timeout=30.0,
)
create.raise_for_status()
session_id = str(create.json()["session_id"])
_bind_session_runner(base_url, session_id, runner_id)
return session_id
def _create_native_goose_session(base_url: str, runner_id: str) -> str:
"""Register the ``goose-native`` wrapper agent and bind its session.
Mirrors :func:`_create_native_cursor_session`: reuses the exact terminal-first
spec ``omnigent goose`` ships
(:func:`omnigent.goose_native._materialize_goose_agent_spec`) and stamps the
same wrapper / terminal-first labels. Binding triggers the runner's
goose-native auto-bootstrap
(:func:`omnigent.runner.app._auto_create_goose_terminal`), which launches
``goose session`` in the session terminal and starts the forwarder that
mirrors the TUI transcript back as conversation items. Goose's tool-approval
gating is its own ``GOOSE_MODE`` (no ``-f`` equivalent), so no launch args.
:param base_url: Spawned server base URL.
:param runner_id: The token-bound runner id to bind.
:returns: The new session/conversation id.
"""
import json as _json
import tempfile
from omnigent._wrapper_labels import (
GOOSE_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.goose_native import _materialize_goose_agent_spec
with tempfile.TemporaryDirectory() as _tmp:
spec_path = _materialize_goose_agent_spec(Path(_tmp))
yaml_text = spec_path.read_text()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = yaml_text.encode()
info = tarfile.TarInfo("goose-native-ui.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
labels = {
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: GOOSE_NATIVE_WRAPPER_VALUE,
}
metadata = {
"labels": labels,
"workspace": str(_REPO_ROOT),
}
create = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps(metadata)},
files={"bundle": ("goose-native-ui.tar.gz", buf.getvalue(), "application/gzip")},
timeout=30.0,
)
create.raise_for_status()
session_id = str(create.json()["session_id"])
_bind_session_runner(base_url, session_id, runner_id)
return session_id
@pytest.fixture
def native_goose_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound session on the real ``goose-native`` ("Goose") wrapper.
The runner auto-launches ``goose session`` in the session terminal on bind,
so the SPA's Terminal view attaches to a live Goose TUI and its Chat view
renders the same canonical transcript. Drives the goose render-parity suite.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_native_goose_session(live_server, runner_id)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
def _create_native_kiro_session(base_url: str, runner_id: str) -> str:
"""Register the ``kiro-native`` wrapper agent and bind its session.
Mirrors :func:`_create_native_goose_session`: reuses the terminal-first spec
``omnigent kiro`` ships
(:func:`omnigent.kiro_native._materialize_kiro_agent_spec`) and stamps the
same wrapper / terminal-first labels. Binding triggers the runner's
kiro-native auto-bootstrap
(:func:`omnigent.runner.app._auto_create_kiro_terminal`), which launches the
``kiro-cli`` TUI in the session terminal and starts the forwarder that mirrors
the TUI transcript back as conversation items.
:param base_url: Spawned server base URL.
:param runner_id: The token-bound runner id to bind.
:returns: The new session/conversation id.
"""
import json as _json
import tempfile
from omnigent._wrapper_labels import (
KIRO_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.kiro_native import _materialize_kiro_agent_spec
with tempfile.TemporaryDirectory() as _tmp:
spec_path = _materialize_kiro_agent_spec(Path(_tmp), model=None)
yaml_text = spec_path.read_text()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = yaml_text.encode()
info = tarfile.TarInfo("kiro-native-ui.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
labels = {
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: KIRO_NATIVE_WRAPPER_VALUE,
}
metadata = {
"labels": labels,
"workspace": str(_REPO_ROOT),
}
create = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps(metadata)},
files={"bundle": ("kiro-native-ui.tar.gz", buf.getvalue(), "application/gzip")},
timeout=30.0,
)
create.raise_for_status()
session_id = str(create.json()["session_id"])
_bind_session_runner(base_url, session_id, runner_id)
return session_id
@pytest.fixture
def native_kiro_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound session on the real ``kiro-native`` ("Kiro") wrapper.
The runner auto-launches the ``kiro-cli`` TUI in the session terminal on bind,
so the SPA's Terminal view attaches to a live Kiro TUI and its Chat view
renders the same canonical transcript. Drives the kiro render-parity suite.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_native_kiro_session(live_server, runner_id)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
def _create_native_hermes_session(base_url: str, runner_id: str) -> str:
"""Register the ``hermes-native`` wrapper agent and bind its session.
Mirrors :func:`_create_native_goose_session`: reuses the exact terminal-first
spec ``omnigent hermes`` ships
(:func:`omnigent.hermes_native._materialize_hermes_agent_spec`) and stamps the
same wrapper / terminal-first labels. Binding triggers the runner's
hermes-native auto-bootstrap
(:func:`omnigent.runner.app._auto_create_hermes_terminal`), which launches the
``hermes`` TUI in the session terminal and starts the forwarder that mirrors
the TUI transcript back as conversation items.
:param base_url: Spawned server base URL.
:param runner_id: The token-bound runner id to bind.
:returns: The new session/conversation id.
"""
import json as _json
import tempfile
from omnigent._wrapper_labels import (
HERMES_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.hermes_native import _materialize_hermes_agent_spec
with tempfile.TemporaryDirectory() as _tmp:
spec_path = _materialize_hermes_agent_spec(Path(_tmp))
yaml_text = spec_path.read_text()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = yaml_text.encode()
info = tarfile.TarInfo("hermes-native-ui.yaml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
labels = {
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: HERMES_NATIVE_WRAPPER_VALUE,
}
metadata = {
"labels": labels,
"workspace": str(_REPO_ROOT),
}
create = httpx.post(
f"{base_url}/v1/sessions",
data={"metadata": _json.dumps(metadata)},
files={"bundle": ("hermes-native-ui.tar.gz", buf.getvalue(), "application/gzip")},
timeout=30.0,
)
create.raise_for_status()
session_id = str(create.json()["session_id"])
_bind_session_runner(base_url, session_id, runner_id)
return session_id
@pytest.fixture
def native_hermes_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound session on the real ``hermes-native`` ("Hermes") wrapper.
The runner auto-launches the ``hermes`` TUI in the session terminal on bind,
so the SPA's Terminal view attaches to a live Hermes TUI and its Chat view
renders the same canonical transcript. Drives the hermes render-parity suite.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_native_hermes_session(live_server, runner_id)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
@pytest.fixture
def native_cursor_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A runner-bound session on the real ``cursor-native`` ("Cursor") wrapper.
The runner auto-launches ``cursor-agent`` in the session terminal on bind
(ambient ``cursor-agent login`` / ``CURSOR_API_KEY`` auth + ``-f`` trust
handled runner-side), so the SPA's Terminal view attaches to a live Cursor
TUI and its Chat view renders the same canonical transcript. Drives the
native cursor render-parity suite.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_native_cursor_session(live_server, runner_id)
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
# Escalate to SIGKILL if the runner ignores SIGTERM, so a wedged
# process can't raise in teardown and leak / fail unrelated tests
# (matching terminal_session / seeded_session_pair).
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)
@pytest.fixture
def native_cursor_approval_session(
live_server: str,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[tuple[str, str]]:
"""A ``cursor-native`` session launched WITHOUT ``-f`` (prompts fire).
Identical to :func:`native_cursor_session` but omits the force/trust flag,
so ``cursor-agent`` raises its real per-tool approval prompts. The runner-
side mirror (:mod:`omnigent.cursor_native_permissions`) surfaces those as
web ``response.elicitation_request`` cards — what the approval-ordering test
drives. The first-run workspace-trust modal is dismissed by the executor's
inject path on the first composer turn.
:param live_server: Spawned server fixture; its runner is reused.
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
:returns: ``(base_url, session_id)``.
"""
respawned = _ensure_runner_online(live_server, tmp_path_factory)
runner_id = str(_server_state["runner_id"])
session_id = _create_native_cursor_session(live_server, runner_id, launch_args=())
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
if respawned is not None:
respawned.terminate()
try:
respawned.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned.kill()
respawned.wait(timeout=5)