Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 71 additions & 16 deletions src/panopticon/container/cli/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
- **auth** → an API key (``CODEX_API_KEY`` / ``OPENAI_API_KEY``) materialized into
``$CODEX_HOME/auth.json`` (a bare env var does *not* log codex in), or a ChatGPT workspace
access token (``CODEX_ACCESS_TOKEN``) read straight from the env — see :meth:`write_credentials`;
- **launch / resume** → ``codex`` first-run vs ``codex resume --last`` (the ``claude --continue``
analogue), probing ``$CODEX_HOME/sessions`` for a prior transcript.
- **launch / resume** → ``codex`` first-run vs ``codex resume <session_id>`` (the ``claude
--continue`` analogue), selecting the resumable session via :func:`_find_resume_target`.

Scope now includes the **turn-flip hooks** (M3.6): :meth:`~CodexAgentCLI.write_settings` wires
codex's ``[hooks]`` ``Stop`` / ``UserPromptSubmit`` block to the shared callback, and the hook-payload
Expand All @@ -43,6 +43,55 @@
from panopticon.container.skills import write_agent_operation_skills, write_agent_skills
from panopticon.core.models import Skill


def _find_resume_target(sessions_dir: Path) -> str | None:
"""Return the session id of the newest resumable codex session, or ``None``.

``$CODEX_HOME/sessions`` is shared by **all** codex invocations in the container —
``codex exec`` subprocesses (anything the agent shells out to) and codex-tui's own
internal subagent threads (e.g. compaction) all write ``.jsonl`` rollout files there.
Resuming by ``--last`` (newest mtime) can therefore land on a non-resumable or wrong
session. This function reads only the **first line** of each file (the ``session_meta``
record, cheap regardless of session length) and filters to sessions where:

- ``payload["originator"] == "codex-tui"`` — interactive TUI, not ``codex_exec``
- ``payload["thread_source"] == "user"`` — root thread, not an internal subagent thread

Returns ``payload["id"]`` of the eligible file with the highest ``st_mtime_ns`` (integer
nanoseconds — float mtime loses sub-second precision). Malformed or empty first lines and
any ``OSError`` are silently skipped. Returns ``None`` when nothing qualifies.
"""
best_mtime: int = -1
best_id: str | None = None

for path in sessions_dir.rglob("*.jsonl"):
try:
first_line = path.read_text().split("\n", 1)[0].strip()
if not first_line:
continue
record = json.loads(first_line)
if not isinstance(record, dict):
continue
payload = record.get("payload", {})
if not isinstance(payload, dict):
continue
if payload.get("originator") != "codex-tui":
continue
if payload.get("thread_source") != "user":
continue
session_id = payload.get("id")
if not session_id or not isinstance(session_id, str):
continue
mtime = path.stat().st_mtime_ns
if mtime > best_mtime:
best_mtime = mtime
best_id = session_id
except (OSError, json.JSONDecodeError, ValueError):
continue

return best_id


#: The control plane's abstract model **tiers** mapped to codex's concrete model ids (ADR 0014 §3a).
#: The only place a provider model name appears; ``core``/``workflows`` name only the tier. ``primary``
#: maps to codex's flagship (``gpt-5.6-sol``), verified against the pinned codex release (the
Expand Down Expand Up @@ -273,26 +322,32 @@ def launch_argv(
turn: str | None = None,
starting_model: str | None = None,
) -> list[str]:
"""`codex` argv, resuming the config dir's most recent session if one exists.
"""`codex` argv, resuming the most recent resumable session by id if one exists.

The agent runs unattended in a throwaway container on a per-task clone, so it launches with
``--dangerously-bypass-approvals-and-sandbox`` (the ``claude --dangerously-skip-permissions``
analogue) — no operator to answer prompts, blast radius the task's own checkout. Codex keeps
session transcripts under ``$CODEX_HOME/sessions``; when one is present we ``resume --last``
instead of starting fresh. The config dir is a **per-task volume**, so this resumes both
within a container's life and **across respawn/recreate**.

