mirror of
https://github.com/openai/codex.git
synced 2026-05-20 11:12:43 +00:00
## 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`.
272 lines
10 KiB
Python
272 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
from app_server_harness import AppServerHarness
|
|
from app_server_helpers import request_kind
|
|
|
|
from openai_codex import AsyncCodex, Codex
|
|
|
|
|
|
def _thread_message_summary(read_response) -> list[tuple[str, str]]:
|
|
"""Return persisted user/agent messages from a thread read response."""
|
|
messages: list[tuple[str, str]] = []
|
|
for turn in read_response.thread.turns:
|
|
for item in turn.items:
|
|
root = item.root
|
|
if root.type == "userMessage":
|
|
text = "\n".join(
|
|
input_item.root.text
|
|
for input_item in root.content
|
|
if input_item.root.type == "text"
|
|
)
|
|
messages.append(("user", text))
|
|
if root.type == "agentMessage":
|
|
messages.append(("agent", root.text))
|
|
return messages
|
|
|
|
|
|
def test_thread_set_name_and_read(tmp_path) -> None:
|
|
"""Thread naming should round-trip through app-server JSON-RPC."""
|
|
with AppServerHarness(tmp_path) as harness:
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
thread = codex.thread_start()
|
|
thread.set_name("sdk integration thread")
|
|
named = thread.read(include_turns=True)
|
|
|
|
assert {"thread_name": named.thread.name} == {
|
|
"thread_name": "sdk integration thread",
|
|
}
|
|
|
|
|
|
def test_sync_and_async_initialization_round_trip_metadata(tmp_path) -> None:
|
|
"""Public clients should initialize and start threads through app-server."""
|
|
|
|
async def async_scenario(harness: AppServerHarness) -> dict[str, object]:
|
|
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
|
thread = await codex.thread_start()
|
|
server = codex.metadata.serverInfo
|
|
return {
|
|
"thread_id": thread.id,
|
|
"user_agent": codex.metadata.userAgent,
|
|
"server_name": None if server is None else server.name,
|
|
"server_version": None if server is None else server.version,
|
|
}
|
|
|
|
with AppServerHarness(tmp_path) as harness:
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
thread = codex.thread_start()
|
|
server = codex.metadata.serverInfo
|
|
sync_summary = {
|
|
"thread_id": thread.id,
|
|
"user_agent": codex.metadata.userAgent,
|
|
"server_name": None if server is None else server.name,
|
|
"server_version": None if server is None else server.version,
|
|
}
|
|
async_summary = asyncio.run(async_scenario(harness))
|
|
|
|
assert {
|
|
"sync": {
|
|
"thread_id_present": bool(sync_summary["thread_id"]),
|
|
"user_agent_present": bool(sync_summary["user_agent"]),
|
|
"server_name_present": bool(sync_summary["server_name"]),
|
|
"server_version_present": bool(sync_summary["server_version"]),
|
|
},
|
|
"async": {
|
|
"thread_id_present": bool(async_summary["thread_id"]),
|
|
"user_agent_present": bool(async_summary["user_agent"]),
|
|
"server_name_present": bool(async_summary["server_name"]),
|
|
"server_version_present": bool(async_summary["server_version"]),
|
|
},
|
|
} == {
|
|
"sync": {
|
|
"thread_id_present": True,
|
|
"user_agent_present": True,
|
|
"server_name_present": True,
|
|
"server_version_present": True,
|
|
},
|
|
"async": {
|
|
"thread_id_present": True,
|
|
"user_agent_present": True,
|
|
"server_name_present": True,
|
|
"server_version_present": True,
|
|
},
|
|
}
|
|
|
|
|
|
def test_thread_list_filters_archived_threads(tmp_path) -> None:
|
|
"""Thread listing should reflect archive state through app-server."""
|
|
with AppServerHarness(tmp_path) as harness:
|
|
harness.responses.enqueue_assistant_message("active", response_id="list-active")
|
|
harness.responses.enqueue_assistant_message(
|
|
"archived",
|
|
response_id="list-archived",
|
|
)
|
|
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
active_thread = codex.thread_start()
|
|
archived_thread = codex.thread_start()
|
|
active_thread.run("keep this listed")
|
|
archived_thread.run("archive this")
|
|
codex.thread_archive(archived_thread.id)
|
|
active_list = codex.thread_list(archived=False)
|
|
archived_list = codex.thread_list(archived=True)
|
|
|
|
expected_ids = {active_thread.id, archived_thread.id}
|
|
assert {
|
|
"active_ids": sorted(thread.id for thread in active_list.data if thread.id in expected_ids),
|
|
"archived_ids": sorted(
|
|
thread.id for thread in archived_list.data if thread.id in expected_ids
|
|
),
|
|
} == {
|
|
"active_ids": [active_thread.id],
|
|
"archived_ids": [archived_thread.id],
|
|
}
|
|
|
|
|
|
def test_read_include_turns_returns_persisted_history(tmp_path) -> None:
|
|
"""Thread.read(include_turns=True) should load real persisted turn items."""
|
|
with AppServerHarness(tmp_path) as harness:
|
|
harness.responses.enqueue_assistant_message("first answer", response_id="read-1")
|
|
harness.responses.enqueue_assistant_message("second answer", response_id="read-2")
|
|
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
thread = codex.thread_start()
|
|
thread.run("first question")
|
|
thread.run("second question")
|
|
read = thread.read(include_turns=True)
|
|
|
|
assert _thread_message_summary(read) == [
|
|
("user", "first question"),
|
|
("agent", "first answer"),
|
|
("user", "second question"),
|
|
("agent", "second answer"),
|
|
]
|
|
|
|
|
|
def test_async_lifecycle_methods_round_trip(tmp_path) -> None:
|
|
"""Async lifecycle helpers should preserve the same app-server thread state."""
|
|
|
|
async def scenario() -> None:
|
|
"""Exercise async wrappers over one materialized thread."""
|
|
with AppServerHarness(tmp_path) as harness:
|
|
harness.responses.enqueue_assistant_message(
|
|
"async materialized",
|
|
response_id="async-lifecycle",
|
|
)
|
|
|
|
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
|
thread = await codex.thread_start()
|
|
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)
|
|
forked = await codex.thread_fork(thread.id)
|
|
archive_response = await codex.thread_archive(thread.id)
|
|
unarchived = await codex.thread_unarchive(thread.id)
|
|
|
|
assert {
|
|
"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,
|
|
} == {
|
|
"turn_final_response": "async materialized",
|
|
"named_thread": "async lifecycle",
|
|
"resumed_id": thread.id,
|
|
"forked_is_distinct": True,
|
|
"archive_response": {},
|
|
"unarchived_id": thread.id,
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_thread_fork_returns_distinct_thread(tmp_path) -> None:
|
|
"""Thread fork should return a distinct thread for a persisted rollout."""
|
|
with AppServerHarness(tmp_path) as harness:
|
|
harness.responses.enqueue_assistant_message("materialized", response_id="fork-seed")
|
|
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
thread = codex.thread_start()
|
|
seeded = thread.run("materialize this thread before fork")
|
|
forked = codex.thread_fork(thread.id)
|
|
|
|
assert {
|
|
"seeded_response": seeded.final_response,
|
|
"forked_is_distinct": forked.id != thread.id,
|
|
} == {
|
|
"seeded_response": "materialized",
|
|
"forked_is_distinct": True,
|
|
}
|
|
|
|
|
|
def test_archive_unarchive_round_trip_uses_materialized_rollout(tmp_path) -> None:
|
|
"""Archive helpers should work once the app-server has persisted a rollout."""
|
|
with AppServerHarness(tmp_path) as harness:
|
|
harness.responses.enqueue_assistant_message("materialized", response_id="archive-seed")
|
|
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
thread = codex.thread_start()
|
|
seeded = thread.run("materialize this thread before archive")
|
|
archived = codex.thread_archive(thread.id)
|
|
unarchived = codex.thread_unarchive(thread.id)
|
|
read = unarchived.read()
|
|
|
|
assert {
|
|
"seeded_response": seeded.final_response,
|
|
"archive_response": archived.model_dump(by_alias=True, mode="json"),
|
|
"unarchived_id": unarchived.id,
|
|
"read_id": read.thread.id,
|
|
} == {
|
|
"seeded_response": "materialized",
|
|
"archive_response": {},
|
|
"unarchived_id": thread.id,
|
|
"read_id": thread.id,
|
|
}
|
|
|
|
|
|
def test_models_rpc(tmp_path) -> None:
|
|
"""Model listing should go through the pinned app-server method."""
|
|
with AppServerHarness(tmp_path) as harness:
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
models = codex.models(include_hidden=True)
|
|
|
|
assert {
|
|
"models_payload_has_data": isinstance(
|
|
models.model_dump(by_alias=True, mode="json").get("data"),
|
|
list,
|
|
),
|
|
} == {"models_payload_has_data": True}
|
|
|
|
|
|
def test_compact_rpc_hits_mock_responses(tmp_path) -> None:
|
|
"""Compaction should run through app-server and hit the mock Responses boundary."""
|
|
with AppServerHarness(tmp_path) as harness:
|
|
harness.responses.enqueue_assistant_message("history", response_id="compact-history")
|
|
harness.responses.enqueue_assistant_message(
|
|
"compact summary",
|
|
response_id="compact-summary",
|
|
)
|
|
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
thread = codex.thread_start()
|
|
turn_result = thread.run("create history")
|
|
compact_response = thread.compact()
|
|
requests = harness.responses.wait_for_requests(2)
|
|
|
|
assert {
|
|
"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],
|
|
} == {
|
|
"turn_final_response": "history",
|
|
"compact_response": {},
|
|
"request_kinds": ["responses", "responses"],
|
|
}
|