mirror of
https://github.com/mcloh/codex-chatgpt-mcp-proxy.git
synced 2026-11-05 21:34:11 +00:00
227 lines
7.2 KiB
Python
227 lines
7.2 KiB
Python
import asyncio
|
|
import httpx
|
|
import pytest
|
|
from codex_chatgpt_mcp_proxy.config import WorkerConfig
|
|
from codex_chatgpt_mcp_proxy.journal import Journal
|
|
from codex_chatgpt_mcp_proxy.server import create_app
|
|
from codex_chatgpt_mcp_proxy.worker import Worker
|
|
from conftest import claim
|
|
|
|
|
|
class FakeCodex:
|
|
starts = 0
|
|
threads = []
|
|
delay = 0.05
|
|
disconnect = False
|
|
approval_needed = False
|
|
|
|
def __init__(self, config, approval):
|
|
self.approval = approval
|
|
self.notifications = asyncio.Queue()
|
|
self.job = None
|
|
|
|
async def start(self, cwd):
|
|
pass
|
|
|
|
async def thread(self, cwd, thread_id=None):
|
|
self.threads.append(thread_id)
|
|
return thread_id or "fake-thread"
|
|
|
|
async def turn(self, thread_id, instruction):
|
|
type(self).starts += 1
|
|
|
|
async def complete():
|
|
await asyncio.sleep(self.delay)
|
|
if self.approval_needed:
|
|
decision = await self.approval(
|
|
{
|
|
"id": 1,
|
|
"method": "item/commandExecution/requestApproval",
|
|
"params": {"command": "harmless"},
|
|
}
|
|
)
|
|
assert decision in ("accept", "decline", "cancel")
|
|
if self.disconnect:
|
|
await self.notifications.put({"method": "bridge/disconnected", "params": {}})
|
|
else:
|
|
await self.notifications.put(
|
|
{
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": thread_id,
|
|
"turnId": "fake-turn",
|
|
"item": {"type": "agentMessage", "text": "done"},
|
|
},
|
|
}
|
|
)
|
|
await self.notifications.put(
|
|
{
|
|
"method": "turn/completed",
|
|
"params": {
|
|
"threadId": thread_id,
|
|
"turn": {"id": "fake-turn", "status": "completed"},
|
|
},
|
|
}
|
|
)
|
|
|
|
self.job = asyncio.create_task(complete())
|
|
return "fake-turn"
|
|
|
|
async def interrupt(self, thread_id, turn_id):
|
|
if self.job:
|
|
self.job.cancel()
|
|
await self.notifications.put(
|
|
{
|
|
"method": "turn/completed",
|
|
"params": {"threadId": thread_id, "turn": {"id": turn_id, "status": "interrupted"}},
|
|
}
|
|
)
|
|
|
|
async def close(self):
|
|
if self.job:
|
|
self.job.cancel()
|
|
await asyncio.gather(self.job, return_exceptions=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def worker_setup(config, tmp_path):
|
|
demo = tmp_path / "demo"
|
|
demo.mkdir()
|
|
app = create_app(config)
|
|
wc = WorkerConfig(
|
|
url=config.public_url,
|
|
token=config.worker_token,
|
|
state=str(tmp_path / "worker.sqlite"),
|
|
workspaces={"demo": str(demo)},
|
|
poll=0.1,
|
|
)
|
|
http = httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app),
|
|
base_url=config.public_url,
|
|
headers={"Authorization": "Bearer " + config.worker_token},
|
|
)
|
|
for n, v in [
|
|
("starts", 0),
|
|
("threads", []),
|
|
("delay", 0.05),
|
|
("disconnect", False),
|
|
("approval_needed", False),
|
|
]:
|
|
setattr(FakeCodex, n, v)
|
|
return app.state.store, Worker(wc, FakeCodex, http)
|
|
|
|
|
|
async def run_task(store, worker, timeout=None):
|
|
row = store.submit("hello", "demo", "key", timeout)
|
|
task = claim(store)
|
|
worker.journal.save(task)
|
|
await worker.execute(task)
|
|
await worker.flush()
|
|
return store.get(row["id"])
|
|
|
|
|
|
async def test_worker_success_and_continuation(worker_setup):
|
|
store, worker = worker_setup
|
|
task = await run_task(store, worker)
|
|
assert task["state"] == "succeeded" and task["result"] == "done"
|
|
second = store.continue_task(task["id"], "remember", "key2")
|
|
await worker.execute(claim(store))
|
|
await worker.flush()
|
|
assert store.get(second["id"])["state"] == "succeeded"
|
|
assert FakeCodex.threads == [None, "fake-thread"]
|
|
await worker.http.aclose()
|
|
|
|
|
|
async def test_worker_timeout_and_cancel(worker_setup):
|
|
store, worker = worker_setup
|
|
FakeCodex.delay = 10
|
|
task = await run_task(store, worker, 1)
|
|
assert task["state"] == "timed_out"
|
|
row = store.submit("cancel", "demo", "key2")
|
|
task = claim(store)
|
|
store.cancel(row["id"])
|
|
await worker.execute(task)
|
|
await worker.flush()
|
|
assert store.get(row["id"])["state"] == "cancelled"
|
|
await worker.http.aclose()
|
|
|
|
|
|
async def test_codex_disconnect_requires_reconciliation(worker_setup):
|
|
store, worker = worker_setup
|
|
FakeCodex.disconnect = True
|
|
task = await run_task(store, worker)
|
|
assert task["state"] == "needs_reconciliation"
|
|
assert claim(store) is None
|
|
await worker.http.aclose()
|
|
|
|
|
|
async def test_network_loss_durable_final_and_restart_no_reexecution(worker_setup):
|
|
store, worker = worker_setup
|
|
row = store.submit("hello", "demo", "key")
|
|
task = claim(store)
|
|
worker.journal.save(task)
|
|
original = worker.http
|
|
|
|
class Offline:
|
|
async def post(self, *args, **kwargs):
|
|
raise httpx.ConnectError("offline")
|
|
|
|
worker.http = Offline()
|
|
await worker.execute(task)
|
|
with pytest.raises(httpx.ConnectError):
|
|
await worker.flush()
|
|
assert worker.journal.pending()
|
|
worker.http = original
|
|
await worker.recover()
|
|
assert store.get(row["id"])["state"] == "succeeded"
|
|
assert FakeCodex.starts == 1 and worker.journal.tasks() == []
|
|
await worker.http.aclose()
|
|
|
|
|
|
async def test_uncertain_worker_restart_never_replays(worker_setup):
|
|
store, worker = worker_setup
|
|
row = store.submit("hello", "demo", "key")
|
|
task = claim(store)
|
|
task["turn_start_intent"] = True
|
|
worker.journal.save(task)
|
|
await worker.recover()
|
|
assert store.get(row["id"])["state"] == "needs_reconciliation"
|
|
assert FakeCodex.starts == 0
|
|
await worker.http.aclose()
|
|
|
|
|
|
async def test_local_approval_only(worker_setup):
|
|
store, worker = worker_setup
|
|
FakeCodex.approval_needed = True
|
|
job = asyncio.create_task(run_task(store, worker))
|
|
for _ in range(100):
|
|
approvals = worker.journal.approvals()
|
|
if approvals:
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
assert approvals
|
|
restarted = Journal(worker.config.state)
|
|
restarted.decide(approvals[0]["id"], "decline")
|
|
assert (await job)["state"] == "succeeded"
|
|
assert restarted.approvals() == []
|
|
assert "harmless" not in str(store.events(store.tasks()["items"][0]["id"])["items"])
|
|
await worker.http.aclose()
|
|
|
|
|
|
def test_workspace_symlinks_and_retarget(tmp_path):
|
|
demo = tmp_path / "demo"
|
|
demo.mkdir()
|
|
alias = tmp_path / "alias"
|
|
alias.symlink_to(demo, target_is_directory=True)
|
|
config = WorkerConfig(token="w" * 40, workspaces={"demo": str(alias)})
|
|
with pytest.raises(ValueError):
|
|
config.validate()
|
|
config.workspaces = {"demo": str(demo)}
|
|
config.validate()
|
|
demo.rmdir()
|
|
demo.symlink_to(tmp_path, target_is_directory=True)
|
|
with pytest.raises(ValueError):
|
|
config.resolve_workspace("demo")
|
|
with pytest.raises(ValueError):
|
|
config.resolve_workspace("../escape")
|