feat(agent): add full codex agent support (v4.2.0)

- Implement CodexAdapter in lib_py/agents/adapters/codex.py with safe flags and TUI readiness
- Extend create_session, resume_session, stop_session, and reconcile for codex CLI
- Support codex agent delegation and loop integration across all multi-agent skills
- Add unit, component, and contract test suites for codex integration
- Verify all 462 tests passing cleanly
This commit is contained in:
Godopu committed 2026-09-05 23:02:29 +09:00
1 parent de0a9c2e81
commit 76f10b2096
45 files changed
+2045 -276

No files matched your search

+161 -2
View File
@@ -32,6 +32,7 @@ def test_agent_adapter_registry():
'hermes': 'hermes_conversation_id_own',
'grok': 'grok_session_id_own',
'opencode': 'opencode_session_id_own',
'codex': 'codex_session_id_own',
}
for agent, expected in EXPECTED_OWN_KEYS.items():
adapter = get_adapter(agent)
@@ -75,6 +76,7 @@ def test_adapter_required_properties():
'hermes': ('Hermes|Welcome to Hermes Agent|NOUS HERMES', '/exit', 'hermes-agent', ('session_id',)),
'grok': ('Grok|xAI|Assistant|❯|>>>', '/exit', 'grok-build', ('session_id', 'session_jsonl')),
'opencode': ('OpenCode|Chat|Ask anything|tab agents|ctrl\\+p|Build auto|commands', '/exit', 'opencode-cli', ('session_id',)),
'codex': ('Codex|Ask Codex to do anything', 'Ctrl+C', 'codex-cli', ('session_id',)),
}
for agent, (toks, exitk, delk, cache_f) in expected.items():
adapter = get_adapter(agent)
@@ -96,6 +98,7 @@ def test_adapter_modal_tokens_contract():
'hermes': None,
'grok': None,
'opencode': None,
'codex': None,
}
for agent, expected in expected_modal.items():
adapter = get_adapter(agent)
@@ -109,7 +112,7 @@ def test_facts_bridge_eval_contract():
env = os.environ.copy()
skills_dir = str(Path(__file__).resolve().parent.parent / ".agents" / "skills")
env["PYTHONPATH"] = f"{skills_dir}:{env.get('PYTHONPATH', '')}"
for agent in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
for agent in ('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex'):
res = subprocess.run([sys.executable, "-m", "lib_py.agents", "facts", agent], capture_output=True, text=True, env=env)
assert res.returncode == 0
facts_output = res.stdout
@@ -355,6 +358,12 @@ def test_adapter_spawn_and_resume_specs():
assert opencode.resume_spec('opencode', 'u1', materialized=True) == 'opencode --session u1 --auto --agent build'
assert opencode.resume_spec('opencode', 'u1', materialized=False) == 'opencode --auto --agent build'
codex = get_adapter('codex')
assert codex.spawn_spec('codex', 'u1') == 'codex --dangerously-bypass-approvals-and-sandbox'
assert codex.spawn_spec('codex', '') == 'codex --dangerously-bypass-approvals-and-sandbox'
assert codex.resume_spec('codex', 'u1', materialized=True) == 'codex resume --dangerously-bypass-approvals-and-sandbox u1'
assert codex.resume_spec('codex', 'u1', materialized=False) == 'codex --dangerously-bypass-approvals-and-sandbox'
def test_adapter_auth_ok(tmp_path, monkeypatch):
monkeypatch.setenv("HOME_DIR", str(tmp_path))
# Claude auth runner
@@ -395,6 +404,17 @@ def test_adapter_auth_ok(tmp_path, monkeypatch):
opencode_auth.write_text("{}")
assert opencode.auth_ok() is True
# Codex auth check (env var, auth.json file, or CLI)
codex = get_adapter('codex')
assert codex.auth_ok(run_cmd=lambda cmd: (1, '', '')) is False
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
assert codex.auth_ok() is True
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
codex_auth = tmp_path / ".codex" / "auth.json"
codex_auth.parent.mkdir(parents=True, exist_ok=True)
codex_auth.write_text("{}")
assert codex.auth_ok() is True
def test_adapter_discover(tmp_path):
import sqlite3
from lib_py.agents.base import DiscoveryContext
@@ -457,6 +477,23 @@ def test_adapter_discover(tmp_path):
_seed_opencode_session(o_db, 'u-o1', ws, 2000.0)
assert opencode.discover(ctx_o) == ['u-o1']
# 6. Codex
codex = get_adapter('codex')
ctx_x = DiscoveryContext(workspace=ws, agent_name='codex', home_dir=home)
codex_home = f"{home}/.codex"
os.makedirs(codex_home, exist_ok=True)
c_db = f"{codex_home}/state_5.sqlite"
conn = sqlite3.connect(c_db)
conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT)")
c_rollout = f"{codex_home}/sessions/2026/09/05/rollout-001-u-x1.jsonl"
os.makedirs(os.path.dirname(c_rollout), exist_ok=True)
with open(c_rollout, 'w') as f:
f.write('{"payload": {"cwd": "' + ws + '", "id": "u-x1"}}\n')
conn.execute("INSERT INTO threads VALUES ('u-x1', ?, ?)", (ws, c_rollout))
conn.commit()
conn.close()
assert codex.discover(ctx_x) == ['u-x1']
def test_cli_bridge_subcommands_and_quote_safety():
import subprocess, sys
from pathlib import Path
@@ -475,7 +512,7 @@ def test_cli_bridge_subcommands_and_quote_safety():
assert res.stdout.strip() == "/bin/claude --dangerously-skip-permissions --session-id uuid-test"
# 3. exit-key
for agent, expected_key in [('claude', '/exit'), ('agy', 'Exit'), ('hermes', '/exit'), ('grok', '/exit'), ('opencode', '/exit')]:
for agent, expected_key in [('claude', '/exit'), ('agy', 'Exit'), ('hermes', '/exit'), ('grok', '/exit'), ('opencode', '/exit'), ('codex', 'Ctrl+C')]:
res = subprocess.run([sys.executable, "-m", "lib_py.agents", "exit-key", agent], capture_output=True, text=True, env=env)
assert res.returncode == 0
assert res.stdout.strip() == expected_key
@@ -488,6 +525,7 @@ def test_delegate_agent_resolution_and_fallback():
'hermes': 'hermes-agent',
'grok': 'grok-build',
'opencode': 'opencode-cli',
'codex': 'codex-cli',
}
# 1. Adapter property
for agent, expected_key in expected_map.items():
@@ -507,6 +545,7 @@ def test_delegate_agent_resolution_and_fallback():
agy) delegate_agent="antigravity-cli" ;;
grok) delegate_agent="grok-build" ;;
opencode) delegate_agent="opencode-cli" ;;
codex) delegate_agent="codex-cli" ;;
*) echo "ERROR: cannot resolve delegate agent key for '$AGENT'" >&2; exit 2 ;;
esac
fi
@@ -837,6 +876,126 @@ exit 1
assert adapter._resolve_db(ctx) == expected_db
def _seed_codex_session(home_dir, session_id, ws_path, epoch_sec):
import sqlite3, json, time
codex_home = f"{home_dir}/.codex"
os.makedirs(codex_home, exist_ok=True)
db_path = f"{codex_home}/state_5.sqlite"
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE IF NOT EXISTS threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT)")
sessions_dir = f"{codex_home}/sessions/2026/09/05"
os.makedirs(sessions_dir, exist_ok=True)
rollout_file = f"{sessions_dir}/rollout-001-{session_id}.jsonl"
with open(rollout_file, "w") as rf:
rf.write(json.dumps({"payload": {"cwd": ws_path, "id": session_id}}) + "\n")
if epoch_sec:
os.utime(rollout_file, (epoch_sec, epoch_sec))
conn.execute("INSERT OR REPLACE INTO threads (id, cwd, rollout_path) VALUES (?, ?, ?)", (session_id, ws_path, rollout_file))
conn.commit()
conn.close()
return rollout_file
def test_codex_purge_artifacts(tmp_path):
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
ws = str(tmp_path / "ws")
home = str(tmp_path / "home")
os.makedirs(ws, exist_ok=True)
os.makedirs(home, exist_ok=True)
adapter = get_adapter('codex')
ctx = DiscoveryContext(workspace=ws, agent_name='codex', home_dir=home)
rollout = _seed_codex_session(home, 'u-del', ws, 2000.0)
assert os.path.exists(rollout)
purged = adapter.purge_artifacts('u-del', ctx)
assert len(purged) == 2
assert not os.path.exists(rollout)
import sqlite3
conn = sqlite3.connect(f"{home}/.codex/state_5.sqlite")
assert conn.execute("SELECT count(*) FROM threads WHERE id='u-del'").fetchone()[0] == 0
conn.close()
def test_codex_verify_artifact_epoch_filtering(tmp_path):
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
ws = str(tmp_path / "ws")
home = str(tmp_path / "home")
os.makedirs(ws, exist_ok=True)
os.makedirs(home, exist_ok=True)
adapter = get_adapter('codex')
ctx_old = DiscoveryContext(workspace=ws, agent_name='codex', home_dir=home, epoch=1500)
_seed_codex_session(home, 'u-old', ws, 1000.0)
_seed_codex_session(home, 'u-new', ws, 2000.0)
assert adapter.verify_artifact('u-old', ctx_old) is False
assert adapter.verify_artifact('u-new', ctx_old) is True
def test_codex_reconcile_dual_direction_epoch_guards(tmp_path):
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
from lib_py.verify_session import verify_session_uuid
ws = str(tmp_path / "ws")
home = str(tmp_path / "home")
os.makedirs(ws, exist_ok=True)
os.makedirs(home, exist_ok=True)
adapter = get_adapter('codex')
_seed_codex_session(home, 'u-hist', ws, 1000.0)
_seed_codex_session(home, 'u-fresh1', ws, 2000.0)
epoch = 1500
s_row = {'name': 'sess1', 'pane': {'cwd': ws}, 'herdr_session_epoch': epoch}
valid = [u for u in adapter.discover(DiscoveryContext(workspace=ws, agent_name='codex', home_dir=home, epoch=epoch, row=s_row)) if verify_session_uuid(ws, 'codex', u, s_row, home_dir=home, mode="discover")]
assert valid == ['u-fresh1']
assert len(valid) == 1
_seed_codex_session(home, 'u-fresh2', ws, 2100.0)
valid2 = [u for u in adapter.discover(DiscoveryContext(workspace=ws, agent_name='codex', home_dir=home, epoch=epoch, row=s_row)) if verify_session_uuid(ws, 'codex', u, s_row, home_dir=home, mode="discover")]
assert len(valid2) == 2
def test_codex_reconcile_full_block_integration(tmp_path):
"""Verify codex drift-C block behavior with sibling_claimed exclusion."""
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
from lib_py.verify_session import verify_session_uuid
ws = str(tmp_path / "ws")
home = str(tmp_path / "home")
os.makedirs(ws, exist_ok=True)
os.makedirs(home, exist_ok=True)
adapter = get_adapter('codex')
_seed_codex_session(home, 'u-claimed', ws, 2000.0)
_seed_codex_session(home, 'u-target', ws, 2001.0)
epoch = 1900
s_eval = {
'name': 'codex-target-01',
'pane': {'cwd': ws},
'herdr_session_epoch': epoch,
'_sibling_claimed_uuids': ['u-claimed']
}
candidates = adapter.discover(DiscoveryContext(workspace=ws, agent_name='codex', home_dir=home, epoch=epoch, row=s_eval))
valid_candidates = []
for uuid in candidates:
if uuid in s_eval['_sibling_claimed_uuids']:
continue
if verify_session_uuid(ws, 'codex', uuid, s_eval, home_dir=home, mode="discover"):
valid_candidates.append(uuid)
assert valid_candidates == ['u-target']