ReMe/tests/unit/test_codex_agent_wrapper.py
jinliyl c1a25e9ff4
feat(agent): add Codex agent wrapper and ReMe MCP bridge (#358)
* feat(agent): add Codex wrapper integration

* feat(agent): enhance agent wrapper functionality and add comprehensive testing

- Implement structured output schema normalization across all wrappers
- Add Claude Code system prompt mode support with append/replace options
- Introduce Codex agent wrapper with streaming, tool context isolation, and skill management
- Enhance skill linking with validation and conflict resolution
- Add approval event streaming support for Codex wrapper
- Implement output schema validation and normalize function
- Create dedicated test suites for Claude Code and Codex integration
- Update README documentation for Codex wrapper capabilities
- Refactor kwargs merging with proper schema handling
- Add tool context validation when resuming sessions
- Implement proper cleanup and session management for Codex wrapper

* test(cc-agent): add test coverage for structured output scenarios

- Add docstring for empty schema validation in build_options
- Document falsy structured output preservation behavior
- Add docstring for streaming wrapper schema rejection
- Include lambda function reference for wrapper factory consistency
- Add test documentation for live Codex wrapper contract exercise

* docs: revert README changes

* fix(agent): interrupt abandoned Codex turns
2026-07-17 13:39:18 +08:00

730 lines
25 KiB
Python

"""Unit tests for the Codex agent wrapper and its FastMCP bridge."""
# pylint: disable=missing-class-docstring,missing-function-docstring,protected-access
import asyncio
from dataclasses import dataclass
import json
from pathlib import Path
import stat
import sys
from types import SimpleNamespace
import pytest
from openai_codex.generated.v2_all import TokenUsageBreakdown
from pydantic import BaseModel
from reme.components.agent_wrapper.codex_agent_wrapper import CodexAgentWrapper
from reme.components.agent_wrapper.codex_mcp_server import _load_job_names, _make_tool, build_server
from reme.components.job import BackgroundJob
from reme.config import resolve_app_config
from reme.enumeration import ChunkEnum, ComponentEnum
from reme.schema import ApplicationConfig, Response
class _Job:
def __init__(self, name="search"):
self.name = name
self.description = "Search memory"
self.parameters = {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
}
self.calls = []
async def __call__(self, **kwargs):
self.calls.append(kwargs)
return Response(answer=f"found:{kwargs['query']}")
def _wrapper(tmp_path, **kwargs):
job = _Job()
config = SimpleNamespace(
workspace_dir=str(tmp_path),
mem_session_dir="mem_session",
components={ComponentEnum.AS_LLM: {}},
model_dump=lambda **_kwargs: {
"workspace_dir": str(tmp_path),
"enable_logo": False,
"log_to_console": False,
"log_to_file": False,
"jobs": {},
"components": {},
},
)
context = SimpleNamespace(app_config=config, jobs={job.name: job})
return CodexAgentWrapper(app_context=context, **kwargs), job
def test_mcp_config_uses_stdio_bridge_and_selected_jobs(tmp_path):
wrapper, _job = _wrapper(tmp_path, mcp_config="custom.yaml")
config = wrapper._mcp_server_config( # pylint: disable=protected-access
{"job_tools": ["search"], "tool_context_id": "ctx-1"},
)
assert config["command"]
assert config["enabled_tools"] == ["search"]
assert "reme.components.agent_wrapper.codex_mcp_server" in config["args"]
assert config["args"][config["args"].index("--config") + 1] == str(tmp_path / "custom.yaml")
assert config["args"][config["args"].index("--tool-context-id") + 1] == "ctx-1"
def test_thread_config_preserves_other_mcp_servers(tmp_path):
wrapper, _job = _wrapper(tmp_path)
config = wrapper._thread_config( # pylint: disable=protected-access
{
"job_tools": ["search"],
"config": {"mcp_servers": {"docs": {"url": "https://example.test/mcp"}}},
},
)
assert "docs" in config["mcp_servers"]
assert len(config["mcp_servers"]) == 2
assert next(name for name in config["mcp_servers"] if name != "docs").startswith("reme_jobs_")
def test_mcp_config_rejects_background_jobs(tmp_path):
wrapper, _job = _wrapper(tmp_path)
wrapper.app_context.jobs["watch"] = BackgroundJob(name="watch", app_context=wrapper.app_context)
with pytest.raises(TypeError, match="non-stream request jobs"):
wrapper._mcp_server_config({"job_tools": ["watch"]})
def test_bridge_tool_injects_tool_context_id():
async def run():
job = _Job()
tool = _make_tool(job, "ctx-1")
result = await tool.run({"query": "alpha"})
assert job.calls == [{"query": "alpha", "tool_context_id": "ctx-1"}]
assert "found:alpha" in str(result.content)
asyncio.run(run())
def test_bridge_rejects_caller_tool_context_id():
async def run():
job = _Job()
tool = _make_tool(job, "ctx-1")
with pytest.raises(Exception, match="managed by the Codex agent wrapper"):
await tool.run({"query": "alpha", "tool_context_id": "caller"})
asyncio.run(run())
def test_build_server_registers_only_selected_jobs():
app = SimpleNamespace(
context=SimpleNamespace(jobs={"one": _Job("one"), "two": _Job("two")}),
start=lambda: None,
close=lambda: None,
)
async def run():
server = build_server(app, ["two"])
tools = await server.list_tools(run_middleware=False)
assert [tool.name for tool in tools] == ["two"]
asyncio.run(run())
def test_load_job_names_validates_json_array():
assert _load_job_names('["one", "two"]') == ["one", "two"]
with pytest.raises(ValueError, match="JSON array"):
_load_job_names('{"one": true}')
@pytest.mark.asyncio
async def test_stdio_bridge_starts_and_lists_selected_job(tmp_path):
from fastmcp import Client
from fastmcp.client import StdioTransport
config_path = tmp_path / "bridge.json"
config_path.write_text(
json.dumps(
{
"service": {"backend": "mcp"},
"workspace_dir": str(tmp_path / "workspace"),
"jobs": {
"empty": {
"backend": "base",
"description": "Return an empty response",
"parameters": {"type": "object", "properties": {}},
"steps": [],
},
},
},
),
encoding="utf-8",
)
transport = StdioTransport(
command=sys.executable,
args=[
"-m",
"reme.components.agent_wrapper.codex_mcp_server",
"--config",
str(config_path),
"--workspace",
str(tmp_path / "workspace"),
"--jobs",
'["empty"]',
],
cwd=str(Path(__file__).resolve().parents[2]),
)
async with Client(transport, timeout=10) as client:
tools = await client.list_tools()
assert [tool.name for tool in tools] == ["empty"]
@pytest.mark.asyncio
async def test_stdio_bridge_stdout_is_protocol_clean(tmp_path):
config_path = tmp_path / "bridge.json"
config_path.write_text(
json.dumps(
{
"service": {"backend": "mcp"},
"workspace_dir": str(tmp_path / "workspace"),
"jobs": {
"empty": {
"backend": "base",
"description": "Empty",
"parameters": {"type": "object", "properties": {}},
"steps": [],
},
},
},
),
encoding="utf-8",
)
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"reme.components.agent_wrapper.codex_mcp_server",
"--config",
str(config_path),
"--workspace",
str(tmp_path / "workspace"),
"--jobs",
'["empty"]',
cwd=str(Path(__file__).resolve().parents[2]),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
request = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"},
},
}
assert proc.stdin is not None and proc.stdout is not None and proc.stderr is not None
proc.stdin.write((json.dumps(request) + "\n").encode())
await proc.stdin.drain()
first_line = await asyncio.wait_for(proc.stdout.readline(), timeout=10)
message = json.loads(first_line)
assert message["jsonrpc"] == "2.0"
assert message["id"] == 1
proc.terminate()
await asyncio.wait_for(proc.wait(), timeout=10)
stdout = first_line + await proc.stdout.read()
stderr = (await proc.stderr.read()).decode()
assert b"Loading config" not in stdout
assert b"INFO" not in stdout
assert b"WARNING" not in stdout
assert b"2026-" not in stdout
assert "Failed to parse JSONRPC message" not in stderr
assert "Invalid JSON" not in stderr
def test_sdk_responds_to_interactive_approval_server_requests():
from openai_codex.client import CodexClient
client = CodexClient()
assert client._default_approval_handler(
"item/commandExecution/requestApproval",
{},
) == { # pylint: disable=protected-access
"decision": "accept",
}
assert client._default_approval_handler(
"item/fileChange/requestApproval",
{},
) == { # pylint: disable=protected-access
"decision": "accept",
}
def test_event_to_chunks_maps_content_usage_and_completion():
content_event = SimpleNamespace(
method="item/agentMessage/delta",
payload=SimpleNamespace(item_id="item-1", delta="hello"),
)
usage = TokenUsageBreakdown(
cachedInputTokens=1,
inputTokens=3,
outputTokens=5,
reasoningOutputTokens=2,
totalTokens=8,
)
usage_event = SimpleNamespace(
method="thread/tokenUsage/updated",
payload=SimpleNamespace(token_usage=SimpleNamespace(last=usage)),
)
completed_event = SimpleNamespace(
method="turn/completed",
payload=SimpleNamespace(
turn=SimpleNamespace(id="turn-1", status=SimpleNamespace(value="completed"), duration_ms=10, error=None),
),
)
content = CodexAgentWrapper._event_to_chunks(content_event, "thread-1") # pylint: disable=protected-access
usage_chunks = CodexAgentWrapper._event_to_chunks(usage_event, "thread-1") # pylint: disable=protected-access
completed = CodexAgentWrapper._event_to_chunks(completed_event, "thread-1") # pylint: disable=protected-access
assert content[0].chunk_type == ChunkEnum.CONTENT
assert content[0].chunk == "hello"
assert usage_chunks[0].chunk_type == ChunkEnum.USAGE
assert usage_chunks[0].input_tokens == 3
assert usage_chunks[0].output_tokens == 5
assert completed[0].chunk_type == ChunkEnum.REPLY_END
assert completed[0].metadata["status"] == "completed"
@dataclass
class _TurnResult:
final_response: str
status: str = "completed"
def test_reply_returns_thread_id_and_structured_output(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path)
class FakeThread:
id = "thread-1"
async def run(self, inputs, **kwargs):
assert inputs == "answer"
assert kwargs["output_schema"] == {"type": "object"}
return _TurnResult(final_response=json.dumps({"ok": True}))
class FakeCodex:
def __init__(self, _config):
pass
async def __aenter__(self):
return self
async def __aexit__(self, _exc_type, _exc, _tb):
return None
async def close(self):
return None
async def thread_start(self, **_kwargs):
return FakeThread()
monkeypatch.setattr("openai_codex.AsyncCodex", FakeCodex)
monkeypatch.setattr("reme.components.agent_wrapper.codex_agent_wrapper.load_env", lambda *_args: {})
result = asyncio.run(wrapper.reply("answer", output_schema={"type": "object"}))
assert result["session_id"] == "thread-1"
assert result["structured_output"] == {"ok": True}
def test_codex_skills_add_all_without_deleting_existing_content(tmp_path):
wrapper, _job = _wrapper(tmp_path)
for name in ("reme_memory", "qwenpaw_memory"):
source = tmp_path / "skills" / name
source.mkdir(parents=True)
(source / "SKILL.md").write_text(f"# {name}", encoding="utf-8")
existing = tmp_path / ".agents" / "skills" / "user_skill"
existing.mkdir(parents=True)
marker = existing / "marker"
marker.write_text("keep", encoding="utf-8")
wrapper._ensure_skills("all") # pylint: disable=protected-access
assert marker.read_text(encoding="utf-8") == "keep"
for name in ("reme_memory", "qwenpaw_memory"):
target = tmp_path / ".agents" / "skills" / name
assert target.is_symlink()
assert target.resolve() == (tmp_path / "skills" / name).resolve()
def test_codex_skills_support_single_name_and_are_idempotent(tmp_path):
wrapper, _job = _wrapper(tmp_path)
source = tmp_path / "skills" / "one"
source.mkdir(parents=True)
(source / "SKILL.md").write_text("# one", encoding="utf-8")
wrapper._ensure_skills("one") # pylint: disable=protected-access
wrapper._ensure_skills(["one"]) # pylint: disable=protected-access
assert (tmp_path / ".agents" / "skills" / "one").resolve() == source.resolve()
@pytest.mark.parametrize("kind", ["directory", "external_link"])
def test_codex_skills_preserve_conflicts(tmp_path, kind):
wrapper, _job = _wrapper(tmp_path)
source = tmp_path / "skills" / "one"
source.mkdir(parents=True)
(source / "SKILL.md").write_text("# one", encoding="utf-8")
target = tmp_path / ".agents" / "skills" / "one"
target.parent.mkdir(parents=True)
if kind == "directory":
target.mkdir()
(target / "marker").write_text("keep", encoding="utf-8")
else:
external = tmp_path / "external"
external.mkdir()
target.symlink_to(external, target_is_directory=True)
with pytest.raises(FileExistsError, match="Codex skill conflict"):
wrapper._ensure_skills("one") # pylint: disable=protected-access
assert target.exists()
@pytest.mark.parametrize("create_dir", [False, True])
def test_codex_skills_reject_missing_or_invalid_skill(tmp_path, create_dir):
wrapper, _job = _wrapper(tmp_path)
if create_dir:
(tmp_path / "skills" / "missing_manifest").mkdir(parents=True)
name = "missing_manifest"
else:
name = "missing"
with pytest.raises(FileNotFoundError):
wrapper._ensure_skills(name) # pylint: disable=protected-access
def test_codex_skills_do_not_modify_codex_home(tmp_path):
codex_home = tmp_path / "codex-home"
marker = codex_home / "skills" / "marker"
marker.parent.mkdir(parents=True)
marker.write_text("keep", encoding="utf-8")
wrapper, _job = _wrapper(tmp_path, codex_home=codex_home)
source = tmp_path / "skills" / "one"
source.mkdir(parents=True)
(source / "SKILL.md").write_text("# one", encoding="utf-8")
wrapper._ensure_skills("one") # pylint: disable=protected-access
assert marker.read_text(encoding="utf-8") == "keep"
def test_effective_mcp_config_snapshot_is_private_and_removed_on_close(tmp_path):
wrapper, _job = _wrapper(tmp_path)
config = wrapper._mcp_server_config({"job_tools": ["search"]}) # pylint: disable=protected-access
snapshot = Path(config["args"][config["args"].index("--config") + 1])
assert snapshot.exists()
assert stat.S_IMODE(snapshot.stat().st_mode) == 0o600
assert json.loads(snapshot.read_text(encoding="utf-8"))["workspace_dir"] == str(tmp_path)
async def close_started_wrapper():
await wrapper.start()
await wrapper.close()
asyncio.run(close_started_wrapper())
assert not snapshot.exists()
@pytest.mark.asyncio
async def test_effective_snapshot_exposes_parent_only_custom_job(tmp_path):
from fastmcp import Client
from fastmcp.client import StdioTransport
app_config = ApplicationConfig(
workspace_dir=str(tmp_path),
enable_logo=False,
log_to_console=False,
log_to_file=False,
service={"backend": "mcp"},
jobs={
"only_custom": {
"backend": "base",
"description": "Parent-only inline job",
"parameters": {"type": "object", "properties": {}},
"steps": [],
},
"referenced_helper": {
"backend": "base",
"description": "A normal job that custom jobs may reference.",
"parameters": {"type": "object", "properties": {}},
"steps": [],
},
},
)
job = _Job("only_custom")
context = SimpleNamespace(app_config=app_config, jobs={"only_custom": job})
wrapper = CodexAgentWrapper(app_context=context)
server_config = wrapper._mcp_server_config({"job_tools": ["only_custom"]}) # pylint: disable=protected-access
transport = StdioTransport(
command=server_config["command"],
args=server_config["args"],
cwd=server_config["cwd"],
)
await wrapper.start()
async with Client(transport, timeout=10) as client:
tools = await client.list_tools()
snapshot = Path(server_config["args"][server_config["args"].index("--config") + 1])
assert snapshot.exists()
assert set(json.loads(snapshot.read_text(encoding="utf-8"))["jobs"]) == {
"only_custom",
"referenced_helper",
}
await wrapper.close()
assert [tool.name for tool in tools] == ["only_custom"]
assert not snapshot.exists()
@pytest.mark.asyncio
async def test_open_thread_defaults_to_full_access(tmp_path):
from openai_codex import ApprovalMode, Sandbox
wrapper, _job = _wrapper(tmp_path)
observed = {}
class FakeCodex:
async def thread_start(self, **kwargs):
observed.update(kwargs)
return SimpleNamespace(id="thread-1")
await wrapper._open_thread(FakeCodex(), {}) # pylint: disable=protected-access
assert observed["approval_mode"] == ApprovalMode.auto_review
assert observed["sandbox"] == Sandbox.full_access
@pytest.mark.asyncio
async def test_resume_reuses_tool_context_and_rejects_context_change(tmp_path):
wrapper, _job = _wrapper(tmp_path)
class FakeCodex:
async def thread_start(self, **_kwargs):
return SimpleNamespace(id="thread-1")
async def thread_resume(self, _thread_id, **_kwargs):
return SimpleNamespace(id="thread-1")
await wrapper._open_thread(FakeCodex(), {"tool_context_id": "ctx-a"}) # pylint: disable=protected-access
await wrapper._open_thread( # pylint: disable=protected-access
FakeCodex(),
{"resume": "thread-1", "tool_context_id": "ctx-a"},
)
with pytest.raises(ValueError, match="cannot change"):
await wrapper._open_thread( # pylint: disable=protected-access
FakeCodex(),
{"resume": "thread-1", "tool_context_id": "ctx-b"},
)
class _StructuredModel(BaseModel):
ok: bool
@pytest.mark.parametrize("schema", [_StructuredModel(ok=True), str])
def test_output_schema_rejects_instances_and_arbitrary_classes(tmp_path, schema):
wrapper, _job = _wrapper(tmp_path)
with pytest.raises(TypeError, match="JSON schema dict or BaseModel class"):
wrapper._merged_kwargs({"output_schema": schema}) # pylint: disable=protected-access
def test_output_schema_normalizes_model_class_and_preserves_dict(tmp_path):
wrapper, _job = _wrapper(tmp_path)
schema = {"type": "object", "properties": {"ok": {"type": "boolean"}}}
assert (
wrapper._merged_kwargs({"output_schema": _StructuredModel})["output_schema"] # pylint: disable=protected-access
== _StructuredModel.model_json_schema()
)
assert (
wrapper._merged_kwargs({"output_schema": schema})["output_schema"] is schema
) # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_reply_normalizes_schema_and_reuses_persistent_client(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path)
clients = []
observed_schemas = []
close_count = 0
class FakeThread:
id = "thread-1"
async def run(self, _inputs, **kwargs):
observed_schemas.append(kwargs["output_schema"])
return _TurnResult(final_response=json.dumps({"ok": True}))
class FakeCodex:
def __init__(self, _config):
clients.append(self)
async def __aenter__(self):
return self
async def close(self):
nonlocal close_count
close_count += 1
async def thread_start(self, **_kwargs):
return FakeThread()
monkeypatch.setattr("openai_codex.AsyncCodex", FakeCodex)
monkeypatch.setattr("reme.components.agent_wrapper.codex_agent_wrapper.load_env", lambda *_args: {})
await wrapper.start()
result = await wrapper.reply("first", output_schema=_StructuredModel)
await wrapper.close()
assert result["structured_output"] == {"ok": True}
assert observed_schemas == [_StructuredModel.model_json_schema()]
assert len(clients) == 1
assert close_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("schema", [{}, _StructuredModel])
async def test_reply_stream_rejects_output_schema(tmp_path, schema):
wrapper, _job = _wrapper(tmp_path)
with pytest.raises(NotImplementedError, match="Structured output is not supported"):
await anext(wrapper.reply_stream("answer", output_schema=schema))
@pytest.mark.asyncio
async def test_reply_stream_interrupts_turn_when_consumer_closes_early(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path)
stream_closed = False
interrupt_count = 0
class FakeTurn:
id = "turn-1"
async def stream(self):
nonlocal stream_closed
try:
yield SimpleNamespace(
method="turn/started",
payload=SimpleNamespace(turn=SimpleNamespace(id=self.id)),
)
await asyncio.Event().wait()
finally:
stream_closed = True
async def interrupt(self):
nonlocal interrupt_count
interrupt_count += 1
class FakeThread:
id = "thread-1"
async def turn(self, _inputs, **_kwargs):
return FakeTurn()
async def get_codex(_kwargs):
return SimpleNamespace()
async def open_thread(_codex, _kwargs):
return FakeThread()
monkeypatch.setattr(wrapper, "_get_codex", get_codex)
monkeypatch.setattr(wrapper, "_open_thread", open_thread)
stream = wrapper.reply_stream("answer")
first = await anext(stream)
assert first.chunk_type == ChunkEnum.REPLY_START
await stream.aclose()
assert interrupt_count == 1
assert stream_closed
@pytest.mark.asyncio
async def test_persistent_client_rejects_launch_config_changes(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path)
class FakeCodex:
def __init__(self, _config):
pass
async def close(self):
return None
monkeypatch.setattr("openai_codex.AsyncCodex", FakeCodex)
monkeypatch.setattr("reme.components.agent_wrapper.codex_agent_wrapper.load_env", lambda *_args: {})
await wrapper.start()
first = await wrapper._get_codex({"api_key": "one"}) # pylint: disable=protected-access
assert await wrapper._get_codex({"api_key": "one"}) is first # pylint: disable=protected-access
with pytest.raises(RuntimeError, match="configuration changed"):
await wrapper._get_codex({"api_key": "two"}) # pylint: disable=protected-access
await wrapper.close()
@pytest.mark.parametrize("review_status", ["approved", "denied"])
def test_event_to_chunks_maps_approval_started_and_completed(review_status):
action = {"type": "futureApprovalAction", "value": "preserved"}
review = {"status": review_status, "rationale": "policy"}
started = SimpleNamespace(
method="item/autoApprovalReview/started",
payload=SimpleNamespace(
action=action,
review=review,
review_id="review-1",
target_item_id="item-1",
turn_id="turn-1",
),
)
completed = SimpleNamespace(
method="item/autoApprovalReview/completed",
payload=SimpleNamespace(
action=action,
review=review,
review_id="review-1",
target_item_id="item-1",
turn_id="turn-1",
decision_source={"type": "guardian"},
),
)
started_chunk = CodexAgentWrapper._event_to_chunks(started, "thread-1")[0] # pylint: disable=protected-access
completed_chunk = CodexAgentWrapper._event_to_chunks(completed, "thread-1")[0] # pylint: disable=protected-access
assert started_chunk.chunk_type == ChunkEnum.APPROVAL
assert started_chunk.chunk == action
assert started_chunk.metadata["status"] == "started"
assert completed_chunk.metadata["status"] == "completed"
assert completed_chunk.metadata["review"]["status"] == review_status
assert completed_chunk.metadata["decision_source"] == {"type": "guardian"}
def test_codex_home_expands_user_directory(tmp_path):
wrapper, _job = _wrapper(tmp_path, codex_home="~/.codex")
assert wrapper.session_path == Path.home() / ".codex"
def test_named_default_mcp_config_remains_supported(tmp_path):
wrapper, _job = _wrapper(tmp_path, mcp_config="default")
assert wrapper._mcp_config_source({}) == "default" # pylint: disable=protected-access
def test_default_config_provides_codex_oauth_wrapper(monkeypatch):
monkeypatch.delenv("CODEX_HOME", raising=False)
config = resolve_app_config(log_config=False)
oauth = config["components"]["agent_wrapper"]["codex_oauth"]
codex = config["components"]["agent_wrapper"]["codex"]
assert oauth["backend"] == "codex"
assert oauth["codex_home"] == "~/.codex"
assert oauth["sandbox"] == "full-access"
assert "api_key" not in oauth
assert codex["sandbox"] == "full-access"