Workspace moves stamp agent.session_cwd so a lazily started Codex thread starts in the moved-to directory. Agents that never had the attribute (test doubles, slotted objects) must keep working, so stamp only when the attribute exists. Test stubs of _set_session_context mirror the new cwd kwarg, and the Codex gateway test asserts the contract (no pinned session cwd) instead of the attribute's absence.
789 lines
34 KiB
Python
789 lines
34 KiB
Python
"""Integration test for the codex_app_server runtime path through AIAgent.
|
||
|
||
Verifies that:
|
||
- api_mode='codex_app_server' is accepted on AIAgent construction
|
||
- run_conversation() takes the early-return path and never enters the
|
||
chat completions loop
|
||
- Projected messages from a fake Codex session land in the messages list
|
||
- tool_iterations from the codex session tick the skill nudge counter
|
||
- Memory nudge counter ticks once per turn
|
||
- The returned dict has the same shape as the chat_completions path
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from types import SimpleNamespace
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
import run_agent
|
||
from agent.transports.codex_app_server_session import CodexAppServerSession, TurnResult
|
||
|
||
|
||
@pytest.fixture
|
||
def fake_session(monkeypatch):
|
||
"""Replace CodexAppServerSession with a stub that returns a fixed
|
||
TurnResult, so we can drive AIAgent without spawning real codex."""
|
||
|
||
def fake_run_turn(self, user_input: str, **kwargs):
|
||
return TurnResult(
|
||
final_text=f"echo: {user_input}",
|
||
projected_messages=[
|
||
{"role": "assistant", "content": None,
|
||
"tool_calls": [{"id": "exec_1", "type": "function",
|
||
"function": {"name": "exec_command",
|
||
"arguments": "{}"}}]},
|
||
{"role": "tool", "tool_call_id": "exec_1", "content": "ok"},
|
||
{"role": "assistant", "content": f"echo: {user_input}"},
|
||
],
|
||
tool_iterations=1,
|
||
interrupted=False,
|
||
error=None,
|
||
turn_id="turn-stub-1",
|
||
thread_id="thread-stub-1",
|
||
)
|
||
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||
monkeypatch.setattr(
|
||
CodexAppServerSession, "ensure_started", lambda self: "thread-stub-1"
|
||
)
|
||
|
||
|
||
def _make_codex_agent(**kwargs):
|
||
"""Construct an AIAgent in codex_app_server mode without contacting any
|
||
real provider. We pass api_mode explicitly so the constructor takes the
|
||
fast path for direct credentials."""
|
||
return run_agent.AIAgent(
|
||
api_key="stub",
|
||
base_url="https://stub.invalid",
|
||
provider="openai",
|
||
api_mode="codex_app_server",
|
||
quiet_mode=True,
|
||
skip_context_files=True,
|
||
skip_memory=True,
|
||
**kwargs,
|
||
)
|
||
|
||
|
||
class TestApiModeAccepted:
|
||
def test_api_mode_is_codex_app_server(self):
|
||
agent = _make_codex_agent()
|
||
assert agent.api_mode == "codex_app_server"
|
||
|
||
|
||
class TestRunConversationCodexPath:
|
||
def test_run_conversation_returns_codex_shape(self, fake_session):
|
||
agent = _make_codex_agent()
|
||
# No background review fork during tests
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("hello there")
|
||
assert result["final_response"] == "echo: hello there"
|
||
assert result["completed"] is True
|
||
assert result["partial"] is False
|
||
assert result["error"] is None
|
||
assert result["api_calls"] == 1
|
||
assert result["codex_thread_id"] == "thread-stub-1"
|
||
assert result["codex_turn_id"] == "turn-stub-1"
|
||
|
||
def test_codex_app_server_token_usage_updates_session_accounting(self, monkeypatch):
|
||
def fake_run_turn(self, user_input: str, **kwargs):
|
||
return TurnResult(
|
||
final_text="done",
|
||
projected_messages=[{"role": "assistant", "content": "done"}],
|
||
turn_id="turn-usage-1",
|
||
thread_id="thread-usage-1",
|
||
token_usage_last={
|
||
"totalTokens": 130,
|
||
"inputTokens": 80,
|
||
"cachedInputTokens": 20,
|
||
"outputTokens": 25,
|
||
"reasoningOutputTokens": 5,
|
||
},
|
||
model_context_window=200000,
|
||
)
|
||
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||
monkeypatch.setattr(
|
||
CodexAppServerSession, "ensure_started", lambda self: "thread-usage-1"
|
||
)
|
||
agent = _make_codex_agent()
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("hello")
|
||
|
||
assert result["api_calls"] == 1
|
||
assert result["prompt_tokens"] == 100
|
||
assert result["completion_tokens"] == 25
|
||
assert result["total_tokens"] == 130
|
||
assert result["input_tokens"] == 80
|
||
assert result["output_tokens"] == 25
|
||
assert result["cache_read_tokens"] == 20
|
||
assert result["cache_write_tokens"] == 0
|
||
assert result["reasoning_tokens"] == 5
|
||
assert result["last_prompt_tokens"] == 100
|
||
|
||
assert agent.session_api_calls == 1
|
||
assert agent.session_prompt_tokens == 100
|
||
assert agent.session_completion_tokens == 25
|
||
assert agent.session_total_tokens == 130
|
||
assert agent.session_input_tokens == 80
|
||
assert agent.session_output_tokens == 25
|
||
assert agent.session_cache_read_tokens == 20
|
||
assert agent.session_cache_write_tokens == 0
|
||
assert agent.session_reasoning_tokens == 5
|
||
assert agent.context_compressor.last_prompt_tokens == 100
|
||
assert agent.context_compressor.last_completion_tokens == 25
|
||
assert agent.context_compressor.last_total_tokens == 130
|
||
assert agent.context_compressor.context_length == 200000
|
||
|
||
def test_native_codex_compaction_updates_bookkeeping(self, monkeypatch):
|
||
def fake_run_turn(self, user_input: str, **kwargs):
|
||
return TurnResult(
|
||
final_text="done",
|
||
projected_messages=[{"role": "assistant", "content": "done"}],
|
||
turn_id="turn-compact-1",
|
||
thread_id="thread-compact-1",
|
||
compacted=True,
|
||
token_usage_last={
|
||
"totalTokens": 300_000,
|
||
"inputTokens": 300_000,
|
||
"cachedInputTokens": 0,
|
||
"outputTokens": 0,
|
||
"reasoningOutputTokens": 0,
|
||
},
|
||
)
|
||
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||
monkeypatch.setattr(
|
||
CodexAppServerSession, "ensure_started", lambda self: "thread-compact-1"
|
||
)
|
||
events = []
|
||
agent = _make_codex_agent(event_callback=lambda name, payload: events.append((name, payload)))
|
||
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("hello")
|
||
|
||
assert result["completed"] is True
|
||
assert agent.context_compressor.compression_count == 1
|
||
# A compacted turn with real usage is judged against that same real
|
||
# prompt count, exactly like a normal completed compression boundary.
|
||
assert agent.context_compressor.last_prompt_tokens == 300_000
|
||
assert agent.context_compressor.awaiting_real_usage_after_compression is False
|
||
assert agent.context_compressor._ineffective_compression_count == 1
|
||
assert events == [
|
||
(
|
||
"session:compress",
|
||
{
|
||
"platform": "",
|
||
"session_id": agent.session_id,
|
||
"old_session_id": "",
|
||
"in_place": False,
|
||
"compression_count": 1,
|
||
"runtime": "codex_app_server",
|
||
"thread_id": "thread-compact-1",
|
||
"turn_id": "turn-compact-1",
|
||
},
|
||
)
|
||
]
|
||
|
||
def test_projected_messages_are_spliced(self, fake_session):
|
||
agent = _make_codex_agent()
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("hello")
|
||
msgs = result["messages"]
|
||
# User message + 3 projected (assistant tool_call + tool + assistant text)
|
||
assert len(msgs) >= 4
|
||
assert msgs[0]["role"] == "user"
|
||
assert msgs[0]["content"] == "hello"
|
||
# Last assistant message has the final text
|
||
final = [m for m in msgs if m.get("role") == "assistant"
|
||
and m.get("content") == "echo: hello"]
|
||
assert final, f"expected final assistant message in {msgs}"
|
||
|
||
def test_projected_messages_are_synced_to_external_memory(self, fake_session):
|
||
agent = _make_codex_agent()
|
||
agent._memory_manager = MagicMock()
|
||
agent._memory_manager.build_system_prompt.return_value = ""
|
||
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("hello")
|
||
|
||
agent._memory_manager.sync_all.assert_called_once()
|
||
assert agent._memory_manager.sync_all.call_args.kwargs["messages"] == result["messages"]
|
||
|
||
def test_nudge_counters_tick(self, fake_session):
|
||
"""The skill nudge counter must accumulate tool_iterations across
|
||
turns. The memory nudge counter is gated on memory being configured
|
||
(which we skip via skip_memory=True), so we don't assert on it here —
|
||
a separate test below covers that path explicitly."""
|
||
agent = _make_codex_agent()
|
||
agent._iters_since_skill = 0
|
||
agent._user_turn_count = 0
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
agent.run_conversation("first")
|
||
assert agent._iters_since_skill == 1 # one tool_iteration in fake turn
|
||
# _user_turn_count is incremented by run_conversation pre-loop, not
|
||
# by the codex helper — confirms we delegate that to the standard flow.
|
||
assert agent._user_turn_count == 1
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
agent.run_conversation("second")
|
||
assert agent._iters_since_skill == 2
|
||
assert agent._user_turn_count == 2
|
||
|
||
def test_user_message_not_duplicated(self, fake_session):
|
||
"""Regression guard: the user message must appear exactly once in
|
||
the messages list. The standard run_conversation pre-loop appends
|
||
it, and the codex helper must NOT append again."""
|
||
agent = _make_codex_agent()
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("ping unique 12345")
|
||
user_count = sum(
|
||
1 for m in result["messages"]
|
||
if m.get("role") == "user" and m.get("content") == "ping unique 12345"
|
||
)
|
||
assert user_count == 1, f"user message appeared {user_count}× in {result['messages']}"
|
||
|
||
def test_background_review_NOT_invoked_below_threshold(self, fake_session):
|
||
"""A single turn shouldn't trigger background review — counters
|
||
haven't reached the nudge interval (default 10)."""
|
||
agent = _make_codex_agent()
|
||
agent._memory_nudge_interval = 10
|
||
agent._skill_nudge_interval = 10
|
||
agent._iters_since_skill = 0
|
||
with patch.object(agent, "_spawn_background_review",
|
||
return_value=None) as spawn:
|
||
agent.run_conversation("ping")
|
||
# Below threshold → review should NOT fire (was a real bug:
|
||
# the helper was calling _spawn_background_review() with no
|
||
# args after every turn, which would crash with TypeError).
|
||
assert not spawn.called
|
||
|
||
def test_background_review_skill_trigger_fires_above_threshold(
|
||
self, monkeypatch
|
||
):
|
||
"""When tool iterations cross the skill nudge interval, the
|
||
background review fires with review_skills=True and the right
|
||
messages_snapshot signature."""
|
||
from agent.transports.codex_app_server_session import (
|
||
CodexAppServerSession, TurnResult,
|
||
)
|
||
# Make the fake session report 10 tool iterations in one turn
|
||
# (matching the default skill threshold).
|
||
def fake_run_turn(self, user_input: str, **kwargs):
|
||
return TurnResult(
|
||
final_text=f"echo: {user_input}",
|
||
projected_messages=[
|
||
{"role": "assistant", "content": f"echo: {user_input}"},
|
||
],
|
||
tool_iterations=10,
|
||
turn_id="t1", thread_id="th1",
|
||
)
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||
monkeypatch.setattr(
|
||
CodexAppServerSession, "ensure_started", lambda self: "th1"
|
||
)
|
||
|
||
agent = _make_codex_agent()
|
||
agent._skill_nudge_interval = 10
|
||
agent._iters_since_skill = 0
|
||
# Make valid_tool_names include 'skill_manage' so the gate passes
|
||
agent.valid_tool_names = set(getattr(agent, "valid_tool_names", set()))
|
||
agent.valid_tool_names.add("skill_manage")
|
||
|
||
with patch.object(agent, "_spawn_background_review",
|
||
return_value=None) as spawn:
|
||
agent.run_conversation("do tool work")
|
||
|
||
assert spawn.called, "skill threshold tripped but review didn't fire"
|
||
# Verify the call signature matches what _spawn_background_review
|
||
# actually expects — this is the regression guard for the original
|
||
# bug where the codex path called it with no args at all.
|
||
call = spawn.call_args
|
||
assert "messages_snapshot" in call.kwargs
|
||
assert isinstance(call.kwargs["messages_snapshot"], list)
|
||
assert call.kwargs["review_skills"] is True
|
||
# Counter should be reset after the review fires
|
||
assert agent._iters_since_skill == 0
|
||
|
||
def test_background_review_signature_never_breaks(self, fake_session):
|
||
"""Even when no trigger fires, the helper must never call
|
||
_spawn_background_review with the wrong signature. Run a turn,
|
||
then run another turn after manually tripping the skill counter
|
||
and confirm the call shape is the kwargs-only form the function
|
||
actually accepts."""
|
||
agent = _make_codex_agent()
|
||
agent._skill_nudge_interval = 1 # very low so any iter trips it
|
||
agent._iters_since_skill = 0
|
||
agent.valid_tool_names = set(getattr(agent, "valid_tool_names", set()))
|
||
agent.valid_tool_names.add("skill_manage")
|
||
|
||
with patch.object(agent, "_spawn_background_review",
|
||
return_value=None) as spawn:
|
||
agent.run_conversation("first")
|
||
# The fake session reports tool_iterations=1, which trips
|
||
# _skill_nudge_interval=1. So review should fire.
|
||
assert spawn.called
|
||
# Critical invariant: positional args must be empty, all real
|
||
# args must be kwargs (matching _spawn_background_review's
|
||
# actual signature).
|
||
call = spawn.call_args
|
||
assert call.args == (), (
|
||
f"expected no positional args, got {call.args!r} — "
|
||
"would crash _spawn_background_review at runtime"
|
||
)
|
||
assert "messages_snapshot" in call.kwargs
|
||
|
||
def test_chat_completions_loop_is_not_entered(self, fake_session):
|
||
"""The early-return must bypass the regular API call loop entirely.
|
||
We confirm by patching the SDK call and asserting it's never invoked."""
|
||
agent = _make_codex_agent()
|
||
# The chat_completions loop calls self.client.chat.completions.create(...)
|
||
# If our early-return works, that path is dead.
|
||
with patch.object(agent, "client") as client_mock, patch.object(
|
||
agent, "_spawn_background_review", return_value=None
|
||
):
|
||
agent.run_conversation("hi")
|
||
assert not client_mock.chat.completions.create.called
|
||
|
||
def test_gateway_terminal_cwd_seeds_codex_thread_cwd(self, monkeypatch, tmp_path):
|
||
"""Gateway sessions set TERMINAL_CWD without pinning agent.session_cwd.
|
||
Codex app-server must still start in that configured workspace instead
|
||
of falling back to the Hermes daemon process cwd."""
|
||
from agent.transports.codex_app_server_session import (
|
||
CodexAppServerSession, TurnResult,
|
||
)
|
||
|
||
captured: dict[str, str] = {}
|
||
|
||
def fake_init(self, **kwargs):
|
||
captured["cwd"] = kwargs["cwd"]
|
||
self._thread_id = "thread-stub-1"
|
||
|
||
def fake_run_turn(self, user_input: str, **kwargs):
|
||
return TurnResult(
|
||
final_text="ok",
|
||
projected_messages=[{"role": "assistant", "content": "ok"}],
|
||
turn_id="turn-stub-1",
|
||
thread_id="thread-stub-1",
|
||
)
|
||
|
||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||
monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init)
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||
|
||
agent = _make_codex_agent()
|
||
assert agent.session_cwd is None
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
agent.run_conversation("hi")
|
||
|
||
assert captured["cwd"] == str(tmp_path)
|
||
|
||
def _capture_routing_agent(self, monkeypatch):
|
||
"""Build a codex agent with a CodexAppServerSession stub that captures
|
||
the request_routing passed at construction time, so we can assert how
|
||
the gateway-context approval routing was resolved."""
|
||
captured: dict = {}
|
||
|
||
def fake_init(self, **kwargs):
|
||
captured.update(kwargs)
|
||
self._thread_id = "thread-stub-1"
|
||
|
||
def fake_run_turn(self, user_input: str, **kwargs):
|
||
return TurnResult(
|
||
final_text="ok",
|
||
projected_messages=[{"role": "assistant", "content": "ok"}],
|
||
turn_id="turn-stub-1",
|
||
thread_id="thread-stub-1",
|
||
)
|
||
|
||
monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init)
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||
monkeypatch.setattr(
|
||
CodexAppServerSession, "ensure_started", lambda self: "thread-stub-1"
|
||
)
|
||
return captured
|
||
|
||
def test_approvals_mode_off_auto_approves_codex_server_requests(
|
||
self, monkeypatch
|
||
):
|
||
"""When the user disables Hermes approvals, codex app-server approval
|
||
requests should not fail closed just because no interactive callback is
|
||
wired (the typical gateway path). Codex's own sandbox permission
|
||
profile remains the filesystem boundary."""
|
||
captured = self._capture_routing_agent(monkeypatch)
|
||
with patch(
|
||
"hermes_cli.config.load_config_readonly",
|
||
return_value={"approvals": {"mode": "off"}},
|
||
):
|
||
agent = _make_codex_agent()
|
||
with patch.object(
|
||
agent, "_spawn_background_review", return_value=None
|
||
):
|
||
agent.run_conversation("write something")
|
||
routing = captured["request_routing"]
|
||
assert routing.auto_approve_exec is True
|
||
assert routing.auto_approve_apply_patch is True
|
||
|
||
def test_yaml_boolean_false_approval_mode_also_auto_approves(
|
||
self, monkeypatch
|
||
):
|
||
"""YAML 1.1 parses unquoted `off` as False; match the normal approval
|
||
subsystem's compatibility behavior for codex app-server routing too."""
|
||
captured = self._capture_routing_agent(monkeypatch)
|
||
with patch(
|
||
"hermes_cli.config.load_config_readonly",
|
||
return_value={"approvals": {"mode": False}},
|
||
):
|
||
agent = _make_codex_agent()
|
||
with patch.object(
|
||
agent, "_spawn_background_review", return_value=None
|
||
):
|
||
agent.run_conversation("write something")
|
||
routing = captured["request_routing"]
|
||
assert routing.auto_approve_exec is True
|
||
assert routing.auto_approve_apply_patch is True
|
||
|
||
def test_manual_approvals_keep_codex_server_requests_fail_closed(
|
||
self, monkeypatch
|
||
):
|
||
"""Default (manual) approvals must preserve the fail-closed behavior —
|
||
this fix is a no-op for users who haven't opted out."""
|
||
captured = self._capture_routing_agent(monkeypatch)
|
||
with patch(
|
||
"hermes_cli.config.load_config",
|
||
return_value={"approvals": {"mode": "manual"}},
|
||
):
|
||
agent = _make_codex_agent()
|
||
with patch.object(
|
||
agent, "_spawn_background_review", return_value=None
|
||
):
|
||
agent.run_conversation("write something")
|
||
routing = captured["request_routing"]
|
||
assert routing.auto_approve_exec is False
|
||
assert routing.auto_approve_apply_patch is False
|
||
|
||
def test_frozen_yolo_env_auto_approves_codex_server_requests(
|
||
self, monkeypatch
|
||
):
|
||
"""--yolo / HERMES_YOLO_MODE (frozen into _YOLO_MODE_FROZEN at import
|
||
time — a prompt-injection-safe process-scoped bypass) should flow
|
||
through to codex app-server routing so gateway/cron contexts do not
|
||
fail closed when the user launched with yolo mode."""
|
||
import tools.approval as _approval
|
||
|
||
captured = self._capture_routing_agent(monkeypatch)
|
||
monkeypatch.setattr(_approval, "_YOLO_MODE_FROZEN", True)
|
||
with patch(
|
||
"hermes_cli.config.load_config",
|
||
return_value={"approvals": {"mode": "manual"}},
|
||
):
|
||
agent = _make_codex_agent()
|
||
with patch.object(
|
||
agent, "_spawn_background_review", return_value=None
|
||
):
|
||
agent.run_conversation("write something")
|
||
routing = captured["request_routing"]
|
||
assert routing.auto_approve_exec is True
|
||
assert routing.auto_approve_apply_patch is True
|
||
|
||
def test_session_yolo_auto_approves_codex_server_requests(
|
||
self, monkeypatch
|
||
):
|
||
"""The /yolo session toggle should be honored at Codex session creation
|
||
time, independent of the startup-time approvals config."""
|
||
captured = self._capture_routing_agent(monkeypatch)
|
||
with patch(
|
||
"hermes_cli.config.load_config",
|
||
return_value={"approvals": {"mode": "manual"}},
|
||
):
|
||
agent = _make_codex_agent()
|
||
with patch(
|
||
"tools.approval.is_approval_bypass_active_for_session",
|
||
return_value=True,
|
||
), patch.object(
|
||
agent, "_spawn_background_review", return_value=None
|
||
):
|
||
agent.run_conversation("write something")
|
||
routing = captured["request_routing"]
|
||
assert routing.auto_approve_exec is True
|
||
assert routing.auto_approve_apply_patch is True
|
||
|
||
|
||
class TestReviewForkApiModeDowngrade:
|
||
"""When the parent agent runs on codex_app_server, the background
|
||
review fork must downgrade to codex_responses — otherwise the fork
|
||
can't dispatch agent-loop tools (memory, skill_manage) which is the
|
||
whole point of the review."""
|
||
|
||
def test_codex_app_server_parent_downgrades_review_fork(self):
|
||
"""Live test against the real _spawn_background_review code path:
|
||
verify the review_agent gets api_mode=codex_responses when the
|
||
parent is codex_app_server."""
|
||
from unittest.mock import MagicMock, patch as _patch
|
||
agent = _make_codex_agent()
|
||
# Pretend memory + skills are configured so the review fork
|
||
# reaches the AIAgent constructor.
|
||
agent._memory_store = MagicMock()
|
||
agent._memory_enabled = True
|
||
agent._user_profile_enabled = True
|
||
# Mock _current_main_runtime to return the parent's codex_app_server
|
||
# state so we can confirm the helper detects + downgrades it.
|
||
agent._current_main_runtime = lambda: {
|
||
"api_mode": "codex_app_server",
|
||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||
"api_key": "stub-token",
|
||
}
|
||
# Capture what AIAgent gets constructed with inside the helper.
|
||
captured = {}
|
||
|
||
def _capture_init(self, **kwargs):
|
||
captured.update(kwargs)
|
||
# Set bare attributes the rest of the spawn function reads
|
||
# so it can finish without exploding.
|
||
self.api_mode = kwargs.get("api_mode")
|
||
self.provider = kwargs.get("provider")
|
||
self.model = kwargs.get("model")
|
||
self._memory_write_origin = None
|
||
self._memory_write_context = None
|
||
self._memory_store = None
|
||
self._memory_enabled = False
|
||
self._user_profile_enabled = False
|
||
self._memory_nudge_interval = 0
|
||
self._skill_nudge_interval = 0
|
||
self.suppress_status_output = False
|
||
self._session_messages = []
|
||
|
||
def _no_op_run_conv(*a, **kw):
|
||
return {"final_response": "", "messages": []}
|
||
self.run_conversation = _no_op_run_conv
|
||
|
||
def _no_op_close(*a, **kw):
|
||
return None
|
||
self.close = _no_op_close
|
||
|
||
with _patch("run_agent.AIAgent.__init__", _capture_init):
|
||
agent._spawn_background_review(
|
||
messages_snapshot=[{"role": "user", "content": "x"}],
|
||
review_memory=True,
|
||
review_skills=False,
|
||
)
|
||
# Wait for the spawned thread to actually execute
|
||
import time
|
||
for _ in range(30):
|
||
if "api_mode" in captured:
|
||
break
|
||
time.sleep(0.1)
|
||
|
||
assert captured.get("api_mode") == "codex_responses", (
|
||
f"review fork should be downgraded to codex_responses when "
|
||
f"parent is codex_app_server; got {captured.get('api_mode')!r}"
|
||
)
|
||
|
||
|
||
class TestErrorHandling:
|
||
def test_session_exception_returns_partial_with_error(self, monkeypatch):
|
||
def boom_run_turn(self, user_input, **kwargs):
|
||
raise RuntimeError("subprocess died")
|
||
|
||
monkeypatch.setattr(CodexAppServerSession, "ensure_started",
|
||
lambda self: "t1")
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", boom_run_turn)
|
||
|
||
agent = _make_codex_agent()
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("hi")
|
||
assert result["completed"] is False
|
||
assert result["partial"] is True
|
||
assert "subprocess died" in result["error"]
|
||
assert "codex-runtime auto" in result["final_response"]
|
||
|
||
def test_interrupted_turn_marked_partial(self, monkeypatch):
|
||
def interrupted_turn(self, user_input, **kwargs):
|
||
return TurnResult(
|
||
final_text="",
|
||
projected_messages=[],
|
||
tool_iterations=0,
|
||
interrupted=True,
|
||
error="user interrupted",
|
||
turn_id="t",
|
||
thread_id="th",
|
||
)
|
||
monkeypatch.setattr(CodexAppServerSession, "ensure_started",
|
||
lambda self: "th")
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", interrupted_turn)
|
||
|
||
agent = _make_codex_agent()
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("hi")
|
||
assert result["completed"] is False
|
||
assert result["partial"] is True
|
||
assert result["error"] == "user interrupted"
|
||
|
||
|
||
class TestSessionRetirementOnRunAgent:
|
||
"""run_agent.py side: when run_turn returns should_retire=True, the
|
||
AIAgent must close + null _codex_session so the next turn respawns."""
|
||
|
||
def test_should_retire_drops_session(self, monkeypatch):
|
||
closes = {"count": 0}
|
||
|
||
def fake_run_turn(self, user_input, **kwargs):
|
||
return TurnResult(
|
||
final_text="",
|
||
projected_messages=[],
|
||
tool_iterations=0,
|
||
interrupted=True,
|
||
error="turn timed out after 600.0s",
|
||
turn_id="tu1",
|
||
thread_id="th1",
|
||
should_retire=True,
|
||
)
|
||
|
||
def fake_close(self):
|
||
closes["count"] += 1
|
||
|
||
monkeypatch.setattr(CodexAppServerSession, "ensure_started",
|
||
lambda self: "th1")
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||
monkeypatch.setattr(CodexAppServerSession, "close", fake_close)
|
||
|
||
agent = _make_codex_agent()
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("hi")
|
||
|
||
# The session was closed and cleared
|
||
assert closes["count"] == 1
|
||
assert getattr(agent, "_codex_session", "MISSING") is None
|
||
# Partial result was still returned (caller still sees the error)
|
||
assert result["partial"] is True
|
||
assert result["error"] == "turn timed out after 600.0s"
|
||
|
||
def test_normal_turn_keeps_session(self, fake_session):
|
||
"""fake_session fixture returns should_retire=False (default).
|
||
The session must stay attached for the next turn to reuse."""
|
||
agent = _make_codex_agent()
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
agent.run_conversation("hi")
|
||
# Session was lazily created and still attached.
|
||
assert getattr(agent, "_codex_session", None) is not None
|
||
|
||
def test_exception_path_also_drops_session(self, monkeypatch):
|
||
"""Even if run_turn raises (not just sets should_retire), we must
|
||
drop the session — a thrown exception is the strongest possible
|
||
signal the process is dead."""
|
||
closes = {"count": 0}
|
||
|
||
def boom_run_turn(self, user_input, **kwargs):
|
||
raise RuntimeError("codex segfaulted")
|
||
|
||
def fake_close(self):
|
||
closes["count"] += 1
|
||
|
||
monkeypatch.setattr(CodexAppServerSession, "ensure_started",
|
||
lambda self: "th1")
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", boom_run_turn)
|
||
monkeypatch.setattr(CodexAppServerSession, "close", fake_close)
|
||
|
||
agent = _make_codex_agent()
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
result = agent.run_conversation("hi")
|
||
|
||
assert closes["count"] == 1
|
||
assert agent._codex_session is None
|
||
assert result["completed"] is False
|
||
assert "codex segfaulted" in result["error"]
|
||
|
||
|
||
class TestCodexToolProgressBridge:
|
||
"""#38835 / #33200: Codex app-server item notifications must surface as
|
||
Hermes tool-progress so gateways show verbose breadcrumbs on this route.
|
||
The original item/started-only mapper was superseded by the full event
|
||
bridge (make_codex_app_server_event_bridge); these tests pin the same
|
||
mapping contract against the bridge helpers."""
|
||
|
||
def test_mapper_command_execution(self):
|
||
from agent.codex_runtime import (
|
||
_codex_item_to_args,
|
||
_codex_item_to_preview,
|
||
_codex_item_to_tool_name,
|
||
)
|
||
item = {"type": "commandExecution", "command": "ls -la", "cwd": "/tmp"}
|
||
assert _codex_item_to_tool_name(item) == "exec_command"
|
||
assert _codex_item_to_preview(item) == "ls -la"
|
||
assert _codex_item_to_args(item) == {"command": "ls -la", "cwd": "/tmp"}
|
||
|
||
def test_mapper_file_change(self):
|
||
from agent.codex_runtime import (
|
||
_codex_item_to_preview,
|
||
_codex_item_to_tool_name,
|
||
)
|
||
item = {
|
||
"type": "fileChange",
|
||
"changes": [{"path": "a.py"}, {"path": "b.py"}],
|
||
}
|
||
assert _codex_item_to_tool_name(item) == "apply_patch"
|
||
assert _codex_item_to_preview(item) == "a.py, b.py"
|
||
|
||
def test_mapper_mcp_and_dynamic_tool_calls(self):
|
||
from agent.codex_runtime import (
|
||
_codex_item_to_args,
|
||
_codex_item_to_tool_name,
|
||
)
|
||
mcp = {"type": "mcpToolCall", "server": "fs", "tool": "read", "arguments": {"p": 1}}
|
||
assert _codex_item_to_tool_name(mcp) == "mcp.fs.read"
|
||
assert _codex_item_to_args(mcp) == {"p": 1}
|
||
|
||
dyn = {"type": "dynamicToolCall", "tool": "web_search", "arguments": {"q": "x"}}
|
||
assert _codex_item_to_tool_name(dyn) == "web_search"
|
||
|
||
def test_bridge_ignores_non_tool_items_and_other_methods(self):
|
||
from agent.codex_runtime import make_codex_app_server_event_bridge
|
||
events = []
|
||
agent = SimpleNamespace(
|
||
tool_progress_callback=lambda *a, **kw: events.append(a),
|
||
_fire_stream_delta=None,
|
||
_fire_reasoning_delta=None,
|
||
_emit_interim_assistant_message=None,
|
||
)
|
||
on_event = make_codex_app_server_event_bridge(agent)
|
||
# agentMessage started items are not tool-shaped
|
||
on_event({"method": "item/started", "params": {
|
||
"item": {"type": "agentMessage", "text": "hi"}}})
|
||
# malformed / empty notes
|
||
on_event({"method": "item/completed", "params": {}})
|
||
on_event({})
|
||
assert events == []
|
||
|
||
def test_session_wired_with_on_event_that_fires_tool_progress(self, monkeypatch):
|
||
"""The session is constructed with an on_event hook that, when fed an
|
||
item/started note, calls the agent's tool_progress_callback."""
|
||
captured_init = {}
|
||
events = []
|
||
|
||
def fake_init(self, **kwargs):
|
||
captured_init.update(kwargs)
|
||
# minimal attrs so the rest of run_turn stubs work
|
||
self._client = None
|
||
|
||
def fake_run_turn(self, user_input, **kwargs):
|
||
# Exercise the wired on_event hook with a real item/started note.
|
||
on_event = captured_init.get("on_event")
|
||
if on_event:
|
||
on_event({"method": "item/started", "params": {"item": {
|
||
"type": "commandExecution", "command": "pytest", "cwd": "/repo"}}})
|
||
return TurnResult(final_text="done", projected_messages=[
|
||
{"role": "assistant", "content": "done"}], turn_id="t1", thread_id="th1")
|
||
|
||
monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init)
|
||
monkeypatch.setattr(CodexAppServerSession, "ensure_started", lambda self: "th1")
|
||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||
|
||
agent = _make_codex_agent()
|
||
agent.tool_progress_callback = lambda kind, name, preview, args: events.append(
|
||
(kind, name, preview))
|
||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||
agent.run_conversation("run the tests")
|
||
|
||
assert "on_event" in captured_init and captured_init["on_event"] is not None
|
||
assert ("tool.started", "exec_command", "pytest") in events
|