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:
1 parent
de0a9c2e81
commit
76f10b2096
45 files changed
+2045
-276
No files matched your search
@@ -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']
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user