Files
codexpi/tests/test_codex_sessions.py
CodexPi 995b17a6e2
Some checks are pending
Qualité / tests (push) Waiting to run
feat: publish CodexPi contest snapshot
2026-11-14 20:23:59 +01:00

770 lines
34 KiB
Python

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(
"<!-- codexpi-title: Gestionnaire Git élégant -->\n\nVoici les changements."
)
assert title == "Gestionnaire Git élégant"
assert answer == "Voici les changements."