codex-channel-molecule/tests/test_codex_runner.py
Hongming Wang d6eb78dcee feat: initial bridge daemon
codex-channel-molecule is the codex-side counterpart to
hermes-channel-molecule. It long-polls the molecule platform inbox via
molecule_runtime.a2a_tools.tool_wait_for_message, runs `codex exec
--resume <session>` per inbound message, captures the assistant reply
from stdout, and routes it back through send_message_to_user (canvas
chat) or delegate_task (peer agent), then acks the inbox row.

Per chat thread (one canvas-user thread or one peer-workspace thread)
gets its own codex session_id, persisted to disk so daemon restarts
keep conversation context. Reply-routing failures skip the inbox_pop
ack so the platform's at-least-once delivery re-surfaces the row on
the next poll.

This daemon is the operator-unblock until openai/codex#17543 lands —
once codex itself accepts MCP custom notifications as Op::UserInput
through the wired-in MCP server, this daemon becomes redundant. The
README's deprecation-path section calls that out so future operators
know when to switch off.

Tests cover the dispatch loop with fake tools (8 tests asserting
exact contracts: canvas vs peer routing, session continuity,
persistence across restarts, timeout sentinel handling, at-least-once
on reply failure, exit-code surfacing, A2A multipart text). The
codex_runner tests are real-subprocess (fake codex script spawned via
asyncio.create_subprocess_exec) so the boot path matches production —
no in-process mocking of the spawn boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:09:09 -07:00

139 lines
4.4 KiB
Python

"""Real-subprocess test for CodexRunner — boot path coverage.
In-process mocking would miss subprocess-level bugs (env handling,
arg passthrough, stderr capture, signal/timeout). The fake script is a
real Python program spawned via asyncio.create_subprocess_exec, exactly
as a real codex install would be.
"""
from __future__ import annotations
import os
import textwrap
from pathlib import Path
import pytest
from codex_channel_molecule.codex_runner import (
CodexRunner,
_extract_session_id,
)
_FAKE_CODEX_SCRIPT = textwrap.dedent("""\
#!/usr/bin/env python3
\"\"\"Fake codex CLI for tests.
Behaviors keyed on argv shape and env:
argv: codex exec [--skip-git-repo-check] [--resume <sid>] <message>
Echoes a banner to stderr (\"session: <uuid>\") and the input message
to stdout. Honors FAKE_EXIT_CODE for failure-path tests.
\"\"\"
import os, sys
args = sys.argv[1:]
assert args[0] == \"exec\", f\"unexpected first arg: {args[0]!r}\"
resume_id = None
i = 1
while i < len(args):
if args[i] == \"--resume\":
resume_id = args[i + 1]
i += 2
elif args[i].startswith(\"--\"):
i += 1 # skip unrecognized flag
else:
break
msg = args[i] if i < len(args) else \"\"
sid = resume_id or os.environ.get(\"FAKE_NEW_SESSION_ID\", \"a1b2c3d4-1111-2222-3333-444455556666\")
sys.stderr.write(f\"session: {sid}\\n\")
sys.stderr.flush()
sys.stdout.write(f\"echo: {msg}\\n\")
sys.stdout.flush()
sys.exit(int(os.environ.get(\"FAKE_EXIT_CODE\", \"0\")))
""")
@pytest.fixture
def fake_codex(tmp_path: Path) -> Path:
p = tmp_path / "codex"
p.write_text(_FAKE_CODEX_SCRIPT)
p.chmod(0o755)
return p
@pytest.mark.asyncio
async def test_run_returns_stdout_text(fake_codex):
runner = CodexRunner(codex_bin=str(fake_codex), timeout_secs=10.0)
result = await runner.run(message="hello world")
assert result.text == "echo: hello world"
assert result.exit_code == 0
@pytest.mark.asyncio
async def test_run_extracts_new_session_id_from_stderr(fake_codex, monkeypatch):
runner = CodexRunner(codex_bin=str(fake_codex), timeout_secs=10.0)
monkeypatch.setenv("FAKE_NEW_SESSION_ID", "deadbeef-0000-1111-2222-333344445555")
result = await runner.run(message="any")
assert result.session_id == "deadbeef-0000-1111-2222-333344445555"
@pytest.mark.asyncio
async def test_run_resumes_existing_session(fake_codex):
runner = CodexRunner(codex_bin=str(fake_codex), timeout_secs=10.0)
given = "11111111-2222-3333-4444-555555555555"
result = await runner.run(message="follow up", session_id=given)
# Resume → no new session id is captured (input is echoed back as-is).
assert result.session_id == given
@pytest.mark.asyncio
async def test_run_surfaces_nonzero_exit_code(fake_codex, monkeypatch):
runner = CodexRunner(codex_bin=str(fake_codex), timeout_secs=10.0)
monkeypatch.setenv("FAKE_EXIT_CODE", "7")
result = await runner.run(message="any")
assert result.exit_code == 7
@pytest.mark.asyncio
async def test_run_kills_subprocess_on_timeout(tmp_path):
"""A codex turn that hangs past the timeout must be killed and
surfaced as a timeout result — not block the bridge forever."""
sleeper = tmp_path / "codex"
sleeper.write_text(textwrap.dedent("""\
#!/usr/bin/env python3
import time
time.sleep(60)
"""))
sleeper.chmod(0o755)
runner = CodexRunner(codex_bin=str(sleeper), timeout_secs=0.5)
result = await runner.run(message="hang please")
assert "timed out" in result.text
assert result.exit_code == -1
def test_constructor_fails_fast_when_codex_missing():
with pytest.raises(FileNotFoundError) as excinfo:
CodexRunner(codex_bin="/nonexistent/path/to/codex-binary-xyzzy")
assert "codex" in str(excinfo.value).lower()
def test_extract_session_id_matches_canonical_banner():
assert _extract_session_id("session: a1b2c3d4-aaaa-bbbb-cccc-dddddddddddd\n") == \
"a1b2c3d4-aaaa-bbbb-cccc-dddddddddddd"
def test_extract_session_id_matches_alternate_banner_shape():
assert _extract_session_id(
"blah blah\nsession_id=12345678-aaaa-bbbb-cccc-dddddddddddd\nmore\n"
) == "12345678-aaaa-bbbb-cccc-dddddddddddd"
def test_extract_session_id_returns_none_on_no_match():
assert _extract_session_id("nothing relevant here") is None