133 lines
7.9 KiB
Python
133 lines
7.9 KiB
Python
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from codexpi.database import Database, utcnow
|
|
from codexpi.message_results import collect_event, recover_results, snapshot_changes, workspace_snapshot
|
|
from codexpi.session_worker import main
|
|
|
|
|
|
def test_commands_are_deduplicated_and_failures_preserved():
|
|
results = {}
|
|
collect_event(results, {'type': 'item.started', 'item': {'id': 'c', 'type': 'command_execution', 'command': 'false'}}, '2026-09-13T10:00:00Z')
|
|
collect_event(results, {'type': 'item.completed', 'item': {'id': 'c', 'type': 'command_execution', 'command': 'false', 'exit_code': 1, 'aggregated_output': 'failure'}}, '2026-09-13T10:00:02Z')
|
|
assert len(results['actions']) == 1
|
|
assert results['actions'][0]['status'] == 'error'
|
|
assert results['actions'][0]['duration'] == 2000
|
|
assert results['actions'][0]['output'] == 'failure'
|
|
|
|
|
|
def test_snapshot_excludes_preexisting_changes(tmp_path):
|
|
subprocess.run(['git', 'init', str(tmp_path)], check=True, capture_output=True)
|
|
(tmp_path / 'existing.txt').write_text('already changed')
|
|
(tmp_path / 'edited.txt').write_text('before')
|
|
(tmp_path / 'deleted.txt').write_text('before')
|
|
before = workspace_snapshot(str(tmp_path))
|
|
(tmp_path / 'edited.txt').write_text('after')
|
|
(tmp_path / 'deleted.txt').unlink()
|
|
(tmp_path / 'new.txt').write_text('new')
|
|
assert snapshot_changes(before, workspace_snapshot(str(tmp_path))) == [
|
|
{'path': 'deleted.txt', 'status': 'D'}, {'path': 'edited.txt', 'status': 'M'}, {'path': 'new.txt', 'status': 'A'}]
|
|
|
|
|
|
def test_recovery_does_not_mix_queued_prompts(tmp_path):
|
|
path = tmp_path / 'events.jsonl'
|
|
events = [
|
|
{'type': 'queue.started', 'data': {'id': 'first'}},
|
|
{'type': 'codex.event', 'at': '2026-09-13T10:00:00Z', 'data': {'type': 'item.completed', 'item': {'id': 'c', 'type': 'command_execution', 'command': 'ls', 'exit_code': 0}}},
|
|
{'type': 'codex.event', 'at': '2026-09-13T10:00:01Z', 'data': {'type': 'item.completed', 'item': {'type': 'agent_message', 'text': 'Checking files'}}},
|
|
{'type': 'codex.event', 'at': '2026-09-13T10:00:02Z', 'data': {'type': 'item.completed', 'item': {'type': 'agent_message', 'text': 'First answer'}}},
|
|
{'type': 'codex.event', 'at': '2026-09-13T10:00:02Z', 'data': {'type': 'error', 'message': 'Temporary transport error'}},
|
|
{'type': 'codex.event', 'at': '2026-09-13T10:00:02Z', 'data': {'type': 'turn.failed', 'error': {'message': 'Selected model is at capacity.'}}},
|
|
{'type': 'queue.started', 'data': {'id': 'second'}},
|
|
{'type': 'codex.event', 'at': '2026-09-13T10:00:00Z', 'data': {'type': 'item.completed', 'item': {'type': 'file_change', 'changes': [{'path': 'app.py', 'kind': 'update'}]}}},
|
|
{'type': 'codex.event', 'at': '2026-09-13T10:00:03Z', 'data': {'type': 'item.completed', 'item': {'type': 'agent_message', 'text': 'Second answer'}}},
|
|
{'type': 'queue.started', 'data': {'id': 'third'}},
|
|
{'type': 'codex.event', 'at': '2026-09-13T10:00:04Z', 'data': {'type': 'output', 'text': 'CLI authentication failed'}},
|
|
{'type': 'queue.finished', 'data': {'id': 'third', 'returncode': 1, 'status': 'failed'}},
|
|
]
|
|
path.write_text('\n'.join(map(json.dumps, events)) + '\n{broken')
|
|
recovered = recover_results([path])
|
|
assert len(recovered['first']['actions']) == 1
|
|
assert recovered['first']['files'] == []
|
|
assert recovered['first']['progress'] == ['Checking files']
|
|
assert recovered['first']['answer'] == 'First answer'
|
|
assert recovered['first']['error'] == 'Selected model is at capacity.'
|
|
assert recovered['second']['actions'] == []
|
|
assert recovered['second']['files'] == [{'path': 'app.py', 'status': 'update'}]
|
|
assert recovered['second']['progress'] == []
|
|
assert recovered['second']['answer'] == 'Second answer'
|
|
assert recovered['third']['error'] == 'CLI authentication failed'
|
|
|
|
|
|
def test_worker_persists_structured_engine_error(tmp_path, monkeypatch):
|
|
repo = tmp_path / 'repo'
|
|
repo.mkdir()
|
|
executable = tmp_path / 'fake-codex'
|
|
executable.write_text('''#!/usr/bin/env python3
|
|
import json, sys
|
|
print(json.dumps({'type':'error','message':'Selected model is at capacity.'}))
|
|
print(json.dumps({'type':'turn.failed','error':{'message':'Selected model is at capacity.'}}))
|
|
sys.exit(1)
|
|
''')
|
|
executable.chmod(0o755)
|
|
db = Database(tmp_path / 'db.sqlite')
|
|
db.initialize()
|
|
stamp = utcnow()
|
|
db.execute('INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)', ('p', 'Demo', str(repo), stamp))
|
|
db.execute('INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)', ('c', 'p', 'Demo', 'running', stamp, stamp))
|
|
db.execute('INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)', ('q', 'c', 'Build', 'running', stamp))
|
|
monkeypatch.setattr(sys, 'argv', ['worker', '--database', str(db.path), '--events', str(tmp_path / 'events.jsonl'), '--conversation', 'c', '--cwd', str(repo), '--codex', str(executable), '--queue', 'q'])
|
|
|
|
assert main() == 1
|
|
assert db.one('SELECT status,error FROM prompt_queue WHERE id=?', ('q',)) == {
|
|
'status': 'failed',
|
|
'error': 'Selected model is at capacity.',
|
|
}
|
|
|
|
|
|
def test_worker_persists_results_per_message(tmp_path, monkeypatch):
|
|
repo = tmp_path / 'repo'
|
|
repo.mkdir()
|
|
subprocess.run(['git', 'init', str(repo)], check=True, capture_output=True)
|
|
(repo / 'preexisting.txt').write_text('preexisting dirty file')
|
|
executable = tmp_path / 'fake-codex'
|
|
executable.write_text('''#!/usr/bin/env python3
|
|
import json, sys
|
|
from pathlib import Path
|
|
if sys.argv[-1].startswith('first'):
|
|
Path('changed.txt').write_text('new content')
|
|
print(json.dumps({'type':'item.completed','item':{'id':'c','type':'command_execution','command':'write file','exit_code':0}}))
|
|
print(json.dumps({'type':'item.completed','item':{'type':'agent_message','text':'Progress'}}))
|
|
print(json.dumps({'type':'item.completed','item':{'type':'agent_message','text':'Answer'}}))
|
|
''')
|
|
executable.chmod(0o755)
|
|
db = Database(tmp_path / 'db.sqlite')
|
|
db.initialize()
|
|
stamp = utcnow()
|
|
db.execute('INSERT INTO projects(id,name,path,created_at) VALUES(?,?,?,?)', ('p', 'Demo', str(repo), stamp))
|
|
db.execute('INSERT INTO conversations(id,project_id,title,status,created_at,updated_at) VALUES(?,?,?,?,?,?)', ('c', 'p', 'Demo', 'running', stamp, stamp))
|
|
for identifier, status in [('first', 'running'), ('second', 'queued')]:
|
|
db.execute('INSERT INTO prompt_queue(id,conversation_id,content,status,created_at) VALUES(?,?,?,?,?)', (identifier, 'c', identifier, status, stamp))
|
|
monkeypatch.setattr(sys, 'argv', ['worker', '--database', str(db.path), '--events', str(tmp_path / 'events.jsonl'), '--conversation', 'c', '--cwd', str(repo), '--codex', str(executable), '--queue', 'first'])
|
|
started_before = utcnow()
|
|
assert main() == 0
|
|
events = [json.loads(line) for line in (tmp_path / 'events.jsonl').read_text().splitlines()]
|
|
starts = [event['data'] for event in events if event['type'] == 'queue.started']
|
|
assert len(starts) == 2
|
|
for start in starts:
|
|
stored = db.one('SELECT started_at FROM prompt_queue WHERE id=?', (start['id'],))
|
|
assert stored['started_at'] == start['started_at']
|
|
assert start['started_at'] >= started_before
|
|
stored_messages = db.all('SELECT * FROM messages')
|
|
rows = {json.loads(row['metadata'])['queue_id']: json.loads(row['metadata']) for row in stored_messages}
|
|
answers = {json.loads(row['metadata'])['queue_id']: row['content'] for row in stored_messages}
|
|
assert len(rows['first']['actions']) == 1
|
|
assert rows['first']['files'] == [{'path': 'changed.txt', 'status': 'A'}]
|
|
assert rows['second']['actions'] == []
|
|
assert rows['second']['files'] == []
|
|
assert rows['first']['progress'] == ['Progress']
|
|
assert rows['second']['progress'] == ['Progress']
|
|
assert answers == {'first': 'Answer', 'second': 'Answer'}
|