feat: publish CodexPi contest snapshot
Some checks are pending
Qualité / tests (push) Waiting to run
Some checks are pending
Qualité / tests (push) Waiting to run
This commit is contained in:
769
tests/test_codex_sessions.py
Normal file
769
tests/test_codex_sessions.py
Normal file
@@ -0,0 +1,769 @@
|
||||
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 "" 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."
|
||||
Reference in New Issue
Block a user