mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Add Python SDK goal turns
This commit is contained in:
@@ -150,16 +150,16 @@ attempt. API-key login completes synchronously and does not return a handle.
|
||||
|
||||
### Thread
|
||||
|
||||
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnResult`
|
||||
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnHandle`
|
||||
- `run(input: str | Input, *, goal: bool = False, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnResult`
|
||||
- `turn(input: str | Input, *, goal: bool = False, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnHandle`
|
||||
- `read(*, include_turns: bool = False) -> ThreadReadResponse`
|
||||
- `set_name(name: str) -> ThreadSetNameResponse`
|
||||
- `compact() -> ThreadCompactStartResponse`
|
||||
|
||||
### AsyncThread
|
||||
|
||||
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[TurnResult]`
|
||||
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]`
|
||||
- `run(input: str | Input, *, goal: bool = False, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[TurnResult]`
|
||||
- `turn(input: str | Input, *, goal: bool = False, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]`
|
||||
- `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]`
|
||||
- `set_name(name: str) -> Awaitable[ThreadSetNameResponse]`
|
||||
- `compact() -> Awaitable[ThreadCompactStartResponse]`
|
||||
@@ -184,6 +184,10 @@ phase-less assistant message item.
|
||||
Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`,
|
||||
`interrupt()`) before collecting the turn result.
|
||||
|
||||
Pass `goal=True` to either method for an objective that can continue through
|
||||
multiple internal turns. Streaming, steering, interruption, and the returned
|
||||
`TurnResult` still present one logical turn with one stable ID.
|
||||
|
||||
## Sandbox
|
||||
|
||||
Use `sandbox=` consistently on thread lifecycle methods and turns:
|
||||
|
||||
@@ -72,6 +72,16 @@ with Codex() as codex:
|
||||
Use `Thread.turn(...)` when you need a `TurnHandle` for streaming, steering,
|
||||
or interrupting an active turn.
|
||||
|
||||
For a longer objective, opt into goal mode on the operation producing the
|
||||
result:
|
||||
|
||||
```python
|
||||
result = thread.run("Improve the benchmark coverage in this repository.", goal=True)
|
||||
```
|
||||
|
||||
Goal mode may continue working internally, but it streams and returns as one
|
||||
logical turn.
|
||||
|
||||
## 4. Choose Sandbox Access
|
||||
|
||||
Use one enum for the initial thread and later turn overrides:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from openai_codex import AsyncCodex
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start()
|
||||
result = await thread.run(
|
||||
"Improve the benchmark coverage in this repository.",
|
||||
goal=True,
|
||||
)
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,17 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
result = thread.run("Improve the benchmark coverage in this repository.", goal=True)
|
||||
print(result.final_response)
|
||||
@@ -89,3 +89,5 @@ python examples/01_quickstart_constructor/async.py
|
||||
- separate `steer()` and `interrupt()` demos with concise summaries
|
||||
- `15_login_and_account/`
|
||||
- browser-login handle lifecycle, cancellation, and account inspection
|
||||
- `16_goal_turns/`
|
||||
- run a longer autonomous objective as one logical turn
|
||||
|
||||
@@ -527,6 +527,18 @@ def _normalized_schema_bundle_text(schema_dir: Path) -> str:
|
||||
schema = json.loads(schema_bundle_path(schema_dir).read_text())
|
||||
definitions = schema.get("definitions", {})
|
||||
if isinstance(definitions, dict):
|
||||
turn_start = definitions.get("TurnStartParams")
|
||||
if isinstance(turn_start, dict):
|
||||
properties = turn_start.get("properties")
|
||||
if isinstance(properties, dict):
|
||||
properties["goal"] = {
|
||||
"default": False,
|
||||
"description": (
|
||||
"Replace the thread's active goal with an objective derived "
|
||||
"from this turn's text input."
|
||||
),
|
||||
"type": "boolean",
|
||||
}
|
||||
for definition in definitions.values():
|
||||
if isinstance(definition, dict):
|
||||
_flatten_string_enum_one_of(definition)
|
||||
@@ -648,6 +660,27 @@ def _notification_turn_id_specs(
|
||||
return (sorted(set(direct)), sorted(set(nested)))
|
||||
|
||||
|
||||
def _notification_thread_id_specs(
|
||||
schema_dir: Path,
|
||||
specs: list[tuple[str, str]],
|
||||
) -> list[str]:
|
||||
"""Return notification payloads that carry a direct thread id."""
|
||||
server_notifications = json.loads((schema_dir / "ServerNotification.json").read_text())
|
||||
definitions = server_notifications.get("definitions", {})
|
||||
if not isinstance(definitions, dict):
|
||||
return []
|
||||
|
||||
direct: list[str] = []
|
||||
for _, class_name in specs:
|
||||
definition = definitions.get(class_name)
|
||||
if not isinstance(definition, dict):
|
||||
continue
|
||||
props = definition.get("properties", {})
|
||||
if isinstance(props, dict) and "threadId" in props:
|
||||
direct.append(class_name)
|
||||
return sorted(set(direct))
|
||||
|
||||
|
||||
def _type_tuple_source(class_names: list[str]) -> str:
|
||||
"""Render a generated tuple literal for notification payload classes."""
|
||||
if not class_names:
|
||||
@@ -666,6 +699,7 @@ def generate_notification_registry(schema_dir: Path) -> None:
|
||||
schema_dir,
|
||||
specs,
|
||||
)
|
||||
direct_thread_id_types = _notification_thread_id_specs(schema_dir, specs)
|
||||
|
||||
lines = [
|
||||
"# Auto-generated by scripts/update_sdk_artifacts.py",
|
||||
@@ -697,6 +731,9 @@ def generate_notification_registry(schema_dir: Path) -> None:
|
||||
"NESTED_TURN_NOTIFICATION_TYPES: tuple[type[BaseModel], ...] = "
|
||||
f"{_type_tuple_source(nested_turn_types)}",
|
||||
"",
|
||||
"DIRECT_THREAD_ID_NOTIFICATION_TYPES: tuple[type[BaseModel], ...] = "
|
||||
f"{_type_tuple_source(direct_thread_id_types)}",
|
||||
"",
|
||||
"",
|
||||
"def notification_turn_id(payload: BaseModel) -> str | None:",
|
||||
' """Return the turn id carried by generated notification payload metadata."""',
|
||||
@@ -706,6 +743,13 @@ def generate_notification_registry(schema_dir: Path) -> None:
|
||||
" return payload.turn.id",
|
||||
" return None",
|
||||
"",
|
||||
"",
|
||||
"def notification_thread_id(payload: BaseModel) -> str | None:",
|
||||
' """Return the thread id carried by generated notification payload metadata."""',
|
||||
" if isinstance(payload, DIRECT_THREAD_ID_NOTIFICATION_TYPES):",
|
||||
" return payload.thread_id",
|
||||
" return None",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1077,6 +1121,7 @@ def _render_thread_block(
|
||||
" self,",
|
||||
" input: RunInput,",
|
||||
" *,",
|
||||
" goal: bool = False,",
|
||||
*_approval_mode_override_signature_lines(),
|
||||
*_kw_signature_lines(turn_fields),
|
||||
" ) -> TurnHandle:",
|
||||
@@ -1089,8 +1134,16 @@ def _render_thread_block(
|
||||
*_approval_mode_model_arg_lines(),
|
||||
*_model_arg_lines(turn_fields),
|
||||
" )",
|
||||
" turn = self._client.turn_start(self.id, wire_input, params=params)",
|
||||
" return TurnHandle(self._client, self.id, turn.turn.id)",
|
||||
" goal_state = self._client.register_goal_operation(self.id) if goal else None",
|
||||
" try:",
|
||||
" turn = self._client.turn_start(self.id, wire_input, params=params, goal=goal)",
|
||||
" except BaseException:",
|
||||
" if goal_state is not None:",
|
||||
" self._client.unregister_goal_operation(goal_state)",
|
||||
" raise",
|
||||
" if goal_state is not None:",
|
||||
" self._client.bind_goal_operation(goal_state, turn.turn.id)",
|
||||
" return TurnHandle(self._client, self.id, turn.turn.id, _goal=goal_state)",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1103,6 +1156,7 @@ def _render_async_thread_block(
|
||||
" self,",
|
||||
" input: RunInput,",
|
||||
" *,",
|
||||
" goal: bool = False,",
|
||||
*_approval_mode_override_signature_lines(),
|
||||
*_kw_signature_lines(turn_fields),
|
||||
" ) -> AsyncTurnHandle:",
|
||||
@@ -1116,12 +1170,21 @@ def _render_async_thread_block(
|
||||
*_approval_mode_model_arg_lines(),
|
||||
*_model_arg_lines(turn_fields),
|
||||
" )",
|
||||
" turn = await self._codex._client.turn_start(",
|
||||
" self.id,",
|
||||
" wire_input,",
|
||||
" params=params,",
|
||||
" )",
|
||||
" return AsyncTurnHandle(self._codex, self.id, turn.turn.id)",
|
||||
" goal_state = self._codex._client.register_goal_operation(self.id) if goal else None",
|
||||
" try:",
|
||||
" turn = await self._codex._client.turn_start(",
|
||||
" self.id,",
|
||||
" wire_input,",
|
||||
" params=params,",
|
||||
" goal=goal,",
|
||||
" )",
|
||||
" except BaseException:",
|
||||
" if goal_state is not None:",
|
||||
" self._codex._client.unregister_goal_operation(goal_state)",
|
||||
" raise",
|
||||
" if goal_state is not None:",
|
||||
" self._codex._client.bind_goal_operation(goal_state, turn.turn.id)",
|
||||
" return AsyncTurnHandle(self._codex, self.id, turn.turn.id, _goal=goal_state)",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1163,9 +1226,9 @@ def generate_public_api_flat_methods() -> None:
|
||||
turn_start_fields = _load_public_fields(
|
||||
"openai_codex.generated.v2_all",
|
||||
"TurnStartParams",
|
||||
# Keep the wire model current without exposing this app-server field
|
||||
# through the ergonomic Python API yet.
|
||||
exclude={"thread_id", "input", "client_user_message_id", *approval_fields},
|
||||
# `goal` has a stable bool default and private routing setup, so render
|
||||
# it explicitly rather than inheriting the generated model default.
|
||||
exclude={"thread_id", "input", "client_user_message_id", "goal", *approval_fields},
|
||||
)
|
||||
turn_start_fields = _replace_public_sandbox_field(turn_start_fields, wire_name="sandbox_policy")
|
||||
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import queue
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncIterator, Awaitable, Callable, Iterator
|
||||
|
||||
from .generated.notification_registry import notification_turn_id
|
||||
from .generated.v2_all import (
|
||||
ItemCompletedNotification,
|
||||
ThreadGoalClearedNotification,
|
||||
ThreadGoalStatus,
|
||||
ThreadGoalUpdatedNotification,
|
||||
ThreadItem,
|
||||
ThreadTokenUsage,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
Turn,
|
||||
TurnCompletedNotification,
|
||||
TurnStartedNotification,
|
||||
TurnStatus,
|
||||
)
|
||||
from .models import Notification, UnknownNotification
|
||||
|
||||
|
||||
class _GoalStreamClosed(Exception):
|
||||
"""Wake a notification reader after its logical stream closes."""
|
||||
|
||||
|
||||
def _terminal_goal_status(status: ThreadGoalStatus | None) -> bool:
|
||||
return status in {
|
||||
ThreadGoalStatus.paused,
|
||||
ThreadGoalStatus.blocked,
|
||||
ThreadGoalStatus.usage_limited,
|
||||
ThreadGoalStatus.budget_limited,
|
||||
ThreadGoalStatus.complete,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _GoalOperationState:
|
||||
"""Private state for one goal operation exposed as a logical turn."""
|
||||
|
||||
thread_id: str
|
||||
logical_turn_id: str | None = None
|
||||
current_turn_id: str | None = None
|
||||
status: ThreadGoalStatus | None = None
|
||||
started_turn: Turn | None = None
|
||||
completed_turn: Turn | None = None
|
||||
items: list[ThreadItem] = field(default_factory=list)
|
||||
usage: ThreadTokenUsage | None = None
|
||||
interrupted: bool = False
|
||||
interrupt_requested: bool = False
|
||||
cleared: bool = False
|
||||
_condition: threading.Condition = field(default_factory=threading.Condition)
|
||||
_notifications: queue.Queue[Notification | BaseException] = field(default_factory=queue.Queue)
|
||||
_failure: BaseException | None = None
|
||||
_finished: bool = False
|
||||
|
||||
def bind(self, logical_turn_id: str) -> None:
|
||||
with self._condition:
|
||||
self.logical_turn_id = logical_turn_id
|
||||
if self.current_turn_id is None and self.completed_turn is None:
|
||||
self.current_turn_id = logical_turn_id
|
||||
self._condition.notify_all()
|
||||
|
||||
def observe(self, notification: Notification) -> None:
|
||||
payload = notification.payload
|
||||
with self._condition:
|
||||
if isinstance(payload, TurnStartedNotification):
|
||||
self.current_turn_id = payload.turn.id
|
||||
if self.started_turn is None:
|
||||
self.started_turn = payload.turn
|
||||
elif isinstance(payload, TurnCompletedNotification):
|
||||
self.completed_turn = payload.turn
|
||||
if self.current_turn_id == payload.turn.id:
|
||||
self.current_turn_id = None
|
||||
elif isinstance(payload, ThreadGoalUpdatedNotification):
|
||||
self.status = payload.goal.status
|
||||
elif isinstance(payload, ThreadGoalClearedNotification):
|
||||
self.cleared = True
|
||||
elif isinstance(payload, ItemCompletedNotification):
|
||||
self.items.append(payload.item)
|
||||
elif isinstance(payload, ThreadTokenUsageUpdatedNotification):
|
||||
self.usage = payload.token_usage
|
||||
if (
|
||||
isinstance(payload, TurnCompletedNotification)
|
||||
and payload.turn.status in {TurnStatus.failed, TurnStatus.interrupted}
|
||||
) or (
|
||||
self.current_turn_id is None
|
||||
and self.completed_turn is not None
|
||||
and (self.cleared or _terminal_goal_status(self.status))
|
||||
):
|
||||
self._finished = True
|
||||
self._condition.notify_all()
|
||||
self._notifications.put(notification)
|
||||
|
||||
def fail(self, exc: BaseException) -> None:
|
||||
with self._condition:
|
||||
self._failure = exc
|
||||
self._condition.notify_all()
|
||||
self._notifications.put(exc)
|
||||
|
||||
def next_notification(self) -> Notification:
|
||||
item = self._notifications.get()
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
def finish(self) -> None:
|
||||
"""Mark the logical operation inactive and wake waiting controls."""
|
||||
with self._condition:
|
||||
self._finished = True
|
||||
self.current_turn_id = None
|
||||
self._condition.notify_all()
|
||||
|
||||
def is_finished(self) -> bool:
|
||||
with self._condition:
|
||||
return self._finished
|
||||
|
||||
def begin_interrupt(self) -> bool:
|
||||
with self._condition:
|
||||
if self._finished:
|
||||
return False
|
||||
self.interrupt_requested = True
|
||||
return True
|
||||
|
||||
def confirm_interrupt(self) -> None:
|
||||
with self._condition:
|
||||
self.interrupted = True
|
||||
self.interrupt_requested = False
|
||||
|
||||
def cancel_interrupt(self) -> None:
|
||||
with self._condition:
|
||||
self.interrupt_requested = False
|
||||
|
||||
def explicit_interrupt(self, status: ThreadGoalStatus | None) -> bool:
|
||||
with self._condition:
|
||||
return self.interrupted or (
|
||||
self.interrupt_requested and status == ThreadGoalStatus.paused
|
||||
)
|
||||
|
||||
def active_turn(self, *, after: str | None = None) -> str | None:
|
||||
"""Wait for the current turn, or return None once the goal has ended."""
|
||||
with self._condition:
|
||||
while True:
|
||||
if self._failure is not None:
|
||||
raise self._failure
|
||||
if self._finished:
|
||||
return None
|
||||
if self.current_turn_id is not None and self.current_turn_id != after:
|
||||
return self.current_turn_id
|
||||
if self.cleared or _terminal_goal_status(self.status):
|
||||
return None
|
||||
self._condition.wait()
|
||||
|
||||
def current_turn(self) -> str | None:
|
||||
"""Return the current physical turn without waiting for rollover."""
|
||||
with self._condition:
|
||||
return self.current_turn_id
|
||||
|
||||
def wake_notification_reader(self) -> None:
|
||||
"""Release a reader blocked after its stream has been closed."""
|
||||
self._notifications.put(_GoalStreamClosed())
|
||||
|
||||
|
||||
def _logical_notification(notification: Notification, logical_turn_id: str) -> Notification:
|
||||
"""Return a copy whose turn metadata uses the logical operation id."""
|
||||
payload = notification.payload
|
||||
if isinstance(payload, UnknownNotification):
|
||||
params = dict(payload.params)
|
||||
if isinstance(params.get("turnId"), str):
|
||||
params["turnId"] = logical_turn_id
|
||||
turn = params.get("turn")
|
||||
if isinstance(turn, dict) and isinstance(turn.get("id"), str):
|
||||
params["turn"] = {**turn, "id": logical_turn_id}
|
||||
return Notification(notification.method, UnknownNotification(params))
|
||||
|
||||
turn_id = notification_turn_id(payload)
|
||||
if turn_id is None:
|
||||
return notification
|
||||
if hasattr(payload, "turn_id"):
|
||||
return Notification(
|
||||
notification.method,
|
||||
payload.model_copy(update={"turn_id": logical_turn_id}),
|
||||
)
|
||||
if hasattr(payload, "turn"):
|
||||
logical_turn = payload.turn.model_copy(update={"id": logical_turn_id})
|
||||
return Notification(
|
||||
notification.method,
|
||||
payload.model_copy(update={"turn": logical_turn}),
|
||||
)
|
||||
return notification
|
||||
|
||||
|
||||
def _logical_completion(
|
||||
completed: TurnCompletedNotification,
|
||||
*,
|
||||
logical_turn_id: str,
|
||||
started: Turn | None,
|
||||
interrupted: bool,
|
||||
) -> TurnCompletedNotification:
|
||||
"""Coalesce the final physical completion into one logical completion."""
|
||||
final_turn = completed.turn
|
||||
started_at = started.started_at if started is not None else final_turn.started_at
|
||||
duration_ms = final_turn.duration_ms
|
||||
if started_at is not None and final_turn.completed_at is not None:
|
||||
duration_ms = max(0, final_turn.completed_at - started_at) * 1000
|
||||
updates: dict[str, object] = {
|
||||
"id": logical_turn_id,
|
||||
"started_at": started_at,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
if interrupted:
|
||||
updates["status"] = TurnStatus.interrupted
|
||||
return completed.model_copy(update={"turn": final_turn.model_copy(update=updates)})
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _GoalStreamCursor:
|
||||
"""Consume physical goal events as one ordered logical turn stream."""
|
||||
|
||||
state: _GoalOperationState
|
||||
started: Turn | None = None
|
||||
last_completed: TurnCompletedNotification | None = None
|
||||
status: ThreadGoalStatus | None = None
|
||||
active: bool = False
|
||||
cleared: bool = False
|
||||
|
||||
def process(self, notification: Notification) -> tuple[list[Notification], bool]:
|
||||
logical_turn_id = self.state.logical_turn_id
|
||||
if logical_turn_id is None:
|
||||
raise RuntimeError("goal operation has not been bound to a logical turn id")
|
||||
|
||||
payload = notification.payload
|
||||
if isinstance(payload, TurnStartedNotification):
|
||||
self.active = True
|
||||
if self.started is not None:
|
||||
return [], False
|
||||
self.started = payload.turn
|
||||
return [_logical_notification(notification, logical_turn_id)], False
|
||||
|
||||
if isinstance(payload, TurnCompletedNotification):
|
||||
self.active = False
|
||||
self.last_completed = payload
|
||||
if payload.turn.status in {TurnStatus.failed, TurnStatus.interrupted}:
|
||||
self.state.finish()
|
||||
return [self._completion(notification.method, payload)], True
|
||||
if self.status is None and not self.cleared:
|
||||
raise RuntimeError(
|
||||
"the connected Codex runtime did not activate goal mode for this turn"
|
||||
)
|
||||
if self.cleared or _terminal_goal_status(self.status):
|
||||
self.state.finish()
|
||||
return [self._completion(notification.method, payload)], True
|
||||
return [], False
|
||||
|
||||
events = [_logical_notification(notification, logical_turn_id)]
|
||||
if isinstance(payload, ThreadGoalUpdatedNotification):
|
||||
self.status = payload.goal.status
|
||||
events = []
|
||||
elif isinstance(payload, ThreadGoalClearedNotification):
|
||||
self.cleared = True
|
||||
events = []
|
||||
|
||||
if (
|
||||
not self.active
|
||||
and self.last_completed is not None
|
||||
and (self.cleared or _terminal_goal_status(self.status))
|
||||
):
|
||||
self.state.finish()
|
||||
events.append(self._completion("turn/completed", self.last_completed))
|
||||
return events, True
|
||||
return events, False
|
||||
|
||||
def _completion(
|
||||
self,
|
||||
method: str,
|
||||
payload: TurnCompletedNotification,
|
||||
) -> Notification:
|
||||
logical_turn_id = self.state.logical_turn_id
|
||||
if logical_turn_id is None:
|
||||
raise RuntimeError("goal operation has not been bound to a logical turn id")
|
||||
return Notification(
|
||||
method,
|
||||
_logical_completion(
|
||||
payload,
|
||||
logical_turn_id=logical_turn_id,
|
||||
started=self.started,
|
||||
interrupted=self.state.explicit_interrupt(self.status),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _GoalNotificationStream(Iterator[Notification]):
|
||||
"""Closeable synchronous view of one logical goal operation."""
|
||||
|
||||
state: _GoalOperationState
|
||||
next_notification: Callable[[], Notification]
|
||||
unregister: Callable[[], None]
|
||||
_cursor: _GoalStreamCursor = field(init=False)
|
||||
_pending: deque[Notification] = field(default_factory=deque)
|
||||
_closed: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._cursor = _GoalStreamCursor(self.state)
|
||||
|
||||
def __iter__(self) -> "_GoalNotificationStream":
|
||||
return self
|
||||
|
||||
def __next__(self) -> Notification:
|
||||
if self._closed:
|
||||
raise StopIteration
|
||||
try:
|
||||
while not self._pending:
|
||||
events, completed = self._cursor.process(self.next_notification())
|
||||
self._pending.extend(events)
|
||||
if completed:
|
||||
self._finish()
|
||||
return self._pending.popleft()
|
||||
except _GoalStreamClosed:
|
||||
self.close()
|
||||
raise StopIteration from None
|
||||
except BaseException:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def _finish(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self.state.finish()
|
||||
self.state.wake_notification_reader()
|
||||
self.unregister()
|
||||
self._closed = True
|
||||
|
||||
def close(self) -> None:
|
||||
self._finish()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _AsyncGoalNotificationStream(AsyncIterator[Notification]):
|
||||
"""Closeable asynchronous view of one logical goal operation."""
|
||||
|
||||
state: _GoalOperationState
|
||||
next_notification: Callable[[], Awaitable[Notification]]
|
||||
unregister: Callable[[], None]
|
||||
_cursor: _GoalStreamCursor = field(init=False)
|
||||
_pending: deque[Notification] = field(default_factory=deque)
|
||||
_closed: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._cursor = _GoalStreamCursor(self.state)
|
||||
|
||||
def __aiter__(self) -> "_AsyncGoalNotificationStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Notification:
|
||||
if self._closed:
|
||||
raise StopAsyncIteration
|
||||
try:
|
||||
while not self._pending:
|
||||
events, completed = self._cursor.process(await self.next_notification())
|
||||
self._pending.extend(events)
|
||||
if completed:
|
||||
self._finish()
|
||||
return self._pending.popleft()
|
||||
except _GoalStreamClosed:
|
||||
await self.aclose()
|
||||
raise StopAsyncIteration from None
|
||||
except BaseException:
|
||||
await self.aclose()
|
||||
raise
|
||||
|
||||
def _finish(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self.state.finish()
|
||||
self.state.wake_notification_reader()
|
||||
self.unregister()
|
||||
self._closed = True
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self._finish()
|
||||
@@ -4,8 +4,9 @@ import queue
|
||||
import threading
|
||||
from collections import deque
|
||||
|
||||
from ._goal import _GoalOperationState
|
||||
from .errors import CodexError, map_jsonrpc_error
|
||||
from .generated.notification_registry import notification_turn_id
|
||||
from .generated.notification_registry import notification_thread_id, notification_turn_id
|
||||
from .generated.v2_all import AccountLoginCompletedNotification
|
||||
from .models import JsonValue, Notification, UnknownNotification
|
||||
|
||||
@@ -30,6 +31,7 @@ class MessageRouter:
|
||||
self._pending_login_notifications: dict[str, deque[Notification]] = {}
|
||||
self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {}
|
||||
self._pending_turn_notifications: dict[str, deque[Notification]] = {}
|
||||
self._goal_operations: dict[str, _GoalOperationState] = {}
|
||||
self._global_notifications: queue.Queue[NotificationQueueItem] = queue.Queue()
|
||||
|
||||
def create_response_waiter(self, request_id: str) -> queue.Queue[ResponseQueueItem]:
|
||||
@@ -116,6 +118,21 @@ class MessageRouter:
|
||||
raise item
|
||||
return item
|
||||
|
||||
def register_goal(self, thread_id: str) -> _GoalOperationState:
|
||||
"""Register one thread-scoped logical goal operation before it starts."""
|
||||
state = _GoalOperationState(thread_id=thread_id)
|
||||
with self._lock:
|
||||
if thread_id in self._goal_operations:
|
||||
raise RuntimeError(f"thread {thread_id!r} already has an active goal operation")
|
||||
self._goal_operations[thread_id] = state
|
||||
return state
|
||||
|
||||
def unregister_goal(self, state: _GoalOperationState) -> None:
|
||||
"""Stop routing notifications to a completed logical goal operation."""
|
||||
with self._lock:
|
||||
if self._goal_operations.get(state.thread_id) is state:
|
||||
self._goal_operations.pop(state.thread_id)
|
||||
|
||||
def route_response(self, msg: dict[str, JsonValue]) -> None:
|
||||
"""Deliver a JSON-RPC response or error to its request waiter."""
|
||||
|
||||
@@ -157,6 +174,17 @@ class MessageRouter:
|
||||
return
|
||||
|
||||
turn_id = self._notification_turn_id(notification)
|
||||
thread_id = self._notification_thread_id(notification)
|
||||
if thread_id is not None:
|
||||
with self._lock:
|
||||
goal_state = self._goal_operations.get(thread_id)
|
||||
if goal_state is not None and (
|
||||
turn_id is not None or notification.method.startswith("thread/goal/")
|
||||
):
|
||||
goal_state.observe(notification)
|
||||
if goal_state.is_finished():
|
||||
self.unregister_goal(goal_state)
|
||||
return
|
||||
if turn_id is None:
|
||||
self._global_notifications.put(notification)
|
||||
return
|
||||
@@ -182,6 +210,8 @@ class MessageRouter:
|
||||
self._pending_login_notifications.clear()
|
||||
turn_queues = list(self._turn_notifications.values())
|
||||
self._pending_turn_notifications.clear()
|
||||
goal_operations = list(self._goal_operations.values())
|
||||
self._goal_operations.clear()
|
||||
# Put the same transport failure into every queue so no SDK call blocks
|
||||
# forever waiting for a response that cannot arrive.
|
||||
for waiter in response_waiters:
|
||||
@@ -190,6 +220,8 @@ class MessageRouter:
|
||||
login_queue.put(exc)
|
||||
for turn_queue in turn_queues:
|
||||
turn_queue.put(exc)
|
||||
for goal_operation in goal_operations:
|
||||
goal_operation.fail(exc)
|
||||
self._global_notifications.put(exc)
|
||||
|
||||
def _notification_login_id(self, notification: Notification) -> str | None:
|
||||
@@ -220,3 +252,11 @@ class MessageRouter:
|
||||
return raw_nested_turn_id
|
||||
return None
|
||||
return notification_turn_id(payload)
|
||||
|
||||
def _notification_thread_id(self, notification: Notification) -> str | None:
|
||||
"""Extract thread ids from known generated payloads or raw payloads."""
|
||||
payload = notification.payload
|
||||
if isinstance(payload, UnknownNotification):
|
||||
raw_thread_id = payload.params.get("threadId")
|
||||
return raw_thread_id if isinstance(raw_thread_id, str) else None
|
||||
return notification_thread_id(payload)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator, Iterator
|
||||
|
||||
@@ -9,6 +10,11 @@ from ._approval_mode import (
|
||||
_approval_mode_override_settings,
|
||||
_approval_mode_settings,
|
||||
)
|
||||
from ._goal import (
|
||||
_AsyncGoalNotificationStream,
|
||||
_GoalNotificationStream,
|
||||
_GoalOperationState,
|
||||
)
|
||||
from ._initialize_metadata import validate_initialize_metadata
|
||||
from ._inputs import (
|
||||
ImageInput as ImageInput,
|
||||
@@ -40,6 +46,7 @@ from ._run import (
|
||||
from ._sandbox import Sandbox as Sandbox, _sandbox_mode, _sandbox_policy
|
||||
from .async_client import AsyncCodexClient
|
||||
from .client import CodexClient, CodexConfig
|
||||
from .errors import InvalidRequestError
|
||||
from .generated.v2_all import (
|
||||
ApiKeyLoginAccountParams,
|
||||
GetAccountParams,
|
||||
@@ -72,6 +79,19 @@ from .generated.v2_all import (
|
||||
from .models import InitializeResponse, JsonObject, Notification
|
||||
|
||||
|
||||
def _active_turn_id_from_error(exc: InvalidRequestError) -> str | None:
|
||||
match = re.search(r" but found `?([^`]+)`?$", exc.message)
|
||||
return match.group(1) if match is not None else None
|
||||
|
||||
|
||||
def _inactive_turn_error() -> InvalidRequestError:
|
||||
return InvalidRequestError(-32600, "no active turn to steer")
|
||||
|
||||
|
||||
def _inactive_interrupt_error() -> InvalidRequestError:
|
||||
return InvalidRequestError(-32600, "no active turn to interrupt")
|
||||
|
||||
|
||||
class Codex:
|
||||
"""Synchronous client for creating threads and running Codex turns.
|
||||
|
||||
@@ -541,6 +561,7 @@ class Thread:
|
||||
self,
|
||||
input: RunInput,
|
||||
*,
|
||||
goal: bool = False,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
@@ -554,6 +575,7 @@ class Thread:
|
||||
"""Run a complete turn and collect its final result."""
|
||||
turn = self.turn(
|
||||
input,
|
||||
goal=goal,
|
||||
approval_mode=approval_mode,
|
||||
cwd=cwd,
|
||||
effort=effort,
|
||||
@@ -575,6 +597,7 @@ class Thread:
|
||||
self,
|
||||
input: RunInput,
|
||||
*,
|
||||
goal: bool = False,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
@@ -602,8 +625,16 @@ class Thread:
|
||||
service_tier=service_tier,
|
||||
summary=summary,
|
||||
)
|
||||
turn = self._client.turn_start(self.id, wire_input, params=params)
|
||||
return TurnHandle(self._client, self.id, turn.turn.id)
|
||||
goal_state = self._client.register_goal_operation(self.id) if goal else None
|
||||
try:
|
||||
turn = self._client.turn_start(self.id, wire_input, params=params, goal=goal)
|
||||
except BaseException:
|
||||
if goal_state is not None:
|
||||
self._client.unregister_goal_operation(goal_state)
|
||||
raise
|
||||
if goal_state is not None:
|
||||
self._client.bind_goal_operation(goal_state, turn.turn.id)
|
||||
return TurnHandle(self._client, self.id, turn.turn.id, _goal=goal_state)
|
||||
|
||||
# END GENERATED: Thread.flat_methods
|
||||
|
||||
@@ -629,6 +660,7 @@ class AsyncThread:
|
||||
self,
|
||||
input: RunInput,
|
||||
*,
|
||||
goal: bool = False,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
@@ -642,6 +674,7 @@ class AsyncThread:
|
||||
"""Run a complete turn asynchronously and collect its final result."""
|
||||
turn = await self.turn(
|
||||
input,
|
||||
goal=goal,
|
||||
approval_mode=approval_mode,
|
||||
cwd=cwd,
|
||||
effort=effort,
|
||||
@@ -663,6 +696,7 @@ class AsyncThread:
|
||||
self,
|
||||
input: RunInput,
|
||||
*,
|
||||
goal: bool = False,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
@@ -691,12 +725,21 @@ class AsyncThread:
|
||||
service_tier=service_tier,
|
||||
summary=summary,
|
||||
)
|
||||
turn = await self._codex._client.turn_start(
|
||||
self.id,
|
||||
wire_input,
|
||||
params=params,
|
||||
)
|
||||
return AsyncTurnHandle(self._codex, self.id, turn.turn.id)
|
||||
goal_state = self._codex._client.register_goal_operation(self.id) if goal else None
|
||||
try:
|
||||
turn = await self._codex._client.turn_start(
|
||||
self.id,
|
||||
wire_input,
|
||||
params=params,
|
||||
goal=goal,
|
||||
)
|
||||
except BaseException:
|
||||
if goal_state is not None:
|
||||
self._codex._client.unregister_goal_operation(goal_state)
|
||||
raise
|
||||
if goal_state is not None:
|
||||
self._codex._client.bind_goal_operation(goal_state, turn.turn.id)
|
||||
return AsyncTurnHandle(self._codex, self.id, turn.turn.id, _goal=goal_state)
|
||||
|
||||
# END GENERATED: AsyncThread.flat_methods
|
||||
|
||||
@@ -721,9 +764,30 @@ class TurnHandle:
|
||||
_client: CodexClient
|
||||
thread_id: str
|
||||
id: str
|
||||
_goal: _GoalOperationState | None = None
|
||||
|
||||
def steer(self, input: RunInput) -> TurnSteerResponse:
|
||||
"""Send additional input to this active turn."""
|
||||
if self._goal is not None:
|
||||
wire_input = _to_wire_input(_normalize_run_input(input))
|
||||
turn_id = self._goal.active_turn()
|
||||
if turn_id is None:
|
||||
raise _inactive_turn_error()
|
||||
try:
|
||||
response = self._client.turn_steer(self.thread_id, turn_id, wire_input)
|
||||
except InvalidRequestError as exc:
|
||||
if not (
|
||||
exc.message == "no active turn to steer"
|
||||
or exc.message.startswith("expected active turn id")
|
||||
):
|
||||
raise
|
||||
next_turn_id = _active_turn_id_from_error(exc)
|
||||
if next_turn_id is None:
|
||||
next_turn_id = self._goal.active_turn(after=turn_id)
|
||||
if next_turn_id is None:
|
||||
raise _inactive_turn_error() from exc
|
||||
response = self._client.turn_steer(self.thread_id, next_turn_id, wire_input)
|
||||
return response.model_copy(update={"turn_id": self.id})
|
||||
return self._client.turn_steer(
|
||||
self.thread_id,
|
||||
self.id,
|
||||
@@ -732,23 +796,61 @@ class TurnHandle:
|
||||
|
||||
def interrupt(self) -> TurnInterruptResponse:
|
||||
"""Request interruption of this active turn."""
|
||||
if self._goal is not None:
|
||||
if not self._goal.begin_interrupt():
|
||||
raise _inactive_interrupt_error()
|
||||
try:
|
||||
self._client.pause_goal(self.thread_id)
|
||||
except BaseException:
|
||||
self._goal.cancel_interrupt()
|
||||
raise
|
||||
self._goal.confirm_interrupt()
|
||||
turn_id = self._goal.current_turn()
|
||||
if turn_id is None:
|
||||
return TurnInterruptResponse()
|
||||
try:
|
||||
return self._client.turn_interrupt(self.thread_id, turn_id)
|
||||
except InvalidRequestError as exc:
|
||||
if exc.message == "no active turn to interrupt":
|
||||
return TurnInterruptResponse()
|
||||
if exc.message.startswith("expected active turn id"):
|
||||
next_turn_id = _active_turn_id_from_error(exc) or self._goal.current_turn()
|
||||
if next_turn_id is None or next_turn_id == turn_id:
|
||||
return TurnInterruptResponse()
|
||||
try:
|
||||
return self._client.turn_interrupt(self.thread_id, next_turn_id)
|
||||
except InvalidRequestError as retry_exc:
|
||||
if retry_exc.message == "no active turn to interrupt":
|
||||
return TurnInterruptResponse()
|
||||
raise
|
||||
raise
|
||||
return self._client.turn_interrupt(self.thread_id, self.id)
|
||||
|
||||
def stream(self) -> Iterator[Notification]:
|
||||
"""Yield only notifications routed to this turn handle."""
|
||||
self._client.register_turn_notifications(self.id)
|
||||
try:
|
||||
while True:
|
||||
event = self._client.next_turn_notification(self.id)
|
||||
yield event
|
||||
if (
|
||||
event.method == "turn/completed"
|
||||
and isinstance(event.payload, TurnCompletedNotification)
|
||||
and event.payload.turn.id == self.id
|
||||
):
|
||||
break
|
||||
finally:
|
||||
self._client.unregister_turn_notifications(self.id)
|
||||
if self._goal is not None:
|
||||
return _GoalNotificationStream(
|
||||
self._goal,
|
||||
lambda: self._client.next_goal_notification(self._goal),
|
||||
lambda: self._client.unregister_goal_operation(self._goal),
|
||||
)
|
||||
|
||||
def ordinary_stream() -> Iterator[Notification]:
|
||||
self._client.register_turn_notifications(self.id)
|
||||
try:
|
||||
while True:
|
||||
event = self._client.next_turn_notification(self.id)
|
||||
yield event
|
||||
if (
|
||||
event.method == "turn/completed"
|
||||
and isinstance(event.payload, TurnCompletedNotification)
|
||||
and event.payload.turn.id == self.id
|
||||
):
|
||||
break
|
||||
finally:
|
||||
self._client.unregister_turn_notifications(self.id)
|
||||
|
||||
return ordinary_stream()
|
||||
|
||||
def run(self) -> TurnResult:
|
||||
"""Consume the turn stream and return its completed result."""
|
||||
@@ -766,10 +868,39 @@ class AsyncTurnHandle:
|
||||
_codex: AsyncCodex
|
||||
thread_id: str
|
||||
id: str
|
||||
_goal: _GoalOperationState | None = None
|
||||
|
||||
async def steer(self, input: RunInput) -> TurnSteerResponse:
|
||||
"""Send additional input to this active turn."""
|
||||
await self._codex._ensure_initialized()
|
||||
if self._goal is not None:
|
||||
wire_input = _to_wire_input(_normalize_run_input(input))
|
||||
turn_id = await asyncio.to_thread(self._goal.active_turn)
|
||||
if turn_id is None:
|
||||
raise _inactive_turn_error()
|
||||
try:
|
||||
response = await self._codex._client.turn_steer(
|
||||
self.thread_id,
|
||||
turn_id,
|
||||
wire_input,
|
||||
)
|
||||
except InvalidRequestError as exc:
|
||||
if not (
|
||||
exc.message == "no active turn to steer"
|
||||
or exc.message.startswith("expected active turn id")
|
||||
):
|
||||
raise
|
||||
next_turn_id = _active_turn_id_from_error(exc)
|
||||
if next_turn_id is None:
|
||||
next_turn_id = await asyncio.to_thread(self._goal.active_turn, after=turn_id)
|
||||
if next_turn_id is None:
|
||||
raise _inactive_turn_error() from exc
|
||||
response = await self._codex._client.turn_steer(
|
||||
self.thread_id,
|
||||
next_turn_id,
|
||||
wire_input,
|
||||
)
|
||||
return response.model_copy(update={"turn_id": self.id})
|
||||
return await self._codex._client.turn_steer(
|
||||
self.thread_id,
|
||||
self.id,
|
||||
@@ -779,24 +910,70 @@ class AsyncTurnHandle:
|
||||
async def interrupt(self) -> TurnInterruptResponse:
|
||||
"""Request interruption of this active turn."""
|
||||
await self._codex._ensure_initialized()
|
||||
if self._goal is not None:
|
||||
if not self._goal.begin_interrupt():
|
||||
raise _inactive_interrupt_error()
|
||||
try:
|
||||
await self._codex._client.pause_goal(self.thread_id)
|
||||
except BaseException:
|
||||
self._goal.cancel_interrupt()
|
||||
raise
|
||||
self._goal.confirm_interrupt()
|
||||
turn_id = self._goal.current_turn()
|
||||
if turn_id is None:
|
||||
return TurnInterruptResponse()
|
||||
try:
|
||||
return await self._codex._client.turn_interrupt(self.thread_id, turn_id)
|
||||
except InvalidRequestError as exc:
|
||||
if exc.message == "no active turn to interrupt":
|
||||
return TurnInterruptResponse()
|
||||
if exc.message.startswith("expected active turn id"):
|
||||
next_turn_id = _active_turn_id_from_error(exc) or self._goal.current_turn()
|
||||
if next_turn_id is None or next_turn_id == turn_id:
|
||||
return TurnInterruptResponse()
|
||||
try:
|
||||
return await self._codex._client.turn_interrupt(
|
||||
self.thread_id,
|
||||
next_turn_id,
|
||||
)
|
||||
except InvalidRequestError as retry_exc:
|
||||
if retry_exc.message == "no active turn to interrupt":
|
||||
return TurnInterruptResponse()
|
||||
raise
|
||||
raise
|
||||
return await self._codex._client.turn_interrupt(self.thread_id, self.id)
|
||||
|
||||
async def stream(self) -> AsyncIterator[Notification]:
|
||||
def stream(self) -> AsyncIterator[Notification]:
|
||||
"""Yield only notifications routed to this async turn handle."""
|
||||
await self._codex._ensure_initialized()
|
||||
self._codex._client.register_turn_notifications(self.id)
|
||||
try:
|
||||
while True:
|
||||
event = await self._codex._client.next_turn_notification(self.id)
|
||||
yield event
|
||||
if (
|
||||
event.method == "turn/completed"
|
||||
and isinstance(event.payload, TurnCompletedNotification)
|
||||
and event.payload.turn.id == self.id
|
||||
):
|
||||
break
|
||||
finally:
|
||||
self._codex._client.unregister_turn_notifications(self.id)
|
||||
if self._goal is not None:
|
||||
|
||||
async def next_goal_notification() -> Notification:
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.next_goal_notification(self._goal)
|
||||
|
||||
return _AsyncGoalNotificationStream(
|
||||
self._goal,
|
||||
next_goal_notification,
|
||||
lambda: self._codex._client.unregister_goal_operation(self._goal),
|
||||
)
|
||||
|
||||
async def ordinary_stream() -> AsyncIterator[Notification]:
|
||||
await self._codex._ensure_initialized()
|
||||
self._codex._client.register_turn_notifications(self.id)
|
||||
try:
|
||||
while True:
|
||||
event = await self._codex._client.next_turn_notification(self.id)
|
||||
yield event
|
||||
if (
|
||||
event.method == "turn/completed"
|
||||
and isinstance(event.payload, TurnCompletedNotification)
|
||||
and event.payload.turn.id == self.id
|
||||
):
|
||||
break
|
||||
finally:
|
||||
self._codex._client.unregister_turn_notifications(self.id)
|
||||
|
||||
return ordinary_stream()
|
||||
|
||||
async def run(self) -> TurnResult:
|
||||
"""Consume the turn stream and return its completed result."""
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import AsyncIterator, Callable, ParamSpec, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._goal import _GoalOperationState
|
||||
from .client import CodexClient, CodexConfig
|
||||
from .generated.v2_all import (
|
||||
AccountLoginCompletedNotification,
|
||||
@@ -21,6 +22,7 @@ from .generated.v2_all import (
|
||||
ThreadCompactStartResponse,
|
||||
ThreadForkParams as V2ThreadForkParams,
|
||||
ThreadForkResponse,
|
||||
ThreadGoalSetResponse,
|
||||
ThreadListParams as V2ThreadListParams,
|
||||
ThreadListResponse,
|
||||
ThreadReadResponse,
|
||||
@@ -107,6 +109,18 @@ class AsyncCodexClient:
|
||||
"""Unregister a turn notification queue on the wrapped sync client."""
|
||||
self._sync.unregister_turn_notifications(turn_id)
|
||||
|
||||
def register_goal_operation(self, thread_id: str) -> _GoalOperationState:
|
||||
"""Register a logical goal route on the wrapped sync client."""
|
||||
return self._sync.register_goal_operation(thread_id)
|
||||
|
||||
def bind_goal_operation(self, state: _GoalOperationState, turn_id: str) -> None:
|
||||
"""Bind a logical goal route to its stable turn id."""
|
||||
self._sync.bind_goal_operation(state, turn_id)
|
||||
|
||||
def unregister_goal_operation(self, state: _GoalOperationState) -> None:
|
||||
"""Release one logical goal route."""
|
||||
self._sync.unregister_goal_operation(state)
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -192,14 +206,26 @@ class AsyncCodexClient:
|
||||
"""Start thread compaction using the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.thread_compact, thread_id)
|
||||
|
||||
async def pause_goal(self, thread_id: str) -> ThreadGoalSetResponse:
|
||||
"""Pause the active goal through the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.pause_goal, thread_id)
|
||||
|
||||
async def turn_start(
|
||||
self,
|
||||
thread_id: str,
|
||||
input_items: list[JsonObject] | JsonObject | str,
|
||||
params: V2TurnStartParams | JsonObject | None = None,
|
||||
*,
|
||||
goal: bool = False,
|
||||
) -> TurnStartResponse:
|
||||
"""Start a turn using the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.turn_start, thread_id, input_items, params)
|
||||
return await self._call_sync(
|
||||
self._sync.turn_start,
|
||||
thread_id,
|
||||
input_items,
|
||||
params,
|
||||
goal=goal,
|
||||
)
|
||||
|
||||
async def turn_interrupt(self, thread_id: str, turn_id: str) -> TurnInterruptResponse:
|
||||
"""Interrupt a turn using the wrapped sync client."""
|
||||
@@ -256,6 +282,10 @@ class AsyncCodexClient:
|
||||
"""Wait for the next notification routed to one turn."""
|
||||
return await self._call_sync(self._sync.next_turn_notification, turn_id)
|
||||
|
||||
async def next_goal_notification(self, state: _GoalOperationState) -> Notification:
|
||||
"""Wait for the next notification in a logical goal turn."""
|
||||
return await self._call_sync(self._sync.next_goal_notification, state)
|
||||
|
||||
async def wait_for_login_completed(
|
||||
self,
|
||||
login_id: str,
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Callable, Iterator, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._goal import _GoalOperationState
|
||||
from ._message_router import MessageRouter
|
||||
from ._version import __version__ as SDK_VERSION
|
||||
from .errors import CodexError, TransportClosedError
|
||||
@@ -30,6 +31,8 @@ from .generated.v2_all import (
|
||||
ThreadCompactStartResponse,
|
||||
ThreadForkParams as V2ThreadForkParams,
|
||||
ThreadForkResponse,
|
||||
ThreadGoalSetResponse,
|
||||
ThreadGoalStatus,
|
||||
ThreadListParams as V2ThreadListParams,
|
||||
ThreadListResponse,
|
||||
ThreadReadResponse,
|
||||
@@ -352,6 +355,23 @@ class CodexClient:
|
||||
"""Return the next routed notification for the requested turn id."""
|
||||
return self._router.next_turn_notification(turn_id)
|
||||
|
||||
def register_goal_operation(self, thread_id: str) -> _GoalOperationState:
|
||||
"""Register a private thread-scoped route for a logical goal turn."""
|
||||
return self._router.register_goal(thread_id)
|
||||
|
||||
def bind_goal_operation(self, state: _GoalOperationState, turn_id: str) -> None:
|
||||
"""Bind a pending goal route to its stable logical turn id."""
|
||||
state.bind(turn_id)
|
||||
self.unregister_turn_notifications(turn_id)
|
||||
|
||||
def unregister_goal_operation(self, state: _GoalOperationState) -> None:
|
||||
"""Release routing state for one logical goal turn."""
|
||||
self._router.unregister_goal(state)
|
||||
|
||||
def next_goal_notification(self, state: _GoalOperationState) -> Notification:
|
||||
"""Wait for the next notification in a logical goal turn."""
|
||||
return state.next_notification()
|
||||
|
||||
def account_login_start(
|
||||
self,
|
||||
params: V2LoginAccountParams | JsonObject,
|
||||
@@ -452,11 +472,21 @@ class CodexClient:
|
||||
response_model=ThreadCompactStartResponse,
|
||||
)
|
||||
|
||||
def pause_goal(self, thread_id: str) -> ThreadGoalSetResponse:
|
||||
"""Pause the active goal used by a logical goal turn."""
|
||||
return self.request(
|
||||
"thread/goal/set",
|
||||
{"threadId": thread_id, "status": ThreadGoalStatus.paused.value},
|
||||
response_model=ThreadGoalSetResponse,
|
||||
)
|
||||
|
||||
def turn_start(
|
||||
self,
|
||||
thread_id: str,
|
||||
input_items: list[JsonObject] | JsonObject | str,
|
||||
params: V2TurnStartParams | JsonObject | None = None,
|
||||
*,
|
||||
goal: bool = False,
|
||||
) -> TurnStartResponse:
|
||||
"""Start a turn and register its notification queue as early as possible."""
|
||||
payload = {
|
||||
@@ -464,6 +494,11 @@ class CodexClient:
|
||||
"threadId": thread_id,
|
||||
"input": self._normalize_input_items(input_items),
|
||||
}
|
||||
params_goal = payload.pop("goal", False)
|
||||
if not isinstance(params_goal, bool):
|
||||
raise TypeError("turn/start goal must be a bool")
|
||||
if goal or params_goal:
|
||||
payload["goal"] = True
|
||||
started = self.request("turn/start", payload, response_model=TurnStartResponse)
|
||||
self.register_turn_notifications(started.turn.id)
|
||||
return started
|
||||
|
||||
@@ -169,6 +169,53 @@ NESTED_TURN_NOTIFICATION_TYPES: tuple[type[BaseModel], ...] = (
|
||||
TurnStartedNotification,
|
||||
)
|
||||
|
||||
DIRECT_THREAD_ID_NOTIFICATION_TYPES: tuple[type[BaseModel], ...] = (
|
||||
AgentMessageDeltaNotification,
|
||||
CommandExecutionOutputDeltaNotification,
|
||||
ContextCompactedNotification,
|
||||
ErrorNotification,
|
||||
FileChangeOutputDeltaNotification,
|
||||
FileChangePatchUpdatedNotification,
|
||||
GuardianWarningNotification,
|
||||
HookCompletedNotification,
|
||||
HookStartedNotification,
|
||||
ItemCompletedNotification,
|
||||
ItemGuardianApprovalReviewCompletedNotification,
|
||||
ItemGuardianApprovalReviewStartedNotification,
|
||||
ItemStartedNotification,
|
||||
McpToolCallProgressNotification,
|
||||
ModelReroutedNotification,
|
||||
ModelVerificationNotification,
|
||||
PlanDeltaNotification,
|
||||
ReasoningSummaryPartAddedNotification,
|
||||
ReasoningSummaryTextDeltaNotification,
|
||||
ReasoningTextDeltaNotification,
|
||||
ServerRequestResolvedNotification,
|
||||
TerminalInteractionNotification,
|
||||
ThreadArchivedNotification,
|
||||
ThreadClosedNotification,
|
||||
ThreadGoalClearedNotification,
|
||||
ThreadGoalUpdatedNotification,
|
||||
ThreadNameUpdatedNotification,
|
||||
ThreadRealtimeClosedNotification,
|
||||
ThreadRealtimeErrorNotification,
|
||||
ThreadRealtimeItemAddedNotification,
|
||||
ThreadRealtimeOutputAudioDeltaNotification,
|
||||
ThreadRealtimeSdpNotification,
|
||||
ThreadRealtimeStartedNotification,
|
||||
ThreadRealtimeTranscriptDeltaNotification,
|
||||
ThreadRealtimeTranscriptDoneNotification,
|
||||
ThreadSettingsUpdatedNotification,
|
||||
ThreadStatusChangedNotification,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
ThreadUnarchivedNotification,
|
||||
TurnCompletedNotification,
|
||||
TurnDiffUpdatedNotification,
|
||||
TurnPlanUpdatedNotification,
|
||||
TurnStartedNotification,
|
||||
WarningNotification,
|
||||
)
|
||||
|
||||
|
||||
def notification_turn_id(payload: BaseModel) -> str | None:
|
||||
"""Return the turn id carried by generated notification payload metadata."""
|
||||
@@ -177,3 +224,10 @@ def notification_turn_id(payload: BaseModel) -> str | None:
|
||||
if isinstance(payload, NESTED_TURN_NOTIFICATION_TYPES):
|
||||
return payload.turn.id
|
||||
return None
|
||||
|
||||
|
||||
def notification_thread_id(payload: BaseModel) -> str | None:
|
||||
"""Return the thread id carried by generated notification payload metadata."""
|
||||
if isinstance(payload, DIRECT_THREAD_ID_NOTIFICATION_TYPES):
|
||||
return payload.thread_id
|
||||
return None
|
||||
|
||||
@@ -6956,6 +6956,12 @@ class TurnStartParams(BaseModel):
|
||||
ReasoningEffort | None,
|
||||
Field(description="Override the reasoning effort for this turn and subsequent turns."),
|
||||
] = None
|
||||
goal: Annotated[
|
||||
bool | None,
|
||||
Field(
|
||||
description="Replace the thread's active goal with an objective derived from this turn's text input."
|
||||
),
|
||||
] = False
|
||||
input: list[UserInput]
|
||||
model: Annotated[
|
||||
str | None, Field(description="Override the model for this turn and subsequent turns.")
|
||||
|
||||
@@ -27,6 +27,8 @@ from .generated.v2_all import (
|
||||
ReasoningSummaryTextDeltaNotification,
|
||||
ReasoningTextDeltaNotification,
|
||||
TerminalInteractionNotification,
|
||||
ThreadGoalClearedNotification,
|
||||
ThreadGoalUpdatedNotification,
|
||||
ThreadNameUpdatedNotification,
|
||||
ThreadStartedNotification,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
@@ -70,6 +72,8 @@ NotificationPayload: TypeAlias = (
|
||||
| ReasoningTextDeltaNotification
|
||||
| TerminalInteractionNotification
|
||||
| ThreadNameUpdatedNotification
|
||||
| ThreadGoalClearedNotification
|
||||
| ThreadGoalUpdatedNotification
|
||||
| ThreadStartedNotification
|
||||
| ThreadTokenUsageUpdatedNotification
|
||||
| TurnCompletedNotification
|
||||
|
||||
@@ -3,7 +3,10 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from openai_codex.client import CodexClient, _params_dict
|
||||
from openai_codex.generated.notification_registry import notification_turn_id
|
||||
from openai_codex.generated.notification_registry import (
|
||||
notification_thread_id,
|
||||
notification_turn_id,
|
||||
)
|
||||
from openai_codex.generated.v2_all import (
|
||||
AgentMessageDeltaNotification,
|
||||
ApprovalsReviewer,
|
||||
@@ -11,6 +14,8 @@ from openai_codex.generated.v2_all import (
|
||||
ThreadResumeResponse,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
TurnCompletedNotification,
|
||||
TurnStartParams,
|
||||
TurnStartResponse,
|
||||
WarningNotification,
|
||||
)
|
||||
from openai_codex.models import Notification, UnknownNotification
|
||||
@@ -26,11 +31,73 @@ def test_generated_params_models_are_snake_case_and_dump_by_alias() -> None:
|
||||
assert dumped == {"searchTerm": "needle", "limit": 5}
|
||||
|
||||
|
||||
def test_turn_start_sends_goal_only_when_enabled() -> None:
|
||||
"""The low-level request should preserve false-by-omission wire behavior."""
|
||||
client = CodexClient()
|
||||
requests: list[dict[str, object]] = []
|
||||
|
||||
def fake_request(method, params, *, response_model):
|
||||
requests.append({"method": method, "params": params, "response_model": response_model})
|
||||
return TurnStartResponse.model_validate(
|
||||
{"turn": {"id": f"turn-{len(requests)}", "items": [], "status": "inProgress"}}
|
||||
)
|
||||
|
||||
client.request = fake_request # type: ignore[method-assign]
|
||||
|
||||
client.turn_start("thread-1", "ordinary")
|
||||
client.turn_start("thread-1", "goal", goal=True)
|
||||
client.turn_start(
|
||||
"thread-1",
|
||||
"typed goal",
|
||||
TurnStartParams(threadId="thread-1", input=[], goal=True),
|
||||
)
|
||||
|
||||
assert requests == [
|
||||
{
|
||||
"method": "turn/start",
|
||||
"params": {
|
||||
"threadId": "thread-1",
|
||||
"input": [{"type": "text", "text": "ordinary"}],
|
||||
},
|
||||
"response_model": TurnStartResponse,
|
||||
},
|
||||
{
|
||||
"method": "turn/start",
|
||||
"params": {
|
||||
"threadId": "thread-1",
|
||||
"input": [{"type": "text", "text": "goal"}],
|
||||
"goal": True,
|
||||
},
|
||||
"response_model": TurnStartResponse,
|
||||
},
|
||||
{
|
||||
"method": "turn/start",
|
||||
"params": {
|
||||
"threadId": "thread-1",
|
||||
"input": [{"type": "text", "text": "typed goal"}],
|
||||
"goal": True,
|
||||
},
|
||||
"response_model": TurnStartResponse,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_generated_v2_bundle_has_single_shared_plan_type_definition() -> None:
|
||||
source = (ROOT / "src" / "openai_codex" / "generated" / "v2_all.py").read_text()
|
||||
assert source.count("class PlanType(") == 1
|
||||
|
||||
|
||||
def test_generated_turn_start_params_include_goal_mode() -> None:
|
||||
assert (
|
||||
TurnStartParams(
|
||||
threadId="thread-1",
|
||||
input=[{"type": "text", "text": "goal"}],
|
||||
goal=True,
|
||||
).goal
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_thread_resume_response_accepts_auto_review_reviewer() -> None:
|
||||
"""Generated response models should keep accepting the auto review enum value."""
|
||||
response = ThreadResumeResponse.model_validate(
|
||||
@@ -142,6 +209,24 @@ def test_generated_notification_turn_id_handles_known_payload_shapes() -> None:
|
||||
] == ["turn-1", "turn-2", None]
|
||||
|
||||
|
||||
def test_generated_notification_thread_id_handles_known_payload_shapes() -> None:
|
||||
"""Generated routing metadata should expose thread ids without field guessing."""
|
||||
direct = AgentMessageDeltaNotification.model_validate(
|
||||
{
|
||||
"delta": "hello",
|
||||
"itemId": "item-1",
|
||||
"threadId": "thread-1",
|
||||
"turnId": "turn-1",
|
||||
}
|
||||
)
|
||||
unscoped = WarningNotification(message="heads up")
|
||||
|
||||
assert [notification_thread_id(direct), notification_thread_id(unscoped)] == [
|
||||
"thread-1",
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
def test_turn_notification_router_demuxes_registered_turns() -> None:
|
||||
"""The router should deliver out-of-order turn events to the matching queues."""
|
||||
client = CodexClient()
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
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())
|
||||
@@ -185,6 +185,19 @@ def test_turn_input_methods_accept_string_shortcut() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_turn_result_producers_expose_goal_mode() -> None:
|
||||
"""Goal mode belongs to APIs that produce a turn result or handle."""
|
||||
funcs = [Thread.run, Thread.turn, AsyncThread.run, AsyncThread.turn]
|
||||
|
||||
assert {
|
||||
fn: (
|
||||
inspect.signature(fn).parameters["goal"].annotation,
|
||||
inspect.signature(fn).parameters["goal"].default,
|
||||
)
|
||||
for fn in funcs
|
||||
} == dict.fromkeys(funcs, ("bool", False))
|
||||
|
||||
|
||||
def test_root_exports_approval_mode() -> None:
|
||||
"""The root package should expose the high-level approval mode enum."""
|
||||
assert [(mode.name, mode.value) for mode in ApprovalMode] == [
|
||||
@@ -370,6 +383,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"thread_source",
|
||||
],
|
||||
Thread.turn: [
|
||||
"goal",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
@@ -381,6 +395,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"summary",
|
||||
],
|
||||
Thread.run: [
|
||||
"goal",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
@@ -445,6 +460,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"thread_source",
|
||||
],
|
||||
AsyncThread.turn: [
|
||||
"goal",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
@@ -456,6 +472,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"summary",
|
||||
],
|
||||
AsyncThread.run: [
|
||||
"goal",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
|
||||
Reference in New Issue
Block a user