On a **first run** (no prior session) the ``starting_model`` tier is resolved via
:meth:`resolve_model` and passed as ``--model`` (on resume codex uses the session's model),
and an ``initial_prompt`` is appended as codex's first message. ``turn`` is accepted for
signature parity with the claude adapter; auto-continuing a resumed session on the agent's
turn (claude's interrupt prompt) is deferred with the rest of the turn wiring to M3.6, since
injecting a prompt into a resumed codex session isn't yet verified.
session transcripts under ``$CODEX_HOME/sessions``; :func:`_find_resume_target` scans them
and returns the id of the newest session whose first-line ``session_meta`` record marks it as
a resumable interactive TUI root thread (``originator=codex-tui``, ``thread_source=user``).
When one is found, ``codex resume <session_id>`` is used instead of starting fresh. The
config dir is a **per-task volume**, so this resumes both within a container's life and
**across respawn/recreate**.

On **resume** with ``turn == "agent"`` (the agent was interrupted mid-turn), the interrupt
prompt ``"You were interrupted. Continue."`` is appended as codex's first positional message
so the agent picks up where it left off. On a **first run** (no resumable session) the
``starting_model`` tier is resolved via :meth:`resolve_model` and passed as ``--model`` (on
resume codex uses the session's model), and ``initial_prompt`` is appended as the first
message.
"""
argv = ["codex", "--dangerously-bypass-approvals-and-sandbox"]
sessions = config_dir / self.SESSIONS_DIRNAME
if sessions.exists() and any(sessions.rglob("*.jsonl")):
argv += ["resume", "--last"] # resume the config dir's most recent session
sessions_dir = config_dir / self.SESSIONS_DIRNAME
session_id = _find_resume_target(sessions_dir) if sessions_dir.exists() else None
if session_id:
argv += ["resume", session_id]
if turn == "agent":
argv.append("You were interrupted. Continue.")
else:
if starting_model: # first run only — on resume codex uses the session's model
argv += ["--model", self.resolve_model(starting_model)]
Expand Down
161 changes: 155 additions & 6 deletions tests/container/test_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@

import io
import json
import time
import tomllib
from pathlib import Path

import pytest

from panopticon.container.cli.codex import CodexAgentCLI
from panopticon.container.cli.codex import CodexAgentCLI, _find_resume_target


class _FakeClient:
Expand Down Expand Up @@ -241,15 +242,16 @@ def test_launch_argv_starts_fresh_without_a_session(tmp_path: Path) -> None:
]


def test_launch_argv_resumes_when_a_session_transcript_exists(tmp_path: Path) -> None:
def test_launch_argv_resumes_when_an_interactive_session_exists(tmp_path: Path) -> None:
sessions = tmp_path / "sessions" / "2026" / "08"
sessions.mkdir(parents=True)
(sessions / "rollout-abc.jsonl").write_text("{}")
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "sess-abc"}}'
(sessions / "rollout-abc.jsonl").write_text(meta)
assert CodexAgentCLI().launch_argv(tmp_path, Path("/workspace")) == [
"codex",
"--dangerously-bypass-approvals-and-sandbox",
"resume",
"--last",
"sess-abc",
]


Expand All @@ -260,7 +262,8 @@ def test_launch_argv_appends_initial_prompt_on_first_run(tmp_path: Path) -> None

def test_launch_argv_omits_initial_prompt_when_resuming(tmp_path: Path) -> None:
(tmp_path / "sessions").mkdir()
(tmp_path / "sessions" / "s.jsonl").write_text("{}")
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
(tmp_path / "sessions" / "s.jsonl").write_text(meta)
argv = CodexAgentCLI().launch_argv(tmp_path, Path("/workspace"), initial_prompt="review plan")
assert "resume" in argv and "review plan" not in argv

Expand All @@ -277,7 +280,8 @@ def test_launch_argv_passes_the_resolved_model_on_first_run(tmp_path: Path) -> N

def test_launch_argv_omits_model_on_resume(tmp_path: Path) -> None:
(tmp_path / "sessions").mkdir()
(tmp_path / "sessions" / "s.jsonl").write_text("{}")
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
(tmp_path / "sessions" / "s.jsonl").write_text(meta)
argv = CodexAgentCLI().launch_argv(tmp_path, Path("/workspace"), starting_model="primary")
assert "--model" not in argv and "resume" in argv

Expand All @@ -295,6 +299,151 @@ def test_launch_argv_passes_model_before_initial_prompt_on_first_run(tmp_path: P
]


# -- _find_resume_target -----------------------------------------------------------------------


def _session_meta(
session_id: str, originator: str = "codex-tui", thread_source: str = "user"
) -> str:
"""One-liner session_meta first-line JSON for tests."""
import json

return json.dumps(
{"payload": {"originator": originator, "thread_source": thread_source, "id": session_id}}
)


def test_find_resume_target_returns_none_when_no_sessions_dir(tmp_path: Path) -> None:
assert _find_resume_target(tmp_path / "sessions") is None


def test_find_resume_target_returns_none_when_no_jsonl_files(tmp_path: Path) -> None:
(tmp_path / "sessions").mkdir()
assert _find_resume_target(tmp_path / "sessions") is None


def test_find_resume_target_skips_codex_exec_rollout(tmp_path: Path) -> None:
# originator != "codex-tui" → not eligible
d = tmp_path / "sessions"
d.mkdir()
(d / "exec.jsonl").write_text(_session_meta("exec-1", originator="codex_exec"))
assert _find_resume_target(d) is None


def test_find_resume_target_skips_subagent_thread(tmp_path: Path) -> None:
# thread_source != "user" → internal subagent thread, not resumable
d = tmp_path / "sessions"
d.mkdir()
(d / "sub.jsonl").write_text(_session_meta("sub-1", thread_source="agent"))
assert _find_resume_target(d) is None


def test_find_resume_target_skips_malformed_first_line(tmp_path: Path) -> None:
d = tmp_path / "sessions"
d.mkdir()
(d / "bad.jsonl").write_text("not json\n")
assert _find_resume_target(d) is None


def test_find_resume_target_skips_empty_first_line(tmp_path: Path) -> None:
d = tmp_path / "sessions"
d.mkdir()
(d / "empty.jsonl").write_text("\n{}\n") # empty first line
assert _find_resume_target(d) is None


def test_find_resume_target_skips_bare_object_without_payload(tmp_path: Path) -> None:
d = tmp_path / "sessions"
d.mkdir()
(d / "bare.jsonl").write_text("{}") # valid JSON, but no payload → skip
assert _find_resume_target(d) is None


def test_find_resume_target_returns_id_of_interactive_session(tmp_path: Path) -> None:
d = tmp_path / "sessions"
d.mkdir()
(d / "sess.jsonl").write_text(_session_meta("interactive-1"))
assert _find_resume_target(d) == "interactive-1"


def test_find_resume_target_newest_interactive_beats_older_exec(tmp_path: Path) -> None:
# A newer exec rollout must not shadow an older interactive session.
d = tmp_path / "sessions"
d.mkdir()
old = d / "old-interactive.jsonl"
old.write_text(_session_meta("good-sess"))
time.sleep(0.01)
new = d / "new-exec.jsonl"
new.write_text(_session_meta("exec-sess", originator="codex_exec"))
# exec is newer by mtime but ineligible → interactive wins
assert _find_resume_target(d) == "good-sess"


def test_find_resume_target_picks_newest_of_multiple_interactive(tmp_path: Path) -> None:
d = tmp_path / "sessions"
d.mkdir()
first = d / "first.jsonl"
first.write_text(_session_meta("old-sess"))
time.sleep(0.01)
second = d / "second.jsonl"
second.write_text(_session_meta("new-sess"))
assert _find_resume_target(d) == "new-sess"


def test_find_resume_target_searches_subdirectories(tmp_path: Path) -> None:
d = tmp_path / "sessions"
sub = d / "2026" / "08"
sub.mkdir(parents=True)
(sub / "deep.jsonl").write_text(_session_meta("deep-sess"))
assert _find_resume_target(d) == "deep-sess"


# -- launch_argv resume + interrupt prompt -------------------------------------------------------


def test_launch_argv_resumes_with_interrupt_prompt_when_agent_turn(tmp_path: Path) -> None:
(tmp_path / "sessions").mkdir()
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
(tmp_path / "sessions" / "s.jsonl").write_text(meta)
argv = CodexAgentCLI().launch_argv(tmp_path, Path("/workspace"), turn="agent")
assert argv == [
"codex",
"--dangerously-bypass-approvals-and-sandbox",
"resume",
"s1",
"You were interrupted. Continue.",
]


def test_launch_argv_resumes_without_interrupt_prompt_when_user_turn(tmp_path: Path) -> None:
(tmp_path / "sessions").mkdir()
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
(tmp_path / "sessions" / "s.jsonl").write_text(meta)
argv = CodexAgentCLI().launch_argv(tmp_path, Path("/workspace"), turn="user")
assert argv == [
"codex",
"--dangerously-bypass-approvals-and-sandbox",
"resume",
"s1",
]


def test_launch_argv_falls_back_to_first_run_when_only_exec_sessions(tmp_path: Path) -> None:
(tmp_path / "sessions").mkdir()
exec_meta = '{"payload": {"originator": "codex_exec", "thread_source": "user", "id": "e1"}}'
(tmp_path / "sessions" / "exec.jsonl").write_text(exec_meta)
argv = CodexAgentCLI().launch_argv(
tmp_path, Path("/workspace"), initial_prompt="hi", starting_model="primary"
)
assert argv == [
"codex",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-5.6-sol",
"hi",
]


# -- hook seam (M3.6) ---------------------------------------------------------------------------


Expand Down
Loading