66
77import io
88import json
9+ import time
910import tomllib
1011from pathlib import Path
1112
1213import pytest
1314
14- from panopticon .container .cli .codex import CodexAgentCLI
15+ from panopticon .container .cli .codex import CodexAgentCLI , _find_resume_target
1516
1617
1718class _FakeClient :
@@ -241,15 +242,16 @@ def test_launch_argv_starts_fresh_without_a_session(tmp_path: Path) -> None:
241242 ]
242243
243244
244- def test_launch_argv_resumes_when_a_session_transcript_exists (tmp_path : Path ) -> None :
245+ def test_launch_argv_resumes_when_an_interactive_session_exists (tmp_path : Path ) -> None :
245246 sessions = tmp_path / "sessions" / "2026" / "08"
246247 sessions .mkdir (parents = True )
247- (sessions / "rollout-abc.jsonl" ).write_text ("{}" )
248+ meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "sess-abc"}}'
249+ (sessions / "rollout-abc.jsonl" ).write_text (meta )
248250 assert CodexAgentCLI ().launch_argv (tmp_path , Path ("/workspace" )) == [
249251 "codex" ,
250252 "--dangerously-bypass-approvals-and-sandbox" ,
251253 "resume" ,
252- "--last " ,
254+ "sess-abc " ,
253255 ]
254256
255257
@@ -260,7 +262,8 @@ def test_launch_argv_appends_initial_prompt_on_first_run(tmp_path: Path) -> None
260262
261263def test_launch_argv_omits_initial_prompt_when_resuming (tmp_path : Path ) -> None :
262264 (tmp_path / "sessions" ).mkdir ()
263- (tmp_path / "sessions" / "s.jsonl" ).write_text ("{}" )
265+ meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
266+ (tmp_path / "sessions" / "s.jsonl" ).write_text (meta )
264267 argv = CodexAgentCLI ().launch_argv (tmp_path , Path ("/workspace" ), initial_prompt = "review plan" )
265268 assert "resume" in argv and "review plan" not in argv
266269
@@ -277,7 +280,8 @@ def test_launch_argv_passes_the_resolved_model_on_first_run(tmp_path: Path) -> N
277280
278281def test_launch_argv_omits_model_on_resume (tmp_path : Path ) -> None :
279282 (tmp_path / "sessions" ).mkdir ()
280- (tmp_path / "sessions" / "s.jsonl" ).write_text ("{}" )
283+ meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
284+ (tmp_path / "sessions" / "s.jsonl" ).write_text (meta )
281285 argv = CodexAgentCLI ().launch_argv (tmp_path , Path ("/workspace" ), starting_model = "primary" )
282286 assert "--model" not in argv and "resume" in argv
283287
@@ -295,6 +299,151 @@ def test_launch_argv_passes_model_before_initial_prompt_on_first_run(tmp_path: P
295299 ]
296300
297301
302+ # -- _find_resume_target -----------------------------------------------------------------------
303+
304+
305+ def _session_meta (
306+ session_id : str , originator : str = "codex-tui" , thread_source : str = "user"
307+ ) -> str :
308+ """One-liner session_meta first-line JSON for tests."""
309+ import json
310+
311+ return json .dumps (
312+ {"payload" : {"originator" : originator , "thread_source" : thread_source , "id" : session_id }}
313+ )
314+
315+
316+ def test_find_resume_target_returns_none_when_no_sessions_dir (tmp_path : Path ) -> None :
317+ assert _find_resume_target (tmp_path / "sessions" ) is None
318+
319+
320+ def test_find_resume_target_returns_none_when_no_jsonl_files (tmp_path : Path ) -> None :
321+ (tmp_path / "sessions" ).mkdir ()
322+ assert _find_resume_target (tmp_path / "sessions" ) is None
323+
324+
325+ def test_find_resume_target_skips_codex_exec_rollout (tmp_path : Path ) -> None :
326+ # originator != "codex-tui" → not eligible
327+ d = tmp_path / "sessions"
328+ d .mkdir ()
329+ (d / "exec.jsonl" ).write_text (_session_meta ("exec-1" , originator = "codex_exec" ))
330+ assert _find_resume_target (d ) is None
331+
332+
333+ def test_find_resume_target_skips_subagent_thread (tmp_path : Path ) -> None :
334+ # thread_source != "user" → internal subagent thread, not resumable
335+ d = tmp_path / "sessions"
336+ d .mkdir ()
337+ (d / "sub.jsonl" ).write_text (_session_meta ("sub-1" , thread_source = "agent" ))
338+ assert _find_resume_target (d ) is None
339+
340+
341+ def test_find_resume_target_skips_malformed_first_line (tmp_path : Path ) -> None :
342+ d = tmp_path / "sessions"
343+ d .mkdir ()
344+ (d / "bad.jsonl" ).write_text ("not json\n " )
345+ assert _find_resume_target (d ) is None
346+
347+
348+ def test_find_resume_target_skips_empty_first_line (tmp_path : Path ) -> None :
349+ d = tmp_path / "sessions"
350+ d .mkdir ()
351+ (d / "empty.jsonl" ).write_text ("\n {}\n " ) # empty first line
352+ assert _find_resume_target (d ) is None
353+
354+
355+ def test_find_resume_target_skips_bare_object_without_payload (tmp_path : Path ) -> None :
356+ d = tmp_path / "sessions"
357+ d .mkdir ()
358+ (d / "bare.jsonl" ).write_text ("{}" ) # valid JSON, but no payload → skip
359+ assert _find_resume_target (d ) is None
360+
361+
362+ def test_find_resume_target_returns_id_of_interactive_session (tmp_path : Path ) -> None :
363+ d = tmp_path / "sessions"
364+ d .mkdir ()
365+ (d / "sess.jsonl" ).write_text (_session_meta ("interactive-1" ))
366+ assert _find_resume_target (d ) == "interactive-1"
367+
368+
369+ def test_find_resume_target_newest_interactive_beats_older_exec (tmp_path : Path ) -> None :
370+ # A newer exec rollout must not shadow an older interactive session.
371+ d = tmp_path / "sessions"
372+ d .mkdir ()
373+ old = d / "old-interactive.jsonl"
374+ old .write_text (_session_meta ("good-sess" ))
375+ time .sleep (0.01 )
376+ new = d / "new-exec.jsonl"
377+ new .write_text (_session_meta ("exec-sess" , originator = "codex_exec" ))
378+ # exec is newer by mtime but ineligible → interactive wins
379+ assert _find_resume_target (d ) == "good-sess"
380+
381+
382+ def test_find_resume_target_picks_newest_of_multiple_interactive (tmp_path : Path ) -> None :
383+ d = tmp_path / "sessions"
384+ d .mkdir ()
385+ first = d / "first.jsonl"
386+ first .write_text (_session_meta ("old-sess" ))
387+ time .sleep (0.01 )
388+ second = d / "second.jsonl"
389+ second .write_text (_session_meta ("new-sess" ))
390+ assert _find_resume_target (d ) == "new-sess"
391+
392+
393+ def test_find_resume_target_searches_subdirectories (tmp_path : Path ) -> None :
394+ d = tmp_path / "sessions"
395+ sub = d / "2026" / "08"
396+ sub .mkdir (parents = True )
397+ (sub / "deep.jsonl" ).write_text (_session_meta ("deep-sess" ))
398+ assert _find_resume_target (d ) == "deep-sess"
399+
400+
401+ # -- launch_argv resume + interrupt prompt -------------------------------------------------------
402+
403+
404+ def test_launch_argv_resumes_with_interrupt_prompt_when_agent_turn (tmp_path : Path ) -> None :
405+ (tmp_path / "sessions" ).mkdir ()
406+ meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
407+ (tmp_path / "sessions" / "s.jsonl" ).write_text (meta )
408+ argv = CodexAgentCLI ().launch_argv (tmp_path , Path ("/workspace" ), turn = "agent" )
409+ assert argv == [
410+ "codex" ,
411+ "--dangerously-bypass-approvals-and-sandbox" ,
412+ "resume" ,
413+ "s1" ,
414+ "You were interrupted. Continue." ,
415+ ]
416+
417+
418+ def test_launch_argv_resumes_without_interrupt_prompt_when_user_turn (tmp_path : Path ) -> None :
419+ (tmp_path / "sessions" ).mkdir ()
420+ meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
421+ (tmp_path / "sessions" / "s.jsonl" ).write_text (meta )
422+ argv = CodexAgentCLI ().launch_argv (tmp_path , Path ("/workspace" ), turn = "user" )
423+ assert argv == [
424+ "codex" ,
425+ "--dangerously-bypass-approvals-and-sandbox" ,
426+ "resume" ,
427+ "s1" ,
428+ ]
429+
430+
431+ def test_launch_argv_falls_back_to_first_run_when_only_exec_sessions (tmp_path : Path ) -> None :
432+ (tmp_path / "sessions" ).mkdir ()
433+ exec_meta = '{"payload": {"originator": "codex_exec", "thread_source": "user", "id": "e1"}}'
434+ (tmp_path / "sessions" / "exec.jsonl" ).write_text (exec_meta )
435+ argv = CodexAgentCLI ().launch_argv (
436+ tmp_path , Path ("/workspace" ), initial_prompt = "hi" , starting_model = "primary"
437+ )
438+ assert argv == [
439+ "codex" ,
440+ "--dangerously-bypass-approvals-and-sandbox" ,
441+ "--model" ,
442+ "gpt-5.6-sol" ,
443+ "hi" ,
444+ ]
445+
446+
298447# -- hook seam (M3.6) ---------------------------------------------------------------------------
299448
300449
0 commit comments