ReMe/tests/integration/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

241 lines
8.5 KiB
Python

"""Opt-in live coverage for the Codex app-server wrapper.
Run with ``REME_CODEX_INTEGRATION=1``. These tests consume the caller's active
API-key or Codex OAuth account and are intentionally excluded from normal CI.
"""
# pylint: disable=protected-access
import asyncio
import os
from pathlib import Path
import subprocess
from types import SimpleNamespace
import pytest
from pydantic import BaseModel
from reme.components.agent_wrapper.codex_agent_wrapper import CodexAgentWrapper
from reme.enumeration import ChunkEnum, ComponentEnum
from reme.schema import ApplicationConfig, Response
pytestmark = pytest.mark.skipif(
os.getenv("REME_CODEX_INTEGRATION") != "1",
reason="set REME_CODEX_INTEGRATION=1 to run live Codex tests",
)
class _StructuredResult(BaseModel):
marker: str
class _CustomJob:
name = "only_custom"
description = "Return the fixed marker CUSTOM_JOB_OK."
parameters = {"type": "object", "properties": {}, "additionalProperties": False}
async def __call__(self, **_kwargs):
return Response(answer="CUSTOM_JOB_OK")
class _DraftJob:
def __init__(self, name: str, parameters: dict):
self.name = name
self.description = f"Live tool-context contract job: {name}"
self.parameters = parameters
def _child_pids(root_pid: int) -> set[int]:
result = subprocess.run(
["ps", "-axo", "pid=,ppid="],
check=True,
capture_output=True,
text=True,
)
children: dict[int, list[int]] = {}
for line in result.stdout.splitlines():
pid, parent = (int(value) for value in line.split())
children.setdefault(parent, []).append(pid)
found: set[int] = set()
pending = list(children.get(root_pid, []))
while pending:
pid = pending.pop()
if pid not in found:
found.add(pid)
pending.extend(children.get(pid, []))
return found
@pytest.mark.asyncio
async def test_live_codex_reply_stream_tools_skills_resume_fork_approval_and_close(tmp_path):
"""Exercise the live Codex wrapper contract when explicitly enabled."""
workspace = tmp_path / "workspace"
workspace.mkdir()
project_skill = Path(__file__).resolve().parents[2] / "skills" / "reme_memory"
skills_root = workspace / "skills"
skills_root.mkdir()
(skills_root / "reme_memory").symlink_to(project_skill, target_is_directory=True)
app_config = ApplicationConfig(
workspace_dir=str(workspace),
mem_session_dir="sessions",
enable_logo=False,
log_to_console=False,
log_to_file=False,
service={"backend": "mcp"},
jobs={
"only_custom": {
"backend": "base",
"description": _CustomJob.description,
"parameters": _CustomJob.parameters,
"steps": [],
},
"add_live_draft": {
"backend": "base",
"description": "Append text to the current tool context.",
"parameters": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
"steps": [{"backend": "add_draft_step"}],
},
"read_live_draft": {
"backend": "base",
"description": "Read text accumulated in the current tool context.",
"parameters": {"type": "object", "properties": {}},
"steps": [{"backend": "read_all_draft_step"}],
},
},
components={ComponentEnum.AS_LLM: {}},
)
context = SimpleNamespace(
app_config=app_config,
jobs={
"only_custom": _CustomJob(),
"add_live_draft": _DraftJob(
"add_live_draft",
{
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
),
"read_live_draft": _DraftJob(
"read_live_draft",
{"type": "object", "properties": {}},
),
},
)
codex_home = os.getenv("REME_CODEX_HOME")
if codex_home is None and not any(os.getenv(name) for name in ("CODEX_API_KEY", "OPENAI_API_KEY", "LLM_API_KEY")):
codex_home = str(Path.home() / ".codex")
wrapper = CodexAgentWrapper(app_context=context, codex_home=codex_home)
await wrapper.start()
first = await wrapper.reply(
"Call add_live_draft with text STATE_OK, then reply FIRST_TURN_OK.",
job_tools=["add_live_draft", "read_live_draft"],
tool_context_id="live-context",
)
assert "FIRST_TURN_OK" in first["last_message"]
resumed = await wrapper.reply(
"Call read_live_draft. Include its exact result and RESUME_OK in your answer.",
resume=first["session_id"],
job_tools=["add_live_draft", "read_live_draft"],
tool_context_id="live-context",
)
assert resumed["session_id"] == first["session_id"]
assert "RESUME_OK" in resumed["last_message"]
assert "STATE_OK" in resumed["last_message"]
isolated = await wrapper.reply(
"Call read_live_draft. If it is empty, reply ISOLATED_OK.",
job_tools=["add_live_draft", "read_live_draft"],
tool_context_id="other-context",
)
assert "ISOLATED_OK" in isolated["last_message"]
assert "STATE_OK" not in isolated["last_message"]
resumed_after_isolated_context = await wrapper.reply(
"Call read_live_draft again and include its exact result.",
resume=first["session_id"],
job_tools=["add_live_draft", "read_live_draft"],
tool_context_id="live-context",
)
assert "STATE_OK" in resumed_after_isolated_context["last_message"]
forked = await wrapper.reply(
"Reply with exactly FORK_OK.",
resume=first["session_id"],
fork_session=True,
tool_context_id="fork-context",
)
assert forked["session_id"] != first["session_id"]
assert "FORK_OK" in forked["last_message"]
structured = await wrapper.reply(
"Return marker STRUCTURED_OK.",
output_schema=_StructuredResult,
)
assert structured["structured_output"] == {"marker": "STRUCTURED_OK"}
tool_result = await wrapper.reply(
"Call the only_custom tool once, then include its result in your answer.",
job_tools=["only_custom"],
tool_context_id="tool-context",
)
assert "CUSTOM_JOB_OK" in tool_result["last_message"]
skill_result = await wrapper.reply(
"Use the reme_memory skill. Read only its instructions and reply SKILL_OK; do not run its scripts or CLI.",
skills=["reme_memory"],
)
assert "SKILL_OK" in skill_result["last_message"]
assert (workspace / ".agents" / "skills" / "reme_memory").is_symlink()
outside_target = tmp_path / "approval-target.txt"
approval_chunks = [
chunk
async for chunk in wrapper.reply_stream(
f"Try to write the word approved to {outside_target} using a shell command.",
approval_mode="auto_review",
sandbox="workspace-write",
)
]
assert approval_chunks[0].chunk_type == ChunkEnum.REPLY_START
assert approval_chunks[-1].chunk_type == ChunkEnum.REPLY_END
assert any(chunk.chunk_type == ChunkEnum.APPROVAL for chunk in approval_chunks)
codex_proc = wrapper._codex._client._sync._proc
assert codex_proc is not None and codex_proc.poll() is None
child_pids = _child_pids(codex_proc.pid)
await wrapper.close()
await asyncio.sleep(0.2)
assert codex_proc.poll() is not None
running_pids = {
int(line)
for line in subprocess.run(
["ps", "-axo", "pid="],
check=True,
capture_output=True,
text=True,
).stdout.split()
}
assert child_pids.isdisjoint(running_pids)
assert wrapper._codex is None
assert wrapper._mcp_snapshot_path is None
second_context = SimpleNamespace(app_config=app_config, jobs=context.jobs.copy())
second_wrapper = CodexAgentWrapper(app_context=second_context, codex_home=codex_home)
await second_wrapper.start()
isolated_application = await second_wrapper.reply(
"Call read_live_draft. If it is empty, reply NEW_APPLICATION_OK.",
job_tools=["read_live_draft"],
tool_context_id="live-context",
)
await second_wrapper.close()
assert "NEW_APPLICATION_OK" in isolated_application["last_message"]
assert "STATE_OK" not in isolated_application["last_message"]