mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
599 lines
18 KiB
Python
599 lines
18 KiB
Python
import asyncio
|
|
import threading
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
|
|
from openai_codex.api import AsyncTurnHandle, TurnHandle
|
|
from openai_codex.client import CodexClient
|
|
from openai_codex.errors import InvalidRequestError
|
|
from openai_codex.generated.notification_registry import notification_turn_id
|
|
from openai_codex.generated.v2_all import (
|
|
TurnCompletedNotification,
|
|
TurnInterruptResponse,
|
|
TurnStatus,
|
|
TurnSteerResponse,
|
|
)
|
|
|
|
|
|
def _route(client: CodexClient, method: str, params: dict[str, object]) -> None:
|
|
client._router.route_notification(client._coerce_notification(method, params))
|
|
|
|
|
|
def _turn(turn_id: str, status: str, *, started_at: int, completed_at: int | None = None):
|
|
turn: dict[str, object] = {
|
|
"id": turn_id,
|
|
"items": [],
|
|
"startedAt": started_at,
|
|
"status": status,
|
|
}
|
|
if completed_at is not None:
|
|
turn["completedAt"] = completed_at
|
|
return turn
|
|
|
|
|
|
def _goal(thread_id: str, status: str) -> dict[str, object]:
|
|
return {
|
|
"createdAt": 1,
|
|
"objective": "Improve benchmark coverage",
|
|
"status": status,
|
|
"threadId": thread_id,
|
|
"timeUsedSeconds": 0,
|
|
"tokensUsed": 0,
|
|
"updatedAt": 1,
|
|
}
|
|
|
|
|
|
def test_goal_turn_collects_continuations_as_one_logical_turn() -> None:
|
|
client = CodexClient()
|
|
state = client.register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
handle = TurnHandle(client, "thread-1", "turn-1", _goal=state)
|
|
|
|
_route(
|
|
client,
|
|
"thread/goal/updated",
|
|
{"threadId": "thread-1", "turnId": "turn-1", "goal": _goal("thread-1", "active")},
|
|
)
|
|
_route(
|
|
client,
|
|
"turn/started",
|
|
{"threadId": "thread-1", "turn": _turn("turn-1", "inProgress", started_at=10)},
|
|
)
|
|
_route(
|
|
client,
|
|
"item/completed",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turnId": "turn-1",
|
|
"item": {"id": "message-1", "type": "agentMessage", "text": "working"},
|
|
},
|
|
)
|
|
_route(
|
|
client,
|
|
"turn/completed",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turn": _turn("turn-1", "completed", started_at=10, completed_at=12),
|
|
},
|
|
)
|
|
_route(
|
|
client,
|
|
"turn/started",
|
|
{"threadId": "thread-1", "turn": _turn("turn-2", "inProgress", started_at=13)},
|
|
)
|
|
_route(
|
|
client,
|
|
"item/completed",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turnId": "turn-2",
|
|
"item": {
|
|
"id": "message-2",
|
|
"type": "agentMessage",
|
|
"text": "done",
|
|
"phase": "final_answer",
|
|
},
|
|
},
|
|
)
|
|
_route(
|
|
client,
|
|
"thread/goal/updated",
|
|
{"threadId": "thread-1", "turnId": "turn-2", "goal": _goal("thread-1", "complete")},
|
|
)
|
|
_route(
|
|
client,
|
|
"thread/tokenUsage/updated",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turnId": "turn-2",
|
|
"tokenUsage": {
|
|
"last": {
|
|
"cachedInputTokens": 1,
|
|
"inputTokens": 2,
|
|
"outputTokens": 3,
|
|
"reasoningOutputTokens": 4,
|
|
"totalTokens": 9,
|
|
},
|
|
"total": {
|
|
"cachedInputTokens": 10,
|
|
"inputTokens": 20,
|
|
"outputTokens": 30,
|
|
"reasoningOutputTokens": 40,
|
|
"totalTokens": 90,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
_route(
|
|
client,
|
|
"turn/completed",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turn": _turn("turn-2", "completed", started_at=13, completed_at=15),
|
|
},
|
|
)
|
|
|
|
result = handle.run()
|
|
|
|
assert {
|
|
"id": result.id,
|
|
"status": result.status,
|
|
"started_at": result.started_at,
|
|
"completed_at": result.completed_at,
|
|
"duration_ms": result.duration_ms,
|
|
"final_response": result.final_response,
|
|
"item_count": len(result.items),
|
|
"total_tokens": result.usage.total.total_tokens if result.usage is not None else None,
|
|
"registered_goals": client._router._goal_operations,
|
|
} == {
|
|
"id": "turn-1",
|
|
"status": TurnStatus.completed,
|
|
"started_at": 10,
|
|
"completed_at": 15,
|
|
"duration_ms": 5000,
|
|
"final_response": "done",
|
|
"item_count": 2,
|
|
"total_tokens": 90,
|
|
"registered_goals": {},
|
|
}
|
|
|
|
|
|
def test_goal_stream_hides_continuation_boundaries_and_rewrites_ids() -> None:
|
|
client = CodexClient()
|
|
state = client.register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
handle = TurnHandle(client, "thread-1", "turn-1", _goal=state)
|
|
|
|
for method, params in [
|
|
(
|
|
"thread/goal/updated",
|
|
{"threadId": "thread-1", "turnId": "turn-1", "goal": _goal("thread-1", "active")},
|
|
),
|
|
(
|
|
"turn/started",
|
|
{"threadId": "thread-1", "turn": _turn("turn-1", "inProgress", started_at=10)},
|
|
),
|
|
(
|
|
"turn/completed",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turn": _turn("turn-1", "completed", started_at=10, completed_at=11),
|
|
},
|
|
),
|
|
(
|
|
"turn/started",
|
|
{"threadId": "thread-1", "turn": _turn("turn-2", "inProgress", started_at=12)},
|
|
),
|
|
(
|
|
"thread/goal/updated",
|
|
{"threadId": "thread-1", "turnId": "turn-2", "goal": _goal("thread-1", "complete")},
|
|
),
|
|
(
|
|
"turn/completed",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turn": _turn("turn-2", "completed", started_at=12, completed_at=13),
|
|
},
|
|
),
|
|
]:
|
|
_route(client, method, params)
|
|
|
|
events = list(handle.stream())
|
|
lifecycle = [event for event in events if event.method in {"turn/started", "turn/completed"}]
|
|
|
|
assert {
|
|
"lifecycle": [event.method for event in lifecycle],
|
|
"turn_ids": [
|
|
turn_id
|
|
for event in events
|
|
if (turn_id := notification_turn_id(event.payload)) is not None
|
|
],
|
|
"completed": [
|
|
event.payload.turn.status
|
|
for event in events
|
|
if isinstance(event.payload, TurnCompletedNotification)
|
|
],
|
|
} == {
|
|
"lifecycle": ["turn/started", "turn/completed"],
|
|
"turn_ids": ["turn-1", "turn-1"],
|
|
"completed": [TurnStatus.completed],
|
|
}
|
|
|
|
|
|
def test_goal_router_releases_and_wakes_operations_on_transport_failure() -> None:
|
|
client = CodexClient()
|
|
state = client.register_goal_operation("thread-1")
|
|
failure = RuntimeError("transport closed")
|
|
|
|
client._router.fail_all(failure)
|
|
|
|
with pytest.raises(RuntimeError, match="transport closed"):
|
|
state.next_notification()
|
|
assert client._router._goal_operations == {}
|
|
|
|
|
|
def test_closing_unstarted_goal_stream_releases_route_and_controls() -> None:
|
|
client = CodexClient()
|
|
state = client.register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
handle = TurnHandle(client, "thread-1", "turn-1", _goal=state)
|
|
|
|
stream = handle.stream()
|
|
stream.close()
|
|
|
|
assert {
|
|
"active_turn": state.active_turn(),
|
|
"registered_goals": client._router._goal_operations,
|
|
} == {"active_turn": None, "registered_goals": {}}
|
|
|
|
|
|
def test_failed_goal_start_propagates_turn_error() -> None:
|
|
client = CodexClient()
|
|
state = client.register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
handle = TurnHandle(client, "thread-1", "turn-1", _goal=state)
|
|
failed_turn = _turn("turn-1", "failed", started_at=10, completed_at=11)
|
|
failed_turn["error"] = {"message": "failed to start goal"}
|
|
|
|
_route(
|
|
client,
|
|
"turn/started",
|
|
{"threadId": "thread-1", "turn": _turn("turn-1", "inProgress", started_at=10)},
|
|
)
|
|
_route(
|
|
client,
|
|
"turn/completed",
|
|
{"threadId": "thread-1", "turn": failed_turn},
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="failed to start goal"):
|
|
handle.run()
|
|
assert client._router._goal_operations == {}
|
|
|
|
|
|
class _SyncControlClient:
|
|
def __init__(self, *, fail_first: bool = True) -> None:
|
|
self.steer_ids: list[str] = []
|
|
self.interrupt_ids: list[str] = []
|
|
self.paused = False
|
|
self.fail_first = fail_first
|
|
|
|
def turn_steer(self, _thread_id, turn_id, _input):
|
|
self.steer_ids.append(turn_id)
|
|
if self.fail_first and len(self.steer_ids) == 1:
|
|
raise InvalidRequestError(
|
|
-32600,
|
|
"expected active turn id `turn-1` but found `turn-2`",
|
|
)
|
|
return TurnSteerResponse(turn_id=turn_id)
|
|
|
|
def pause_goal(self, _thread_id):
|
|
self.paused = True
|
|
|
|
def turn_interrupt(self, _thread_id, turn_id):
|
|
self.interrupt_ids.append(turn_id)
|
|
if self.fail_first and len(self.interrupt_ids) == 1:
|
|
raise InvalidRequestError(
|
|
-32600,
|
|
"expected active turn id turn-1 but found turn-2",
|
|
)
|
|
return TurnInterruptResponse()
|
|
|
|
|
|
def test_goal_controls_hide_rollover_turn_ids() -> None:
|
|
state = CodexClient().register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
client = _SyncControlClient()
|
|
handle = TurnHandle(client, "thread-1", "turn-1", _goal=state) # type: ignore[arg-type]
|
|
|
|
steered = handle.steer("keep going")
|
|
interrupted = handle.interrupt()
|
|
|
|
assert {
|
|
"steer_ids": client.steer_ids,
|
|
"public_steer_id": steered.turn_id,
|
|
"paused": client.paused,
|
|
"interrupt_ids": client.interrupt_ids,
|
|
"interrupt": interrupted.model_dump(mode="json"),
|
|
} == {
|
|
"steer_ids": ["turn-1", "turn-2"],
|
|
"public_steer_id": "turn-1",
|
|
"paused": True,
|
|
"interrupt_ids": ["turn-1", "turn-2"],
|
|
"interrupt": {},
|
|
}
|
|
|
|
|
|
def test_finished_goal_controls_match_inactive_turn_errors() -> None:
|
|
state = CodexClient().register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
state.finish()
|
|
client = _SyncControlClient(fail_first=False)
|
|
handle = TurnHandle(client, "thread-1", "turn-1", _goal=state) # type: ignore[arg-type]
|
|
|
|
with pytest.raises(InvalidRequestError, match="no active turn to steer"):
|
|
handle.steer("keep going")
|
|
with pytest.raises(InvalidRequestError, match="no active turn to interrupt"):
|
|
handle.interrupt()
|
|
assert client.paused is False
|
|
|
|
|
|
def test_goal_steer_waits_for_rollover_turn() -> None:
|
|
client = CodexClient()
|
|
state = client.register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
control = _SyncControlClient(fail_first=False)
|
|
handle = TurnHandle(control, "thread-1", "turn-1", _goal=state) # type: ignore[arg-type]
|
|
results: list[TurnSteerResponse] = []
|
|
|
|
_route(
|
|
client,
|
|
"thread/goal/updated",
|
|
{"threadId": "thread-1", "turnId": "turn-1", "goal": _goal("thread-1", "active")},
|
|
)
|
|
_route(
|
|
client,
|
|
"turn/started",
|
|
{"threadId": "thread-1", "turn": _turn("turn-1", "inProgress", started_at=10)},
|
|
)
|
|
_route(
|
|
client,
|
|
"turn/completed",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turn": _turn("turn-1", "completed", started_at=10, completed_at=11),
|
|
},
|
|
)
|
|
|
|
steering = threading.Thread(target=lambda: results.append(handle.steer("continue")))
|
|
steering.start()
|
|
_route(
|
|
client,
|
|
"turn/started",
|
|
{"threadId": "thread-1", "turn": _turn("turn-2", "inProgress", started_at=12)},
|
|
)
|
|
steering.join(timeout=5)
|
|
|
|
assert {
|
|
"alive": steering.is_alive(),
|
|
"steer_ids": control.steer_ids,
|
|
"public_turn_id": results[0].turn_id,
|
|
} == {
|
|
"alive": False,
|
|
"steer_ids": ["turn-2"],
|
|
"public_turn_id": "turn-1",
|
|
}
|
|
|
|
|
|
def test_goal_interrupt_succeeds_during_rollover() -> None:
|
|
client = CodexClient()
|
|
state = client.register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
control = _SyncControlClient()
|
|
handle = TurnHandle(control, "thread-1", "turn-1", _goal=state) # type: ignore[arg-type]
|
|
_route(
|
|
client,
|
|
"thread/goal/updated",
|
|
{"threadId": "thread-1", "turnId": "turn-1", "goal": _goal("thread-1", "active")},
|
|
)
|
|
_route(
|
|
client,
|
|
"turn/started",
|
|
{"threadId": "thread-1", "turn": _turn("turn-1", "inProgress", started_at=10)},
|
|
)
|
|
_route(
|
|
client,
|
|
"turn/completed",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turn": _turn("turn-1", "completed", started_at=10, completed_at=11),
|
|
},
|
|
)
|
|
|
|
response = handle.interrupt()
|
|
|
|
assert {
|
|
"paused": control.paused,
|
|
"interrupt_ids": control.interrupt_ids,
|
|
"response": response.model_dump(mode="json"),
|
|
"interrupted": state.interrupted,
|
|
} == {
|
|
"paused": True,
|
|
"interrupt_ids": [],
|
|
"response": {},
|
|
"interrupted": True,
|
|
}
|
|
|
|
|
|
class _AsyncGoalClient:
|
|
def __init__(self, state) -> None:
|
|
self.state = state
|
|
self.unregistered = False
|
|
|
|
async def next_goal_notification(self, state):
|
|
return await asyncio.to_thread(state.next_notification)
|
|
|
|
def unregister_goal_operation(self, _state) -> None:
|
|
self.unregistered = True
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _AsyncCodexStub:
|
|
_client: object
|
|
|
|
async def _ensure_initialized(self) -> None:
|
|
return None
|
|
|
|
|
|
def test_async_goal_stream_matches_sync_logical_lifecycle() -> None:
|
|
async def scenario() -> None:
|
|
client = CodexClient()
|
|
state = client.register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
async_client = _AsyncGoalClient(state)
|
|
codex = _AsyncCodexStub(async_client)
|
|
handle = AsyncTurnHandle(codex, "thread-1", "turn-1", _goal=state) # type: ignore[arg-type]
|
|
|
|
for method, params in [
|
|
(
|
|
"thread/goal/updated",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turnId": "turn-1",
|
|
"goal": _goal("thread-1", "active"),
|
|
},
|
|
),
|
|
(
|
|
"turn/started",
|
|
{"threadId": "thread-1", "turn": _turn("turn-1", "inProgress", started_at=10)},
|
|
),
|
|
(
|
|
"thread/goal/updated",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turnId": "turn-1",
|
|
"goal": _goal("thread-1", "complete"),
|
|
},
|
|
),
|
|
(
|
|
"turn/completed",
|
|
{
|
|
"threadId": "thread-1",
|
|
"turn": _turn("turn-1", "completed", started_at=10, completed_at=11),
|
|
},
|
|
),
|
|
]:
|
|
_route(client, method, params)
|
|
|
|
events = [event async for event in handle.stream()]
|
|
|
|
assert {
|
|
"methods": [event.method for event in events],
|
|
"ids": [notification_turn_id(event.payload) for event in events],
|
|
"unregistered": async_client.unregistered,
|
|
} == {
|
|
"methods": ["turn/started", "turn/completed"],
|
|
"ids": ["turn-1", "turn-1"],
|
|
"unregistered": True,
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_cancelling_async_goal_stream_wakes_notification_reader() -> None:
|
|
async def scenario() -> None:
|
|
state = CodexClient().register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
async_client = _AsyncGoalClient(state)
|
|
codex = _AsyncCodexStub(async_client)
|
|
handle = AsyncTurnHandle(codex, "thread-1", "turn-1", _goal=state) # type: ignore[arg-type]
|
|
stream = handle.stream()
|
|
reading = asyncio.create_task(anext(stream))
|
|
await asyncio.sleep(0)
|
|
|
|
reading.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await reading
|
|
|
|
assert {"finished": state.is_finished(), "unregistered": async_client.unregistered} == {
|
|
"finished": True,
|
|
"unregistered": True,
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
class _AsyncControlClient:
|
|
def __init__(self) -> None:
|
|
self.steer_ids: list[str] = []
|
|
self.interrupt_ids: list[str] = []
|
|
self.paused = False
|
|
|
|
async def turn_steer(self, _thread_id, turn_id, _input):
|
|
self.steer_ids.append(turn_id)
|
|
if len(self.steer_ids) == 1:
|
|
raise InvalidRequestError(
|
|
-32600,
|
|
"expected active turn id `turn-1` but found `turn-2`",
|
|
)
|
|
return TurnSteerResponse(turn_id=turn_id)
|
|
|
|
async def pause_goal(self, _thread_id):
|
|
self.paused = True
|
|
|
|
async def turn_interrupt(self, _thread_id, turn_id):
|
|
self.interrupt_ids.append(turn_id)
|
|
if len(self.interrupt_ids) == 1:
|
|
raise InvalidRequestError(
|
|
-32600,
|
|
"expected active turn id turn-1 but found turn-2",
|
|
)
|
|
return TurnInterruptResponse()
|
|
|
|
|
|
def test_async_goal_controls_hide_rollover_turn_ids() -> None:
|
|
async def scenario() -> None:
|
|
state = CodexClient().register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
client = _AsyncControlClient()
|
|
codex = _AsyncCodexStub(client) # type: ignore[arg-type]
|
|
handle = AsyncTurnHandle(codex, "thread-1", "turn-1", _goal=state) # type: ignore[arg-type]
|
|
|
|
steered = await handle.steer("keep going")
|
|
interrupted = await handle.interrupt()
|
|
|
|
assert {
|
|
"steer_ids": client.steer_ids,
|
|
"public_steer_id": steered.turn_id,
|
|
"paused": client.paused,
|
|
"interrupt_ids": client.interrupt_ids,
|
|
"interrupt": interrupted.model_dump(mode="json"),
|
|
} == {
|
|
"steer_ids": ["turn-1", "turn-2"],
|
|
"public_steer_id": "turn-1",
|
|
"paused": True,
|
|
"interrupt_ids": ["turn-1", "turn-2"],
|
|
"interrupt": {},
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_finished_async_goal_controls_match_inactive_turn_errors() -> None:
|
|
async def scenario() -> None:
|
|
state = CodexClient().register_goal_operation("thread-1")
|
|
state.bind("turn-1")
|
|
state.finish()
|
|
client = _AsyncControlClient()
|
|
codex = _AsyncCodexStub(client)
|
|
handle = AsyncTurnHandle(codex, "thread-1", "turn-1", _goal=state) # type: ignore[arg-type]
|
|
|
|
with pytest.raises(InvalidRequestError, match="no active turn to steer"):
|
|
await handle.steer("keep going")
|
|
with pytest.raises(InvalidRequestError, match="no active turn to interrupt"):
|
|
await handle.interrupt()
|
|
assert client.paused is False
|
|
|
|
asyncio.run(scenario())
|