Files
omnigent-ai--omnigent/tests/test_codex_native_bridge.py
2026-07-13 13:12:00 +08:00

327 lines
13 KiB
Python

"""Tests for native Codex bridge state helpers."""
from __future__ import annotations
from pathlib import Path
import pytest
from omnigent.codex_native_bridge import (
CodexNativeBridgeState,
cancel_pending_mcp_startup,
clear_active_turn_id_if_matches,
clear_bridge_state,
codex_home_for_bridge_dir,
mcp_startup_waiting_detail,
pending_mcp_servers,
prepare_bridge_dir,
read_bridge_startup_error,
read_bridge_state,
read_codex_config_model,
read_mcp_startup,
read_policy_hook_config,
settle_pending_mcp_startup,
update_mcp_server_startup,
write_bridge_startup_error,
write_bridge_state,
write_policy_hook_config,
)
def _seed_active_turn(bridge_dir: Path, active_turn_id: str | None) -> None:
"""
Write bridge state with a given active turn id.
:param bridge_dir: Native Codex bridge directory.
:param active_turn_id: Active turn id to seed, e.g. ``"turn_1"``,
or ``None`` for no running turn.
:returns: None.
"""
write_bridge_state(
bridge_dir,
CodexNativeBridgeState(
session_id="conv_test",
socket_path=str(bridge_dir / "app-server.sock"),
thread_id="thread_test",
codex_home=str(bridge_dir / "codex-home"),
active_turn_id=active_turn_id,
),
)
@pytest.fixture
def bridge_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""
Create an isolated bridge directory rooted under ``tmp_path``.
:param tmp_path: pytest temp directory.
:param monkeypatch: pytest monkeypatch fixture.
:returns: Prepared bridge directory.
"""
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "codex-native")
return prepare_bridge_dir("bridge_test")
def _write_config(bridge_dir: Path, body: str) -> None:
"""
Write a ``config.toml`` into the bridge's per-session ``CODEX_HOME``.
:param bridge_dir: Bridge dir whose ``codex-home/config.toml`` is written.
:param body: Raw TOML body, e.g. ``'model = "gpt-5.4"\\n'``.
"""
home = codex_home_for_bridge_dir(bridge_dir)
home.mkdir(parents=True, exist_ok=True)
(home / "config.toml").write_text(body)
def test_read_codex_config_model_returns_top_level_model(bridge_dir: Path) -> None:
"""The top-level ``model`` key (what /model writes) is returned.
This is the cost gate's source of truth read at evaluation time; if it
returned the wrong key or None, a ``/model`` downgrade would never take
effect on the next tool call.
"""
_write_config(bridge_dir, 'model_provider = "databricks"\nmodel = "gpt-5.4"\n')
assert read_codex_config_model(bridge_dir) == "gpt-5.4"
def test_read_codex_config_model_none_when_missing(bridge_dir: Path) -> None:
"""No ``config.toml`` → ``None`` (fail-safe), so the caller falls back."""
assert read_codex_config_model(bridge_dir) is None
def test_read_codex_config_model_none_when_no_model_key(bridge_dir: Path) -> None:
"""A config without a top-level ``model`` key → ``None`` (no invented value)."""
_write_config(bridge_dir, 'model_reasoning_effort = "medium"\n')
assert read_codex_config_model(bridge_dir) is None
def test_read_codex_config_model_none_when_unparsable(bridge_dir: Path) -> None:
"""Malformed TOML → ``None``, not a crash (guards a partial write)."""
_write_config(bridge_dir, 'model = "gpt-5.4\n[broken')
assert read_codex_config_model(bridge_dir) is None
def test_policy_hook_config_round_trips(bridge_dir: Path) -> None:
"""
Written Omnigent coordinates read back verbatim for the policy hook.
The codex hook subprocess depends on this exact payload to reach the
Omnigent server. A failure (dropped/renamed field) would leave the hook
unable to POST, silently disabling enforcement.
"""
write_policy_hook_config(
bridge_dir,
ap_server_url="http://127.0.0.1:8787",
ap_auth_headers={"Authorization": "Bearer abc"},
)
config = read_policy_hook_config(bridge_dir)
assert config == {
"ap_server_url": "http://127.0.0.1:8787",
"ap_auth_headers": {"Authorization": "Bearer abc"},
}
def test_policy_hook_config_absent_returns_none(bridge_dir: Path) -> None:
"""
Reading before any write returns None (no Omnigent server configured).
The hook treats None as "nothing to enforce" and no-ops. A failure
(e.g. raising, or returning a partial dict) would crash the hook or
make it POST to a missing URL.
"""
assert read_policy_hook_config(bridge_dir) is None
@pytest.mark.parametrize(
("active_turn_id", "completed_turn_id", "expected_return", "expected_active_after"),
[
# Matching terminal: the active turn really ended → clear + report
# cleared, so the forwarder posts idle.
("turn_1", "turn_1", True, None),
# Stale terminal for an older turn while a newer one is live → ignore,
# leaving the newer turn intact (no premature idle).
("turn_1", "turn_2", False, "turn_1"),
# No-id terminal while a turn is live is ambiguous → ignore. This is
# the fix: clearing here posted a premature idle that hid the
# "working" spinner mid-turn while Codex kept streaming.
("turn_1", None, False, "turn_1"),
# No-id terminal with no active turn: nothing to protect → clear is a
# no-op and reports cleared (the session is already idle).
(None, None, True, None),
# Id terminal with no active turn: it matches nothing → ignore.
(None, "turn_1", False, None),
],
)
def test_clear_active_turn_id_if_matches(
bridge_dir: Path,
active_turn_id: str | None,
completed_turn_id: str | None,
expected_return: bool,
expected_active_after: str | None,
) -> None:
"""
Terminal events only clear the active turn when they belong to it.
Guards the spinner/steering invariant: a terminal event clears the
active turn (and lets the forwarder post idle) only when it matches
the live turn. A stale id, or an ambiguous id-less event while a turn
is live, must be ignored so a still-running turn is not marked idle.
:param bridge_dir: Isolated bridge directory fixture.
:param active_turn_id: Active turn id seeded before the call, e.g.
``"turn_1"``, or ``None`` for no running turn.
:param completed_turn_id: Terminal event's turn id, e.g. ``"turn_1"``,
or ``None`` when Codex omitted it.
:param expected_return: Expected ``clear_active_turn_id_if_matches``
return — ``True`` means the forwarder will post idle.
:param expected_active_after: Expected ``active_turn_id`` afterward.
:returns: None.
"""
_seed_active_turn(bridge_dir, active_turn_id)
result = clear_active_turn_id_if_matches(bridge_dir, completed_turn_id)
# Return value drives whether the forwarder posts idle. A wrong True on
# the (active="turn_1", completed=None) row is the spinner bug: idle
# posted mid-turn. A wrong False on the matching row would leave the
# spinner stuck on after the turn really ended.
assert result is expected_return
state = read_bridge_state(bridge_dir)
assert state is not None
# The cleared/preserved active turn id also governs steering: a turn
# wrongly cleared here means later web messages stop steering it.
assert state.active_turn_id == expected_active_after
def test_clear_active_turn_id_if_matches_no_state_returns_true(bridge_dir: Path) -> None:
"""
With no bridge state on disk, clearing is a no-op that reports cleared.
A missing state file means there is no turn to protect, so the helper
returns True (nothing to ignore). A failure (returning False) would
make the forwarder treat a normal terminal as stale and never post
idle, hanging the spinner.
"""
# bridge_dir exists (fixture) but no state.json was written.
assert clear_active_turn_id_if_matches(bridge_dir, "turn_1") is True
def test_bridge_startup_error_round_trips_and_is_cleared(bridge_dir: Path) -> None:
"""
The startup-error breadcrumb round-trips, and ``clear_bridge_state``
drops it before each launch so stale failures don't linger (issue #59).
"""
assert read_bridge_startup_error(bridge_dir) is None
write_bridge_startup_error(bridge_dir, "thread never started (TimeoutError)")
assert read_bridge_startup_error(bridge_dir) == "thread never started (TimeoutError)"
clear_bridge_state(bridge_dir)
assert read_bridge_startup_error(bridge_dir) is None
def test_mcp_startup_updates_round_trip(bridge_dir: Path) -> None:
"""
Per-server MCP startup updates accumulate and read back (issue #2058).
The executor's first-turn gate and the runner's Stop handler both key
off this map; the ``pending``/``waiting`` views must name exactly the
servers whose latest status is ``starting``.
"""
assert read_mcp_startup(bridge_dir) == {}
assert pending_mcp_servers({}) == []
assert mcp_startup_waiting_detail({}) is None
update_mcp_server_startup(bridge_dir, "safe", "starting")
update_mcp_server_startup(bridge_dir, "storage-console", "starting")
servers = update_mcp_server_startup(bridge_dir, "safe", "failed", error="handshake failed")
assert servers == read_mcp_startup(bridge_dir)
assert read_mcp_startup(bridge_dir) == {
"safe": {"status": "failed", "error": "handshake failed"},
"storage-console": {"status": "starting", "error": None},
}
# Only still-starting servers are pending; the failed one settled.
assert pending_mcp_servers(read_mcp_startup(bridge_dir)) == ["storage-console"]
assert (
mcp_startup_waiting_detail(read_mcp_startup(bridge_dir))
== "MCP startup still waiting on storage-console"
)
def test_cancel_pending_mcp_startup_flips_only_starting(bridge_dir: Path) -> None:
"""
Stop's local cancel flips ``starting`` servers to ``cancelled`` only.
Settled servers (ready/failed) must keep their state — rewriting them
would misreport what actually happened; a second cancel is a no-op so
a repeated Stop doesn't claim it cancelled anything.
"""
update_mcp_server_startup(bridge_dir, "safe", "ready")
update_mcp_server_startup(bridge_dir, "testman", "failed", error="boom")
update_mcp_server_startup(bridge_dir, "storage-console", "starting")
assert cancel_pending_mcp_startup(bridge_dir) == ["storage-console"]
assert read_mcp_startup(bridge_dir) == {
"safe": {"status": "ready", "error": None},
"testman": {"status": "failed", "error": "boom"},
"storage-console": {"status": "cancelled", "error": None},
}
# Nothing pending anymore → repeat cancel reports nothing flipped.
assert cancel_pending_mcp_startup(bridge_dir) == []
def test_settle_pending_mcp_startup_drops_only_starting(bridge_dir: Path) -> None:
"""
Settling drops unresolved ``starting`` entries and keeps terminal ones.
Codex never delivers per-server outcomes to Omnigent's observer
connection, so at settle the unresolved entries are removed rather
than guessed; a locally-cancelled server must survive so the web band
can keep saying it was cancelled. A second settle is a no-op.
"""
update_mcp_server_startup(bridge_dir, "safe", "starting")
update_mcp_server_startup(bridge_dir, "storage-console", "cancelled")
servers, changed = settle_pending_mcp_startup(bridge_dir)
assert changed is True
assert servers == {"storage-console": {"status": "cancelled", "error": None}}
assert read_mcp_startup(bridge_dir) == servers
# Fully settled → nothing to drop, nothing rewritten.
assert settle_pending_mcp_startup(bridge_dir) == (servers, False)
def test_read_mcp_startup_ignores_malformed_entries(bridge_dir: Path) -> None:
"""
Malformed or unknown-status entries are dropped on read.
A corrupt file must degrade to "no state" rather than crash the
executor gate or feed a bogus status into the web UI.
"""
(bridge_dir / "mcp_startup.json").write_text("not json")
assert read_mcp_startup(bridge_dir) == {}
(bridge_dir / "mcp_startup.json").write_text(
'{"servers": {"ok": {"status": "ready"}, "bad": {"status": "exploded"},'
' "": {"status": "ready"}}}'
)
assert read_mcp_startup(bridge_dir) == {"ok": {"status": "ready", "error": None}}
def test_clear_bridge_state_removes_mcp_startup(bridge_dir: Path) -> None:
"""
``clear_bridge_state`` drops the MCP startup map with the other
runtime state, so a relaunch never gates its first turn on a prior
app-server's startup round.
"""
update_mcp_server_startup(bridge_dir, "safe", "starting")
clear_bridge_state(bridge_dir)
assert read_mcp_startup(bridge_dir) == {}