import json import fcntl import io import os import socket import sqlite3 import subprocess import sys import threading from contextlib import ExitStack from pathlib import Path from codexpi.codex_service import CodexManager, is_host_agent_project from codexpi.database import Database, utcnow from codexpi.session_worker import ( codex_command, engine_command, engine_output, engine_execution_slot, extract_conversation_title, main as session_worker_main, missing_rollout_thread, rate_limit_retry_delay, thread_writer_retry_delay, wait_for_retry, watch_for_cancellation, ) def test_missing_rollout_recovery_only_matches_the_codex_resume_failure(): message = "no rollout found for thread id 01a17c67-0e63-7751-a96c-19821f7f5f0e" assert missing_rollout_thread(message, "codex", "thread-id") is True assert missing_rollout_thread("Authentication failed", "codex", "thread-id") is False assert missing_rollout_thread(message, "codex", None) is False assert missing_rollout_thread(message, "claude", "thread-id") is False def test_worker_replays_message_in_a_new_thread_when_rollout_is_missing(tmp_path: Path, monkeypatch): db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", ("p", "Demo", str(tmp_path), stamp)) db.execute( "INSERT INTO conversations(id,project_id,title,codex_thread_id,status,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?)", ("c", "p", "Task", "missing-thread", "running", stamp, stamp), ) db.execute( "INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", ("q", "c", "Relance ce message", "running", stamp), ) launches: list[list[str]] = [] outputs = [ ( '{"type":"error","message":"thread/resume failed: no rollout found for thread id ' 'missing-thread (code -32600)"}\n', 1, ), ( '{"type":"thread.started","thread_id":"replacement-thread"}\n' '{"type":"item.completed","item":{"type":"agent_message","text":"Terminé"}}\n', 0, ), ] class FakeProcess: pid = 12345 def __init__(self, output: str, returncode: int): self.stdout = io.StringIO(output) self.returncode = returncode def wait(self): return self.returncode def poll(self): return self.returncode def fake_popen(command, **_kwargs): launches.append(command) return FakeProcess(*outputs[len(launches) - 1]) monkeypatch.setattr("codexpi.session_worker.subprocess.Popen", fake_popen) monkeypatch.setattr("codexpi.session_worker.workspace_snapshot", lambda _cwd: None) events = tmp_path / "events.jsonl" monkeypatch.setattr(sys, "argv", [ "session-worker", "--database", str(db.path), "--events", str(events), "--conversation", "c", "--cwd", str(tmp_path), "--codex", "codex", "--queue", "q", ]) assert session_worker_main() == 0 assert db.one("SELECT status FROM prompt_queue WHERE id='q'")["status"] == "done" conversation = db.one("SELECT status,codex_thread_id FROM conversations WHERE id='c'") assert conversation == {"status": "completed", "codex_thread_id": "replacement-thread"} assert launches[0][-3:-1] == ["resume", "missing-thread"] assert launches[0][-1].startswith("Relance ce message") assert "insère chaque image directement" in launches[0][-1] assert "![description](output/image.png)" in launches[0][-1] assert "WebP" in launches[0][-1] assert "largeur maximale de 1280 px" in launches[0][-1] assert "resume" not in launches[1] assert launches[1][-1].startswith("Relance ce message") assert "insère chaque image directement" in launches[1][-1] assert '"type": "queue.recovering"' in events.read_text(encoding="utf-8") def test_rate_limit_errors_use_bounded_progressive_delays(): assert rate_limit_retry_delay("exceeded retry limit, last status: 429 Too Many Requests", 0) == 15 assert rate_limit_retry_delay("rate limit exceeded", 1) == 45 assert rate_limit_retry_delay("HTTP 429", 2) == 120 assert rate_limit_retry_delay("HTTP 429", 3) is None assert rate_limit_retry_delay("Authentication failed", 0) is None def test_active_thread_writer_conflicts_use_short_bounded_retries(): message = "thread-store conflict: thread abc already has an active writer" assert [thread_writer_retry_delay(message, "codex", "abc", index) for index in range(5)] == [2, 5, 15, 30, None] assert thread_writer_retry_delay(message, "codex", None, 0) is None assert thread_writer_retry_delay(message, "claude", "abc", 0) is None assert thread_writer_retry_delay("Authentication failed", "codex", "abc", 0) is None def test_worker_retries_the_same_thread_after_active_writer_conflict(tmp_path: Path, monkeypatch): db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", ("p", "Demo", str(tmp_path), stamp)) db.execute( "INSERT INTO conversations(id,project_id,title,codex_thread_id,status,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?)", ("c", "p", "Task", "busy-thread", "running", stamp, stamp), ) db.execute( "INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", ("q", "c", "Reprendre sans doublon", "running", stamp), ) launches: list[list[str]] = [] outputs = [ ('{"type":"error","message":"thread-store conflict: thread busy-thread already has an active writer"}\n', 1), ( '{"type":"thread.started","thread_id":"busy-thread"}\n' '{"type":"item.completed","item":{"type":"agent_message","text":"Terminé"}}\n', 0, ), ] class FakeProcess: pid = 12345 def __init__(self, output: str, returncode: int): self.stdout = io.StringIO(output) self.returncode = returncode def wait(self): return self.returncode def poll(self): return self.returncode def fake_popen(command, **_kwargs): launches.append(command) return FakeProcess(*outputs[len(launches) - 1]) monkeypatch.setattr("codexpi.session_worker.subprocess.Popen", fake_popen) monkeypatch.setattr("codexpi.session_worker.wait_for_retry", lambda *_args, **_kwargs: True) monkeypatch.setattr("codexpi.session_worker.workspace_snapshot", lambda _cwd: None) events = tmp_path / "events.jsonl" monkeypatch.setattr(sys, "argv", [ "session-worker", "--database", str(db.path), "--events", str(events), "--conversation", "c", "--cwd", str(tmp_path), "--codex", "codex", "--queue", "q", ]) assert session_worker_main() == 0 assert db.one("SELECT status FROM prompt_queue WHERE id='q'")["status"] == "done" assert len(launches) == 2 assert launches[0][-3:-1] == ["resume", "busy-thread"] assert launches[1][-3:-1] == ["resume", "busy-thread"] event_log = events.read_text(encoding="utf-8") assert '"reason": "thread_writer"' in event_log assert "reprise automatique après libération" in event_log def test_two_api_processes_launch_only_one_worker_for_a_conversation(tmp_path: Path): path = tmp_path / "db.sqlite" setup = Database(path) setup.initialize() stamp = utcnow() setup.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", ("p", "Demo", str(tmp_path), stamp)) setup.execute( "INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", ("c", "p", "Task", "idle", stamp, stamp), ) conversation = setup.one("SELECT * FROM conversations WHERE id='c'") project = setup.one("SELECT * FROM projects WHERE id='p'") managers = [ CodexManager(Database(path), tmp_path / "data-a", "/bin/true"), CodexManager(Database(path), tmp_path / "data-b", "/bin/true"), ] launches: list[str] = [] launch_lock = threading.Lock() for manager in managers: def fake_spawn(_conversation, _project, queue_id, _event_file, _binary): with launch_lock: launches.append(queue_id) return 4200 + len(launches) manager._spawn = fake_spawn barrier = threading.Barrier(3) results: list[dict] = [] def submit(manager: CodexManager, prompt: str): barrier.wait() results.append(manager.enqueue(conversation, project, prompt)) workers = [ threading.Thread(target=submit, args=(managers[0], "Premier")), threading.Thread(target=submit, args=(managers[1], "Second")), ] for worker in workers: worker.start() barrier.wait() for worker in workers: worker.join(timeout=10) assert all(not worker.is_alive() for worker in workers) assert sorted(result["status"] for result in results) == ["queued", "running"] assert len(launches) == 1 assert setup.one("SELECT COUNT(*) AS total FROM prompt_queue WHERE status='running'")["total"] == 1 assert setup.one("SELECT COUNT(*) AS total FROM prompt_queue WHERE status='queued'")["total"] == 1 def test_rate_limit_wait_can_be_cancelled(tmp_path: Path): db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", ("p", "Demo", str(tmp_path), stamp)) db.execute("INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", ("c", "p", "Task", "running", stamp, stamp)) db.execute("INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", ("q", "c", "Build", "cancelled", stamp)) assert wait_for_retry(str(db.path), "q", 10, interval=0.01) is False def test_codex_engine_slot_wait_is_cancellable(tmp_path: Path, monkeypatch): monkeypatch.setenv("CODEXPI_MAX_CONCURRENT_AGENTS", "1") db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", ("p", "Demo", str(tmp_path), stamp)) db.execute("INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", ("c", "p", "Task", "running", stamp, stamp)) db.execute("INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", ("q", "c", "Build", "cancelled", stamp)) lock_path = tmp_path / "codex-engine-slot-0.lock" lock_path.touch() events = io.StringIO() with lock_path.open("a+") as held: fcntl.flock(held.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) with engine_execution_slot(str(db.path), "codex", "q", events, project_id="p", interval=0.01) as acquired: assert acquired is False def test_three_projects_can_run_codex_while_a_fourth_waits(tmp_path: Path, monkeypatch): monkeypatch.setenv("CODEXPI_MAX_CONCURRENT_AGENTS", "3") db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() for index in range(4): project_id = f"p{index}" conversation_id = f"c{index}" queue_id = f"q{index}" project_path = tmp_path / project_id project_path.mkdir() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", (project_id, project_id, str(project_path), stamp)) db.execute("INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", (conversation_id, project_id, project_id, "running", stamp, stamp)) db.execute("INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", (queue_id, conversation_id, project_id, "running", stamp)) events = io.StringIO() with ExitStack() as stack: acquired = [ stack.enter_context(engine_execution_slot(str(db.path), "codex", f"q{index}", events, project_id=f"p{index}", interval=0.01)) for index in range(3) ] assert acquired == [True, True, True] db.execute("UPDATE prompt_queue SET status='cancelled' WHERE id='q3'") with engine_execution_slot(str(db.path), "codex", "q3", events, project_id="p3", interval=0.01) as fourth: assert fourth is False def test_two_conversations_from_the_same_project_stay_serialized(tmp_path: Path, monkeypatch): monkeypatch.setenv("CODEXPI_MAX_CONCURRENT_AGENTS", "3") db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", ("p", "Demo", str(tmp_path), stamp)) for index in range(2): db.execute("INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", (f"c{index}", "p", "Task", "running", stamp, stamp)) db.execute("INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", (f"q{index}", f"c{index}", "Build", "running", stamp)) events = io.StringIO() with engine_execution_slot(str(db.path), "codex", "q0", events, project_id="p", interval=0.01) as first: assert first is True db.execute("UPDATE prompt_queue SET status='cancelled' WHERE id='q1'") with engine_execution_slot(str(db.path), "codex", "q1", events, project_id="p", interval=0.01) as second: assert second is False def test_two_projects_in_the_same_execution_group_stay_serialized(tmp_path: Path, monkeypatch): monkeypatch.setenv("CODEXPI_MAX_CONCURRENT_AGENTS", "3") db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() for index in range(2): project_id = f"p{index}" project_path = tmp_path / project_id project_path.mkdir() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", (project_id, project_id, str(project_path), stamp)) db.execute("INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", (f"c{index}", project_id, "TV", "running", stamp, stamp)) db.execute("INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", (f"q{index}", f"c{index}", "Build", "running", stamp)) events = io.StringIO() with engine_execution_slot(str(db.path), "codex", "q0", events, project_id="p0", execution_group="tv", interval=0.01) as first: assert first is True db.execute("UPDATE prompt_queue SET status='cancelled' WHERE id='q1'") with engine_execution_slot(str(db.path), "codex", "q1", events, project_id="p1", execution_group="tv", interval=0.01) as second: assert second is False def test_global_agents_do_not_share_the_reserved_workspace_marker(tmp_path: Path, monkeypatch): monkeypatch.setenv("CODEXPI_MAX_CONCURRENT_AGENTS", "3") db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() for index in range(2): project_id = f"global-p{index}" project_path = tmp_path / project_id project_path.mkdir() db.execute( "INSERT INTO projects(id,name,path,execution_group,created_at) VALUES(?,?,?,?,?)", (project_id, project_id, str(project_path), "__global_agent__", stamp), ) db.execute( "INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", (f"global-c{index}", project_id, "Global", "running", stamp, stamp), ) db.execute( "INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", (f"global-q{index}", f"global-c{index}", "Build", "running", stamp), ) events = io.StringIO() with engine_execution_slot( str(db.path), "codex", "global-q0", events, project_id="global-p0", execution_group="__global_agent__", interval=0.01, ) as first: assert first is True # Avoid a hanging regression test: the old behavior observes this # cancellation while waiting on the shared reserved-marker lock. db.execute("UPDATE prompt_queue SET status='cancelled' WHERE id='global-q1'") with engine_execution_slot( str(db.path), "codex", "global-q1", events, project_id="global-p1", execution_group="__global_agent__", interval=0.01, ) as second: assert second is True def test_projects_with_any_overlapping_group_stay_serialized(tmp_path: Path, monkeypatch): monkeypatch.setenv("CODEXPI_MAX_CONCURRENT_AGENTS", "3") db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() for index in range(2): project_id = f"overlap-p{index}" project_path = tmp_path / project_id project_path.mkdir() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", (project_id, project_id, str(project_path), stamp)) db.execute("INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", (f"overlap-c{index}", project_id, "Shared", "running", stamp, stamp)) db.execute("INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", (f"overlap-q{index}", f"overlap-c{index}", "Build", "running", stamp)) events = io.StringIO() with engine_execution_slot( str(db.path), "codex", "overlap-q0", events, project_id="overlap-p0", execution_groups=["salon", "tv"], interval=0.01, ) as first: assert first is True db.execute("UPDATE prompt_queue SET status='cancelled' WHERE id='overlap-q1'") with engine_execution_slot( str(db.path), "codex", "overlap-q1", events, project_id="overlap-p1", execution_groups=["media", "tv"], interval=0.01, ) as second: assert second is False def test_other_engines_do_not_share_the_codex_execution_slot(tmp_path: Path): with engine_execution_slot(str(tmp_path / "db.sqlite"), "claude", "q", io.StringIO()) as acquired: assert acquired is True def test_all_engines_share_project_group_locks(tmp_path: Path): db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() for index in range(2): project_id = f"engine-p{index}" project_path = tmp_path / project_id project_path.mkdir() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", (project_id, project_id, str(project_path), stamp)) db.execute("INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", (f"engine-c{index}", project_id, "Shared", "running", stamp, stamp)) db.execute("INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", (f"engine-q{index}", f"engine-c{index}", "Build", "running", stamp)) events = io.StringIO() with engine_execution_slot( str(db.path), "codex", "engine-q0", events, project_id="engine-p0", execution_groups=["tv"], interval=0.01, ) as first: assert first is True db.execute("UPDATE prompt_queue SET status='cancelled' WHERE id='engine-q1'") with engine_execution_slot( str(db.path), "claude", "engine-q1", events, project_id="engine-p1", execution_groups=["tv"], interval=0.01, ) as second: assert second is False def test_cancellation_watcher_terminates_the_active_engine_process(tmp_path: Path): db = Database(tmp_path / "db.sqlite") db.initialize() stamp = utcnow() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", ("p", "Demo", str(tmp_path), stamp)) db.execute("INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)", ("c", "p", "Task", "running", stamp, stamp)) db.execute("INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)", ("q", "c", "Stop", "cancelled", stamp)) process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], start_new_session=True) stopped = threading.Event() try: watch_for_cancellation(str(db.path), "q", process, stopped, poll_interval=0.01) assert process.wait(timeout=2) != 0 finally: if process.poll() is None: process.kill() def test_conversation_and_event_recovery(tmp_path: Path): db = Database(tmp_path / "db.sqlite") db.initialize() project_id = "11111111-1111-1111-1111-111111111111" db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", (project_id, "Demo", str(tmp_path), utcnow())) manager = CodexManager(db, tmp_path / "data", "missing-codex") conversation = manager.create_conversation(project_id, "Persistent task") event_file = tmp_path / "data" / "sessions" / f"{conversation['id']}.jsonl" event_file.write_text(json.dumps({"type": "worker.started"}) + "\n") db.execute("UPDATE conversations SET event_file=? WHERE id=?", (str(event_file), conversation["id"])) current = db.one("SELECT * FROM conversations WHERE id=?", (conversation["id"],)) events, offset = manager.read_events(current, 0) assert events == [{"type": "worker.started"}] assert offset > 0 assert manager.read_events(current, offset)[0] == [] def test_full_access_is_disabled_by_default(tmp_path: Path): db = Database(tmp_path / "db.sqlite") db.initialize() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", ("p", "Demo", str(tmp_path), utcnow())) assert db.one("SELECT full_access FROM projects WHERE id='p'")["full_access"] == 0 assert db.one("SELECT archived FROM projects WHERE id='p'")["archived"] == 0 def test_automatic_context_compaction_preference_is_persisted(logged_in): client, _app, _projects, _forbidden, headers = logged_in assert client.get("/api/application/preferences").json() == { "auto_compact_context": False, "interface_type": "dashboard", "theme": "system", } response = client.put("/api/application/preferences", headers=headers, json={"auto_compact_context": True}) assert response.status_code == 200 assert response.json() == { "auto_compact_context": True, "interface_type": "dashboard", "theme": "system", } assert client.get("/api/application/preferences").json() == response.json() def test_interface_and_theme_preferences_are_independent_and_validated(logged_in): client, _app, _projects, _forbidden, headers = logged_in selected = client.put( "/api/application/preferences", headers=headers, json={"interface_type": "atelier", "theme": "dark"}, ) assert selected.status_code == 200 assert selected.json() == { "auto_compact_context": False, "interface_type": "atelier", "theme": "dark", } compression = client.put( "/api/application/preferences", headers=headers, json={"auto_compact_context": True}, ) assert compression.json()["interface_type"] == "atelier" assert compression.json()["theme"] == "dark" assert client.put( "/api/application/preferences", headers=headers, json={"interface_type": "unknown"}, ).status_code == 422 def test_safe_codex_command_writes_without_requiring_git(): command = codex_command("codex", "Modify app.py", None, False) assert command == [ "codex", "--sandbox", "workspace-write", "--ask-for-approval", "never", "exec", "--json", "--skip-git-repo-check", "Modify app.py", ] def test_resumed_non_git_session_keeps_safe_write_mode(): command = codex_command("codex", "Continue", "thread-id", False) assert command[-3:] == ["resume", "thread-id", "Continue"] assert "--skip-git-repo-check" in command assert "workspace-write" in command def test_images_are_attached_to_initial_and_resumed_prompts(): initial = codex_command("codex", "Inspect", None, False, ["/data/a.png", "/data/b.jpg"]) resumed = codex_command("codex", "Continue", "thread-id", False, ["/data/a.png"]) assert initial[-6:] == ["--image", "/data/a.png", "--image", "/data/b.jpg", "--", "Inspect"] assert resumed[-5:] == ["resume", "--image", "/data/a.png", "thread-id", "Continue"] def test_full_access_command_is_explicit_and_project_scoped_by_cwd(): command = codex_command("codex", "Run task", None, True) assert "--dangerously-bypass-approvals-and-sandbox" in command assert "workspace-write" not in command def test_privileged_pi_doctor_uses_the_private_host_worker(tmp_path: Path, monkeypatch): db = Database(tmp_path / "db.sqlite") db.initialize() project_root = tmp_path / "pi-doctor" project_root.mkdir() project = { "id": "pi-doctor", "name": "Pi Doctor", "path": str(project_root), "kind": "local", "full_access": 1, "execution_group": "__global_agent__", } db.execute( "INSERT INTO projects(id,name,path,kind,full_access,execution_group,created_at) VALUES(?,?,?,?,?,?,?)", (project["id"], project["name"], project["path"], project["kind"], 1, project["execution_group"], utcnow()), ) manager = CodexManager(db, tmp_path / "data", "codex") conversation = manager.create_conversation(project["id"], "Host audit") socket_path = tmp_path / "agent.sock" token_file = tmp_path / "agent.token" token_file.write_text("x" * 48, encoding="utf-8") os.chmod(token_file, 0o600) channel = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) channel.bind(str(socket_path)) captured: dict = {} class Process: pid = 4321 def fake_popen(argv, **kwargs): captured["argv"] = argv return Process() monkeypatch.setenv("CODEXPI_HOST_AGENT_SOCKET", str(socket_path)) monkeypatch.setenv("CODEXPI_HOST_AGENT_TOKEN_FILE", str(token_file)) monkeypatch.setattr("codexpi.codex_service.subprocess.Popen", fake_popen) try: manager._spawn(conversation, project, "queue", tmp_path / "events.jsonl", "codex") finally: channel.close() assert is_host_agent_project(project) is True assert "codexpi.host_session_worker" in captured["argv"] assert "--privileged" in captured["argv"] assert str(socket_path) in captured["argv"] def test_remote_consultation_forces_read_only_even_with_full_access(): command = codex_command("codex", "Answer only", None, True, read_only=True) assert command[command.index("--sandbox") + 1] == "read-only" assert "--dangerously-bypass-approvals-and-sandbox" not in command def test_optional_agent_engines_have_non_interactive_commands_and_output_parsers(): claude = engine_command("claude", "claude", "Build", "session-1", True, model="sonnet") assert claude == [ "claude", "-p", "--verbose", "--output-format", "stream-json", "--model", "sonnet", "--resume", "session-1", "--dangerously-skip-permissions", "Build", ] opencode = engine_command("opencode", "opencode", "Inspect", None, False, ["/tmp/screen.png"]) assert opencode == ["opencode", "run", "--format", "json", "--file", "/tmp/screen.png", "Inspect"] session, text = engine_output({"type": "result", "session_id": "claude-1", "result": "Terminé"}, "claude") assert (session, text) == ("claude-1", "Terminé") def test_remote_worker_receives_only_its_project_ipc_socket(tmp_path: Path, monkeypatch): db = Database(tmp_path / "db.sqlite") db.initialize() project_root = tmp_path / "remote" project_root.mkdir() project = { "id": "remote-project", "name": "Remote", "path": str(project_root), "kind": "remote", "full_access": 0, } db.execute( "INSERT INTO projects(id,name,path,kind,created_at) VALUES(?,?,?,?,?)", (project["id"], project["name"], project["path"], project["kind"], utcnow()), ) manager = CodexManager(db, tmp_path / "data", "codex") conversation = manager.create_conversation(project["id"], "Remote session") captured: dict = {} class Process: pid = 4321 def fake_popen(argv, **kwargs): captured["argv"] = argv captured["env"] = kwargs["env"] return Process() monkeypatch.setattr("codexpi.codex_service.subprocess.Popen", fake_popen) manager._spawn(conversation, project, "queue", tmp_path / "events.jsonl", "codex") assert captured["env"]["CODEXPI_REMOTE_CHANNEL"] == str(project_root / ".codexpi-remote") assert "--full-access" not in captured["argv"] assert "--read-only" in captured["argv"] def test_model_and_reasoning_effort_are_scoped_to_conversation(tmp_path: Path): db = Database(tmp_path / "db.sqlite") db.initialize() db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", ("p", "Demo", str(tmp_path), utcnow())) conversation = CodexManager(db, tmp_path / "data", "codex").create_conversation( "p", "Configured", "gpt-5.6-sol", "high" ) assert conversation["model"] == "gpt-5.6-sol" assert conversation["reasoning_effort"] == "high" command = codex_command("codex", "Build", None, False, model=conversation["model"], reasoning_effort=conversation["reasoning_effort"]) assert command[:5] == ["codex", "--model", "gpt-5.6-sol", "--config", 'model_reasoning_effort="high"'] def test_existing_database_gets_session_configuration_columns(tmp_path: Path): path = tmp_path / "old.sqlite" connection = sqlite3.connect(path) connection.execute( "CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, " "codex_thread_id TEXT, status TEXT NOT NULL DEFAULT 'idle', event_file TEXT, worker_pid INTEGER, " "created_at TEXT NOT NULL, updated_at TEXT NOT NULL)" ) connection.commit() connection.close() Database(path).initialize() connection = sqlite3.connect(path) columns = {row[1] for row in connection.execute("PRAGMA table_info(conversations)")} connection.close() assert {"model", "reasoning_effort", "parent_conversation_id", "forked_from_message_id", "bootstrap_context", "archived", "auto_title", "compacted_message_count", "compacted_character_count", "compacted_at"} <= columns def test_native_context_compaction_updates_the_conversation_baseline(tmp_path: Path): fake = tmp_path / "fake-codex" fake.write_text( """#!/usr/bin/env python3 import json, sys for line in sys.stdin: request=json.loads(line) if request.get('id') == 0: print(json.dumps({'id':0,'result':{'userAgent':'test'}}), flush=True) elif request.get('id') == 1: print(json.dumps({'id':1,'result':{'thread':{'id':request['params']['threadId']}}}), flush=True) elif request.get('id') == 2: print(json.dumps({'id':2,'result':{}}), flush=True) print(json.dumps({'method':'item/completed','params':{'item':{'type':'contextCompaction','id':'compact-1'}}}), flush=True) """, encoding="utf-8", ) fake.chmod(0o755) db = Database(tmp_path / "db.sqlite") db.initialize() project_id = "22222222-2222-4222-8222-222222222222" db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", (project_id, "Demo", str(tmp_path), utcnow())) manager = CodexManager(db, tmp_path / "data", str(fake)) conversation = manager.create_conversation(project_id, "Long task") db.execute("UPDATE conversations SET codex_thread_id=? WHERE id=?", ("thread-123", conversation["id"])) db.add_message(conversation["id"], "user", "First request") db.add_message(conversation["id"], "assistant", "A sufficiently useful answer") current = db.one("SELECT * FROM conversations WHERE id=?", (conversation["id"],)) compacted = manager.compact(current, {"id": project_id, "path": str(tmp_path)}) assert compacted["compacted_message_count"] == 1 assert compacted["compacted_character_count"] == len("First requestA sufficiently useful answer") assert compacted["compacted_at"] action_message = db.one("SELECT metadata FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1", (conversation["id"],)) metadata = json.loads(action_message["metadata"]) assert metadata["context_compaction"] is True assert metadata["actions"][0]["command"] == "Compression du contexte Codex" assert metadata["actions"][0]["status"] == "done" def test_native_context_compaction_resets_a_missing_rollout(tmp_path: Path): fake = tmp_path / "fake-codex" fake.write_text( """#!/usr/bin/env python3 import json, sys for line in sys.stdin: request=json.loads(line) if request.get('id') == 0: print(json.dumps({'id':0,'result':{'userAgent':'test'}}), flush=True) elif request.get('id') == 1: print(json.dumps({'id':1,'result':{'thread':{'id':'missing-thread'}}}), flush=True) elif request.get('id') == 2: print(json.dumps({'id':2,'error':{'message':'no rollout found for thread id missing-thread'}}), flush=True) """, encoding="utf-8", ) fake.chmod(0o755) db = Database(tmp_path / "db.sqlite") db.initialize() project_id = "22222222-2222-4222-8222-222222222222" db.execute("INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)", (project_id, "Demo", str(tmp_path), utcnow())) manager = CodexManager(db, tmp_path / "data", str(fake)) conversation = manager.create_conversation(project_id, "Long task") db.execute("UPDATE conversations SET codex_thread_id=? WHERE id=?", ("missing-thread", conversation["id"])) db.add_message(conversation["id"], "user", "First request") db.add_message(conversation["id"], "assistant", "An answer") current = db.one("SELECT * FROM conversations WHERE id=?", (conversation["id"],)) recovered = manager.compact(current, {"id": project_id, "path": str(tmp_path)}) assert recovered["codex_thread_id"] is None assert recovered["compacted_message_count"] == 1 assert recovered["compacted_character_count"] == len("First requestAn answer") metadata = json.loads(db.one( "SELECT metadata FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1", (conversation["id"],), )["metadata"]) assert metadata["context_compaction"] is True assert metadata["context_reset"] is True assert metadata["actions"][0]["status"] == "done" assert "nouveau fil" in metadata["actions"][0]["output"] def test_generated_conversation_title_is_removed_from_the_visible_answer(): title, answer = extract_conversation_title( "\n\nVoici les changements." ) assert title == "Gestionnaire Git élégant" assert answer == "Voici les changements."