"""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) == {}