| 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"], |
| } |
|
|