From f0166cadbb07c79c193b0c0bcdff4b52a836d111 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 17 May 2026 06:17:22 -0700 Subject: [PATCH] [codex] Return TurnResult from Python turn handles (#23151) ## Why `TurnHandle.run()` returned the raw app-server `Turn`, whose live start/completed payloads do not include loaded `items`, so users saw empty `items` after starting a turn. That made the handle-based path behave differently from `Thread.run(...)`, and pushed examples toward persisted-thread reads plus helper extraction. This PR makes the run APIs standalone: starting a turn and running it returns collected turn data directly, or fails visibly when required stream events are missing. ## What Changed - Replaces the public `RunResult` export with `TurnResult`. - Adds turn metadata to `TurnResult`: `id`, `status`, `error`, `started_at`, `completed_at`, and `duration_ms`, alongside `final_response`, `items`, and `usage`. - Changes `TurnHandle.run()` and `AsyncTurnHandle.run()` to consume stream events with the same collector used by `Thread.run(...)`. - Exports `TurnError` from `openai_codex.types` for the new result shape. - Updates tests, examples, docs, and the walkthrough notebook to use `result.final_response` and `result.items` directly. - Removes persisted-thread helper paths and placeholder/skipped control flows from the public examples and notebook. ## Verification - `python3 -m py_compile ...` over changed SDK, example, and test Python files. - `python3 -c "import json; json.load(open('sdk/python/notebooks/sdk_walkthrough.ipynb'))"` - `git diff --check` - `PYTHONPATH=sdk/python/src python3 -c ...` import/signature smoke for `TurnResult`, `TurnHandle.run`, and `AsyncTurnHandle.run`. --- sdk/python/README.md | 5 +- sdk/python/docs/api-reference.md | 19 +- sdk/python/docs/faq.md | 9 +- sdk/python/docs/getting-started.md | 4 +- sdk/python/examples/02_turn_run/async.py | 16 +- sdk/python/examples/02_turn_run/sync.py | 16 +- .../examples/03_turn_stream_events/async.py | 27 ++- .../examples/03_turn_stream_events/sync.py | 27 ++- .../examples/04_models_and_metadata/async.py | 2 +- .../examples/04_models_and_metadata/sync.py | 2 +- .../examples/05_existing_thread/async.py | 11 +- .../examples/05_existing_thread/sync.py | 11 +- .../06_thread_lifecycle_and_controls/async.py | 48 ++-- .../06_thread_lifecycle_and_controls/sync.py | 42 ++-- .../examples/07_image_and_text/async.py | 11 +- sdk/python/examples/07_image_and_text/sync.py | 11 +- .../examples/08_local_image_and_text/async.py | 6 +- .../examples/08_local_image_and_text/sync.py | 6 +- sdk/python/examples/09_async_parity/sync.py | 12 +- .../10_error_handling_and_retry/async.py | 14 +- .../10_error_handling_and_retry/sync.py | 14 +- sdk/python/examples/11_cli_mini_app/async.py | 31 +-- sdk/python/examples/11_cli_mini_app/sync.py | 31 +-- .../12_turn_params_kitchen_sink/async.py | 17 +- .../12_turn_params_kitchen_sink/sync.py | 17 +- .../13_model_select_and_turn_params/async.py | 44 ++-- .../13_model_select_and_turn_params/sync.py | 39 ++-- sdk/python/examples/14_turn_controls/async.py | 58 ++--- sdk/python/examples/14_turn_controls/sync.py | 56 ++--- sdk/python/examples/README.md | 2 +- sdk/python/examples/_bootstrap.py | 54 +---- sdk/python/notebooks/sdk_walkthrough.ipynb | 212 ++++++------------ sdk/python/src/openai_codex/__init__.py | 4 +- sdk/python/src/openai_codex/_run.py | 33 ++- sdk/python/src/openai_codex/api.py | 39 +--- sdk/python/src/openai_codex/types.py | 2 + sdk/python/tests/app_server_helpers.py | 2 +- sdk/python/tests/test_app_server_lifecycle.py | 12 +- sdk/python/tests/test_app_server_run.py | 22 +- sdk/python/tests/test_app_server_streaming.py | 9 +- .../tests/test_public_api_signatures.py | 43 +++- .../tests/test_real_app_server_integration.py | 36 ++- 42 files changed, 399 insertions(+), 677 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index a04d59b239..9107d15ba9 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -34,8 +34,9 @@ with Codex() as codex: print(len(result.items)) ``` -`result.final_response` is `None` when the turn completes without a final-answer -or phase-less assistant message item. +`thread.run(...)` and `thread.turn(...).run()` return `TurnResult`. Its +`final_response` is `None` when the turn completes without a final-answer or +phase-less assistant message item. ## Login diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 1b885b8c89..4bee871f8c 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -16,11 +16,11 @@ from openai_codex import ( DeviceCodeLoginHandle, AsyncChatgptLoginHandle, AsyncDeviceCodeLoginHandle, - RunResult, Thread, AsyncThread, TurnHandle, AsyncTurnHandle, + TurnResult, Input, InputItem, TextInput, @@ -38,6 +38,7 @@ from openai_codex.types import ( InitializeResponse, ThreadItem, ThreadTokenUsage, + TurnError, TurnStatus, ) ``` @@ -146,7 +147,7 @@ attempt. API-key login completes synchronously and does not return a handle. ### Thread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> RunResult` +- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult` - `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> TurnHandle` - `read(*, include_turns: bool = False) -> ThreadReadResponse` - `set_name(name: str) -> ThreadSetNameResponse` @@ -154,7 +155,7 @@ attempt. API-key login completes synchronously and does not return a handle. ### AsyncThread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[RunResult]` +- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]` - `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Awaitable[AsyncTurnHandle]` - `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]` - `set_name(name: str) -> Awaitable[ThreadSetNameResponse]` @@ -164,6 +165,12 @@ attempt. API-key login completes synchronously and does not return a handle. the turn, consumes notifications until completion, and returns a small result object with: +- `id: str` +- `status: TurnStatus` +- `error: TurnError | None` +- `started_at: int | None` +- `completed_at: int | None` +- `duration_ms: int | None` - `final_response: str | None` - `items: list[ThreadItem]` - `usage: ThreadTokenUsage | None` @@ -172,7 +179,7 @@ object with: phase-less assistant message item. Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, -`interrupt()`) or the public `Turn` model from `TurnHandle.run()`. +`interrupt()`) before collecting the turn result. ## TurnHandle / AsyncTurnHandle @@ -181,7 +188,7 @@ Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, - `steer(input: Input) -> TurnSteerResponse` - `interrupt() -> TurnInterruptResponse` - `stream() -> Iterator[Notification]` -- `run() -> openai_codex.types.Turn` +- `run() -> TurnResult` Behavior notes: @@ -193,7 +200,7 @@ Behavior notes: - `steer(input: Input) -> Awaitable[TurnSteerResponse]` - `interrupt() -> Awaitable[TurnInterruptResponse]` - `stream() -> AsyncIterator[Notification]` -- `run() -> Awaitable[openai_codex.types.Turn]` +- `run() -> Awaitable[TurnResult]` Behavior notes: diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index d1732e74aa..5f2ed87ffd 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -8,7 +8,8 @@ ## `run()` vs `stream()` -- `TurnHandle.run()` / `AsyncTurnHandle.run()` is the easiest path. It consumes events until completion and returns the public app-server `Turn` model from `openai_codex.types`. +- `Thread.run(...)` starts a turn and returns `TurnResult`. +- `TurnHandle.run()` / `AsyncTurnHandle.run()` consumes events for an existing turn handle and returns the same `TurnResult` shape. - `TurnHandle.stream()` / `AsyncTurnHandle.stream()` yields raw notifications (`Notification`) so you can react event-by-event. Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout logic, or custom parsing. @@ -66,7 +67,7 @@ Common causes: - published runtime package (`openai-codex-cli-bin`) is not installed - local `codex_bin` override points to a missing file -- incompatible/old app-server +- app-server version older than the SDK schema ## Why does a turn "hang"? @@ -79,11 +80,11 @@ A turn is complete only when `turn/completed` arrives for that turn ID. Use `retry_on_overload(...)` for transient overload failures (`ServerBusyError`). -Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs/version compatibility instead. +Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs or update the runtime/schema version instead. ## Common pitfalls - Starting a new thread for every prompt when you wanted continuity. - Forgetting to `close()` (or not using context managers). -- Assuming `run()` returns extra SDK-only fields instead of the public `Turn` model. +- Reading `Turn.items` from live start/completed payloads instead of using `TurnResult.items`. - Mixing SDK input classes with raw dicts incorrectly. diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index b362a90347..1df0026c68 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -70,9 +70,9 @@ What happened: - `Codex()` started and initialized `codex app-server`. - `thread_start(...)` created a thread. -- `thread.run("...")` started a turn, consumed events until completion, and returned the final assistant response plus collected items and usage. +- `thread.run("...")` started a turn, consumed events until completion, and returned `TurnResult` with turn metadata, final assistant response, collected items, and usage. - `result.final_response` is `None` when no final-answer or phase-less assistant message item completes for the turn. -- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, interrupting, or turn IDs/status +- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult` - one client can consume multiple active turns concurrently; turn streams are routed by turn ID ## 4) Continue the same thread (multi-turn) diff --git a/sdk/python/examples/02_turn_run/async.py b/sdk/python/examples/02_turn_run/async.py index 0f6ef94f91..debfad8d6e 100644 --- a/sdk/python/examples/02_turn_run/async.py +++ b/sdk/python/examples/02_turn_run/async.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -26,19 +21,14 @@ async def main() -> None: ) turn = await thread.turn(TextInput("Give 3 bullets about SIMD.")) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("thread_id:", thread.id) print("turn_id:", result.id) print("status:", result.status) if result.error is not None: print("error:", result.error) - print("text:", assistant_text_from_turn(persisted_turn)) - print( - "persisted.items.count:", - 0 if persisted_turn is None else len(persisted_turn.items or []), - ) + print("text:", result.final_response) + print("items.count:", len(result.items)) if __name__ == "__main__": diff --git a/sdk/python/examples/02_turn_run/sync.py b/sdk/python/examples/02_turn_run/sync.py index 18aa8c2f81..c42bffa1f2 100644 --- a/sdk/python/examples/02_turn_run/sync.py +++ b/sdk/python/examples/02_turn_run/sync.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -19,16 +14,11 @@ from openai_codex import Codex, TextInput with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) result = thread.turn(TextInput("Give 3 bullets about SIMD.")).run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("thread_id:", thread.id) print("turn_id:", result.id) print("status:", result.status) if result.error is not None: print("error:", result.error) - print("text:", assistant_text_from_turn(persisted_turn)) - print( - "persisted.items.count:", - 0 if persisted_turn is None else len(persisted_turn.items or []), - ) + print("text:", result.final_response) + print("items.count:", len(result.items)) diff --git a/sdk/python/examples/03_turn_stream_events/async.py b/sdk/python/examples/03_turn_stream_events/async.py index dcf57af4f2..f0d3f1427d 100644 --- a/sdk/python/examples/03_turn_stream_events/async.py +++ b/sdk/python/examples/03_turn_stream_events/async.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -29,7 +24,8 @@ async def main() -> None: event_count = 0 saw_started = False saw_delta = False - completed_status = "unknown" + completed_status = None + completed_texts = [] async for event in turn.stream(): event_count += 1 @@ -38,24 +34,27 @@ async def main() -> None: print("stream.started") continue if event.method == "item/agentMessage/delta": - delta = getattr(event.payload, "delta", "") + delta = event.payload.delta if delta: if not saw_delta: print("assistant> ", end="", flush=True) print(delta, end="", flush=True) saw_delta = True continue + if event.method == "item/completed": + root = event.payload.item.root + if root.type == "agentMessage": + completed_texts.append(root.text) + continue if event.method == "turn/completed": - completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + completed_status = event.payload.turn.status.value + if completed_status is None: + raise RuntimeError("stream ended without turn/completed") if saw_delta: print() else: - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id) - final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]" + final_text = "".join(completed_texts).strip() print("assistant>", final_text) print("stream.started.seen:", saw_started) diff --git a/sdk/python/examples/03_turn_stream_events/sync.py b/sdk/python/examples/03_turn_stream_events/sync.py index f96f5c6acd..5a76bcb70f 100644 --- a/sdk/python/examples/03_turn_stream_events/sync.py +++ b/sdk/python/examples/03_turn_stream_events/sync.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -23,7 +18,8 @@ with Codex(config=runtime_config()) as codex: event_count = 0 saw_started = False saw_delta = False - completed_status = "unknown" + completed_status = None + completed_texts = [] for event in turn.stream(): event_count += 1 @@ -32,24 +28,27 @@ with Codex(config=runtime_config()) as codex: print("stream.started") continue if event.method == "item/agentMessage/delta": - delta = getattr(event.payload, "delta", "") + delta = event.payload.delta if delta: if not saw_delta: print("assistant> ", end="", flush=True) print(delta, end="", flush=True) saw_delta = True continue + if event.method == "item/completed": + root = event.payload.item.root + if root.type == "agentMessage": + completed_texts.append(root.text) + continue if event.method == "turn/completed": - completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + completed_status = event.payload.turn.status.value + if completed_status is None: + raise RuntimeError("stream ended without turn/completed") if saw_delta: print() else: - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id) - final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]" + final_text = "".join(completed_texts).strip() print("assistant>", final_text) print("stream.started.seen:", saw_started) diff --git a/sdk/python/examples/04_models_and_metadata/async.py b/sdk/python/examples/04_models_and_metadata/async.py index ba36e51a23..23ca58131f 100644 --- a/sdk/python/examples/04_models_and_metadata/async.py +++ b/sdk/python/examples/04_models_and_metadata/async.py @@ -19,7 +19,7 @@ async def main() -> None: print("server:", server_label(codex.metadata)) models = await codex.models() print("models.count:", len(models.data)) - print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]") + print("models:", ", ".join(model.id for model in models.data[:5])) if __name__ == "__main__": diff --git a/sdk/python/examples/04_models_and_metadata/sync.py b/sdk/python/examples/04_models_and_metadata/sync.py index 849116941f..155e53571d 100644 --- a/sdk/python/examples/04_models_and_metadata/sync.py +++ b/sdk/python/examples/04_models_and_metadata/sync.py @@ -15,4 +15,4 @@ with Codex(config=runtime_config()) as codex: print("server:", server_label(codex.metadata)) models = codex.models() print("models.count:", len(models.data)) - print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]") + print("models:", ", ".join(model.id for model in models.data[:5])) diff --git a/sdk/python/examples/05_existing_thread/async.py b/sdk/python/examples/05_existing_thread/async.py index 4dc36531d8..df3946ac87 100644 --- a/sdk/python/examples/05_existing_thread/async.py +++ b/sdk/python/examples/05_existing_thread/async.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -32,9 +27,7 @@ async def main() -> None: resumed = await codex.thread_resume(original.id) second_turn = await resumed.turn(TextInput("Continue with one more fact.")) second = await second_turn.run() - persisted = await resumed.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) - print(assistant_text_from_turn(persisted_turn)) + print(second.final_response) if __name__ == "__main__": diff --git a/sdk/python/examples/05_existing_thread/sync.py b/sdk/python/examples/05_existing_thread/sync.py index ca441c4223..a0f0d37e27 100644 --- a/sdk/python/examples/05_existing_thread/sync.py +++ b/sdk/python/examples/05_existing_thread/sync.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -25,6 +20,4 @@ with Codex(config=runtime_config()) as codex: # Resume the existing thread by ID. resumed = codex.thread_resume(original.id) second = resumed.turn(TextInput("Continue with one more fact.")).run() - persisted = resumed.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) - print(assistant_text_from_turn(persisted_turn)) + print(second.final_response) diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py index ad73a896ee..24afbde6c2 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py @@ -33,45 +33,31 @@ async def main() -> None: listing_archived = await codex.thread_list(limit=20, archived=True) unarchived = await codex.thread_unarchive(reopened.id) - resumed_info = "n/a" - try: - resumed = await codex.thread_resume( - unarchived.id, - model="gpt-5.4", - config={"model_reasoning_effort": "high"}, - ) - resumed_result = await ( - await resumed.turn(TextInput("Continue in one short sentence.")) - ).run() - resumed_info = f"{resumed_result.id} {resumed_result.status}" - except Exception as exc: - resumed_info = f"skipped({type(exc).__name__})" + resumed = await codex.thread_resume( + unarchived.id, + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + resumed_result = await ( + await resumed.turn(TextInput("Continue in one short sentence.")) + ).run() - forked_info = "n/a" - try: - forked = await codex.thread_fork(unarchived.id, model="gpt-5.4") - forked_result = await ( - await forked.turn(TextInput("Take a different angle in one short sentence.")) - ).run() - forked_info = f"{forked_result.id} {forked_result.status}" - except Exception as exc: - forked_info = f"skipped({type(exc).__name__})" + forked = await codex.thread_fork(unarchived.id, model="gpt-5.4") + forked_result = await ( + await forked.turn(TextInput("Take a different angle in one short sentence.")) + ).run() - compact_info = "sent" - try: - _ = await unarchived.compact() - except Exception as exc: - compact_info = f"skipped({type(exc).__name__})" + compact_result = await unarchived.compact() print("Lifecycle OK:", thread.id) print("first:", first.id, first.status) print("second:", second.id, second.status) - print("read.turns:", len(reading.thread.turns or [])) + print("read.turns:", len(reading.thread.turns)) print("list.active:", len(listing_active.data)) print("list.archived:", len(listing_archived.data)) - print("resumed:", resumed_info) - print("forked:", forked_info) - print("compact:", compact_info) + print("resumed:", resumed_result.id, resumed_result.status) + print("forked:", forked_result.id, forked_result.status) + print("compact:", compact_result.model_dump(mode="json", by_alias=True)) if __name__ == "__main__": diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py index d01f165235..0958002dd9 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py @@ -25,40 +25,24 @@ with Codex(config=runtime_config()) as codex: listing_archived = codex.thread_list(limit=20, archived=True) unarchived = codex.thread_unarchive(reopened.id) - resumed_info = "n/a" - try: - resumed = codex.thread_resume( - unarchived.id, - model="gpt-5.4", - config={"model_reasoning_effort": "high"}, - ) - resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() - resumed_info = f"{resumed_result.id} {resumed_result.status}" - except Exception as exc: - resumed_info = f"skipped({type(exc).__name__})" + resumed = codex.thread_resume( + unarchived.id, + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() - forked_info = "n/a" - try: - forked = codex.thread_fork(unarchived.id, model="gpt-5.4") - forked_result = forked.turn( - TextInput("Take a different angle in one short sentence.") - ).run() - forked_info = f"{forked_result.id} {forked_result.status}" - except Exception as exc: - forked_info = f"skipped({type(exc).__name__})" + forked = codex.thread_fork(unarchived.id, model="gpt-5.4") + forked_result = forked.turn(TextInput("Take a different angle in one short sentence.")).run() - compact_info = "sent" - try: - _ = unarchived.compact() - except Exception as exc: - compact_info = f"skipped({type(exc).__name__})" + compact_result = unarchived.compact() print("Lifecycle OK:", thread.id) print("first:", first.id, first.status) print("second:", second.id, second.status) - print("read.turns:", len(reading.thread.turns or [])) + print("read.turns:", len(reading.thread.turns)) print("list.active:", len(listing_active.data)) print("list.archived:", len(listing_archived.data)) - print("resumed:", resumed_info) - print("forked:", forked_info) - print("compact:", compact_info) + print("resumed:", resumed_result.id, resumed_result.status) + print("forked:", forked_result.id, forked_result.status) + print("compact:", compact_result.model_dump(mode="json", by_alias=True)) diff --git a/sdk/python/examples/07_image_and_text/async.py b/sdk/python/examples/07_image_and_text/async.py index 41fe96a830..c3237e320e 100644 --- a/sdk/python/examples/07_image_and_text/async.py +++ b/sdk/python/examples/07_image_and_text/async.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -33,11 +28,9 @@ async def main() -> None: ] ) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) if __name__ == "__main__": diff --git a/sdk/python/examples/07_image_and_text/sync.py b/sdk/python/examples/07_image_and_text/sync.py index ca82e7cfdc..f7402f18c9 100644 --- a/sdk/python/examples/07_image_and_text/sync.py +++ b/sdk/python/examples/07_image_and_text/sync.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -26,8 +21,6 @@ with Codex(config=runtime_config()) as codex: ImageInput(REMOTE_IMAGE_URL), ] ).run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) diff --git a/sdk/python/examples/08_local_image_and_text/async.py b/sdk/python/examples/08_local_image_and_text/async.py index c3abf4d2ee..292c0c3e3d 100644 --- a/sdk/python/examples/08_local_image_and_text/async.py +++ b/sdk/python/examples/08_local_image_and_text/async.py @@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) from _bootstrap import ( - assistant_text_from_turn, ensure_local_sdk_src, - find_turn_by_id, runtime_config, temporary_sample_image_path, ) @@ -36,11 +34,9 @@ async def main() -> None: ] ) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) if __name__ == "__main__": diff --git a/sdk/python/examples/08_local_image_and_text/sync.py b/sdk/python/examples/08_local_image_and_text/sync.py index 45c5161cb8..ee80a181ff 100644 --- a/sdk/python/examples/08_local_image_and_text/sync.py +++ b/sdk/python/examples/08_local_image_and_text/sync.py @@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) from _bootstrap import ( - assistant_text_from_turn, ensure_local_sdk_src, - find_turn_by_id, runtime_config, temporary_sample_image_path, ) @@ -29,8 +27,6 @@ with temporary_sample_image_path() as image_path: LocalImageInput(str(image_path.resolve())), ] ).run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) diff --git a/sdk/python/examples/09_async_parity/sync.py b/sdk/python/examples/09_async_parity/sync.py index 018e588524..4b5d567159 100644 --- a/sdk/python/examples/09_async_parity/sync.py +++ b/sdk/python/examples/09_async_parity/sync.py @@ -5,13 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, - server_label, -) +from _bootstrap import ensure_local_sdk_src, runtime_config, server_label ensure_local_sdk_src() @@ -23,9 +17,7 @@ with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) turn = thread.turn(TextInput("Say hello in one sentence.")) result = turn.run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Thread:", thread.id) print("Turn:", result.id) - print("Text:", assistant_text_from_turn(persisted_turn).strip()) + print("Text:", result.final_response.strip()) diff --git a/sdk/python/examples/10_error_handling_and_retry/async.py b/sdk/python/examples/10_error_handling_and_retry/async.py index 65c95b2b63..f1711bffc9 100644 --- a/sdk/python/examples/10_error_handling_and_retry/async.py +++ b/sdk/python/examples/10_error_handling_and_retry/async.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -73,19 +68,16 @@ async def main() -> None: ) except ServerBusyError as exc: print("Server overloaded after retries:", exc.message) - print("Text:") return except JsonRpcError as exc: print(f"JSON-RPC error {exc.code}: {exc.message}") - print("Text:") return - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) if result.status == TurnStatus.failed: print("Turn failed:", result.error) + return - print("Text:", assistant_text_from_turn(persisted_turn)) + print("Text:", result.final_response) def _run_turn(thread, prompt: str): diff --git a/sdk/python/examples/10_error_handling_and_retry/sync.py b/sdk/python/examples/10_error_handling_and_retry/sync.py index 24ee243db3..c134063c80 100644 --- a/sdk/python/examples/10_error_handling_and_retry/sync.py +++ b/sdk/python/examples/10_error_handling_and_retry/sync.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -35,13 +30,10 @@ with Codex(config=runtime_config()) as codex: ) except ServerBusyError as exc: print("Server overloaded after retries:", exc.message) - print("Text:") except JsonRpcError as exc: print(f"JSON-RPC error {exc.code}: {exc.message}") - print("Text:") else: - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) if result.status == TurnStatus.failed: print("Turn failed:", result.error) - print("Text:", assistant_text_from_turn(persisted_turn)) + else: + print("Text:", result.final_response) diff --git a/sdk/python/examples/11_cli_mini_app/async.py b/sdk/python/examples/11_cli_mini_app/async.py index 1b0a2cfd3a..df9fc89b86 100644 --- a/sdk/python/examples/11_cli_mini_app/async.py +++ b/sdk/python/examples/11_cli_mini_app/async.py @@ -21,19 +21,9 @@ from openai_codex.types import ( ) -def _status_value(status: object | None) -> str: - return str(getattr(status, "value", status)) - - -def _format_usage(usage: object | None) -> str: - if usage is None: - return "usage> (none)" - - last = getattr(usage, "last", None) - total = getattr(usage, "total", None) - if last is None or total is None: - return f"usage> {usage}" - +def _format_usage(usage: object) -> str: + last = usage.last + total = usage.total return ( "usage>\n" f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n" @@ -65,16 +55,14 @@ async def main() -> None: usage = None status = None error = None - printed_delta = False print("assistant> ", end="", flush=True) async for event in turn.stream(): payload = event.payload if event.method == "item/agentMessage/delta": - delta = getattr(payload, "delta", "") + delta = payload.delta if delta: print(delta, end="", flush=True) - printed_delta = True continue if isinstance(payload, ThreadTokenUsageUpdatedNotification): usage = payload.token_usage @@ -83,12 +71,13 @@ async def main() -> None: status = payload.turn.status error = payload.turn.error - if printed_delta: - print() - else: - print("[no text]") + print() + if status is None: + raise RuntimeError("stream ended without turn/completed") + if usage is None: + raise RuntimeError("stream ended without token usage") - status_text = _status_value(status) + status_text = status.value print(f"assistant.status> {status_text}") if status_text == "failed": print("assistant.error>", error) diff --git a/sdk/python/examples/11_cli_mini_app/sync.py b/sdk/python/examples/11_cli_mini_app/sync.py index dcb801d29c..fd16f930dc 100644 --- a/sdk/python/examples/11_cli_mini_app/sync.py +++ b/sdk/python/examples/11_cli_mini_app/sync.py @@ -21,19 +21,9 @@ from openai_codex.types import ( print("Codex mini CLI. Type /exit to quit.") -def _status_value(status: object | None) -> str: - return str(getattr(status, "value", status)) - - -def _format_usage(usage: object | None) -> str: - if usage is None: - return "usage> (none)" - - last = getattr(usage, "last", None) - total = getattr(usage, "total", None) - if last is None or total is None: - return f"usage> {usage}" - +def _format_usage(usage: object) -> str: + last = usage.last + total = usage.total return ( "usage>\n" f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n" @@ -60,16 +50,14 @@ with Codex(config=runtime_config()) as codex: usage = None status = None error = None - printed_delta = False print("assistant> ", end="", flush=True) for event in turn.stream(): payload = event.payload if event.method == "item/agentMessage/delta": - delta = getattr(payload, "delta", "") + delta = payload.delta if delta: print(delta, end="", flush=True) - printed_delta = True continue if isinstance(payload, ThreadTokenUsageUpdatedNotification): usage = payload.token_usage @@ -78,12 +66,13 @@ with Codex(config=runtime_config()) as codex: status = payload.turn.status error = payload.turn.error - if printed_delta: - print() - else: - print("[no text]") + print() + if status is None: + raise RuntimeError("stream ended without turn/completed") + if usage is None: + raise RuntimeError("stream ended without token usage") - status_text = _status_value(status) + status_text = status.value print(f"assistant.status> {status_text}") if status_text == "failed": print("assistant.error>", error) diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/async.py b/sdk/python/examples/12_turn_params_kitchen_sink/async.py index 30b72f3173..258d674a88 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/async.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/async.py @@ -6,12 +6,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -60,9 +55,7 @@ async def main() -> None: summary=SUMMARY, ) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) - structured_text = assistant_text_from_turn(persisted_turn).strip() + structured_text = result.final_response.strip() try: structured = json.loads(structured_text) except json.JSONDecodeError as exc: @@ -70,8 +63,8 @@ async def main() -> None: f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}" ) from exc - summary = structured.get("summary") - actions = structured.get("actions") + summary = structured["summary"] + actions = structured["actions"] if ( not isinstance(summary, str) or not isinstance(actions, list) @@ -86,7 +79,7 @@ async def main() -> None: print("actions:") for action in actions: print("-", action) - print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or [])) + print("Items:", len(result.items)) if __name__ == "__main__": diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py index dffe74a915..feced36f20 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py @@ -6,12 +6,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -54,9 +49,7 @@ with Codex(config=runtime_config()) as codex: summary=SUMMARY, ) result = turn.run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) - structured_text = assistant_text_from_turn(persisted_turn).strip() + structured_text = result.final_response.strip() try: structured = json.loads(structured_text) except json.JSONDecodeError as exc: @@ -64,8 +57,8 @@ with Codex(config=runtime_config()) as codex: f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}" ) from exc - summary = structured.get("summary") - actions = structured.get("actions") + summary = structured["summary"] + actions = structured["actions"] if ( not isinstance(summary, str) or not isinstance(actions, list) @@ -80,4 +73,4 @@ with Codex(config=runtime_config()) as codex: print("actions:") for action in actions: print("-", action) - print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or [])) + print("Items:", len(result.items)) diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py index f2810d8fd9..31a0c9c243 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/async.py +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -35,29 +30,27 @@ REASONING_RANK = { "high": 4, "xhigh": 5, } -PREFERRED_MODEL = "gpt-5.4" def _pick_highest_model(models): - visible = [m for m in models if not m.hidden] or models - preferred = next( - (m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None - ) - if preferred is not None: - return preferred + visible = [m for m in models if not m.hidden] + if not visible: + raise RuntimeError("models response did not include visible models") + known_names = {m.id for m in visible} | {m.model for m in visible} top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)] - pool = top_candidates or visible - return max(pool, key=lambda m: (m.model, m.id)) + if not top_candidates: + raise RuntimeError("models response did not include top-level visible models") + return max(top_candidates, key=lambda m: (m.model, m.id)) def _pick_highest_turn_effort(model) -> ReasoningEffort: if not model.supported_reasoning_efforts: - return ReasoningEffort.medium + raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts") best = max( model.supported_reasoning_efforts, - key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1), + key=lambda option: REASONING_RANK[option.reasoning_effort.value], ) return ReasoningEffort(best.reasoning_effort.value) @@ -103,13 +96,9 @@ async def main() -> None: effort=selected_effort, ) first = await first_turn.run() - persisted = await thread.read(include_turns=True) - first_persisted_turn = find_turn_by_id(persisted.thread.turns, first.id) - print("agent.message:", assistant_text_from_turn(first_persisted_turn)) - print( - "items:", 0 if first_persisted_turn is None else len(first_persisted_turn.items or []) - ) + print("agent.message:", first.final_response) + print("items:", len(first.items)) second_turn = await thread.turn( TextInput("Return JSON for a safe feature-flag rollout plan."), @@ -122,14 +111,9 @@ async def main() -> None: summary=ReasoningSummary.model_validate("concise"), ) second = await second_turn.run() - persisted = await thread.read(include_turns=True) - second_persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) - print("agent.message.params:", assistant_text_from_turn(second_persisted_turn)) - print( - "items.params:", - 0 if second_persisted_turn is None else len(second_persisted_turn.items or []), - ) + print("agent.message.params:", second.final_response) + print("items.params:", len(second.items)) if __name__ == "__main__": diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py index b1154ce171..9252f3913c 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/sync.py +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -5,12 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -33,29 +28,27 @@ REASONING_RANK = { "high": 4, "xhigh": 5, } -PREFERRED_MODEL = "gpt-5.4" def _pick_highest_model(models): - visible = [m for m in models if not m.hidden] or models - preferred = next( - (m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None - ) - if preferred is not None: - return preferred + visible = [m for m in models if not m.hidden] + if not visible: + raise RuntimeError("models response did not include visible models") + known_names = {m.id for m in visible} | {m.model for m in visible} top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)] - pool = top_candidates or visible - return max(pool, key=lambda m: (m.model, m.id)) + if not top_candidates: + raise RuntimeError("models response did not include top-level visible models") + return max(top_candidates, key=lambda m: (m.model, m.id)) def _pick_highest_turn_effort(model) -> ReasoningEffort: if not model.supported_reasoning_efforts: - return ReasoningEffort.medium + raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts") best = max( model.supported_reasoning_efforts, - key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1), + key=lambda option: REASONING_RANK[option.reasoning_effort.value], ) return ReasoningEffort(best.reasoning_effort.value) @@ -99,11 +92,9 @@ with Codex(config=runtime_config()) as codex: model=selected_model.model, effort=selected_effort, ).run() - persisted = thread.read(include_turns=True) - first_turn = find_turn_by_id(persisted.thread.turns, first.id) - print("agent.message:", assistant_text_from_turn(first_turn)) - print("items:", 0 if first_turn is None else len(first_turn.items or [])) + print("agent.message:", first.final_response) + print("items:", len(first.items)) second = thread.turn( TextInput("Return JSON for a safe feature-flag rollout plan."), @@ -115,8 +106,6 @@ with Codex(config=runtime_config()) as codex: sandbox_policy=SANDBOX_POLICY, summary=ReasoningSummary.model_validate("concise"), ).run() - persisted = thread.read(include_turns=True) - second_turn = find_turn_by_id(persisted.thread.turns, second.id) - print("agent.message.params:", assistant_text_from_turn(second_turn)) - print("items.params:", 0 if second_turn is None else len(second_turn.items or [])) + print("agent.message.params:", second.final_response) + print("items.params:", len(second.items)) diff --git a/sdk/python/examples/14_turn_controls/async.py b/sdk/python/examples/14_turn_controls/async.py index 4f2777c2e4..f044dadbbe 100644 --- a/sdk/python/examples/14_turn_controls/async.py +++ b/sdk/python/examples/14_turn_controls/async.py @@ -5,11 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -26,56 +22,48 @@ async def main() -> None: steer_turn = await thread.turn( TextInput("Count from 1 to 40 with commas, then one summary sentence.") ) - steer_result = "sent" - try: - _ = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) - except Exception as exc: - steer_result = f"skipped {type(exc).__name__}" + steer_result = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) steer_event_count = 0 - steer_completed_status = "unknown" - steer_completed_turn = None + steer_completed_status = None + steer_deltas = [] async for event in steer_turn.stream(): steer_event_count += 1 + if event.method == "item/agentMessage/delta": + steer_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - steer_completed_turn = event.payload.turn - steer_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + steer_completed_status = event.payload.turn.status.value - steer_preview = ( - assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]" - ) + if steer_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + steer_preview = "".join(steer_deltas).strip() interrupt_turn = await thread.turn( TextInput("Count from 1 to 200 with commas, then one summary sentence.") ) - interrupt_result = "sent" - try: - _ = await interrupt_turn.interrupt() - except Exception as exc: - interrupt_result = f"skipped {type(exc).__name__}" + interrupt_result = await interrupt_turn.interrupt() interrupt_event_count = 0 - interrupt_completed_status = "unknown" - interrupt_completed_turn = None + interrupt_completed_status = None + interrupt_deltas = [] async for event in interrupt_turn.stream(): interrupt_event_count += 1 + if event.method == "item/agentMessage/delta": + interrupt_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - interrupt_completed_turn = event.payload.turn - interrupt_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + interrupt_completed_status = event.payload.turn.status.value - interrupt_preview = ( - assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]" - ) + if interrupt_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + interrupt_preview = "".join(interrupt_deltas).strip() - print("steer.result:", steer_result) + print("steer.result:", steer_result.model_dump(mode="json", by_alias=True)) print("steer.final.status:", steer_completed_status) print("steer.events.count:", steer_event_count) print("steer.assistant.preview:", steer_preview) - print("interrupt.result:", interrupt_result) + print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True)) print("interrupt.final.status:", interrupt_completed_status) print("interrupt.events.count:", interrupt_event_count) print("interrupt.assistant.preview:", interrupt_preview) diff --git a/sdk/python/examples/14_turn_controls/sync.py b/sdk/python/examples/14_turn_controls/sync.py index 03180ba8eb..6b6d046a43 100644 --- a/sdk/python/examples/14_turn_controls/sync.py +++ b/sdk/python/examples/14_turn_controls/sync.py @@ -5,11 +5,7 @@ _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 ( - assistant_text_from_turn, - ensure_local_sdk_src, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -20,54 +16,48 @@ with Codex(config=runtime_config()) as codex: steer_turn = thread.turn( TextInput("Count from 1 to 40 with commas, then one summary sentence.") ) - steer_result = "sent" - try: - _ = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) - except Exception as exc: - steer_result = f"skipped {type(exc).__name__}" + steer_result = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) steer_event_count = 0 - steer_completed_status = "unknown" - steer_completed_turn = None + steer_completed_status = None + steer_deltas = [] for event in steer_turn.stream(): steer_event_count += 1 + if event.method == "item/agentMessage/delta": + steer_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - steer_completed_turn = event.payload.turn - steer_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + steer_completed_status = event.payload.turn.status.value - steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]" + if steer_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + steer_preview = "".join(steer_deltas).strip() interrupt_turn = thread.turn( TextInput("Count from 1 to 200 with commas, then one summary sentence.") ) - interrupt_result = "sent" - try: - _ = interrupt_turn.interrupt() - except Exception as exc: - interrupt_result = f"skipped {type(exc).__name__}" + interrupt_result = interrupt_turn.interrupt() interrupt_event_count = 0 - interrupt_completed_status = "unknown" - interrupt_completed_turn = None + interrupt_completed_status = None + interrupt_deltas = [] for event in interrupt_turn.stream(): interrupt_event_count += 1 + if event.method == "item/agentMessage/delta": + interrupt_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - interrupt_completed_turn = event.payload.turn - interrupt_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + interrupt_completed_status = event.payload.turn.status.value - interrupt_preview = ( - assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]" - ) + if interrupt_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + interrupt_preview = "".join(interrupt_deltas).strip() - print("steer.result:", steer_result) + print("steer.result:", steer_result.model_dump(mode="json", by_alias=True)) print("steer.final.status:", steer_completed_status) print("steer.events.count:", steer_event_count) print("steer.assistant.preview:", steer_preview) - print("interrupt.result:", interrupt_result) + print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True)) print("interrupt.final.status:", interrupt_completed_status) print("interrupt.events.count:", interrupt_event_count) print("interrupt.assistant.preview:", interrupt_preview) diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 569a51f477..bef1a16c0b 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -81,6 +81,6 @@ python examples/01_quickstart_constructor/async.py - `13_model_select_and_turn_params/` - list models, pick highest model + highest supported reasoning effort, run turns, print message and usage - `14_turn_controls/` - - separate best-effort `steer()` and `interrupt()` demos with concise summaries + - separate `steer()` and `interrupt()` demos with concise summaries - `15_login_and_account/` - browser-login handle lifecycle, cancellation, and account inspection diff --git a/sdk/python/examples/_bootstrap.py b/sdk/python/examples/_bootstrap.py index af115119ac..92fe18e26a 100644 --- a/sdk/python/examples/_bootstrap.py +++ b/sdk/python/examples/_bootstrap.py @@ -6,7 +6,7 @@ import sys import tempfile import zlib from pathlib import Path -from typing import Iterable, Iterator +from typing import Any, Iterator _SDK_PYTHON_DIR = Path(__file__).resolve().parents[1] _SDK_PYTHON_STR = str(_SDK_PYTHON_DIR) @@ -103,53 +103,5 @@ def temporary_sample_image_path() -> Iterator[Path]: yield image_path -def server_label(metadata: object) -> str: - server = getattr(metadata, "serverInfo", None) - server_name = ((getattr(server, "name", None) or "") if server is not None else "").strip() - server_version = ( - (getattr(server, "version", None) or "") if server is not None else "" - ).strip() - if server_name and server_version: - return f"{server_name} {server_version}" - - user_agent = ( - (getattr(metadata, "userAgent", None) or "") if metadata is not None else "" - ).strip() - return user_agent or "unknown" - - -def find_turn_by_id(turns: Iterable[object] | None, turn_id: str) -> object | None: - for turn in turns or []: - if getattr(turn, "id", None) == turn_id: - return turn - return None - - -def assistant_text_from_turn(turn: object | None) -> str: - if turn is None: - return "" - - chunks: list[str] = [] - for item in getattr(turn, "items", []) or []: - raw_item = item.model_dump(mode="json") if hasattr(item, "model_dump") else item - if not isinstance(raw_item, dict): - continue - - item_type = raw_item.get("type") - if item_type == "agentMessage": - text = raw_item.get("text") - if isinstance(text, str) and text: - chunks.append(text) - continue - - if item_type != "message" or raw_item.get("role") != "assistant": - continue - - for content in raw_item.get("content") or []: - if not isinstance(content, dict) or content.get("type") != "output_text": - continue - text = content.get("text") - if isinstance(text, str) and text: - chunks.append(text) - - return "".join(chunks) +def server_label(metadata: Any) -> str: + return f"{metadata.serverInfo.name} {metadata.serverInfo.version}" diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index e51c217dbd..1eb80b3a71 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -25,23 +25,10 @@ " f'Notebook requires Python 3.10+; current interpreter is {sys.version.split()[0]}.'\n", " )\n", "\n", - "try:\n", - " _ = os.getcwd()\n", - "except FileNotFoundError:\n", - " os.chdir(str(Path.home()))\n", - "\n", - "\n", "def _is_sdk_python_dir(path: Path) -> bool:\n", " return (path / 'pyproject.toml').exists() and (path / 'src' / 'openai_codex').exists()\n", "\n", "\n", - "def _iter_home_fallback_candidates(home: Path):\n", - " # bounded depth scan under home to support launching notebooks from unrelated cwd values\n", - " patterns = ('sdk/python', '*/sdk/python', '*/*/sdk/python', '*/*/*/sdk/python')\n", - " for pattern in patterns:\n", - " yield from home.glob(pattern)\n", - "\n", - "\n", "def _find_sdk_python_dir(start: Path) -> Path | None:\n", " checked = set()\n", "\n", @@ -70,21 +57,6 @@ " if found is not None:\n", " return found\n", "\n", - " for entry in sys.path:\n", - " if not entry:\n", - " continue\n", - " entry_path = Path(entry).expanduser()\n", - " for candidate in (entry_path, entry_path / 'sdk' / 'python'):\n", - " found = _consider(candidate)\n", - " if found is not None:\n", - " return found\n", - "\n", - " home = Path.home()\n", - " for candidate in _iter_home_fallback_candidates(home):\n", - " found = _consider(candidate)\n", - " if found is not None:\n", - " return found\n", - "\n", " return None\n", "\n", "\n", @@ -129,7 +101,7 @@ "outputs": [], "source": [ "# Cell 2: imports (public only)\n", - "from _bootstrap import assistant_text_from_turn, find_turn_by_id, server_label\n", + "from _bootstrap import server_label\n", "from openai_codex import (\n", " AsyncCodex,\n", " Codex,\n", @@ -172,12 +144,10 @@ " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " turn = thread.turn(TextInput('Explain gradient descent in 3 bullets.'))\n", " result = turn.run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('server:', server_label(codex.metadata))\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n", + " print('items:', len(result.items))\n" ] }, { @@ -192,12 +162,9 @@ "\n", " first = thread.turn(TextInput('Give a short summary of transformers.')).run()\n", " second = thread.turn(TextInput('Now explain that to a high-school student.')).run()\n", - " persisted = thread.read(include_turns=True)\n", - " second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n", - "\n", " print('first status:', first.status)\n", " print('second status:', second.status)\n", - " print('second text:', assistant_text_from_turn(second_turn))\n" + " print('second text:', second.final_response)\n" ] }, { @@ -221,41 +188,27 @@ " listing_archived = codex.thread_list(limit=20, archived=True)\n", " unarchived = codex.thread_unarchive(reopened.id)\n", "\n", - " resumed_info = 'n/a'\n", - " try:\n", - " resumed = codex.thread_resume(\n", - " unarchived.id,\n", - " model='gpt-5.4',\n", - " config={'model_reasoning_effort': 'high'},\n", - " )\n", - " resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n", - " resumed_info = f'{resumed_result.id} {resumed_result.status}'\n", - " except Exception as e:\n", - " resumed_info = f'skipped({type(e).__name__})'\n", + " resumed = codex.thread_resume(\n", + " unarchived.id,\n", + " model='gpt-5.4',\n", + " config={'model_reasoning_effort': 'high'},\n", + " )\n", + " resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n", "\n", - " forked_info = 'n/a'\n", - " try:\n", - " forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n", - " forked_info = f'{forked_result.id} {forked_result.status}'\n", - " except Exception as e:\n", - " forked_info = f'skipped({type(e).__name__})'\n", + " forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n", + " forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n", "\n", - " compact_info = 'sent'\n", - " try:\n", - " _ = unarchived.compact()\n", - " except Exception as e:\n", - " compact_info = f'skipped({type(e).__name__})'\n", + " compact_result = unarchived.compact()\n", "\n", " print('Lifecycle OK:', thread.id)\n", " print('first:', first.id, first.status)\n", " print('second:', second.id, second.status)\n", - " print('read.turns:', len(reading.thread.turns or []))\n", + " print('read.turns:', len(reading.thread.turns))\n", " print('list.active:', len(listing_active.data))\n", " print('list.archived:', len(listing_archived.data))\n", - " print('resumed:', resumed_info)\n", - " print('forked:', forked_info)\n", - " print('compact:', compact_info)\n" + " print('resumed:', resumed_result.id, resumed_result.status)\n", + " print('forked:', forked_result.id, forked_result.status)\n", + " print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n" ] }, { @@ -299,11 +252,8 @@ " summary=summary,\n", " )\n", " result = turn.run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -332,17 +282,20 @@ "\n", "\n", "def pick_highest_model(models):\n", - " visible = [m for m in models if not m.hidden] or models\n", + " visible = [m for m in models if not m.hidden]\n", + " if not visible:\n", + " raise RuntimeError('models response did not include visible models')\n", " known_names = {m.id for m in visible} | {m.model for m in visible}\n", " top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]\n", - " pool = top_candidates or visible\n", - " return max(pool, key=lambda m: (m.model, m.id))\n", + " if not top_candidates:\n", + " raise RuntimeError('models response did not include top-level visible models')\n", + " return max(top_candidates, key=lambda m: (m.model, m.id))\n", "\n", "\n", "def pick_highest_turn_effort(model) -> ReasoningEffort:\n", " if not model.supported_reasoning_efforts:\n", - " return ReasoningEffort.medium\n", - " best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank.get(opt.reasoning_effort.value, -1))\n", + " raise RuntimeError(f'{model.model} did not advertise supported reasoning efforts')\n", + " best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank[opt.reasoning_effort.value])\n", " return ReasoningEffort(best.reasoning_effort.value)\n", "\n", "\n", @@ -372,10 +325,8 @@ " model=selected_model.model,\n", " effort=selected_effort,\n", " ).run()\n", - " persisted = thread.read(include_turns=True)\n", - " first_turn = find_turn_by_id(persisted.thread.turns, first.id)\n", - " print('agent.message:', assistant_text_from_turn(first_turn))\n", - " print('items:', 0 if first_turn is None else len(first_turn.items or []))\n", + " print('agent.message:', first.final_response)\n", + " print('items:', len(first.items))\n", "\n", " second = thread.turn(\n", " TextInput('Return JSON for a safe feature-flag rollout plan.'),\n", @@ -387,10 +338,8 @@ " sandbox_policy=sandbox_policy,\n", " summary=ReasoningSummary.model_validate('concise'),\n", " ).run()\n", - " persisted = thread.read(include_turns=True)\n", - " second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n", - " print('agent.message.params:', assistant_text_from_turn(second_turn))\n", - " print('items.params:', 0 if second_turn is None else len(second_turn.items or []))\n" + " print('agent.message.params:', second.final_response)\n", + " print('items.params:', len(second.items))\n" ] }, { @@ -408,11 +357,8 @@ " TextInput('What do you see in this image? 3 bullets.'),\n", " ImageInput(remote_image_url),\n", " ]).run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -429,11 +375,8 @@ " TextInput('Describe the colors and layout in this generated local image in 2 bullets.'),\n", " LocalImageInput(str(local_image_path.resolve())),\n", " ]).run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -452,11 +395,8 @@ " initial_delay_s=0.25,\n", " max_delay_s=2.0,\n", " )\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -484,41 +424,27 @@ " listing_archived = await codex.thread_list(limit=20, archived=True)\n", " unarchived = await codex.thread_unarchive(reopened.id)\n", "\n", - " resumed_info = 'n/a'\n", - " try:\n", - " resumed = await codex.thread_resume(\n", - " unarchived.id,\n", - " model='gpt-5.4',\n", - " config={'model_reasoning_effort': 'high'},\n", - " )\n", - " resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n", - " resumed_info = f'{resumed_result.id} {resumed_result.status}'\n", - " except Exception as e:\n", - " resumed_info = f'skipped({type(e).__name__})'\n", + " resumed = await codex.thread_resume(\n", + " unarchived.id,\n", + " model='gpt-5.4',\n", + " config={'model_reasoning_effort': 'high'},\n", + " )\n", + " resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n", "\n", - " forked_info = 'n/a'\n", - " try:\n", - " forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n", - " forked_info = f'{forked_result.id} {forked_result.status}'\n", - " except Exception as e:\n", - " forked_info = f'skipped({type(e).__name__})'\n", + " forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n", + " forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n", "\n", - " compact_info = 'sent'\n", - " try:\n", - " _ = await unarchived.compact()\n", - " except Exception as e:\n", - " compact_info = f'skipped({type(e).__name__})'\n", + " compact_result = await unarchived.compact()\n", "\n", " print('Lifecycle OK:', thread.id)\n", " print('first:', first.id, first.status)\n", " print('second:', second.id, second.status)\n", - " print('read.turns:', len(reading.thread.turns or []))\n", + " print('read.turns:', len(reading.thread.turns))\n", " print('list.active:', len(listing_active.data))\n", " print('list.archived:', len(listing_archived.data))\n", - " print('resumed:', resumed_info)\n", - " print('forked:', forked_info)\n", - " print('compact:', compact_info)\n", + " print('resumed:', resumed_result.id, resumed_result.status)\n", + " print('forked:', forked_result.id, forked_result.status)\n", + " print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n", "\n", "\n", "await async_lifecycle_demo()\n" @@ -530,7 +456,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Cell 10: async turn controls (best effort steer + interrupt)\n", + "# Cell 10: async turn controls (steer + interrupt)\n", "import asyncio\n", "\n", "\n", @@ -539,46 +465,46 @@ " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " steer_turn = await thread.turn(TextInput('Count from 1 to 40 with commas, then one summary sentence.'))\n", "\n", - " steer_result = 'sent'\n", - " try:\n", - " _ = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", - " except Exception as e:\n", - " steer_result = f'skipped {type(e).__name__}'\n", + " steer_result = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", "\n", " steer_event_count = 0\n", - " steer_completed_status = 'unknown'\n", - " steer_completed_turn = None\n", + " steer_completed_status = None\n", + " steer_deltas = []\n", " async for event in steer_turn.stream():\n", " steer_event_count += 1\n", + " if event.method == 'item/agentMessage/delta':\n", + " steer_deltas.append(event.payload.delta)\n", + " continue\n", " if event.method == 'turn/completed':\n", - " steer_completed_turn = event.payload.turn\n", - " steer_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n", + " steer_completed_status = event.payload.turn.status.value\n", "\n", - " steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or '[no assistant text]'\n", + " if steer_completed_status is None:\n", + " raise RuntimeError('stream ended without turn/completed')\n", + " steer_preview = ''.join(steer_deltas).strip()\n", "\n", " interrupt_turn = await thread.turn(TextInput('Count from 1 to 200 with commas, then one summary sentence.'))\n", - " interrupt_result = 'sent'\n", - " try:\n", - " _ = await interrupt_turn.interrupt()\n", - " except Exception as e:\n", - " interrupt_result = f'skipped {type(e).__name__}'\n", + " interrupt_result = await interrupt_turn.interrupt()\n", "\n", " interrupt_event_count = 0\n", - " interrupt_completed_status = 'unknown'\n", - " interrupt_completed_turn = None\n", + " interrupt_completed_status = None\n", + " interrupt_deltas = []\n", " async for event in interrupt_turn.stream():\n", " interrupt_event_count += 1\n", + " if event.method == 'item/agentMessage/delta':\n", + " interrupt_deltas.append(event.payload.delta)\n", + " continue\n", " if event.method == 'turn/completed':\n", - " interrupt_completed_turn = event.payload.turn\n", - " interrupt_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n", + " interrupt_completed_status = event.payload.turn.status.value\n", "\n", - " interrupt_preview = assistant_text_from_turn(interrupt_completed_turn).strip() or '[no assistant text]'\n", + " if interrupt_completed_status is None:\n", + " raise RuntimeError('stream ended without turn/completed')\n", + " interrupt_preview = ''.join(interrupt_deltas).strip()\n", "\n", - " print('steer.result:', steer_result)\n", + " print('steer.result:', steer_result.model_dump(mode='json', by_alias=True))\n", " print('steer.final.status:', steer_completed_status)\n", " print('steer.events.count:', steer_event_count)\n", " print('steer.assistant.preview:', steer_preview)\n", - " print('interrupt.result:', interrupt_result)\n", + " print('interrupt.result:', interrupt_result.model_dump(mode='json', by_alias=True))\n", " print('interrupt.final.status:', interrupt_completed_status)\n", " print('interrupt.events.count:', interrupt_event_count)\n", " print('interrupt.assistant.preview:', interrupt_preview)\n", diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index 8c8dcc3eb2..b3db7e3175 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -14,11 +14,11 @@ from .api import ( InputItem, LocalImageInput, MentionInput, - RunResult, SkillInput, TextInput, Thread, TurnHandle, + TurnResult, ) from .client import AppServerConfig from .errors import ( @@ -51,7 +51,7 @@ __all__ = [ "AsyncThread", "TurnHandle", "AsyncTurnHandle", - "RunResult", + "TurnResult", "Input", "InputItem", "TextInput", diff --git a/sdk/python/src/openai_codex/_run.py b/sdk/python/src/openai_codex/_run.py index 73ec362460..ac9fa5ed38 100644 --- a/sdk/python/src/openai_codex/_run.py +++ b/sdk/python/src/openai_codex/_run.py @@ -12,13 +12,20 @@ from .generated.v2_all import ( ThreadTokenUsageUpdatedNotification, Turn as AppServerTurn, TurnCompletedNotification, + TurnError, TurnStatus, ) from .models import Notification @dataclass(slots=True) -class RunResult: +class TurnResult: + id: str + status: TurnStatus + error: TurnError | None + started_at: int | None + completed_at: int | None + duration_ms: int | None final_response: str | None items: list[ThreadItem] usage: ThreadTokenUsage | None @@ -56,7 +63,7 @@ def _raise_for_failed_turn(turn: AppServerTurn) -> None: raise RuntimeError(f"turn failed with status {turn.status.value}") -def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunResult: +def _collect_turn_result(stream: Iterator[Notification], *, turn_id: str) -> TurnResult: completed: TurnCompletedNotification | None = None items: list[ThreadItem] = [] usage: ThreadTokenUsage | None = None @@ -76,16 +83,23 @@ def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunR raise RuntimeError("turn completed event not received") _raise_for_failed_turn(completed.turn) - return RunResult( + turn = completed.turn + return TurnResult( + id=turn.id, + status=turn.status, + error=turn.error, + started_at=turn.started_at, + completed_at=turn.completed_at, + duration_ms=turn.duration_ms, final_response=_final_assistant_response_from_items(items), items=items, usage=usage, ) -async def _collect_async_run_result( +async def _collect_async_turn_result( stream: AsyncIterator[Notification], *, turn_id: str -) -> RunResult: +) -> TurnResult: completed: TurnCompletedNotification | None = None items: list[ThreadItem] = [] usage: ThreadTokenUsage | None = None @@ -105,7 +119,14 @@ async def _collect_async_run_result( raise RuntimeError("turn completed event not received") _raise_for_failed_turn(completed.turn) - return RunResult( + turn = completed.turn + return TurnResult( + id=turn.id, + status=turn.status, + error=turn.error, + started_at=turn.started_at, + completed_at=turn.completed_at, + duration_ms=turn.duration_ms, final_response=_final_assistant_response_from_items(items), items=items, usage=usage, diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index fe9b5bc67f..347f95eeae 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -33,9 +33,9 @@ from ._login import ( start_device_code_login, ) from ._run import ( - RunResult, - _collect_async_run_result, - _collect_run_result, + TurnResult, + _collect_async_turn_result, + _collect_turn_result, ) from .async_client import AsyncAppServerClient from .client import AppServerClient, AppServerConfig @@ -65,7 +65,6 @@ from .generated.v2_all import ( ThreadSourceKind, ThreadStartParams, ThreadStartSource, - Turn as AppServerTurn, TurnCompletedNotification, TurnInterruptResponse, TurnStartParams, @@ -533,7 +532,7 @@ class Thread: sandbox_policy: SandboxPolicy | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, - ) -> RunResult: + ) -> TurnResult: turn = self.turn( _normalize_run_input(input), approval_mode=approval_mode, @@ -548,7 +547,7 @@ class Thread: ) stream = turn.stream() try: - return _collect_run_result(stream, turn_id=turn.id) + return _collect_turn_result(stream, turn_id=turn.id) finally: stream.close() @@ -616,7 +615,7 @@ class AsyncThread: sandbox_policy: SandboxPolicy | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, - ) -> RunResult: + ) -> TurnResult: turn = await self.turn( _normalize_run_input(input), approval_mode=approval_mode, @@ -631,7 +630,7 @@ class AsyncThread: ) stream = turn.stream() try: - return await _collect_async_run_result(stream, turn_id=turn.id) + return await _collect_async_turn_result(stream, turn_id=turn.id) finally: await stream.aclose() @@ -717,21 +716,13 @@ class TurnHandle: finally: self._client.unregister_turn_notifications(self.id) - def run(self) -> AppServerTurn: - completed: TurnCompletedNotification | None = None + def run(self) -> TurnResult: stream = self.stream() try: - for event in stream: - payload = event.payload - if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id: - completed = payload + return _collect_turn_result(stream, turn_id=self.id) finally: stream.close() - if completed is None: - raise RuntimeError("turn completed event not received") - return completed.turn - @dataclass(slots=True) class AsyncTurnHandle: @@ -768,17 +759,9 @@ class AsyncTurnHandle: finally: self._codex._client.unregister_turn_notifications(self.id) - async def run(self) -> AppServerTurn: - completed: TurnCompletedNotification | None = None + async def run(self) -> TurnResult: stream = self.stream() try: - async for event in stream: - payload = event.payload - if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id: - completed = payload + return await _collect_async_turn_result(stream, turn_id=self.id) finally: await stream.aclose() - - if completed is None: - raise RuntimeError("turn completed event not received") - return completed.turn diff --git a/sdk/python/src/openai_codex/types.py b/sdk/python/src/openai_codex/types.py index 26ffdbb3ef..f5bbbc86c3 100644 --- a/sdk/python/src/openai_codex/types.py +++ b/sdk/python/src/openai_codex/types.py @@ -33,6 +33,7 @@ from .generated.v2_all import ( ThreadTokenUsageUpdatedNotification, Turn, TurnCompletedNotification, + TurnError, TurnInterruptResponse, TurnStatus, TurnSteerResponse, @@ -73,6 +74,7 @@ __all__ = [ "ThreadTokenUsageUpdatedNotification", "Turn", "TurnCompletedNotification", + "TurnError", "TurnInterruptResponse", "TurnStatus", "TurnSteerResponse", diff --git a/sdk/python/tests/app_server_helpers.py b/sdk/python/tests/app_server_helpers.py index 10db00901c..af96a02e80 100644 --- a/sdk/python/tests/app_server_helpers.py +++ b/sdk/python/tests/app_server_helpers.py @@ -111,7 +111,7 @@ def agent_message_texts(events: list[Notification]) -> list[str]: def agent_message_texts_from_items(items: Iterable[Any]) -> list[str]: - """Extract agent-message text from completed run result items.""" + """Extract agent-message text from completed turn result items.""" texts: list[str] = [] for item in items: root = item.root diff --git a/sdk/python/tests/test_app_server_lifecycle.py b/sdk/python/tests/test_app_server_lifecycle.py index 77ba269450..9636c708c2 100644 --- a/sdk/python/tests/test_app_server_lifecycle.py +++ b/sdk/python/tests/test_app_server_lifecycle.py @@ -157,7 +157,7 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None: async with AsyncCodex(config=harness.app_server_config()) as codex: thread = await codex.thread_start() - run_result = await thread.run("materialize async thread") + turn_result = await thread.run("materialize async thread") await thread.set_name("async lifecycle") named = await thread.read() resumed = await codex.thread_resume(thread.id) @@ -166,14 +166,14 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None: unarchived = await codex.thread_unarchive(thread.id) assert { - "run_final_response": run_result.final_response, + "turn_final_response": turn_result.final_response, "named_thread": named.thread.name, "resumed_id": resumed.id, "forked_is_distinct": forked.id != thread.id, "archive_response": archive_response.model_dump(by_alias=True, mode="json"), "unarchived_id": unarchived.id, } == { - "run_final_response": "async materialized", + "turn_final_response": "async materialized", "named_thread": "async lifecycle", "resumed_id": thread.id, "forked_is_distinct": True, @@ -253,19 +253,19 @@ def test_compact_rpc_hits_mock_responses(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - run_result = thread.run("create history") + turn_result = thread.run("create history") compact_response = thread.compact() requests = harness.responses.wait_for_requests(2) assert { - "run_final_response": run_result.final_response, + "turn_final_response": turn_result.final_response, "compact_response": compact_response.model_dump( by_alias=True, mode="json", ), "request_kinds": [request_kind(request.path) for request in requests], } == { - "run_final_response": "history", + "turn_final_response": "history", "compact_response": {}, "request_kinds": ["responses", "responses"], } diff --git a/sdk/python/tests/test_app_server_run.py b/sdk/python/tests/test_app_server_run.py index a0ac3eca16..3f741e6ec6 100644 --- a/sdk/python/tests/test_app_server_run.py +++ b/sdk/python/tests/test_app_server_run.py @@ -145,8 +145,8 @@ def test_async_thread_run_uses_mock_responses( asyncio.run(scenario()) -def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None: - """RunResult should use the last unknown-phase agent message as final text.""" +def test_sync_turn_result_uses_last_unknown_phase_message(tmp_path) -> None: + """TurnResult should use the last unknown-phase agent message as final text.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( @@ -171,8 +171,8 @@ def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None: } -def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None: - """RunResult should preserve an empty final agent message instead of skipping it.""" +def test_sync_turn_result_preserves_empty_last_message(tmp_path) -> None: + """TurnResult should preserve an empty final agent message instead of skipping it.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( @@ -197,8 +197,8 @@ def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None: } -def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) -> None: - """RunResult final_response should stay unset when app-server marks only commentary.""" +def test_sync_turn_result_does_not_promote_commentary_only_to_final(tmp_path) -> None: + """TurnResult final_response should stay unset when app-server marks only commentary.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( @@ -226,8 +226,8 @@ def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) -> } -def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None: - """Async RunResult should use the last unknown-phase agent message.""" +def test_async_turn_result_uses_last_unknown_phase_message(tmp_path) -> None: + """Async TurnResult should use the last unknown-phase agent message.""" async def scenario() -> None: """Run one async result-mapping case against a pinned app-server.""" @@ -263,10 +263,10 @@ def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None: asyncio.run(scenario()) -def test_async_run_result_does_not_promote_commentary_only_to_final( +def test_async_turn_result_does_not_promote_commentary_only_to_final( tmp_path, ) -> None: - """Async RunResult final_response should stay unset for commentary-only output.""" + """Async TurnResult final_response should stay unset for commentary-only output.""" async def scenario() -> None: """Run one async commentary mapping case against a pinned app-server.""" @@ -318,7 +318,7 @@ def test_thread_run_raises_when_real_app_server_reports_failed_turn(tmp_path) -> def test_final_answer_phase_survives_real_app_server_mapping(tmp_path) -> None: - """RunResult should use the final-answer item emitted by app-server.""" + """TurnResult should use the final-answer item emitted by app-server.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( diff --git a/sdk/python/tests/test_app_server_streaming.py b/sdk/python/tests/test_app_server_streaming.py index ae217adb6a..02d089f2f1 100644 --- a/sdk/python/tests/test_app_server_streaming.py +++ b/sdk/python/tests/test_app_server_streaming.py @@ -5,6 +5,7 @@ import asyncio from app_server_harness import AppServerHarness from app_server_helpers import ( agent_message_texts, + agent_message_texts_from_items, next_async_delta, next_sync_delta, streaming_response, @@ -48,7 +49,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: def test_turn_run_returns_completed_turn(tmp_path) -> None: - """TurnHandle.run should wait for the app-server completion notification.""" + """TurnHandle.run should collect output and completion metadata.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_assistant_message("turn complete", response_id="turn-run-1") @@ -60,11 +61,13 @@ def test_turn_run_returns_completed_turn(tmp_path) -> None: assert { "turn_id": completed.id, "status": completed.status, - "items": completed.items, + "agent_messages": agent_message_texts_from_items(completed.items), + "final_response": completed.final_response, } == { "turn_id": turn.id, "status": TurnStatus.completed, - "items": [], + "agent_messages": ["turn complete"], + "final_response": "turn complete", } diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index 04eb18e4cb..c7a00d3897 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -14,9 +14,11 @@ from openai_codex import ( AppServerConfig, AsyncCodex, AsyncThread, + AsyncTurnHandle, Codex, - RunResult, Thread, + TurnHandle, + TurnResult, ) from openai_codex._initialize_metadata import validate_initialize_metadata from openai_codex.types import InitializeResponse @@ -35,7 +37,7 @@ EXPECTED_ROOT_EXPORTS = [ "AsyncThread", "TurnHandle", "AsyncTurnHandle", - "RunResult", + "TurnResult", "Input", "InputItem", "TextInput", @@ -92,6 +94,7 @@ EXPECTED_TYPES_EXPORTS = [ "ThreadTokenUsageUpdatedNotification", "Turn", "TurnCompletedNotification", + "TurnError", "TurnInterruptResponse", "TurnStatus", "TurnSteerResponse", @@ -128,9 +131,39 @@ def test_root_exports_app_server_config() -> None: assert AppServerConfig.__name__ == "AppServerConfig" -def test_root_exports_run_result() -> None: - """The root package should expose the common-case run result wrapper.""" - assert RunResult.__name__ == "RunResult" +def test_root_exports_turn_result() -> None: + """The root package should expose the collected turn result wrapper.""" + assert { + "name": TurnResult.__name__, + "fields": list(TurnResult.__dataclass_fields__), + } == { + "name": "TurnResult", + "fields": [ + "id", + "status", + "error", + "started_at", + "completed_at", + "duration_ms", + "final_response", + "items", + "usage", + ], + } + + +def test_turn_run_methods_return_turn_result() -> None: + """Both convenience and handle-based run APIs return the same result shape.""" + funcs = [ + Thread.run, + TurnHandle.run, + AsyncThread.run, + AsyncTurnHandle.run, + ] + + assert {fn: inspect.signature(fn).return_annotation for fn in funcs} == dict.fromkeys( + funcs, "TurnResult" + ) def test_root_exports_approval_mode() -> None: diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 0e688ff3c6..08f07c986c 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -135,7 +135,7 @@ def _run_python( ) -def _runtime_compatibility_hint( +def _runtime_schema_hint( runtime_env: PreparedRuntimeEnv, *, stdout: str, @@ -144,7 +144,7 @@ def _runtime_compatibility_hint( combined = f"{stdout}\n{stderr}" if "ThreadStartResponse" in combined and "approvalsReviewer" in combined: return ( - "\nCompatibility hint:\n" + "\nSchema hint:\n" f"Pinned runtime {runtime_env.runtime_version} returned a thread/start payload " "that is older than the current SDK schema and is missing " "`approvalsReviewer`. Bump `sdk/python/_runtime_setup.py` to a matching " @@ -165,7 +165,7 @@ def _run_json_python( "Python snippet failed.\n" f"STDOUT:\n{result.stdout}\n" f"STDERR:\n{result.stderr}" - f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" + f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" ) return json.loads(result.stdout) @@ -242,17 +242,12 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No config={"model_reasoning_effort": "high"}, ) result = thread.turn(TextInput("hello")).run() - persisted = thread.read(include_turns=True) - persisted_turn = next( - (turn for turn in persisted.thread.turns or [] if turn.id == result.id), - None, - ) print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, "status": result.status.value, - "items_count": len(result.items or []), - "persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []), + "items_count": len(result.items), + "final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()), })) """ ), @@ -261,8 +256,8 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No assert isinstance(data["thread_id"], str) and data["thread_id"].strip() assert isinstance(data["turn_id"], str) and data["turn_id"].strip() assert data["status"] == "completed" - assert isinstance(data["items_count"], int) - assert isinstance(data["persisted_items_count"], int) + assert data["items_count"] > 0 + assert data["final_response_is_text"] is True def test_real_thread_run_convenience_smoke(runtime_env: PreparedRuntimeEnv) -> None: @@ -345,17 +340,12 @@ def test_real_async_thread_turn_usage_and_ids_smoke( config={"model_reasoning_effort": "high"}, ) result = await (await thread.turn(TextInput("say ok"))).run() - persisted = await thread.read(include_turns=True) - persisted_turn = next( - (turn for turn in persisted.thread.turns or [] if turn.id == result.id), - None, - ) print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, "status": result.status.value, - "items_count": len(result.items or []), - "persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []), + "items_count": len(result.items), + "final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()), })) asyncio.run(main()) @@ -366,8 +356,8 @@ def test_real_async_thread_turn_usage_and_ids_smoke( assert isinstance(data["thread_id"], str) and data["thread_id"].strip() assert isinstance(data["turn_id"], str) and data["turn_id"].strip() assert data["status"] == "completed" - assert isinstance(data["items_count"], int) - assert isinstance(data["persisted_items_count"], int) + assert data["items_count"] > 0 + assert data["final_response_is_text"] is True def test_real_async_thread_run_convenience_smoke( @@ -531,7 +521,7 @@ def test_real_examples_run_and_assert( f"Example failed: {folder}/{script}\n" f"STDOUT:\n{result.stdout}\n" f"STDERR:\n{result.stderr}" - f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" + f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" ) out = result.stdout @@ -541,7 +531,7 @@ def test_real_examples_run_and_assert( assert "Server: unknown" not in out elif folder == "02_turn_run": assert "thread_id:" in out and "turn_id:" in out and "status:" in out - assert "persisted.items.count:" in out + assert "items.count:" in out elif folder == "03_turn_stream_events": assert "stream.completed:" in out assert "assistant>" in out