From 4c897723142406baf3c5f6f8730f2844ef1aa436 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 17 May 2026 05:49:28 +0300 Subject: [PATCH 01/53] sdk/python: add first-class login support (#23093) ## Why The Python SDK can already create threads and run turns, but authentication still has to be arranged outside the SDK. App-server already exposes account login, account inspection, logout, and `account/login/completed` notifications, so SDK users currently have to work around a missing public client layer for a core setup step. This change makes authentication a normal SDK workflow while preserving the backend flow shape: API-key login completes immediately, and interactive ChatGPT flows return live handles that complete later through app-server notifications. ## What changed - Added public sync and async auth methods on `Codex` / `AsyncCodex`: - `login_api_key(...)` - `login_chatgpt()` - `login_chatgpt_device_code()` - `account(...)` - `logout()` - Added public browser-login and device-code handle types with attempt-local `wait()` and `cancel()` helpers. Cancellation stays on the handle instead of a root-level SDK method. - Extended the Python app-server client and notification router so login completion events are routed by `login_id` without consuming unrelated global notifications. - Kept login request/handle logic in a focused internal `_login.py` module so `api.py` remains the public facade instead of absorbing more auth plumbing. - Exported the new handle types plus curated account/login response types from the SDK surfaces. - Updated SDK docs, added sync/async login walkthrough examples, and added a notebook login walkthrough cell. ## Verification Added SDK coverage for: - API-key login, account readback, and logout through the app-server harness in both sync and async clients. - Browser login cancellation plus `handle.wait()` completion through the real app-server boundary used by the Python SDK harness. - Waiter routing that stays scoped across replaced interactive login attempts, plus async handle cancellation coverage. - Login notification demuxing, replay of early completion events, and async client delegation. - Public export/signature assertions. - Real integration-suite smoke coverage for the new examples and notebook login cell. --- sdk/python/README.md | 30 +++ sdk/python/docs/api-reference.md | 48 +++++ sdk/python/docs/faq.md | 11 +- sdk/python/docs/getting-started.md | 42 ++++- .../examples/15_login_and_account/async.py | 34 ++++ .../examples/15_login_and_account/sync.py | 26 +++ sdk/python/examples/README.md | 2 + sdk/python/notebooks/sdk_walkthrough.ipynb | 21 +++ sdk/python/src/openai_codex/__init__.py | 8 + sdk/python/src/openai_codex/_login.py | 172 ++++++++++++++++++ .../src/openai_codex/_message_router.py | 64 +++++++ sdk/python/src/openai_codex/api.py | 73 ++++++++ sdk/python/src/openai_codex/async_client.py | 48 +++++ sdk/python/src/openai_codex/client.py | 88 ++++++++- sdk/python/src/openai_codex/types.py | 10 + sdk/python/tests/app_server_harness.py | 5 +- sdk/python/tests/test_app_server_login.py | 90 +++++++++ .../tests/test_public_api_signatures.py | 9 + .../tests/test_real_app_server_integration.py | 4 +- 19 files changed, 772 insertions(+), 13 deletions(-) create mode 100644 sdk/python/examples/15_login_and_account/async.py create mode 100644 sdk/python/examples/15_login_and_account/sync.py create mode 100644 sdk/python/src/openai_codex/_login.py create mode 100644 sdk/python/tests/test_app_server_login.py diff --git a/sdk/python/README.md b/sdk/python/README.md index d3aeff7ac5..a04d59b239 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -26,6 +26,8 @@ when you intentionally want to run against a specific local app-server binary. from openai_codex import Codex with Codex() as codex: + # Call login_api_key(...) first when this app-server session is not + # already authenticated. thread = codex.thread_start(model="gpt-5") result = thread.run("Say hello in one sentence.") print(result.final_response) @@ -35,6 +37,34 @@ with Codex() as codex: `result.final_response` is `None` when the turn completes without a final-answer or phase-less assistant message item. +## Login + +Use the auth helper that matches your app: + +```python +from openai_codex import Codex + +with Codex() as codex: + codex.login_api_key("sk-...") + account = codex.account() + print(account.account) +``` + +Interactive ChatGPT login returns a handle. Open the provided URL or device-code +page, then wait for the matching completion event: + +```python +with Codex() as codex: + login = codex.login_chatgpt() + print(login.auth_url) + completed = login.wait() + print(completed.success) +``` + +Use `login_chatgpt_device_code()` for device-code auth, `handle.cancel()` to +stop an in-progress interactive login, and `logout()` to clear the active +app-server account session. + ## Docs map - Golden path tutorial: `docs/getting-started.md` diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 6be7e19737..1b885b8c89 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -12,6 +12,10 @@ from openai_codex import ( Codex, AsyncCodex, ApprovalMode, + ChatgptLoginHandle, + DeviceCodeLoginHandle, + AsyncChatgptLoginHandle, + AsyncDeviceCodeLoginHandle, RunResult, Thread, AsyncThread, @@ -26,6 +30,11 @@ from openai_codex import ( MentionInput, ) from openai_codex.types import ( + Account, + AccountLoginCompletedNotification, + CancelLoginAccountResponse, + CancelLoginAccountStatus, + GetAccountResponse, InitializeResponse, ThreadItem, ThreadTokenUsage, @@ -47,6 +56,11 @@ Properties/methods: - `metadata -> InitializeResponse` - `close() -> None` +- `login_api_key(api_key: str) -> None` +- `login_chatgpt() -> ChatgptLoginHandle` +- `login_chatgpt_device_code() -> DeviceCodeLoginHandle` +- `account(*, refresh_token: bool = False) -> GetAccountResponse` +- `logout() -> None` - `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` - `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> ThreadListResponse` - `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` @@ -82,6 +96,11 @@ Properties/methods: - `metadata -> InitializeResponse` - `close() -> Awaitable[None]` +- `login_api_key(api_key: str) -> Awaitable[None]` +- `login_chatgpt() -> Awaitable[AsyncChatgptLoginHandle]` +- `login_chatgpt_device_code() -> Awaitable[AsyncDeviceCodeLoginHandle]` +- `account(*, refresh_token: bool = False) -> Awaitable[GetAccountResponse]` +- `logout() -> Awaitable[None]` - `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` - `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> Awaitable[ThreadListResponse]` - `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` @@ -97,6 +116,30 @@ async with AsyncCodex() as codex: ... ``` +## Login handles + +### ChatgptLoginHandle / AsyncChatgptLoginHandle + +- `login_id: str` +- `auth_url: str` +- `wait() -> AccountLoginCompletedNotification` +- `cancel() -> CancelLoginAccountResponse` + +Async handle methods return awaitables. + +### DeviceCodeLoginHandle / AsyncDeviceCodeLoginHandle + +- `login_id: str` +- `verification_url: str` +- `user_code: str` +- `wait() -> AccountLoginCompletedNotification` +- `cancel() -> CancelLoginAccountResponse` + +Async handle methods return awaitables. + +`wait()` consumes only the completion notification for its matching login +attempt. API-key login completes synchronously and does not return a handle. + ## Thread / AsyncThread `Thread` and `AsyncThread` share the same shape and intent. @@ -176,6 +219,11 @@ The SDK wrappers return and accept public app-server models wherever possible: ```python from openai_codex.types import ( + Account, + AccountLoginCompletedNotification, + CancelLoginAccountResponse, + CancelLoginAccountStatus, + GetAccountResponse, ThreadReadResponse, Turn, TurnStatus, diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index 1a87b50a14..d1732e74aa 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -23,6 +23,16 @@ Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout If your app is not already async, stay with `Codex`. +## How do I log in? + +- `login_api_key(...)` authenticates immediately with an API key. +- `login_chatgpt()` starts browser login and returns a handle with `auth_url`. +- `login_chatgpt_device_code()` starts device-code login and returns a handle + with `verification_url` and `user_code`. +- Interactive handles expose `wait()` for the matching + `account/login/completed` notification and `cancel()` to stop that attempt. +- `account()` reads the current account state, and `logout()` clears it. + ## Public kwargs are snake_case Public API keyword names are snake_case. The SDK still maps them to wire camelCase under the hood. @@ -56,7 +66,6 @@ Common causes: - published runtime package (`openai-codex-cli-bin`) is not installed - local `codex_bin` override points to a missing file -- local auth/session is missing - incompatible/old app-server ## Why does a turn "hang"? diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 6e37314e77..b362a90347 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -19,9 +19,37 @@ Requirements: - Python `>=3.10` - uv - installed `openai-codex-cli-bin` runtime package, or an explicit `codex_bin` override -- local Codex auth/session configured -## 2) Run your first turn (sync) +## 2) Authenticate when needed + +Existing Codex auth state is reused automatically. To authenticate from the SDK, +use the flow that fits your app: + +```python +from openai_codex import Codex + +with Codex() as codex: + codex.login_api_key("sk-...") + account = codex.account() + print(account.account) +``` + +Interactive ChatGPT browser login returns a handle that carries the URL and the +matching completion event: + +```python +with Codex() as codex: + login = codex.login_chatgpt() + print(login.auth_url) + completed = login.wait() + print(completed.success) +``` + +Device-code login works the same way with +`login_chatgpt_device_code()`, which exposes `verification_url`, `user_code`, +and `wait()`. + +## 3) Run your first turn (sync) ```python from openai_codex import Codex @@ -47,7 +75,7 @@ What happened: - use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, interrupting, or turn IDs/status - one client can consume multiple active turns concurrently; turn streams are routed by turn ID -## 3) Continue the same thread (multi-turn) +## 4) Continue the same thread (multi-turn) ```python from openai_codex import Codex @@ -62,7 +90,7 @@ with Codex() as codex: print("second:", second.final_response) ``` -## 4) Async parity +## 5) Async parity Use `async with AsyncCodex()` as the normal async entrypoint. `AsyncCodex` initializes lazily, and context entry makes startup/shutdown explicit. @@ -82,7 +110,7 @@ async def main() -> None: asyncio.run(main()) ``` -## 5) Resume an existing thread +## 6) Resume an existing thread ```python from openai_codex import Codex @@ -95,7 +123,7 @@ with Codex() as codex: print(result.final_response) ``` -## 6) Public app-server types +## 7) Public app-server types The convenience wrappers live at the package root. Public app-server value and event types live under: @@ -104,7 +132,7 @@ event types live under: from openai_codex.types import ThreadReadResponse, Turn, TurnStatus ``` -## 7) Next stops +## 8) Next stops - API surface and signatures: `docs/api-reference.md` - Common decisions/pitfalls: `docs/faq.md` diff --git a/sdk/python/examples/15_login_and_account/async.py b/sdk/python/examples/15_login_and_account/async.py new file mode 100644 index 0000000000..7de6c2afa5 --- /dev/null +++ b/sdk/python/examples/15_login_and_account/async.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +import asyncio + +from openai_codex import AsyncCodex + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + # Browser login returns a live handle. Open `auth_url` and await `wait()` + # in a real app; this example cancels immediately so it stays non-blocking. + login = await codex.login_chatgpt() + canceled = await login.cancel() + completed = await login.wait() + account = await codex.account() + + print("login.id:", login.login_id) + print("login.auth_url:", login.auth_url) + print("login.cancel.status:", canceled.status) + print("login.completed.success:", completed.success) + print("account.requires_openai_auth:", account.requires_openai_auth) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/15_login_and_account/sync.py b/sdk/python/examples/15_login_and_account/sync.py new file mode 100644 index 0000000000..ed20531186 --- /dev/null +++ b/sdk/python/examples/15_login_and_account/sync.py @@ -0,0 +1,26 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +from openai_codex import Codex + +with Codex(config=runtime_config()) as codex: + # Browser login returns a live handle. Open `auth_url` and call `wait()` + # in a real app; this example cancels immediately so it stays non-blocking. + login = codex.login_chatgpt() + canceled = login.cancel() + completed = login.wait() + account = codex.account() + + print("login.id:", login.login_id) + print("login.auth_url:", login.auth_url) + print("login.cancel.status:", canceled.status) + print("login.completed.success:", completed.success) + print("account.requires_openai_auth:", account.requires_openai_auth) diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index fe5b630898..569a51f477 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -82,3 +82,5 @@ python examples/01_quickstart_constructor/async.py - 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 +- `15_login_and_account/` + - browser-login handle lifecycle, cancellation, and account inspection diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 62480fe18a..e51c217dbd 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -140,6 +140,27 @@ ")\n" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 2b: browser login handle lifecycle\n", + "with Codex() as codex:\n", + " # Open this URL and call `wait()` without canceling when completing login for real.\n", + " login = codex.login_chatgpt()\n", + " canceled = login.cancel()\n", + " completed = login.wait()\n", + " account = codex.account()\n", + "\n", + " print('login.id:', login.login_id)\n", + " print('login.auth_url:', login.auth_url)\n", + " print('login.cancel.status:', canceled.status)\n", + " print('login.completed.success:', completed.success)\n", + " print('account.requires_openai_auth:', account.requires_openai_auth)\n" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index 4a68fabbef..8c8dcc3eb2 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -1,10 +1,14 @@ from ._version import __version__ from .api import ( ApprovalMode, + AsyncChatgptLoginHandle, AsyncCodex, + AsyncDeviceCodeLoginHandle, AsyncThread, AsyncTurnHandle, + ChatgptLoginHandle, Codex, + DeviceCodeLoginHandle, ImageInput, Input, InputItem, @@ -39,6 +43,10 @@ __all__ = [ "Codex", "AsyncCodex", "ApprovalMode", + "ChatgptLoginHandle", + "DeviceCodeLoginHandle", + "AsyncChatgptLoginHandle", + "AsyncDeviceCodeLoginHandle", "Thread", "AsyncThread", "TurnHandle", diff --git a/sdk/python/src/openai_codex/_login.py b/sdk/python/src/openai_codex/_login.py new file mode 100644 index 0000000000..cdfdda41da --- /dev/null +++ b/sdk/python/src/openai_codex/_login.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from .async_client import AsyncAppServerClient +from .client import AppServerClient +from .generated.v2_all import ( + AccountLoginCompletedNotification, + CancelLoginAccountResponse, + ChatgptDeviceCodeLoginAccountParams, + ChatgptDeviceCodeLoginAccountResponse, + ChatgptLoginAccountParams, + ChatgptLoginAccountResponse, + LoginAccountParams, +) + + +class _AsyncLoginOwner(Protocol): + """Subset of AsyncCodex needed by async login handles.""" + + _client: AsyncAppServerClient + + async def _ensure_initialized(self) -> None: + """Ensure the owning SDK client has a live app-server connection.""" + ... + + +def start_chatgpt_login(client: AppServerClient) -> ChatgptLoginHandle: + """Start browser ChatGPT login and return the handle for that attempt.""" + response = client.account_login_start( + LoginAccountParams( + root=ChatgptLoginAccountParams(type="chatgpt"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptLoginAccountResponse): + raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}") + return ChatgptLoginHandle( + client, + response_root.login_id, + response_root.auth_url, + ) + + +async def async_start_chatgpt_login(owner: _AsyncLoginOwner) -> AsyncChatgptLoginHandle: + """Start async browser ChatGPT login and return that attempt's handle.""" + response = await owner._client.account_login_start( + LoginAccountParams( + root=ChatgptLoginAccountParams(type="chatgpt"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptLoginAccountResponse): + raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}") + return AsyncChatgptLoginHandle( + owner, + response_root.login_id, + response_root.auth_url, + ) + + +def start_device_code_login(client: AppServerClient) -> DeviceCodeLoginHandle: + """Start device-code ChatGPT login and return the handle for that attempt.""" + response = client.account_login_start( + LoginAccountParams( + root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse): + raise RuntimeError(f"unexpected device-code login response: {response_root!r}") + return DeviceCodeLoginHandle( + client, + response_root.login_id, + response_root.verification_url, + response_root.user_code, + ) + + +async def async_start_device_code_login( + owner: _AsyncLoginOwner, +) -> AsyncDeviceCodeLoginHandle: + """Start async device-code ChatGPT login and return that attempt's handle.""" + response = await owner._client.account_login_start( + LoginAccountParams( + root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse): + raise RuntimeError(f"unexpected device-code login response: {response_root!r}") + return AsyncDeviceCodeLoginHandle( + owner, + response_root.login_id, + response_root.verification_url, + response_root.user_code, + ) + + +@dataclass(slots=True) +class ChatgptLoginHandle: + """Live browser-login attempt returned by `Codex.login_chatgpt()`.""" + + _client: AppServerClient + login_id: str + auth_url: str + + def wait(self) -> AccountLoginCompletedNotification: + """Wait for this browser login attempt's completion notification.""" + return self._client.wait_for_login_completed(self.login_id) + + def cancel(self) -> CancelLoginAccountResponse: + """Cancel this browser login attempt.""" + return self._client.account_login_cancel(self.login_id) + + +@dataclass(slots=True) +class DeviceCodeLoginHandle: + """Live device-code login attempt returned by `Codex.login_chatgpt_device_code()`.""" + + _client: AppServerClient + login_id: str + verification_url: str + user_code: str + + def wait(self) -> AccountLoginCompletedNotification: + """Wait for this device-code login attempt's completion notification.""" + return self._client.wait_for_login_completed(self.login_id) + + def cancel(self) -> CancelLoginAccountResponse: + """Cancel this device-code login attempt.""" + return self._client.account_login_cancel(self.login_id) + + +@dataclass(slots=True) +class AsyncChatgptLoginHandle: + """Live browser-login attempt returned by `AsyncCodex.login_chatgpt()`.""" + + _codex: _AsyncLoginOwner + login_id: str + auth_url: str + + async def wait(self) -> AccountLoginCompletedNotification: + """Wait for this browser login attempt's completion notification.""" + await self._codex._ensure_initialized() + return await self._codex._client.wait_for_login_completed(self.login_id) + + async def cancel(self) -> CancelLoginAccountResponse: + """Cancel this browser login attempt.""" + await self._codex._ensure_initialized() + return await self._codex._client.account_login_cancel(self.login_id) + + +@dataclass(slots=True) +class AsyncDeviceCodeLoginHandle: + """Live device-code attempt returned by `AsyncCodex.login_chatgpt_device_code()`.""" + + _codex: _AsyncLoginOwner + login_id: str + verification_url: str + user_code: str + + async def wait(self) -> AccountLoginCompletedNotification: + """Wait for this device-code login attempt's completion notification.""" + await self._codex._ensure_initialized() + return await self._codex._client.wait_for_login_completed(self.login_id) + + async def cancel(self) -> CancelLoginAccountResponse: + """Cancel this device-code login attempt.""" + await self._codex._ensure_initialized() + return await self._codex._client.account_login_cancel(self.login_id) diff --git a/sdk/python/src/openai_codex/_message_router.py b/sdk/python/src/openai_codex/_message_router.py index 7d4554bb8a..14a01336a4 100644 --- a/sdk/python/src/openai_codex/_message_router.py +++ b/sdk/python/src/openai_codex/_message_router.py @@ -6,6 +6,7 @@ from collections import deque from .errors import AppServerError, map_jsonrpc_error from .generated.notification_registry import notification_turn_id +from .generated.v2_all import AccountLoginCompletedNotification from .models import JsonValue, Notification, UnknownNotification ResponseQueueItem = JsonValue | BaseException @@ -25,6 +26,8 @@ class MessageRouter: """Create empty response, turn, and global notification queues.""" self._lock = threading.Lock() self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {} + self._login_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {} + self._pending_login_notifications: dict[str, deque[Notification]] = {} self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {} self._pending_turn_notifications: dict[str, deque[Notification]] = {} self._global_notifications: queue.Queue[NotificationQueueItem] = queue.Queue() @@ -51,6 +54,36 @@ class MessageRouter: raise item return item + def register_login(self, login_id: str) -> None: + """Register a queue for one interactive login attempt.""" + + login_queue: queue.Queue[NotificationQueueItem] = queue.Queue() + with self._lock: + if login_id in self._login_notifications: + return + pending = self._pending_login_notifications.pop(login_id, deque()) + self._login_notifications[login_id] = login_queue + for notification in pending: + login_queue.put(notification) + + def unregister_login(self, login_id: str) -> None: + """Stop routing future notifications for one login attempt.""" + + with self._lock: + self._login_notifications.pop(login_id, None) + + def next_login_notification(self, login_id: str) -> Notification: + """Block until the next notification for a registered login attempt.""" + + with self._lock: + login_queue = self._login_notifications.get(login_id) + if login_queue is None: + raise RuntimeError(f"login {login_id!r} is not registered for waiting") + item = login_queue.get() + if isinstance(item, BaseException): + raise item + return item + def register_turn(self, turn_id: str) -> None: """Register a queue for a turn stream and replay early events.""" @@ -111,6 +144,18 @@ class MessageRouter: def route_notification(self, notification: Notification) -> None: """Deliver a notification to a turn queue or the global queue.""" + login_id = self._notification_login_id(notification) + if login_id is not None: + with self._lock: + login_queue = self._login_notifications.get(login_id) + if login_queue is None: + self._pending_login_notifications.setdefault(login_id, deque()).append( + notification + ) + return + login_queue.put(notification) + return + turn_id = self._notification_turn_id(notification) if turn_id is None: self._global_notifications.put(notification) @@ -132,16 +177,35 @@ class MessageRouter: with self._lock: response_waiters = list(self._response_waiters.values()) self._response_waiters.clear() + login_queues = list(self._login_notifications.values()) + self._login_notifications.clear() + self._pending_login_notifications.clear() turn_queues = list(self._turn_notifications.values()) self._pending_turn_notifications.clear() # Put the same transport failure into every queue so no SDK call blocks # forever waiting for a response that cannot arrive. for waiter in response_waiters: waiter.put(exc) + for login_queue in login_queues: + login_queue.put(exc) for turn_queue in turn_queues: turn_queue.put(exc) self._global_notifications.put(exc) + def _notification_login_id(self, notification: Notification) -> str | None: + """Extract the login attempt id from completion notifications.""" + if notification.method != "account/login/completed": + return None + + payload = notification.payload + if isinstance(payload, AccountLoginCompletedNotification): + return payload.login_id + if isinstance(payload, UnknownNotification): + raw_login_id = payload.params.get("loginId") + if isinstance(raw_login_id, str): + return raw_login_id + return None + def _notification_turn_id(self, notification: Notification) -> str | None: """Extract routing ids from known generated payloads or raw unknown payloads.""" payload = notification.payload diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 0ea99f9b3d..fe9b5bc67f 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -22,6 +22,16 @@ from ._inputs import ( _normalize_run_input, _to_wire_input, ) +from ._login import ( + AsyncChatgptLoginHandle, + AsyncDeviceCodeLoginHandle, + ChatgptLoginHandle, + DeviceCodeLoginHandle, + async_start_chatgpt_login, + async_start_device_code_login, + start_chatgpt_login, + start_device_code_login, +) from ._run import ( RunResult, _collect_async_run_result, @@ -30,6 +40,10 @@ from ._run import ( from .async_client import AsyncAppServerClient from .client import AppServerClient, AppServerConfig from .generated.v2_all import ( + ApiKeyLoginAccountParams, + GetAccountParams, + GetAccountResponse, + LoginAccountParams, ModelListResponse, Personality, ReasoningEffort, @@ -85,6 +99,33 @@ class Codex: def close(self) -> None: self._client.close() + def login_api_key(self, api_key: str) -> None: + """Authenticate app-server with an API key.""" + self._client.account_login_start( + LoginAccountParams( + root=ApiKeyLoginAccountParams( + api_key=api_key, + type="apiKey", + ) + ) + ) + + def login_chatgpt(self) -> ChatgptLoginHandle: + """Start browser-based ChatGPT login and return its live handle.""" + return start_chatgpt_login(self._client) + + def login_chatgpt_device_code(self) -> DeviceCodeLoginHandle: + """Start device-code ChatGPT login and return its live handle.""" + return start_device_code_login(self._client) + + def account(self, *, refresh_token: bool = False) -> GetAccountResponse: + """Read the current app-server account state.""" + return self._client.account_read(GetAccountParams(refresh_token=refresh_token)) + + def logout(self) -> None: + """Clear the current app-server account session.""" + self._client.account_logout() + # BEGIN GENERATED: Codex.flat_methods def thread_start( self, @@ -286,6 +327,38 @@ class AsyncCodex: self._init = None self._initialized = False + async def login_api_key(self, api_key: str) -> None: + """Authenticate app-server with an API key.""" + await self._ensure_initialized() + await self._client.account_login_start( + LoginAccountParams( + root=ApiKeyLoginAccountParams( + api_key=api_key, + type="apiKey", + ) + ) + ) + + async def login_chatgpt(self) -> AsyncChatgptLoginHandle: + """Start browser-based ChatGPT login and return its live handle.""" + await self._ensure_initialized() + return await async_start_chatgpt_login(self) + + async def login_chatgpt_device_code(self) -> AsyncDeviceCodeLoginHandle: + """Start device-code ChatGPT login and return its live handle.""" + await self._ensure_initialized() + return await async_start_device_code_login(self) + + async def account(self, *, refresh_token: bool = False) -> GetAccountResponse: + """Read the current app-server account state.""" + await self._ensure_initialized() + return await self._client.account_read(GetAccountParams(refresh_token=refresh_token)) + + async def logout(self) -> None: + """Clear the current app-server account session.""" + await self._ensure_initialized() + await self._client.account_logout() + # BEGIN GENERATED: AsyncCodex.flat_methods async def thread_start( self, diff --git a/sdk/python/src/openai_codex/async_client.py b/sdk/python/src/openai_codex/async_client.py index 2c38a43087..282154b0cc 100644 --- a/sdk/python/src/openai_codex/async_client.py +++ b/sdk/python/src/openai_codex/async_client.py @@ -8,7 +8,14 @@ from pydantic import BaseModel from .client import AppServerClient, AppServerConfig from .generated.v2_all import ( + AccountLoginCompletedNotification, AgentMessageDeltaNotification, + CancelLoginAccountResponse, + GetAccountParams as V2GetAccountParams, + GetAccountResponse, + LoginAccountParams as V2LoginAccountParams, + LoginAccountResponse, + LogoutAccountResponse, ModelListResponse, ThreadArchiveResponse, ThreadCompactStartResponse, @@ -88,6 +95,14 @@ class AsyncAppServerClient: """Register a turn notification queue on the wrapped sync client.""" self._sync.register_turn_notifications(turn_id) + def register_login_notifications(self, login_id: str) -> None: + """Register a login notification queue on the wrapped sync client.""" + self._sync.register_login_notifications(login_id) + + def unregister_login_notifications(self, login_id: str) -> None: + """Unregister a login notification queue on the wrapped sync client.""" + self._sync.unregister_login_notifications(login_id) + def unregister_turn_notifications(self, turn_id: str) -> None: """Unregister a turn notification queue on the wrapped sync client.""" self._sync.unregister_turn_notifications(turn_id) @@ -107,6 +122,28 @@ class AsyncAppServerClient: response_model=response_model, ) + async def account_login_start( + self, + params: V2LoginAccountParams | JsonObject, + ) -> LoginAccountResponse: + """Start one account login attempt through the wrapped sync client.""" + return await self._call_sync(self._sync.account_login_start, params) + + async def account_login_cancel(self, login_id: str) -> CancelLoginAccountResponse: + """Cancel one active account login attempt through the wrapped sync client.""" + return await self._call_sync(self._sync.account_login_cancel, login_id) + + async def account_read( + self, + params: V2GetAccountParams | JsonObject | None = None, + ) -> GetAccountResponse: + """Read current account state through the wrapped sync client.""" + return await self._call_sync(self._sync.account_read, params) + + async def account_logout(self) -> LogoutAccountResponse: + """Clear the active account session through the wrapped sync client.""" + return await self._call_sync(self._sync.account_logout) + async def thread_start( self, params: V2ThreadStartParams | JsonObject | None = None ) -> ThreadStartResponse: @@ -211,10 +248,21 @@ class AsyncAppServerClient: """Wait for the next global notification without blocking the event loop.""" return await self._call_sync(self._sync.next_notification) + async def next_login_notification(self, login_id: str) -> Notification: + """Wait for the next notification routed to one login attempt.""" + return await self._call_sync(self._sync.next_login_notification, login_id) + async def next_turn_notification(self, turn_id: str) -> Notification: """Wait for the next notification routed to one turn.""" return await self._call_sync(self._sync.next_turn_notification, turn_id) + async def wait_for_login_completed( + self, + login_id: str, + ) -> AccountLoginCompletedNotification: + """Wait for the completion notification routed to one login attempt.""" + return await self._call_sync(self._sync.wait_for_login_completed, login_id) + async def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification: """Wait for the completion notification routed to one turn.""" return await self._call_sync(self._sync.wait_for_turn_completed, turn_id) diff --git a/sdk/python/src/openai_codex/client.py b/sdk/python/src/openai_codex/client.py index a4b1e4acaf..6f2e2b8aac 100644 --- a/sdk/python/src/openai_codex/client.py +++ b/sdk/python/src/openai_codex/client.py @@ -17,7 +17,16 @@ from ._version import __version__ as SDK_VERSION from .errors import AppServerError, TransportClosedError from .generated.notification_registry import NOTIFICATION_MODELS from .generated.v2_all import ( + AccountLoginCompletedNotification, AgentMessageDeltaNotification, + CancelLoginAccountResponse, + ChatgptDeviceCodeLoginAccountResponse, + ChatgptLoginAccountResponse, + GetAccountParams as V2GetAccountParams, + GetAccountResponse, + LoginAccountParams as V2LoginAccountParams, + LoginAccountResponse, + LogoutAccountResponse, ModelListResponse, ThreadArchiveResponse, ThreadCompactStartResponse, @@ -59,6 +68,8 @@ def _params_dict( | V2ThreadListParams | V2ThreadForkParams | V2TurnStartParams + | V2GetAccountParams + | V2LoginAccountParams | JsonObject | None ), @@ -246,7 +257,10 @@ class AppServerClient: waiter = self._router.create_response_waiter(request_id) try: - self._write_message({"id": request_id, "method": method, "params": params or {}}) + message: JsonObject = {"id": request_id, "method": method} + if params is not None: + message["params"] = params + self._write_message(message) except BaseException: self._router.discard_response_waiter(request_id) raise @@ -258,12 +272,27 @@ class AppServerClient: def notify(self, method: str, params: JsonObject | None = None) -> None: """Send a JSON-RPC notification without waiting for a response.""" - self._write_message({"method": method, "params": params or {}}) + message: JsonObject = {"method": method} + if params is not None: + message["params"] = params + self._write_message(message) def next_notification(self) -> Notification: """Return the next notification that is not scoped to an active turn.""" return self._router.next_global_notification() + def register_login_notifications(self, login_id: str) -> None: + """Start routing notifications for one interactive login attempt.""" + self._router.register_login(login_id) + + def unregister_login_notifications(self, login_id: str) -> None: + """Stop routing notifications for one interactive login attempt.""" + self._router.unregister_login(login_id) + + def next_login_notification(self, login_id: str) -> Notification: + """Return the next routed notification for the requested login id.""" + return self._router.next_login_notification(login_id) + def register_turn_notifications(self, turn_id: str) -> None: """Start routing notifications for one turn into its dedicated queue.""" self._router.register_turn(turn_id) @@ -276,6 +305,43 @@ class AppServerClient: """Return the next routed notification for the requested turn id.""" return self._router.next_turn_notification(turn_id) + def account_login_start( + self, + params: V2LoginAccountParams | JsonObject, + ) -> LoginAccountResponse: + response = self.request( + "account/login/start", + _params_dict(params), + response_model=LoginAccountResponse, + ) + response_root = response.root + if isinstance( + response_root, + ChatgptLoginAccountResponse | ChatgptDeviceCodeLoginAccountResponse, + ): + self.register_login_notifications(response_root.login_id) + return response + + def account_login_cancel(self, login_id: str) -> CancelLoginAccountResponse: + return self.request( + "account/login/cancel", + {"loginId": login_id}, + response_model=CancelLoginAccountResponse, + ) + + def account_read( + self, + params: V2GetAccountParams | JsonObject | None = None, + ) -> GetAccountResponse: + return self.request( + "account/read", + _params_dict(params), + response_model=GetAccountResponse, + ) + + def account_logout(self) -> LogoutAccountResponse: + return self.request("account/logout", None, response_model=LogoutAccountResponse) + def thread_start( self, params: V2ThreadStartParams | JsonObject | None = None ) -> ThreadStartResponse: @@ -417,6 +483,24 @@ class AppServerClient: finally: self.unregister_turn_notifications(turn_id) + def wait_for_login_completed( + self, + login_id: str, + ) -> AccountLoginCompletedNotification: + """Block until the matching interactive login attempt completes.""" + self.register_login_notifications(login_id) + try: + while True: + notification = self.next_login_notification(login_id) + if ( + notification.method == "account/login/completed" + and isinstance(notification.payload, AccountLoginCompletedNotification) + and notification.payload.login_id == login_id + ): + return notification.payload + finally: + self.unregister_login_notifications(login_id) + def stream_text( self, thread_id: str, diff --git a/sdk/python/src/openai_codex/types.py b/sdk/python/src/openai_codex/types.py index 995daa68b9..26ffdbb3ef 100644 --- a/sdk/python/src/openai_codex/types.py +++ b/sdk/python/src/openai_codex/types.py @@ -3,8 +3,13 @@ from __future__ import annotations from .generated.v2_all import ( + Account, + AccountLoginCompletedNotification, ApprovalsReviewer, AskForApproval, + CancelLoginAccountResponse, + CancelLoginAccountStatus, + GetAccountResponse, ModelListResponse, Personality, PlanType, @@ -35,8 +40,13 @@ from .generated.v2_all import ( from .models import InitializeResponse, JsonObject, Notification __all__ = [ + "Account", + "AccountLoginCompletedNotification", "ApprovalsReviewer", "AskForApproval", + "CancelLoginAccountResponse", + "CancelLoginAccountStatus", + "GetAccountResponse", "InitializeResponse", "JsonObject", "ModelListResponse", diff --git a/sdk/python/tests/app_server_harness.py b/sdk/python/tests/app_server_harness.py index 26fa8cf7ef..63de28d3af 100644 --- a/sdk/python/tests/app_server_harness.py +++ b/sdk/python/tests/app_server_harness.py @@ -206,10 +206,11 @@ class MockResponsesServer: class AppServerHarness: """Test fixture that points a pinned runtime app-server at MockResponsesServer.""" - def __init__(self, tmp_path: Path) -> None: + def __init__(self, tmp_path: Path, *, requires_openai_auth: bool = False) -> None: self.tmp_path = tmp_path self.codex_home = tmp_path / "codex-home" self.workspace = tmp_path / "workspace" + self.requires_openai_auth = requires_openai_auth self.responses = MockResponsesServer() def __enter__(self) -> AppServerHarness: @@ -238,6 +239,7 @@ class AppServerHarness: def _write_config(self) -> None: """Write config.toml that routes model calls to the mock server.""" config_toml = self.codex_home / "config.toml" + requires_openai_auth = "requires_openai_auth = true\n" if self.requires_openai_auth else "" config_toml.write_text( f""" model = "mock-model" @@ -252,6 +254,7 @@ base_url = "{self.responses.url}/v1" wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 +{requires_openai_auth} """.lstrip() ) diff --git a/sdk/python/tests/test_app_server_login.py b/sdk/python/tests/test_app_server_login.py new file mode 100644 index 0000000000..088310e5e8 --- /dev/null +++ b/sdk/python/tests/test_app_server_login.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import base64 +import json + +from app_server_harness import AppServerHarness + +from openai_codex import AppServerConfig, Codex +from openai_codex.generated.v2_all import ( + ChatgptAuthTokensLoginAccountParams, + LoginAccountParams, +) + + +def _app_server_config(harness: AppServerHarness) -> AppServerConfig: + """Build an isolated login config without inheriting ambient API-key auth.""" + config = harness.app_server_config() + config.env = {**(config.env or {}), "OPENAI_API_KEY": ""} + return config + + +def test_api_key_login_authenticates_follow_up_model_requests(tmp_path) -> None: + """API-key login should authorize the next Responses request with that key.""" + with AppServerHarness(tmp_path, requires_openai_auth=True) as harness: + harness.responses.enqueue_assistant_message("api key auth", response_id="api-key-auth") + + with Codex(config=_app_server_config(harness)) as codex: + codex.login_api_key("sk-sdk-login-test") + result = codex.thread_start().run("prove api key auth") + request = harness.responses.single_request() + + assert { + "final_response": result.final_response, + "authorization": request.header("authorization"), + } == { + "final_response": "api key auth", + "authorization": "Bearer sk-sdk-login-test", + } + + +def test_chatgpt_token_login_authenticates_follow_up_model_requests(tmp_path) -> None: + """ChatGPT token handoff should authorize later Responses requests with that token.""" + account_id = "workspace-sdk-chatgpt" + + def _encode(payload: dict[str, object]) -> str: + raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + # App-server parses claims from the access token before persisting external ChatGPT auth. + header = _encode({"alg": "none", "typ": "JWT"}) + claims = _encode( + { + "email": "sdk-chatgpt@example.com", + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": "pro", + }, + } + ) + access_token = f"{header}.{claims}.sig" + + with AppServerHarness(tmp_path, requires_openai_auth=True) as harness: + harness.responses.enqueue_assistant_message( + "chatgpt token auth", + response_id="chatgpt-token-auth", + ) + + with Codex(config=_app_server_config(harness)) as codex: + login = codex._client.account_login_start( + LoginAccountParams( + root=ChatgptAuthTokensLoginAccountParams( + access_token=access_token, + chatgpt_account_id=account_id, + chatgpt_plan_type="pro", + type="chatgptAuthTokens", + ) + ) + ) + result = codex.thread_start().run("prove chatgpt token auth") + request = harness.responses.single_request() + + assert { + "login_type": login.root.type, + "final_response": result.final_response, + "authorization": request.header("authorization"), + } == { + "login_type": "chatgptAuthTokens", + "final_response": "chatgpt token auth", + "authorization": f"Bearer {access_token}", + } diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index f13fb35c08..04eb18e4cb 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -27,6 +27,10 @@ EXPECTED_ROOT_EXPORTS = [ "Codex", "AsyncCodex", "ApprovalMode", + "ChatgptLoginHandle", + "DeviceCodeLoginHandle", + "AsyncChatgptLoginHandle", + "AsyncDeviceCodeLoginHandle", "Thread", "AsyncThread", "TurnHandle", @@ -55,8 +59,13 @@ EXPECTED_ROOT_EXPORTS = [ ] EXPECTED_TYPES_EXPORTS = [ + "Account", + "AccountLoginCompletedNotification", "ApprovalsReviewer", "AskForApproval", + "CancelLoginAccountResponse", + "CancelLoginAccountStatus", + "GetAccountResponse", "InitializeResponse", "JsonObject", "ModelListResponse", diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 7fcadcf534..0e688ff3c6 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -434,7 +434,7 @@ def test_notebook_sync_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None: [ _notebook_cell_source(1), _notebook_cell_source(2), - _notebook_cell_source(3), + _notebook_cell_source(4), ] ) result = _run_python(runtime_env, source, timeout_s=240) @@ -450,7 +450,7 @@ def test_notebook_advanced_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None: [ _notebook_cell_source(1), _notebook_cell_source(2), - _notebook_cell_source(7), + _notebook_cell_source(8), ] ) result = _run_python(runtime_env, source, timeout_s=360) From f0166cadbb07c79c193b0c0bcdff4b52a836d111 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 17 May 2026 06:17:22 -0700 Subject: [PATCH 02/53] [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 From 545ede569cff13d0aabc782ebd8cdaa6d5cb86ab Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sun, 17 May 2026 15:27:43 +0200 Subject: [PATCH 03/53] Make multi-agent v2 tool namespace configurable (#23147) ## Summary - Add `features.multi_agent_v2.tool_namespace` with config/schema validation for Responses-compatible namespace values. - Thread the resolved namespace into `ToolsConfig` for normal turns and review turns. - Wrap MultiAgentV2 tool specs and registry names in the configured namespace when namespace tools are supported, while falling back to the plain tool names when they are not. ## Validation - `just fmt` - `just write-config-schema` - `cargo test -p codex-features multi_agent_v2_feature_config -- --nocapture` - `cargo test -p codex-core test_build_specs_multi_agent_v2 -- --nocapture` - `cargo test -p codex-core multi_agent_v2_config -- --nocapture` - `cargo test -p codex-core multi_agent_v2_rejects_invalid_tool_namespace -- --nocapture` - `cargo test -p codex-tools` - `git diff --check` --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/config_tests.rs | 47 +++++++ codex-rs/core/src/config/mod.rs | 72 ++++++++++ codex-rs/core/src/session/review.rs | 1 + codex-rs/core/src/session/turn_context.rs | 2 + codex-rs/core/src/tools/registry.rs | 4 - codex-rs/core/src/tools/spec_plan.rs | 133 +++++++++++++++--- .../core/src/tools/spec_plan_model_tests.rs | 64 +++++++++ codex-rs/core/src/tools/spec_plan_tests.rs | 88 ++++++++++++ codex-rs/features/src/feature_configs.rs | 3 + codex-rs/features/src/tests.rs | 3 + codex-rs/tools/src/tool_config.rs | 10 ++ 12 files changed, 409 insertions(+), 24 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index de3632d629..07f49a3cd6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1516,6 +1516,12 @@ "subagent_usage_hint_text": { "type": "string" }, + "tool_namespace": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "type": "string" + }, "usage_hint_enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ce7a36d93f..21d291b309 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10112,6 +10112,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -10142,6 +10143,10 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); + assert_eq!( + config.multi_agent_v2.tool_namespace.as_deref(), + Some("agents") + ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); @@ -10164,6 +10169,7 @@ usage_hint_enabled = true usage_hint_text = "base hint" root_agent_usage_hint_text = "base root hint" subagent_usage_hint_text = "base subagent hint" +tool_namespace = "base_agents" hide_spawn_agent_metadata = true non_code_mode_only = false @@ -10176,6 +10182,7 @@ usage_hint_enabled = false usage_hint_text = "profile hint" root_agent_usage_hint_text = "profile root hint" subagent_usage_hint_text = "profile subagent hint" +tool_namespace = "profile_agents" hide_spawn_agent_metadata = false non_code_mode_only = true "#, @@ -10204,6 +10211,10 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("profile subagent hint") ); + assert_eq!( + config.multi_agent_v2.tool_namespace.as_deref(), + Some("profile_agents") + ); assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); @@ -10464,6 +10475,42 @@ default_wait_timeout_ms = 2500 Ok(()) } +#[tokio::test] +async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> { + for (namespace, expected_message) in [ + ( + "bad namespace", + "features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$", + ), + ( + "functions", + "features.multi_agent_v2.tool_namespace uses a reserved namespace: functions", + ), + ] { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#"[features.multi_agent_v2] +enabled = true +tool_namespace = "{namespace}" +"# + ), + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("invalid multi_agent_v2 tool namespace should fail"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(err.to_string(), expected_message); + } + + Ok(()) +} + #[tokio::test] async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 18158671ec..71a55b98dc 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -953,6 +953,7 @@ pub struct MultiAgentV2Config { pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, + pub tool_namespace: Option, pub hide_spawn_agent_metadata: bool, pub non_code_mode_only: bool, } @@ -969,6 +970,7 @@ impl Default for MultiAgentV2Config { usage_hint_text: None, root_agent_usage_hint_text: None, subagent_usage_hint_text: None, + tool_namespace: None, hide_spawn_agent_metadata: false, non_code_mode_only: false, } @@ -2178,6 +2180,11 @@ fn resolve_multi_agent_v2_config( .or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref())) .cloned() .or(default.subagent_usage_hint_text); + let tool_namespace = profile + .and_then(|config| config.tool_namespace.as_ref()) + .or_else(|| base.and_then(|config| config.tool_namespace.as_ref())) + .cloned() + .or(default.tool_namespace); let hide_spawn_agent_metadata = profile .and_then(|config| config.hide_spawn_agent_metadata) .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) @@ -2196,6 +2203,7 @@ fn resolve_multi_agent_v2_config( usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, + tool_namespace, hide_spawn_agent_metadata, non_code_mode_only, } @@ -2290,6 +2298,69 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res Ok(()) } +fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> { + const LABEL: &str = "features.multi_agent_v2.tool_namespace"; + const MAX_LEN: usize = 64; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + let Some(namespace) = namespace else { + return Ok(()); + }; + if namespace.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not be empty"), + )); + } + if namespace.trim() != namespace { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not have leading or trailing whitespace"), + )); + } + if !namespace + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"), + )); + } + if namespace.chars().count() > MAX_LEN { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must be at most {MAX_LEN} characters"), + )); + } + if namespace == "mcp" + || namespace.starts_with("mcp__") + || RESERVED_RESPONSES_NAMESPACES.contains(&namespace) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} uses a reserved namespace: {namespace}"), + )); + } + + Ok(()) +} + impl Config { #[cfg(test)] async fn load_from_base_config_with_overrides( @@ -2911,6 +2982,7 @@ impl Config { "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } + validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?; let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); let agent_max_threads = if features.enabled(Feature::MultiAgentV2) { if agent_max_threads_from_config.is_some() { diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 497cddd912..7850e8f8b6 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread( .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone()) .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(config.agent_max_threads) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 92c7893d5e..8f0ce2d5fd 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -220,6 +220,7 @@ impl TurnContext { .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone()) .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(self.tools_config.goal_tools) .with_max_concurrent_threads_per_session( @@ -539,6 +540,7 @@ impl Session { .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_tool_namespace(per_turn_config.multi_agent_v2.tool_namespace.clone()) .with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session( diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 1cf0e00623..96b686a83a 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -281,10 +281,6 @@ impl ToolRegistry { self.tools.get(name).map(Arc::clone) } - pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { - self.tools.get(name).map(|tool| tool.exposure()) - } - #[cfg(test)] pub(crate) fn has_tool(&self, name: &ToolName) -> bool { self.tool(name).is_some() diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index be5e6741d4..3f355d2509 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -2,6 +2,7 @@ use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::tools::code_mode::execute_spec::create_code_mode_tool; +use crate::tools::context::ToolInvocation; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; @@ -58,12 +59,14 @@ use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_tools::DiscoverableTool; +use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::TOOL_SEARCH_TOOL_NAME; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolEnvironmentMode; use codex_tools::ToolExecutor; use codex_tools::ToolName; +use codex_tools::ToolOutput; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; @@ -73,6 +76,8 @@ use std::collections::HashSet; use std::sync::Arc; use tracing::warn; +const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; + #[derive(Clone, Copy)] struct ToolRegistryBuildParams<'a> { mcp_tools: Option<&'a [ToolInfo]>, @@ -127,22 +132,29 @@ fn build_model_visible_specs_and_registry( let mut specs = Vec::new(); let mut seen_tool_names = HashSet::new(); for executor in &executors { - if !seen_tool_names.insert(executor.tool_name()) { + let tool_name = executor.tool_name(); + if !seen_tool_names.insert(tool_name.clone()) { continue; } - if executor.exposure().is_direct() + let exposure = executor.exposure(); + if exposure.is_direct() + && !is_hidden_by_code_mode_only(config, &tool_name, exposure) && let Some(spec) = executor.spec() { - specs.push(spec_for_model_request(config, executor.exposure(), spec)); + specs.push(spec_for_model_request(config, exposure, spec)); + } + } + for spec in hosted_specs { + if !is_hidden_by_code_mode_only(config, &ToolName::plain(spec.name()), ToolExposure::Direct) + { + specs.push(spec); } } - specs.extend(hosted_specs); let registry = ToolRegistry::from_tools(executors); let model_visible_specs = merge_into_namespaces(specs) .into_iter() .filter(|spec| config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_))) - .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) .collect(); (model_visible_specs, registry) @@ -210,17 +222,14 @@ fn agent_type_description(config: &ToolsConfig, default_agent_type_description: fn is_hidden_by_code_mode_only( config: &ToolsConfig, - registry: &ToolRegistry, - spec: &ToolSpec, + tool_name: &ToolName, + exposure: ToolExposure, ) -> bool { - if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) { - return false; - } - - let exposure = registry - .tool_exposure(&ToolName::plain(spec.name())) - .unwrap_or(ToolExposure::Direct); - exposure != ToolExposure::DirectModelOnly + config.code_mode_only_enabled + && exposure != ToolExposure::DirectModelOnly + && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name( + tool_name, + )) } fn build_code_mode_executors( @@ -442,6 +451,10 @@ fn collect_tool_executors( } else { ToolExposure::Direct }; + let tool_namespace = config + .namespace_tools + .then_some(config.multi_agent_v2_tool_namespace.as_deref()) + .flatten(); let agent_type_description = agent_type_description(config, params.default_agent_type_description); executors.push(multi_agent_v2_handler( @@ -454,15 +467,33 @@ fn collect_tool_executors( max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, }), exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + SendMessageHandlerV2, + exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + FollowupTaskHandlerV2, + exposure, + tool_namespace, )); - executors.push(multi_agent_v2_handler(SendMessageHandlerV2, exposure)); - executors.push(multi_agent_v2_handler(FollowupTaskHandlerV2, exposure)); executors.push(multi_agent_v2_handler( WaitAgentHandlerV2::new(params.wait_agent_timeouts), exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + CloseAgentHandlerV2, + exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + ListAgentsHandlerV2, + exposure, + tool_namespace, )); - executors.push(multi_agent_v2_handler(CloseAgentHandlerV2, exposure)); - executors.push(multi_agent_v2_handler(ListAgentsHandlerV2, exposure)); } else { let agent_type_description = agent_type_description(config, params.default_agent_type_description); @@ -592,8 +623,70 @@ fn append_extension_tool_executors( fn multi_agent_v2_handler( handler: impl CoreToolRuntime + 'static, exposure: ToolExposure, + namespace: Option<&str>, ) -> Arc { - override_tool_exposure(Arc::new(handler), exposure) + let handler: Arc = match namespace { + Some(namespace) => Arc::new(MultiAgentV2NamespaceOverride { + handler: Arc::new(handler), + namespace: namespace.to_string(), + }), + None => Arc::new(handler), + }; + override_tool_exposure(handler, exposure) +} + +struct MultiAgentV2NamespaceOverride { + handler: Arc, + namespace: String, +} + +#[async_trait::async_trait] +impl ToolExecutor for MultiAgentV2NamespaceOverride { + fn tool_name(&self) -> ToolName { + ToolName::namespaced(self.namespace.clone(), self.handler.tool_name().name) + } + + fn spec(&self) -> Option { + match self.handler.spec()? { + ToolSpec::Function(tool) => Some(ToolSpec::Namespace(ResponsesApiNamespace { + name: self.namespace.clone(), + description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(tool)], + })), + spec => Some(spec), + } + } + + fn exposure(&self) -> ToolExposure { + self.handler.exposure() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.handler.supports_parallel_tool_calls() + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, codex_tools::FunctionCallError> { + self.handler.handle(invocation).await + } +} + +impl CoreToolRuntime for MultiAgentV2NamespaceOverride { + fn matches_kind(&self, payload: &crate::tools::context::ToolPayload) -> bool { + self.handler.matches_kind(payload) + } + + fn search_info(&self) -> Option { + self.handler.search_info() + } + + fn create_diff_consumer( + &self, + ) -> Option> { + self.handler.create_diff_consumer() + } } fn compare_code_mode_tools( diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs index dcc1be30ed..86692e48df 100644 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -1341,3 +1341,67 @@ async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { }; assert!(!spawn_agent.description.contains("exec tool declaration")); } + +#[tokio::test] +async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { + let config = test_config().await; + let model_info = construct_model_info_offline("gpt-5.4", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::CodeModeOnly); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_tool_namespace(Some("agents".to_string())) + .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); + let router = ToolRouter::from_config( + &tools_config, + ToolRouterParams { + mcp_tools: None, + deferred_mcp_tools: None, + discoverable_tools: None, + extension_tool_executors: Vec::new(), + dynamic_tools: &[], + }, + ); + let model_visible_specs = router.model_visible_specs(); + let tool_names = model_visible_specs + .iter() + .map(ToolSpec::name) + .collect::>(); + + assert_eq!(tool_names, vec!["exec", "wait", "agents"]); + + let exec = find_tool(&model_visible_specs, "exec"); + let ToolSpec::Freeform(exec) = exec else { + panic!("exec should be a freeform tool"); + }; + assert!(!exec.description.contains("spawn_agent")); + assert!(!exec.description.contains("wait_agent")); + assert!( + !exec + .description + .contains("do not attempt to use any other tools directly") + ); + + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] { + let tool = find_namespace_function_tool(&model_visible_specs, "agents", tool_name); + assert!(!tool.description.contains("exec tool declaration")); + } +} diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 333efe6f06..3442fbb5ff 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -577,6 +577,77 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { assert_lacks_tool_name(&tools, "resume_agent"); } +#[test] +fn test_build_specs_multi_agent_v2_uses_configured_tool_namespace() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); + let (tools, registry) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + + assert_contains_tool_names(&tools, &["agents"]); + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] { + assert_lacks_tool_name(&tools, tool_name); + assert!(registry.has_tool(&ToolName::namespaced("agents", tool_name))); + assert!(!registry.has_tool(&ToolName::plain(tool_name))); + assert_namespace_contains_function(&tools, "agents", tool_name); + } +} + +#[test] +fn test_build_specs_multi_agent_v2_ignores_tool_namespace_without_namespace_support() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let mut tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); + tools_config.namespace_tools = false; + let (tools, registry) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + + assert_contains_tool_names(&tools, &["spawn_agent", "send_message", "list_agents"]); + assert_lacks_tool_name(&tools, "agents"); + assert!(registry.has_tool(&ToolName::plain("spawn_agent"))); + assert!(!registry.has_tool(&ToolName::namespaced("agents", "spawn_agent"))); +} + #[test] fn test_build_specs_multi_agent_v2_does_not_require_collab_feature() { let model_info = model_info(); @@ -2657,6 +2728,23 @@ fn find_tool<'a>(tools: &'a [ToolSpec], expected_name: &str) -> &'a ToolSpec { .unwrap_or_else(|| panic!("expected tool {expected_name}")) } +fn assert_namespace_contains_function( + tools: &[ToolSpec], + expected_namespace: &str, + expected_name: &str, +) { + let namespace_tool = find_tool(tools, expected_namespace); + let ToolSpec::Namespace(namespace) = namespace_tool else { + panic!("expected namespace tool {expected_namespace}"); + }; + assert!( + namespace.tools.iter().any(|tool| { + matches!(tool, ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name) + }), + "expected tool {expected_name} in namespace {expected_namespace}" + ); +} + fn assert_process_tool_environment_id( tools: &[ToolSpec], expected_name: &str, diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 2b69c2d04d..bfd14237d5 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -30,6 +30,9 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 64), regex(pattern = r"^[a-zA-Z0-9_-]+$"))] + pub tool_namespace: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub non_code_mode_only: Option, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index bd55ef8e7d..f4aa3982b9 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -528,6 +528,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -550,6 +551,7 @@ non_code_mode_only = true usage_hint_text: Some("Custom delegation guidance.".to_string()), root_agent_usage_hint_text: Some("Root guidance.".to_string()), subagent_usage_hint_text: Some("Subagent guidance.".to_string()), + tool_namespace: Some("agents".to_string()), hide_spawn_agent_metadata: Some(true), non_code_mode_only: Some(true), })) @@ -588,6 +590,7 @@ usage_hint_enabled = false usage_hint_text: None, root_agent_usage_hint_text: None, subagent_usage_hint_text: None, + tool_namespace: None, hide_spawn_agent_metadata: None, non_code_mode_only: None, })) diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 6e3ce15526..5c1681d164 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -121,6 +121,7 @@ pub struct ToolsConfig { pub hide_spawn_agent_metadata: bool, pub spawn_agent_usage_hint: bool, pub spawn_agent_usage_hint_text: Option, + pub multi_agent_v2_tool_namespace: Option, pub max_concurrent_threads_per_session: Option, pub wait_agent_min_timeout_ms: Option, pub wait_agent_max_timeout_ms: Option, @@ -260,6 +261,7 @@ impl ToolsConfig { hide_spawn_agent_metadata: false, spawn_agent_usage_hint: true, spawn_agent_usage_hint_text: None, + multi_agent_v2_tool_namespace: None, max_concurrent_threads_per_session: None, wait_agent_min_timeout_ms: None, wait_agent_max_timeout_ms: None, @@ -316,6 +318,14 @@ impl ToolsConfig { self } + pub fn with_multi_agent_v2_tool_namespace( + mut self, + multi_agent_v2_tool_namespace: Option, + ) -> Self { + self.multi_agent_v2_tool_namespace = multi_agent_v2_tool_namespace; + self + } + pub fn with_multi_agent_v2_non_code_mode_only( mut self, multi_agent_v2_non_code_mode_only: bool, From 0a83353ca3f7889efbf322b74476c8ebba0a2d36 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 17 May 2026 08:39:41 -0700 Subject: [PATCH 04/53] test: reduce core sandbox policy test setup (#23036) ## Why `SandboxPolicy` is a legacy compatibility shape, but several core tests still used it for ordinary turn setup even when the runtime path now carries `PermissionProfile`. With the first cleanup PR merged, this follow-up trims more core test scaffolding so remaining `SandboxPolicy` matches are easier to classify as production compatibility, legacy-boundary coverage, or explicit conversion tests. ## What Changed - Updated apply-patch handler and runtime tests to pass `PermissionProfile` directly. - Changed sandboxing test helpers to build permission profiles without first creating `SandboxPolicy` values. - Converted request-permissions integration turns to pass `PermissionProfile` through the test helper, leaving legacy sandbox projection at the `Op::UserTurn` boundary. - Converted unified exec integration helpers and direct turn submissions to use `PermissionProfile` values instead of `SandboxPolicy` setup. - Removed now-unused `SandboxPolicy` imports from the touched core tests. ## Test Plan - `just fmt` - `cargo test -p codex-core --lib tools::sandboxing::tests` - `cargo test -p codex-core --lib tools::runtimes::apply_patch::tests` - `cargo test -p codex-core --lib tools::handlers::apply_patch::tests` - `cargo test -p codex-core --lib unified_exec::process_manager::tests` - `cargo test -p codex-core --test all request_permissions::` - `cargo test -p codex-core --test all unified_exec::` - `just fix -p codex-core` --- .../src/tools/handlers/apply_patch_tests.rs | 23 ++-- .../src/tools/runtimes/apply_patch_tests.rs | 4 +- codex-rs/core/src/tools/sandboxing.rs | 2 - codex-rs/core/src/tools/sandboxing_tests.rs | 15 +- .../src/unified_exec/process_manager_tests.rs | 10 +- .../core/tests/suite/request_permissions.rs | 97 ++++++------- codex-rs/core/tests/suite/unified_exec.rs | 130 +++++++++--------- 7 files changed, 127 insertions(+), 154 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index 99854d23ea..e9a118641a 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -3,7 +3,6 @@ use codex_apply_patch::MaybeApplyPatchVerified; use codex_exec_server::LOCAL_FS; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::SandboxPolicy; use core_test_support::PathBufExt; use core_test_support::PathExt; use pretty_assertions::assert_eq; @@ -251,12 +250,11 @@ fn write_permissions_for_paths_skip_dirs_already_writable_under_workspace_root() std::fs::create_dir_all(&nested).expect("create nested dir"); let file_path = AbsolutePathBuf::try_from(nested.join("file.txt")) .expect("nested file path should be absolute"); - let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }); + let sandbox_policy = FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd); @@ -273,12 +271,11 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() { let file_path = AbsolutePathBuf::try_from(outside.join("file.txt")) .expect("outside file path should be absolute"); let cwd_abs = cwd.abs(); - let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }); + let sandbox_policy = FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd_abs); let expected_outside = diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index ff2c3d1e89..f4d8d461b2 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -7,7 +7,6 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxType; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; @@ -201,8 +200,7 @@ async fn file_system_sandbox_context_uses_active_attempt() { additional_permissions: Some(additional_permissions.clone()), permissions_preapproved: false, }; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let file_system_policy = FileSystemSandboxPolicy::default(); let permissions = PermissionProfile::from_runtime_permissions( &file_system_policy, NetworkSandboxPolicy::Restricted, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 656615d4d1..1ca589a5f9 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -19,8 +19,6 @@ use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; -#[cfg(test)] -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformError; diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 3989862ab5..99b43699a9 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -5,7 +5,6 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::NetworkAccess; use pretty_assertions::assert_eq; use serde_json::json; @@ -39,13 +38,10 @@ fn bash_permission_request_payload_includes_description_when_present() { #[test] fn external_sandbox_skips_exec_approval_on_request() { - let sandbox_policy = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }; assert_eq!( default_exec_approval_requirement( AskForApproval::OnRequest, - &FileSystemSandboxPolicy::from(&sandbox_policy), + &FileSystemSandboxPolicy::external_sandbox(), ), ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -56,11 +52,10 @@ fn external_sandbox_skips_exec_approval_on_request() { #[test] fn restricted_sandbox_requires_exec_approval_on_request() { - let sandbox_policy = SandboxPolicy::new_read_only_policy(); assert_eq!( default_exec_approval_requirement( AskForApproval::OnRequest, - &FileSystemSandboxPolicy::from(&sandbox_policy) + &FileSystemSandboxPolicy::default() ), ExecApprovalRequirement::NeedsApproval { reason: None, @@ -79,9 +74,8 @@ fn default_exec_approval_requirement_rejects_sandbox_prompt_when_granular_disabl mcp_elicitations: true, }); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); let requirement = - default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy)); + default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::default()); assert_eq!( requirement, @@ -101,9 +95,8 @@ fn default_exec_approval_requirement_keeps_prompt_when_granular_allows_sandbox_a mcp_elicitations: false, }); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); let requirement = - default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy)); + default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::default()); assert_eq!( requirement, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index c554a2a4e4..cd83ce7a9c 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -71,16 +71,10 @@ fn exec_server_params_use_env_policy_overlay_contract() { .expect("current dir") .try_into() .expect("absolute path"); - let sandbox_policy = codex_protocol::protocol::SandboxPolicy::DangerFullAccess; let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from(&sandbox_policy); + codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted; - let permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ); + let permission_profile = codex_protocol::models::PermissionProfile::Disabled; let request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], cwd: cwd.clone(), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 0463ea3e2b..cf021f29e4 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -15,7 +15,6 @@ use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -33,6 +32,7 @@ use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use regex_lite::Regex; @@ -184,9 +184,11 @@ async fn submit_turn( test: &TestCodex, prompt: &str, approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, + permission_profile: CorePermissionProfile, ) -> Result<()> { let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.cwd.path()); test.codex .submit(Op::UserTurn { environments: None, @@ -199,7 +201,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, - permission_profile: None, + permission_profile, model: session_model, effort: None, summary: None, @@ -285,16 +287,7 @@ async fn expect_request_permissions_event( } } -fn workspace_write_excluding_tmp() -> SandboxPolicy { - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - } -} - -fn workspace_write_excluding_tmp_profile() -> CorePermissionProfile { +fn workspace_write_excluding_tmp() -> CorePermissionProfile { CorePermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, @@ -330,7 +323,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -384,7 +377,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, command).await; assert_eq!( approval.additional_permissions, @@ -428,7 +421,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio request_permissions: false, mcp_elicitations: true, }); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -476,7 +469,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio &test, "request permissions under granular.request_permissions = false", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -514,7 +507,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -578,7 +571,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, command).await; assert_eq!( @@ -618,7 +611,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -675,7 +668,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -721,7 +714,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -779,7 +772,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -823,8 +816,8 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -890,7 +883,7 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -930,8 +923,8 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul skip_if_no_network!(Ok(())); let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -995,7 +988,7 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -1038,8 +1031,8 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1110,7 +1103,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1165,8 +1158,8 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1228,7 +1221,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1286,8 +1279,8 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1345,7 +1338,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1401,8 +1394,8 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1458,7 +1451,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i &test, "write outside the workspace without inline permission feature", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1516,8 +1509,8 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1611,7 +1604,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1683,8 +1676,8 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1733,7 +1726,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { &test, "request permissions for later use", approval_policy, - sandbox_policy.clone(), + permission_profile.clone(), ) .await?; @@ -1778,7 +1771,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { &test, "try to reuse permissions in a later turn", approval_policy, - sandbox_policy, + permission_profile, ) .await?; wait_for_completion(&test).await; @@ -1799,8 +1792,8 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1854,7 +1847,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { &test, "request session permissions for later use", approval_policy, - sandbox_policy.clone(), + permission_profile.clone(), ) .await?; @@ -1896,7 +1889,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { &test, "reuse session permissions in a later turn", approval_policy, - sandbox_policy, + permission_profile, ) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 92b8aa9047..6b962116af 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -15,7 +15,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::managed_network_requirements_loader; @@ -35,6 +34,7 @@ use core_test_support::skip_if_windows; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::wait_for_event_with_timeout; @@ -185,9 +185,11 @@ async fn wait_for_raw_unified_exec_output( async fn submit_unified_exec_turn( test: &TestCodex, prompt: &str, - sandbox_policy: SandboxPolicy, + permission_profile: PermissionProfile, ) -> Result<()> { let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex .submit(Op::UserTurn { @@ -201,7 +203,7 @@ async fn submit_unified_exec_turn( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy, - permission_profile: None, + permission_profile, model: session_model, effort: None, summary: None, @@ -272,6 +274,8 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let codex = test.codex.clone(); let cwd = test.cwd_path().to_path_buf(); let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, &cwd); codex .submit(Op::UserTurn { @@ -284,8 +288,8 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -403,7 +407,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "emit begin event", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "emit begin event", PermissionProfile::Disabled).await?; let begin_event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), @@ -467,7 +471,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { submit_unified_exec_turn( &test, "run relative workdir test", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -532,7 +536,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "run workdir test", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "run workdir test", PermissionProfile::Disabled).await?; let begin_event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), @@ -609,7 +613,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "emit end event", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "emit end event", PermissionProfile::Disabled).await?; let end_event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), @@ -667,7 +671,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "emit delta", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "emit delta", PermissionProfile::Disabled).await?; let event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), @@ -729,7 +733,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { submit_unified_exec_turn( &test, "exercise full unified exec lifecycle", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -872,7 +876,7 @@ async fn unified_exec_short_lived_network_denial_emits_failed_end_event() -> Res #[allow(clippy::expect_used)] async fn unified_exec_network_denial_test( server: &wiremock::MockServer, -) -> Result<(TestCodex, SandboxPolicy)> { +) -> Result<(TestCodex, PermissionProfile)> { use codex_config::Constrained; use std::sync::Arc; use tempfile::TempDir; @@ -897,10 +901,7 @@ allow_local_binding = true /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, ); - let sandbox_policy = permission_profile_for_config - .clone() - .to_legacy_sandbox_policy(home.path()) - .expect("workspace-write profile should project to legacy policy"); + let permission_profile = permission_profile_for_config.clone(); let mut builder = test_codex() .with_home(home) .with_cloud_requirements(managed_network_requirements_loader()) @@ -922,7 +923,7 @@ allow_local_binding = true "expected managed network proxy config to be present" ); - Ok((test, sandbox_policy)) + Ok((test, permission_profile)) } async fn mount_unified_exec_network_denial_responses( @@ -1040,7 +1041,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "stdin delta", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "stdin delta", PermissionProfile::Disabled).await?; let mut terminal_interaction = None; @@ -1160,7 +1161,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( submit_unified_exec_turn( &test, "delayed terminal interaction output", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1305,7 +1306,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { submit_unified_exec_turn( &test, "check poll event behavior", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1395,7 +1396,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "run metadata test", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "run metadata test", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -1494,7 +1495,7 @@ async fn exec_command_clamps_model_requested_max_output_tokens_to_policy() -> Re submit_unified_exec_turn( &test, "run clamped max output test", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1577,7 +1578,7 @@ async fn write_stdin_clamps_model_requested_max_output_tokens_to_policy() -> Res submit_unified_exec_turn( &test, "run clamped write_stdin output test", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1642,7 +1643,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { submit_unified_exec_turn( &test, "check default pipe mode", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1709,7 +1710,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "check tty enabled", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "check tty enabled", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -1776,7 +1777,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { submit_unified_exec_turn( &test, "watch early exit timing", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1895,7 +1896,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { submit_unified_exec_turn( &test, "test write_stdin exit behavior", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -2048,7 +2049,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "end on exit", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "end on exit", PermissionProfile::Disabled).await?; // We expect the ExecCommandEnd event to match the initial exec_command call_id. let end_event = wait_for_event_match(&test.codex, |msg| match msg { @@ -2114,6 +2115,9 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); codex .submit(Op::UserTurn { @@ -2123,11 +2127,11 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2209,6 +2213,9 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); codex .submit(Op::UserTurn { @@ -2218,11 +2225,11 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2314,7 +2321,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "run unified exec", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "run unified exec", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2432,12 +2439,7 @@ PY ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "exercise lag handling", - SandboxPolicy::DangerFullAccess, - ) - .await?; + submit_unified_exec_turn(&test, "exercise lag handling", PermissionProfile::Disabled).await?; // This is a worst case scenario for the truncate logic, and CI can spend a // while draining the lagged tail before the follow-up tool call completes. wait_for_event_with_timeout( @@ -2532,7 +2534,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "check timeout", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "check timeout", PermissionProfile::Disabled).await?; loop { let event = test.codex.next_event().await.expect("event"); @@ -2608,12 +2610,7 @@ PY ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "summarize large output", - SandboxPolicy::DangerFullAccess, - ) - .await?; + submit_unified_exec_turn(&test, "summarize large output", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2683,6 +2680,9 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex .submit(Op::UserTurn { @@ -2692,12 +2692,12 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, // Important! - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2799,7 +2799,9 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let read_only_policy = SandboxPolicy::new_read_only_policy(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex .submit(Op::UserTurn { environments: None, @@ -2808,11 +2810,11 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: read_only_policy, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2929,6 +2931,9 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex .submit(Op::UserTurn { @@ -2938,11 +2943,11 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -3025,12 +3030,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "summarize large output", - SandboxPolicy::DangerFullAccess, - ) - .await?; + submit_unified_exec_turn(&test, "summarize large output", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -3148,7 +3148,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { let response_mock = mount_sse_sequence(&server, vec![first_response, completion_response]).await; - submit_unified_exec_turn(&test, "fill session cache", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "fill session cache", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) From e7bffc5a20e92cbc64d6c16a1b257d0b2e4cd5df Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 17 May 2026 09:05:44 -0700 Subject: [PATCH 05/53] [codex] Accept string input for Python turns (#23162) ## Summary - Allow thread.turn and turn.steer, including async variants, to accept RunInput so plain strings work alongside typed input objects. - Export RunInput and update the SDK artifact generator so regenerated turn methods keep the same signature and normalization. - Update docs, examples, notebook cells, and tests to use string shorthand for text-only turns while keeping typed inputs for multimodal input. ## Validation - uv run --extra dev ruff format . - uv run --extra dev ruff check --output-format=github . - python3 -m py_compile sdk/python/src/openai_codex/__init__.py sdk/python/src/openai_codex/api.py sdk/python/src/openai_codex/_inputs.py sdk/python/scripts/update_sdk_artifacts.py sdk/python/tests/test_public_api_signatures.py sdk/python/tests/test_app_server_streaming.py sdk/python/tests/test_app_server_turn_controls.py sdk/python/tests/test_real_app_server_integration.py - python3 -c "import json; json.load(open('sdk/python/notebooks/sdk_walkthrough.ipynb'))" - sdk/python/.venv/bin/python -c "import inspect, openai_codex; from openai_codex import Thread, AsyncThread, TurnHandle, AsyncTurnHandle, RunInput; funcs=[Thread.run, Thread.turn, AsyncThread.run, AsyncThread.turn, TurnHandle.steer, AsyncTurnHandle.steer]; assert all(inspect.signature(fn).parameters['input'].annotation == 'RunInput' for fn in funcs); assert RunInput is openai_codex.RunInput" --- sdk/python/README.md | 2 + sdk/python/docs/api-reference.md | 19 +++-- sdk/python/docs/getting-started.md | 1 + sdk/python/examples/02_turn_run/async.py | 4 +- sdk/python/examples/02_turn_run/sync.py | 4 +- .../examples/03_turn_stream_events/async.py | 4 +- .../examples/03_turn_stream_events/sync.py | 4 +- .../examples/05_existing_thread/async.py | 6 +- .../examples/05_existing_thread/sync.py | 6 +- .../06_thread_lifecycle_and_controls/async.py | 14 ++-- .../06_thread_lifecycle_and_controls/sync.py | 10 +-- sdk/python/examples/09_async_parity/sync.py | 4 +- .../10_error_handling_and_retry/async.py | 3 +- .../10_error_handling_and_retry/sync.py | 3 +- sdk/python/examples/11_cli_mini_app/async.py | 3 +- sdk/python/examples/11_cli_mini_app/sync.py | 3 +- .../12_turn_params_kitchen_sink/async.py | 3 +- .../12_turn_params_kitchen_sink/sync.py | 3 +- .../13_model_select_and_turn_params/async.py | 5 +- .../13_model_select_and_turn_params/sync.py | 5 +- sdk/python/examples/14_turn_controls/async.py | 10 +-- sdk/python/examples/14_turn_controls/sync.py | 12 +-- sdk/python/examples/README.md | 3 + sdk/python/notebooks/sdk_walkthrough.ipynb | 73 +++++++++++-------- sdk/python/scripts/update_sdk_artifacts.py | 8 +- sdk/python/src/openai_codex/__init__.py | 2 + sdk/python/src/openai_codex/api.py | 26 ++++--- sdk/python/tests/test_app_server_streaming.py | 22 ++++-- .../tests/test_app_server_turn_controls.py | 8 +- .../tests/test_public_api_signatures.py | 17 +++++ .../tests/test_real_app_server_integration.py | 18 ++--- 31 files changed, 171 insertions(+), 134 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index 9107d15ba9..5b8e6b8178 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -102,6 +102,8 @@ target wheel. The SDK package version and runtime package version must match. - `Codex()` is eager and performs startup + `initialize` in the constructor. - Use context managers (`with Codex() as codex:`) to ensure shutdown. +- Plain strings are accepted anywhere a turn input is accepted; they are + shorthand for `TextInput(...)`. - Prefer `thread.run("...")` for the common case. Use `thread.turn(...)` when you need streaming, steering, or interrupt control. - For transient overload, use `retry_on_overload` from the package root. diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 4bee871f8c..db78ff5f1d 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -3,7 +3,7 @@ Public surface of `openai_codex` for app-server v2. This SDK surface is experimental. Turn streams are routed by turn ID so one client can consume multiple active turns concurrently. -Thread and turn starts expose `approval_mode`. `ApprovalMode.auto_review` is the default; use `ApprovalMode.deny_all` to deny escalated permissions. +Thread starts default to `ApprovalMode.auto_review`; turn starts accept an optional `approval_mode` override. ## Package Entry @@ -23,6 +23,7 @@ from openai_codex import ( TurnResult, Input, InputItem, + RunInput, TextInput, ImageInput, LocalImageInput, @@ -147,16 +148,16 @@ 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) -> 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` +- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult` +- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnHandle` - `read(*, include_turns: bool = False) -> ThreadReadResponse` - `set_name(name: str) -> ThreadSetNameResponse` - `compact() -> ThreadCompactStartResponse` ### AsyncThread -- `run(input: str | Input, *, approval_mode=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]` +- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]` +- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]` - `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]` - `set_name(name: str) -> Awaitable[ThreadSetNameResponse]` - `compact() -> Awaitable[ThreadCompactStartResponse]` @@ -185,7 +186,7 @@ Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, ### TurnHandle -- `steer(input: Input) -> TurnSteerResponse` +- `steer(input: str | Input) -> TurnSteerResponse` - `interrupt() -> TurnInterruptResponse` - `stream() -> Iterator[Notification]` - `run() -> TurnResult` @@ -197,7 +198,7 @@ Behavior notes: ### AsyncTurnHandle -- `steer(input: Input) -> Awaitable[TurnSteerResponse]` +- `steer(input: str | Input) -> Awaitable[TurnSteerResponse]` - `interrupt() -> Awaitable[TurnInterruptResponse]` - `stream() -> AsyncIterator[Notification]` - `run() -> Awaitable[TurnResult]` @@ -218,8 +219,12 @@ Behavior notes: InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput Input = list[InputItem] | InputItem +RunInput = Input | str ``` +Use a plain `str` as shorthand for `TextInput(...)` anywhere a turn input is accepted: +`thread.run("...")`, `thread.turn("...")`, and `turn.steer("...")`. + ## Public Types The SDK wrappers return and accept public app-server models wherever possible: diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 1df0026c68..b21db2a207 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -72,6 +72,7 @@ What happened: - `thread_start(...)` created a thread. - `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. +- plain strings are accepted anywhere a turn input is accepted; typed inputs are still available for multimodal and structured cases - 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 diff --git a/sdk/python/examples/02_turn_run/async.py b/sdk/python/examples/02_turn_run/async.py index debfad8d6e..c4071cde47 100644 --- a/sdk/python/examples/02_turn_run/async.py +++ b/sdk/python/examples/02_turn_run/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,7 +19,7 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - turn = await thread.turn(TextInput("Give 3 bullets about SIMD.")) + turn = await thread.turn("Give 3 bullets about SIMD.") result = await turn.run() print("thread_id:", thread.id) diff --git a/sdk/python/examples/02_turn_run/sync.py b/sdk/python/examples/02_turn_run/sync.py index c42bffa1f2..af22a220ba 100644 --- a/sdk/python/examples/02_turn_run/sync.py +++ b/sdk/python/examples/02_turn_run/sync.py @@ -9,11 +9,11 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex 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() + result = thread.turn("Give 3 bullets about SIMD.").run() print("thread_id:", thread.id) print("turn_id:", result.id) diff --git a/sdk/python/examples/03_turn_stream_events/async.py b/sdk/python/examples/03_turn_stream_events/async.py index f0d3f1427d..e145b2222b 100644 --- a/sdk/python/examples/03_turn_stream_events/async.py +++ b/sdk/python/examples/03_turn_stream_events/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,7 +19,7 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - turn = await thread.turn(TextInput("Explain SIMD in 3 short bullets.")) + turn = await thread.turn("Explain SIMD in 3 short bullets.") event_count = 0 saw_started = False diff --git a/sdk/python/examples/03_turn_stream_events/sync.py b/sdk/python/examples/03_turn_stream_events/sync.py index 5a76bcb70f..f5a734dc38 100644 --- a/sdk/python/examples/03_turn_stream_events/sync.py +++ b/sdk/python/examples/03_turn_stream_events/sync.py @@ -9,11 +9,11 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - turn = thread.turn(TextInput("Explain SIMD in 3 short bullets.")) + turn = thread.turn("Explain SIMD in 3 short bullets.") event_count = 0 saw_started = False diff --git a/sdk/python/examples/05_existing_thread/async.py b/sdk/python/examples/05_existing_thread/async.py index df3946ac87..e1f4db7105 100644 --- a/sdk/python/examples/05_existing_thread/async.py +++ b/sdk/python/examples/05_existing_thread/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -20,12 +20,12 @@ async def main() -> None: model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - first_turn = await original.turn(TextInput("Tell me one fact about Saturn.")) + first_turn = await original.turn("Tell me one fact about Saturn.") _ = await first_turn.run() print("Created thread:", original.id) resumed = await codex.thread_resume(original.id) - second_turn = await resumed.turn(TextInput("Continue with one more fact.")) + second_turn = await resumed.turn("Continue with one more fact.") second = await second_turn.run() print(second.final_response) diff --git a/sdk/python/examples/05_existing_thread/sync.py b/sdk/python/examples/05_existing_thread/sync.py index a0f0d37e27..7110ebac7f 100644 --- a/sdk/python/examples/05_existing_thread/sync.py +++ b/sdk/python/examples/05_existing_thread/sync.py @@ -9,15 +9,15 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: # Create an initial thread and turn so we have a real thread to resume. original = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - first = original.turn(TextInput("Tell me one fact about Saturn.")).run() + first = original.turn("Tell me one fact about Saturn.").run() print("Created thread:", original.id) # Resume the existing thread by ID. resumed = codex.thread_resume(original.id) - second = resumed.turn(TextInput("Continue with one more fact.")).run() + second = resumed.turn("Continue with one more fact.").run() 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 24afbde6c2..7a786a1d79 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,10 +19,8 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - first = await ( - await thread.turn(TextInput("One sentence about structured planning.")) - ).run() - second = await (await thread.turn(TextInput("Now restate it for a junior engineer."))).run() + first = await (await thread.turn("One sentence about structured planning.")).run() + second = await (await thread.turn("Now restate it for a junior engineer.")).run() reopened = await codex.thread_resume(thread.id) listing_active = await codex.thread_list(limit=20, archived=False) @@ -38,13 +36,11 @@ async def main() -> None: model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - resumed_result = await ( - await resumed.turn(TextInput("Continue in one short sentence.")) - ).run() + resumed_result = await (await resumed.turn("Continue in one short sentence.")).run() 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.")) + await forked.turn("Take a different angle in one short sentence.") ).run() compact_result = await unarchived.compact() 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 0958002dd9..744b56ebb7 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py @@ -9,12 +9,12 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - first = thread.turn(TextInput("One sentence about structured planning.")).run() - second = thread.turn(TextInput("Now restate it for a junior engineer.")).run() + first = thread.turn("One sentence about structured planning.").run() + second = thread.turn("Now restate it for a junior engineer.").run() reopened = codex.thread_resume(thread.id) listing_active = codex.thread_list(limit=20, archived=False) @@ -30,10 +30,10 @@ with Codex(config=runtime_config()) as codex: model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() + resumed_result = resumed.turn("Continue in one short sentence.").run() 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_result = forked.turn("Take a different angle in one short sentence.").run() compact_result = unarchived.compact() diff --git a/sdk/python/examples/09_async_parity/sync.py b/sdk/python/examples/09_async_parity/sync.py index 4b5d567159..a7ac8abbd7 100644 --- a/sdk/python/examples/09_async_parity/sync.py +++ b/sdk/python/examples/09_async_parity/sync.py @@ -9,13 +9,13 @@ from _bootstrap import ensure_local_sdk_src, runtime_config, server_label ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: print("Server:", server_label(codex.metadata)) thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - turn = thread.turn(TextInput("Say hello in one sentence.")) + turn = thread.turn("Say hello in one sentence.") result = turn.run() print("Thread:", thread.id) 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 f1711bffc9..71f954544f 100644 --- a/sdk/python/examples/10_error_handling_and_retry/async.py +++ b/sdk/python/examples/10_error_handling_and_retry/async.py @@ -18,7 +18,6 @@ from openai_codex import ( AsyncCodex, JsonRpcError, ServerBusyError, - TextInput, is_retryable_error, ) from openai_codex.types import TurnStatus @@ -82,7 +81,7 @@ async def main() -> None: def _run_turn(thread, prompt: str): async def _inner(): - turn = await thread.turn(TextInput(prompt)) + turn = await thread.turn(prompt) return await turn.run() return _inner 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 c134063c80..9c0f9f30e5 100644 --- a/sdk/python/examples/10_error_handling_and_retry/sync.py +++ b/sdk/python/examples/10_error_handling_and_retry/sync.py @@ -13,7 +13,6 @@ from openai_codex import ( Codex, JsonRpcError, ServerBusyError, - TextInput, retry_on_overload, ) from openai_codex.types import TurnStatus @@ -23,7 +22,7 @@ with Codex(config=runtime_config()) as codex: try: result = retry_on_overload( - lambda: thread.turn(TextInput("Summarize retry best practices in 3 bullets.")).run(), + lambda: thread.turn("Summarize retry best practices in 3 bullets.").run(), max_attempts=3, initial_delay_s=0.25, max_delay_s=2.0, diff --git a/sdk/python/examples/11_cli_mini_app/async.py b/sdk/python/examples/11_cli_mini_app/async.py index df9fc89b86..e2a695a924 100644 --- a/sdk/python/examples/11_cli_mini_app/async.py +++ b/sdk/python/examples/11_cli_mini_app/async.py @@ -13,7 +13,6 @@ import asyncio from openai_codex import ( AsyncCodex, - TextInput, ) from openai_codex.types import ( ThreadTokenUsageUpdatedNotification, @@ -51,7 +50,7 @@ async def main() -> None: if user_input in {"/exit", "/quit"}: break - turn = await thread.turn(TextInput(user_input)) + turn = await thread.turn(user_input) usage = None status = None error = None diff --git a/sdk/python/examples/11_cli_mini_app/sync.py b/sdk/python/examples/11_cli_mini_app/sync.py index fd16f930dc..0bf906647c 100644 --- a/sdk/python/examples/11_cli_mini_app/sync.py +++ b/sdk/python/examples/11_cli_mini_app/sync.py @@ -11,7 +11,6 @@ ensure_local_sdk_src() from openai_codex import ( Codex, - TextInput, ) from openai_codex.types import ( ThreadTokenUsageUpdatedNotification, @@ -46,7 +45,7 @@ with Codex(config=runtime_config()) as codex: if user_input in {"/exit", "/quit"}: break - turn = thread.turn(TextInput(user_input)) + turn = thread.turn(user_input) usage = None status = None error = None 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 258d674a88..f44ea229b0 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/async.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/async.py @@ -14,7 +14,6 @@ import asyncio from openai_codex import ( AsyncCodex, - TextInput, ) from openai_codex.types import ( Personality, @@ -49,7 +48,7 @@ async def main() -> None: ) turn = await thread.turn( - TextInput(PROMPT), + PROMPT, output_schema=OUTPUT_SCHEMA, personality=Personality.pragmatic, summary=SUMMARY, 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 feced36f20..160a9bf187 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py @@ -12,7 +12,6 @@ ensure_local_sdk_src() from openai_codex import ( Codex, - TextInput, ) from openai_codex.types import ( Personality, @@ -43,7 +42,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(PROMPT), + PROMPT, output_schema=OUTPUT_SCHEMA, personality=Personality.pragmatic, summary=SUMMARY, 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 31a0c9c243..144169c46c 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 @@ -13,7 +13,6 @@ import asyncio from openai_codex import ( AsyncCodex, - TextInput, ) from openai_codex.types import ( Personality, @@ -91,7 +90,7 @@ async def main() -> None: ) first_turn = await thread.turn( - TextInput("Give one short sentence about reliable production releases."), + "Give one short sentence about reliable production releases.", model=selected_model.model, effort=selected_effort, ) @@ -101,7 +100,7 @@ async def main() -> None: print("items:", len(first.items)) second_turn = await thread.turn( - TextInput("Return JSON for a safe feature-flag rollout plan."), + "Return JSON for a safe feature-flag rollout plan.", cwd=str(Path.cwd()), effort=selected_effort, model=selected_model.model, 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 9252f3913c..962665363f 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 @@ -11,7 +11,6 @@ ensure_local_sdk_src() from openai_codex import ( Codex, - TextInput, ) from openai_codex.types import ( Personality, @@ -88,7 +87,7 @@ with Codex(config=runtime_config()) as codex: ) first = thread.turn( - TextInput("Give one short sentence about reliable production releases."), + "Give one short sentence about reliable production releases.", model=selected_model.model, effort=selected_effort, ).run() @@ -97,7 +96,7 @@ with Codex(config=runtime_config()) as codex: print("items:", len(first.items)) second = thread.turn( - TextInput("Return JSON for a safe feature-flag rollout plan."), + "Return JSON for a safe feature-flag rollout plan.", cwd=str(Path.cwd()), effort=selected_effort, model=selected_model.model, diff --git a/sdk/python/examples/14_turn_controls/async.py b/sdk/python/examples/14_turn_controls/async.py index f044dadbbe..86e8def04f 100644 --- a/sdk/python/examples/14_turn_controls/async.py +++ b/sdk/python/examples/14_turn_controls/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,10 +19,8 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - steer_turn = await thread.turn( - TextInput("Count from 1 to 40 with commas, then one summary sentence.") - ) - steer_result = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) + steer_turn = await thread.turn("Count from 1 to 40 with commas, then one summary sentence.") + steer_result = await steer_turn.steer("Keep it brief and stop after 10 numbers.") steer_event_count = 0 steer_completed_status = None @@ -40,7 +38,7 @@ async def main() -> None: steer_preview = "".join(steer_deltas).strip() interrupt_turn = await thread.turn( - TextInput("Count from 1 to 200 with commas, then one summary sentence.") + "Count from 1 to 200 with commas, then one summary sentence." ) interrupt_result = await interrupt_turn.interrupt() diff --git a/sdk/python/examples/14_turn_controls/sync.py b/sdk/python/examples/14_turn_controls/sync.py index 6b6d046a43..c8f1a75e28 100644 --- a/sdk/python/examples/14_turn_controls/sync.py +++ b/sdk/python/examples/14_turn_controls/sync.py @@ -9,14 +9,12 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - steer_turn = thread.turn( - TextInput("Count from 1 to 40 with commas, then one summary sentence.") - ) - steer_result = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) + steer_turn = thread.turn("Count from 1 to 40 with commas, then one summary sentence.") + steer_result = steer_turn.steer("Keep it brief and stop after 10 numbers.") steer_event_count = 0 steer_completed_status = None @@ -33,9 +31,7 @@ with Codex(config=runtime_config()) as codex: 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_turn = thread.turn("Count from 1 to 200 with commas, then one summary sentence.") interrupt_result = interrupt_turn.interrupt() interrupt_event_count = 0 diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index bef1a16c0b..eef3893721 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -8,6 +8,9 @@ Each example folder contains runnable versions: All examples intentionally use only public SDK exports from `openai_codex` and `openai_codex.types`. +Examples use plain strings for text-only turns and typed input objects for +multimodal or structured input lists. + ## Prerequisites - Python `>=3.10` diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 1eb80b3a71..bd313a02b1 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -12,6 +12,7 @@ { "cell_type": "code", "execution_count": null, + "id": "1b6614a5", "metadata": {}, "outputs": [], "source": [ @@ -97,6 +98,7 @@ { "cell_type": "code", "execution_count": null, + "id": "137a6d64", "metadata": {}, "outputs": [], "source": [ @@ -115,6 +117,7 @@ { "cell_type": "code", "execution_count": null, + "id": "5fae892d", "metadata": {}, "outputs": [], "source": [ @@ -122,46 +125,42 @@ "with Codex() as codex:\n", " # Open this URL and call `wait()` without canceling when completing login for real.\n", " login = codex.login_chatgpt()\n", - " canceled = login.cancel()\n", + " print('Please complete login at:', login.auth_url)\n", " completed = login.wait()\n", " account = codex.account()\n", "\n", " print('login.id:', login.login_id)\n", " print('login.auth_url:', login.auth_url)\n", - " print('login.cancel.status:', canceled.status)\n", " print('login.completed.success:', completed.success)\n", - " print('account.requires_openai_auth:', account.requires_openai_auth)\n" + " print('account:', account.email)\n" ] }, { "cell_type": "code", "execution_count": null, + "id": "ebdc04d9", "metadata": {}, "outputs": [], "source": [ "# Cell 3: simple sync conversation\n", "with Codex() as codex:\n", " 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", - " print('server:', server_label(codex.metadata))\n", - " print('status:', result.status)\n", - " print(result.final_response)\n", - " print('items:', len(result.items))\n" + " result = thread.run('Explain gradient descent in 3 bullets.')\n", + " print(result.final_response)\n" ] }, { "cell_type": "code", "execution_count": null, + "id": "bb4abb96", "metadata": {}, "outputs": [], "source": [ "# Cell 4: multi-turn continuity in same thread\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - "\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", + " first = thread.turn('Give a short summary of transformers.').run()\n", + " second = thread.turn('Now explain that to a high-school student.').run()\n", " print('first status:', first.status)\n", " print('second status:', second.status)\n", " print('second text:', second.final_response)\n" @@ -170,14 +169,15 @@ { "cell_type": "code", "execution_count": null, + "id": "8b0c80fd", "metadata": {}, "outputs": [], "source": [ "# Cell 5: full thread lifecycle and branching (sync)\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " first = thread.turn(TextInput('One sentence about structured planning.')).run()\n", - " second = thread.turn(TextInput('Now restate it for a junior engineer.')).run()\n", + " first = thread.turn('One sentence about structured planning.').run()\n", + " second = thread.turn('Now restate it for a junior engineer.').run()\n", "\n", " reopened = codex.thread_resume(thread.id)\n", " listing_active = codex.thread_list(limit=20, archived=False)\n", @@ -193,10 +193,10 @@ " 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_result = resumed.turn('Continue in one short sentence.').run()\n", "\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_result = forked.turn('Take a different angle in one short sentence.').run()\n", "\n", " compact_result = unarchived.compact()\n", "\n", @@ -214,6 +214,7 @@ { "cell_type": "code", "execution_count": null, + "id": "310db8c0", "metadata": {}, "outputs": [], "source": [ @@ -242,7 +243,7 @@ "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " turn = thread.turn(\n", - " TextInput('Propose a safe production feature-flag rollout. Return JSON matching the schema.'),\n", + " 'Propose a safe production feature-flag rollout. Return JSON matching the schema.',\n", " cwd=str(Path.cwd()),\n", " effort=ReasoningEffort.medium,\n", " model='gpt-5.4',\n", @@ -259,6 +260,7 @@ { "cell_type": "code", "execution_count": null, + "id": "7a33c97d", "metadata": {}, "outputs": [], "source": [ @@ -321,7 +323,7 @@ " thread = codex.thread_start(model=selected_model.model, config={'model_reasoning_effort': selected_effort.value})\n", "\n", " first = thread.turn(\n", - " TextInput('Give one short sentence about reliable production releases.'),\n", + " 'Give one short sentence about reliable production releases.',\n", " model=selected_model.model,\n", " effort=selected_effort,\n", " ).run()\n", @@ -329,7 +331,7 @@ " print('items:', len(first.items))\n", "\n", " second = thread.turn(\n", - " TextInput('Return JSON for a safe feature-flag rollout plan.'),\n", + " 'Return JSON for a safe feature-flag rollout plan.',\n", " cwd=str(Path.cwd()),\n", " effort=selected_effort,\n", " model=selected_model.model,\n", @@ -345,6 +347,7 @@ { "cell_type": "code", "execution_count": null, + "id": "e9aef26a", "metadata": {}, "outputs": [], "source": [ @@ -364,6 +367,7 @@ { "cell_type": "code", "execution_count": null, + "id": "a0cecc6c", "metadata": {}, "outputs": [], "source": [ @@ -382,6 +386,7 @@ { "cell_type": "code", "execution_count": null, + "id": "91afa2b8", "metadata": {}, "outputs": [], "source": [ @@ -390,7 +395,7 @@ " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", "\n", " result = retry_on_overload(\n", - " lambda: thread.turn(TextInput('List 5 failure modes in distributed systems.')).run(),\n", + " lambda: thread.turn('List 5 failure modes in distributed systems.').run(),\n", " max_attempts=3,\n", " initial_delay_s=0.25,\n", " max_delay_s=2.0,\n", @@ -402,6 +407,7 @@ { "cell_type": "code", "execution_count": null, + "id": "103be934", "metadata": {}, "outputs": [], "source": [ @@ -412,8 +418,8 @@ "async def async_lifecycle_demo():\n", " async with AsyncCodex() as codex:\n", " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " first = await (await thread.turn(TextInput('One sentence about structured planning.'))).run()\n", - " second = await (await thread.turn(TextInput('Now restate it for a junior engineer.'))).run()\n", + " first = await (await thread.turn('One sentence about structured planning.')).run()\n", + " second = await (await thread.turn('Now restate it for a junior engineer.')).run()\n", "\n", " reopened = await codex.thread_resume(thread.id)\n", " listing_active = await codex.thread_list(limit=20, archived=False)\n", @@ -429,10 +435,10 @@ " 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_result = await (await resumed.turn('Continue in one short sentence.')).run()\n", "\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_result = await (await forked.turn('Take a different angle in one short sentence.')).run()\n", "\n", " compact_result = await unarchived.compact()\n", "\n", @@ -453,6 +459,7 @@ { "cell_type": "code", "execution_count": null, + "id": "365aa10c", "metadata": {}, "outputs": [], "source": [ @@ -463,9 +470,9 @@ "async def async_stream_demo():\n", " async with AsyncCodex() as codex:\n", " 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", + " steer_turn = await thread.turn('Count from 1 to 40 with commas, then one summary sentence.')\n", "\n", - " steer_result = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", + " steer_result = await steer_turn.steer('Keep it brief and stop after 10 numbers.')\n", "\n", " steer_event_count = 0\n", " steer_completed_status = None\n", @@ -482,7 +489,7 @@ " 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_turn = await thread.turn('Count from 1 to 200 with commas, then one summary sentence.')\n", " interrupt_result = await interrupt_turn.interrupt()\n", "\n", " interrupt_event_count = 0\n", @@ -516,13 +523,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": ".venv", "language": "python", "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.10+" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.3" } }, "nbformat": 4, diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index a40a4aa9c3..5cad14cf5e 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -1040,12 +1040,12 @@ def _render_thread_block( lines = [ " def turn(", " self,", - " input: Input,", + " input: RunInput,", " *,", *_approval_mode_override_signature_lines(), *_kw_signature_lines(turn_fields), " ) -> TurnHandle:", - " wire_input = _to_wire_input(input)", + " wire_input = _to_wire_input(_normalize_run_input(input))", _approval_mode_assignment_line("_approval_mode_override_settings"), " params = TurnStartParams(", " thread_id=self.id,", @@ -1065,13 +1065,13 @@ def _render_async_thread_block( lines = [ " async def turn(", " self,", - " input: Input,", + " input: RunInput,", " *,", *_approval_mode_override_signature_lines(), *_kw_signature_lines(turn_fields), " ) -> AsyncTurnHandle:", " await self._codex._ensure_initialized()", - " wire_input = _to_wire_input(input)", + " wire_input = _to_wire_input(_normalize_run_input(input))", _approval_mode_assignment_line("_approval_mode_override_settings"), " params = TurnStartParams(", " thread_id=self.id,", diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index b3db7e3175..3bc018a769 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -14,6 +14,7 @@ from .api import ( InputItem, LocalImageInput, MentionInput, + RunInput, SkillInput, TextInput, Thread, @@ -54,6 +55,7 @@ __all__ = [ "TurnResult", "Input", "InputItem", + "RunInput", "TextInput", "ImageInput", "LocalImageInput", diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 347f95eeae..f14a311789 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -12,7 +12,7 @@ from ._approval_mode import ( from ._initialize_metadata import validate_initialize_metadata from ._inputs import ( ImageInput as ImageInput, - Input, + Input as Input, InputItem as InputItem, LocalImageInput as LocalImageInput, MentionInput as MentionInput, @@ -534,7 +534,7 @@ class Thread: summary: ReasoningSummary | None = None, ) -> TurnResult: turn = self.turn( - _normalize_run_input(input), + input, approval_mode=approval_mode, cwd=cwd, effort=effort, @@ -554,7 +554,7 @@ class Thread: # BEGIN GENERATED: Thread.flat_methods def turn( self, - input: Input, + input: RunInput, *, approval_mode: ApprovalMode | None = None, cwd: str | None = None, @@ -566,7 +566,7 @@ class Thread: service_tier: str | None = None, summary: ReasoningSummary | None = None, ) -> TurnHandle: - wire_input = _to_wire_input(input) + wire_input = _to_wire_input(_normalize_run_input(input)) approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = TurnStartParams( thread_id=self.id, @@ -617,7 +617,7 @@ class AsyncThread: summary: ReasoningSummary | None = None, ) -> TurnResult: turn = await self.turn( - _normalize_run_input(input), + input, approval_mode=approval_mode, cwd=cwd, effort=effort, @@ -637,7 +637,7 @@ class AsyncThread: # BEGIN GENERATED: AsyncThread.flat_methods async def turn( self, - input: Input, + input: RunInput, *, approval_mode: ApprovalMode | None = None, cwd: str | None = None, @@ -650,7 +650,7 @@ class AsyncThread: summary: ReasoningSummary | None = None, ) -> AsyncTurnHandle: await self._codex._ensure_initialized() - wire_input = _to_wire_input(input) + wire_input = _to_wire_input(_normalize_run_input(input)) approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = TurnStartParams( thread_id=self.id, @@ -694,8 +694,12 @@ class TurnHandle: thread_id: str id: str - def steer(self, input: Input) -> TurnSteerResponse: - return self._client.turn_steer(self.thread_id, self.id, _to_wire_input(input)) + def steer(self, input: RunInput) -> TurnSteerResponse: + return self._client.turn_steer( + self.thread_id, + self.id, + _to_wire_input(_normalize_run_input(input)), + ) def interrupt(self) -> TurnInterruptResponse: return self._client.turn_interrupt(self.thread_id, self.id) @@ -730,12 +734,12 @@ class AsyncTurnHandle: thread_id: str id: str - async def steer(self, input: Input) -> TurnSteerResponse: + async def steer(self, input: RunInput) -> TurnSteerResponse: await self._codex._ensure_initialized() return await self._codex._client.turn_steer( self.thread_id, self.id, - _to_wire_input(input), + _to_wire_input(_normalize_run_input(input)), ) async def interrupt(self) -> TurnInterruptResponse: diff --git a/sdk/python/tests/test_app_server_streaming.py b/sdk/python/tests/test_app_server_streaming.py index 02d089f2f1..3b79d9def3 100644 --- a/sdk/python/tests/test_app_server_streaming.py +++ b/sdk/python/tests/test_app_server_streaming.py @@ -11,7 +11,7 @@ from app_server_helpers import ( streaming_response, ) -from openai_codex import AsyncCodex, Codex, TextInput +from openai_codex import AsyncCodex, Codex from openai_codex.generated.v2_all import ( AgentMessageDeltaNotification, TurnCompletedNotification, @@ -26,8 +26,9 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - stream = thread.turn(TextInput("stream please")).stream() + stream = thread.turn("stream please").stream() events = list(stream) + request = harness.responses.single_request() assert { "deltas": [ @@ -36,6 +37,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: if isinstance(event.payload, AgentMessageDeltaNotification) ], "agent_messages": agent_message_texts(events), + "request_user_texts": request.message_input_texts("user")[-1:], "completed_statuses": [ event.payload.turn.status for event in events @@ -44,6 +46,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: } == { "deltas": ["he", "llo"], "agent_messages": ["hello"], + "request_user_texts": ["stream please"], "completed_statuses": [TurnStatus.completed], } @@ -55,7 +58,7 @@ def test_turn_run_returns_completed_turn(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - turn = thread.turn(TextInput("complete this turn")) + turn = thread.turn("complete this turn") completed = turn.run() assert { @@ -83,8 +86,9 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None: async with AsyncCodex(config=harness.app_server_config()) as codex: thread = await codex.thread_start() - turn = await thread.turn(TextInput("async stream please")) + turn = await thread.turn("async stream please") events = [event async for event in turn.stream()] + request = harness.responses.single_request() assert { "deltas": [ @@ -93,6 +97,7 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None: if isinstance(event.payload, AgentMessageDeltaNotification) ], "agent_messages": agent_message_texts(events), + "request_user_texts": request.message_input_texts("user")[-1:], "completed_statuses": [ event.payload.turn.status for event in events @@ -101,6 +106,7 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None: } == { "deltas": ["as", "ync"], "agent_messages": ["async"], + "request_user_texts": ["async stream please"], "completed_statuses": [TurnStatus.completed], } @@ -178,8 +184,8 @@ def test_interleaved_sync_turn_streams_route_by_turn_id(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: first_thread = codex.thread_start() second_thread = codex.thread_start() - first_turn = first_thread.turn(TextInput("first")) - second_turn = second_thread.turn(TextInput("second")) + first_turn = first_thread.turn("first") + second_turn = second_thread.turn("second") first_stream = first_turn.stream() second_stream = second_turn.stream() @@ -231,8 +237,8 @@ def test_interleaved_async_turn_streams_route_by_turn_id(tmp_path) -> None: async with AsyncCodex(config=harness.app_server_config()) as codex: first_thread = await codex.thread_start() second_thread = await codex.thread_start() - first_turn = await first_thread.turn(TextInput("async first")) - second_turn = await second_thread.turn(TextInput("async second")) + first_turn = await first_thread.turn("async first") + second_turn = await second_thread.turn("async second") first_stream = first_turn.stream() second_stream = second_turn.stream() diff --git a/sdk/python/tests/test_app_server_turn_controls.py b/sdk/python/tests/test_app_server_turn_controls.py index af02f6b74f..dd994b88f9 100644 --- a/sdk/python/tests/test_app_server_turn_controls.py +++ b/sdk/python/tests/test_app_server_turn_controls.py @@ -3,7 +3,7 @@ from __future__ import annotations from app_server_harness import AppServerHarness from app_server_helpers import agent_message_texts, streaming_response -from openai_codex import Codex, TextInput +from openai_codex import Codex from openai_codex.generated.v2_all import TurnStatus @@ -21,9 +21,9 @@ def test_turn_steer_adds_follow_up_input(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - turn = thread.turn(TextInput("Start a steerable turn.")) + turn = thread.turn("Start a steerable turn.") harness.responses.wait_for_requests(1) - steer = turn.steer(TextInput("Use this steering input.")) + steer = turn.steer("Use this steering input.") events = list(turn.stream()) requests = harness.responses.wait_for_requests(2) @@ -61,7 +61,7 @@ def test_turn_interrupt_stops_active_turn_and_follow_up_runs(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - interrupted_turn = thread.turn(TextInput("Start a long turn.")) + interrupted_turn = thread.turn("Start a long turn.") harness.responses.wait_for_requests(1) interrupt_response = interrupted_turn.interrupt() completed = interrupted_turn.run() diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index c7a00d3897..9a1212dfdf 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -40,6 +40,7 @@ EXPECTED_ROOT_EXPORTS = [ "TurnResult", "Input", "InputItem", + "RunInput", "TextInput", "ImageInput", "LocalImageInput", @@ -166,6 +167,22 @@ def test_turn_run_methods_return_turn_result() -> None: ) +def test_turn_input_methods_accept_string_shortcut() -> None: + """Every public turn-input method should accept strings and typed inputs.""" + funcs = [ + Thread.run, + Thread.turn, + AsyncThread.run, + AsyncThread.turn, + TurnHandle.steer, + AsyncTurnHandle.steer, + ] + + assert {fn: inspect.signature(fn).parameters["input"].annotation for fn in funcs} == ( + dict.fromkeys(funcs, "RunInput") + ) + + def test_root_exports_approval_mode() -> None: """The root package should expose the high-level approval mode enum.""" assert [(mode.name, mode.value) for mode in ApprovalMode] == [ diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 08f07c986c..5c4ec83fd4 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -234,14 +234,14 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No textwrap.dedent( """ import json - from openai_codex import Codex, TextInput + from openai_codex import Codex with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - result = thread.turn(TextInput("hello")).run() + result = thread.turn("hello").run() print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, @@ -331,7 +331,7 @@ def test_real_async_thread_turn_usage_and_ids_smoke( """ import asyncio import json - from openai_codex import AsyncCodex, TextInput + from openai_codex import AsyncCodex async def main(): async with AsyncCodex() as codex: @@ -339,7 +339,7 @@ def test_real_async_thread_turn_usage_and_ids_smoke( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - result = await (await thread.turn(TextInput("say ok"))).run() + result = await (await thread.turn("say ok")).run() print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, @@ -458,14 +458,14 @@ def test_real_streaming_smoke_turn_completed(runtime_env: PreparedRuntimeEnv) -> textwrap.dedent( """ import json - from openai_codex import Codex, TextInput + from openai_codex import Codex with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - turn = thread.turn(TextInput("Reply with one short sentence.")) + turn = thread.turn("Reply with one short sentence.") saw_delta = False saw_completed = False for event in turn.stream(): @@ -491,16 +491,16 @@ def test_real_turn_interrupt_smoke(runtime_env: PreparedRuntimeEnv) -> None: textwrap.dedent( """ import json - from openai_codex import Codex, TextInput + from openai_codex import Codex with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - turn = thread.turn(TextInput("Count from 1 to 200 with commas.")) + turn = thread.turn("Count from 1 to 200 with commas.") turn.interrupt() - follow_up = thread.turn(TextInput("Say 'ok' only.")).run() + follow_up = thread.turn("Say 'ok' only.").run() print(json.dumps({"status": follow_up.status.value})) """ ), From 64ead6a83a6ed348229bc98a9b5d8b0c550d8305 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Sun, 17 May 2026 20:07:32 -0700 Subject: [PATCH 06/53] Add exec-server websocket keepalive (#23226) ## Summary - send periodic websocket Ping frames from outbound exec-server websocket clients - cover direct exec-server websocket clients plus rendezvous harness/executor websocket connections - keep inbound axum-accepted exec-server websocket connections passive - add focused keepalive coverage for direct and relay websocket paths ## Validation - /Users/starr/code/openai/project/dotslash-gen/bin/bazel test //codex-rs/exec-server:exec-server-unit-tests --test_filter='websocket_connection_sends_keepalive_ping|harness_connection_sends_keepalive_ping|multiplexed_executor_sends_keepalive_ping' - /Users/starr/code/openai/project/dotslash-gen/bin/bazel test //codex-rs/exec-server:exec-server-relay-test --test_filter=multiplexed_remote_executor_routes_independent_virtual_streams --- codex-rs/exec-server/src/connection.rs | 191 ++++++++++++++++++++++--- codex-rs/exec-server/src/relay.rs | 159 +++++++++++++++++--- 2 files changed, 304 insertions(+), 46 deletions(-) diff --git a/codex-rs/exec-server/src/connection.rs b/codex-rs/exec-server/src/connection.rs index d5ff1f966f..cf504bbea7 100644 --- a/codex-rs/exec-server/src/connection.rs +++ b/codex-rs/exec-server/src/connection.rs @@ -30,6 +30,10 @@ use tokio::io::BufWriter; pub(crate) const CHANNEL_CAPACITY: usize = 128; const STDIO_TERMINATION_GRACE_PERIOD: Duration = Duration::from_secs(2); +#[cfg(test)] +pub(crate) const WEBSOCKET_KEEPALIVE_INTERVAL: Duration = Duration::from_millis(25); +#[cfg(not(test))] +pub(crate) const WEBSOCKET_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); #[derive(Debug)] pub(crate) enum JsonRpcConnectionEvent { @@ -320,18 +324,32 @@ impl JsonRpcConnection { S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { let (websocket_writer, websocket_reader) = stream.split(); - Self::from_websocket_parts(websocket_writer, websocket_reader, connection_label) + Self::from_websocket_parts( + websocket_writer, + websocket_reader, + connection_label, + Some(WEBSOCKET_KEEPALIVE_INTERVAL), + ) } pub(crate) fn from_axum_websocket(stream: AxumWebSocket, connection_label: String) -> Self { let (websocket_writer, websocket_reader) = stream.split(); - Self::from_websocket_parts(websocket_writer, websocket_reader, connection_label) + Self::from_websocket_parts( + websocket_writer, + websocket_reader, + connection_label, + // Axum only wraps inbound exec-server websocket accepts. Outbound websocket clients + // own keepalive pings so one side does not accidentally create redundant traffic. + /*keepalive_interval*/ + None, + ) } fn from_websocket_parts( mut websocket_writer: W, mut websocket_reader: R, connection_label: String, + keepalive_interval: Option, ) -> Self where W: Sink + Unpin + Send + 'static, @@ -404,30 +422,54 @@ impl JsonRpcConnection { }); let writer_task = tokio::spawn(async move { - while let Some(message) = outgoing_rx.recv().await { - match serialize_jsonrpc_message(&message) { - Ok(encoded) => { - if let Err(err) = websocket_writer.send(M::from_text(encoded)).await { - send_disconnected( - &incoming_tx, - &disconnected_tx, - Some(format!( - "failed to write websocket JSON-RPC message to {connection_label}: {err}" - )), + if let Some(keepalive_interval) = keepalive_interval { + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + keepalive_interval, + keepalive_interval, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + maybe_message = outgoing_rx.recv() => { + let Some(message) = maybe_message else { + break; + }; + if let Err(reason) = send_websocket_jsonrpc_message( + &mut websocket_writer, + &connection_label, + &message, ) - .await; - break; + .await + { + send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await; + break; + } + } + _ = keepalive.tick() => { + if let Err(err) = websocket_writer.send(M::ping()).await { + send_disconnected( + &incoming_tx, + &disconnected_tx, + Some(format!( + "failed to write websocket ping to {connection_label}: {err}" + )), + ) + .await; + break; + } } } - Err(err) => { - send_disconnected( - &incoming_tx, - &disconnected_tx, - Some(format!( - "failed to serialize JSON-RPC message for {connection_label}: {err}" - )), - ) - .await; + } + } else { + while let Some(message) = outgoing_rx.recv().await { + if let Err(reason) = send_websocket_jsonrpc_message( + &mut websocket_writer, + &connection_label, + &message, + ) + .await + { + send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await; break; } } @@ -458,6 +500,7 @@ enum JsonRpcWebSocketFrame { trait JsonRpcWebSocketMessage: Send + 'static { fn parse_jsonrpc_frame(self) -> Result; fn from_text(text: String) -> Self; + fn ping() -> Self; } impl JsonRpcWebSocketMessage for Message { @@ -479,6 +522,10 @@ impl JsonRpcWebSocketMessage for Message { fn from_text(text: String) -> Self { Self::Text(text.into()) } + + fn ping() -> Self { + Self::Ping(Vec::new().into()) + } } impl JsonRpcWebSocketMessage for AxumWebSocketMessage { @@ -500,6 +547,10 @@ impl JsonRpcWebSocketMessage for AxumWebSocketMessage { fn from_text(text: String) -> Self { Self::Text(text.into()) } + + fn ping() -> Self { + Self::Ping(Vec::new().into()) + } } async fn send_disconnected( @@ -538,6 +589,100 @@ where writer.flush().await } +async fn send_websocket_jsonrpc_message( + websocket_writer: &mut W, + connection_label: &str, + message: &JSONRPCMessage, +) -> Result<(), String> +where + W: Sink + Unpin, + M: JsonRpcWebSocketMessage, + E: std::fmt::Display, +{ + match serialize_jsonrpc_message(message) { + Ok(encoded) => websocket_writer + .send(M::from_text(encoded)) + .await + .map_err(|err| { + format!("failed to write websocket JSON-RPC message to {connection_label}: {err}") + }), + Err(err) => Err(format!( + "failed to serialize JSON-RPC message for {connection_label}: {err}" + )), + } +} + fn serialize_jsonrpc_message(message: &JSONRPCMessage) -> Result { serde_json::to_string(message) } + +#[cfg(test)] +mod tests { + use std::pin::Pin; + + use futures::channel::mpsc as futures_mpsc; + use futures::stream; + use futures::task::Context; + use futures::task::Poll; + use tokio::time::timeout; + + use super::*; + + struct TestWebSocketSink { + message_tx: futures_mpsc::UnboundedSender, + } + + impl Sink for TestWebSocketSink { + type Error = std::convert::Infallible; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { + self.get_mut() + .message_tx + .unbounded_send(item) + .expect("test websocket receiver should stay open"); + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[tokio::test] + async fn websocket_connection_sends_keepalive_ping() { + let (message_tx, mut message_rx) = futures_mpsc::unbounded::(); + let websocket_writer = TestWebSocketSink { message_tx }; + let websocket_reader = stream::pending::>(); + let connection = JsonRpcConnection::from_websocket_parts( + websocket_writer, + websocket_reader, + "test".into(), + Some(WEBSOCKET_KEEPALIVE_INTERVAL), + ); + + let message = timeout(Duration::from_secs(1), message_rx.next()) + .await + .expect("keepalive ping should arrive before timeout") + .expect("keepalive ping should be sent"); + assert!(matches!(message, Message::Ping(_))); + + drop(connection); + } +} diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index bce787cfc2..7470a6290c 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -19,6 +19,7 @@ use crate::connection::CHANNEL_CAPACITY; use crate::connection::JsonRpcConnection; use crate::connection::JsonRpcConnectionEvent; use crate::connection::JsonRpcTransport; +use crate::connection::WEBSOCKET_KEEPALIVE_INTERVAL; use crate::relay_proto::RelayData; use crate::relay_proto::RelayMessageFrame; use crate::relay_proto::RelayResume; @@ -262,24 +263,42 @@ where return; } + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL, + WEBSOCKET_KEEPALIVE_INTERVAL, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut next_seq = 0u32; - while let Some(message) = outgoing_rx.recv().await { - let payload = match jsonrpc_payload(&message) { - Ok(payload) => payload, - Err(err) => { - warn!("failed to serialize JSON-RPC payload for relay transport: {err}"); - break; + loop { + tokio::select! { + maybe_message = outgoing_rx.recv() => { + let Some(message) = maybe_message else { + break; + }; + let payload = match jsonrpc_payload(&message) { + Ok(payload) => payload, + Err(err) => { + warn!("failed to serialize JSON-RPC payload for relay transport: {err}"); + break; + } + }; + let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload); + next_seq = next_seq.wrapping_add(1); + if websocket_writer + .send(Message::Binary(encode_relay_message_frame(&frame).into())) + .await + .is_err() + { + let _ = disconnected_tx.send(true); + break; + } + } + _ = keepalive.tick() => { + if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() { + let _ = disconnected_tx.send(true); + break; + } } - }; - let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload); - next_seq = next_seq.wrapping_add(1); - if websocket_writer - .send(Message::Binary(encode_relay_message_frame(&frame).into())) - .await - .is_err() - { - let _ = disconnected_tx.send(true); - break; } } }); @@ -303,13 +322,30 @@ pub(crate) async fn run_multiplexed_executor( let (physical_outgoing_tx, mut physical_outgoing_rx) = mpsc::channel::>(CHANNEL_CAPACITY); let writer_task = tokio::spawn(async move { - while let Some(encoded) = physical_outgoing_rx.recv().await { - if websocket_writer - .send(Message::Binary(encoded.into())) - .await - .is_err() - { - break; + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL, + WEBSOCKET_KEEPALIVE_INTERVAL, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + maybe_encoded = physical_outgoing_rx.recv() => { + let Some(encoded) = maybe_encoded else { + break; + }; + if websocket_writer + .send(Message::Binary(encoded.into())) + .await + .is_err() + { + break; + } + } + _ = keepalive.tick() => { + if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() { + break; + } + } } } }); @@ -453,3 +489,80 @@ fn spawn_virtual_stream( disconnected_tx, } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tokio::net::TcpListener; + use tokio::time::timeout; + use tokio_tungstenite::accept_async; + use tokio_tungstenite::connect_async; + use tokio_tungstenite::tungstenite::Message; + + use super::*; + + fn test_runtime_paths() -> anyhow::Result { + crate::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + ) + .map_err(anyhow::Error::from) + } + + #[tokio::test] + async fn multiplexed_executor_sends_keepalive_ping() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let executor_task = tokio::spawn(run_multiplexed_executor( + client_websocket, + ConnectionProcessor::new(test_runtime_paths()?), + )); + + read_keepalive_ping(&mut server_websocket).await?; + + executor_task.abort(); + let _ = executor_task.await; + Ok(()) + } + + #[tokio::test] + async fn harness_connection_sends_keepalive_ping() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let connection = harness_connection_from_websocket(client_websocket, "test".to_string()); + + read_keepalive_ping(&mut server_websocket).await?; + + drop(connection); + Ok(()) + } + + async fn websocket_pair() -> anyhow::Result<( + WebSocketStream>, + WebSocketStream, + )> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + accept_async(stream).await.map_err(anyhow::Error::from) + }); + let (client_websocket, _) = connect_async(websocket_url).await?; + let server_websocket = server_task.await??; + Ok((client_websocket, server_websocket)) + } + + async fn read_keepalive_ping( + websocket: &mut WebSocketStream, + ) -> anyhow::Result<()> { + loop { + let Some(message) = timeout(Duration::from_secs(1), websocket.next()).await? else { + anyhow::bail!("websocket closed before keepalive ping"); + }; + match message? { + Message::Ping(_) => return Ok(()), + Message::Binary(_) | Message::Text(_) | Message::Pong(_) | Message::Frame(_) => {} + Message::Close(_) => anyhow::bail!("websocket closed before keepalive ping"), + } + } + } +} From 22dd9ad3929253ed24d7ee4f10f238e95ab25f37 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 09:59:34 +0200 Subject: [PATCH 07/53] Densify and version memory summaries (#23148) ## Why `memory_summary.md` is injected into every session, so its value depends on staying compact, navigational, and easy to regenerate when the expected shape changes. The previous consolidation prompt encouraged a broad actionable inventory and allowed older summary structures to be patched in place, which makes it easier for stale or overly verbose summaries to keep accumulating. This change makes the summary format explicitly versioned and biases Phase 2 memory consolidation toward denser prompt-loaded context. ## What changed - Require `memory_summary.md` to begin with an exact `v1` header. - Teach consolidation to regenerate `memory_summary.md` from scratch when the header is missing or incompatible, while still allowing incremental updates to `MEMORY.md`. - Tighten the `memory_summary.md` instructions so it acts as a compact routing/index layer instead of a second handbook. - Lower `MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT` from `5_000` to `2_500` so the runtime prompt budget matches the denser summary target. ## Verification Not run; this is a prompt/template update plus a prompt budget constant change. --- codex-rs/memories/read/src/lib.rs | 2 +- .../write/templates/memories/consolidation.md | 96 +++++++++++++------ 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/codex-rs/memories/read/src/lib.rs b/codex-rs/memories/read/src/lib.rs index 2f7b7645e4..ac6aa4d7f1 100644 --- a/codex-rs/memories/read/src/lib.rs +++ b/codex-rs/memories/read/src/lib.rs @@ -13,7 +13,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub use prompts::build_memory_tool_developer_instructions; -const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 5_000; +const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 2_500; pub fn memory_root(codex_home: &AbsolutePathBuf) -> AbsolutePathBuf { codex_home.join("memories") diff --git a/codex-rs/memories/write/templates/memories/consolidation.md b/codex-rs/memories/write/templates/memories/consolidation.md index cc586bd4c1..0eef7580b5 100644 --- a/codex-rs/memories/write/templates/memories/consolidation.md +++ b/codex-rs/memories/write/templates/memories/consolidation.md @@ -20,8 +20,8 @@ CONTEXT: MEMORY FOLDER STRUCTURE Folder structure (under {{ memory_root }}/): - memory_summary.md - - Always loaded into the system prompt. Must remain informative and highly navigational, - but still discriminative enough to guide retrieval. + - Always loaded into the system prompt. First line must be exactly `v1`. + Must stay dense, highly navigational, and discriminative enough to guide retrieval. - MEMORY.md - Handbook entries. Used to grep for keywords; aggregated insights from rollouts; pointers to rollout summaries if certain past rollouts are very relevant. @@ -133,7 +133,8 @@ Under `{{ memory_root }}/`: - merged memories; produce a lightly clustered version if applicable - `rollout_summaries/*.md` - `memory_summary.md` - - read the existing summary so updates stay consistent + - read the existing summary so updates stay consistent only if its first line is exactly `v1`; + otherwise treat the summary as schema-incompatible and regenerate the whole file from scratch - `skills/*` - read existing skills so updates are incremental and non-duplicative {{ memory_extensions_primary_inputs }} @@ -143,6 +144,8 @@ Mode selection: and `skills/`). - INCREMENTAL UPDATE: existing artifacts already exist and `raw_memories.md` mostly contains new additions. +- Summary schema reset: if `memory_summary.md` is missing, empty, or does not start with exactly + `v1`, regenerate only `memory_summary.md` from scratch after `MEMORY.md` is current. Memory workspace diff: @@ -183,6 +186,8 @@ Rules: - If there is no meaningful signal to add beyond what already exists, keep outputs minimal. - You should always make sure `MEMORY.md` and `memory_summary.md` exist and are up to date. +- `memory_summary.md` must start with the exact line `v1`; if it does not, rewrite the entire + file rather than patching the previous summary in place. - Follow the format and schema of the artifacts below. - Do not target fixed counts (memory blocks, task groups, topics, or bullets). Let the signal determine the granularity and depth. @@ -441,14 +446,40 @@ task group/task` from the full raw inventory so you can have a better overview. - Prefer placing reusable user preferences in `## User preferences` and the rest of the durable know-how in `## Reusable knowledge` and `## Failures and how to do differently`. - Use `memory_summary.md` as the cross-task summary layer, not the place for project-specific - runbooks. It should stay compact in narrative/profile sections, but its `## User preferences` - section is the main actionable payload and may be much longer when that helps future agents - avoid repeated user steering. + runbooks. Its `## User preferences` section is the main actionable payload, but it should + still stay compact, deduplicated, and limited to preferences likely to change future behavior. ============================================================ 2) `memory_summary.md` FORMAT (STRICT) ============================================================ +File header: + +The file must begin exactly: + +```md +v1 + +## User Profile +``` + +- The first line must be exactly `v1` with no leading/trailing whitespace and no frontmatter + before it. +- If the existing `memory_summary.md` first line is not exactly `v1`, discard the old summary + structure and regenerate the entire file from the finalized `MEMORY.md`, skills, and current + rollout evidence. + +Density objective (strict): + +- `memory_summary.md` is prompt-loaded context, so optimize for high signal per token. +- Keep only high-level, cross-task signal and brief routing summaries. Put details, provenance, + runbooks, and task-local nuance in `MEMORY.md`, skills, or rollout summaries. +- Deduplicate aggressively. If two bullets would cause the same future behavior or route to the + same `MEMORY.md` area, merge them or keep the sharper one. +- Prefer short, concrete bullets over narrative explanation. Delete low-signal caveats, + examples, and historical detail unless they change future agent behavior. +- Give directly links to important information to maximize the retrieval efficiency. + Format: ## User Profile @@ -474,15 +505,15 @@ For example, include (when known): You may end with short fun facts if they are real and useful, but keep the main profile concrete and grounded. Do not let the optional fun-facts tail make the rest of the section more stylized or abstract. -This entire section is free-form, <= 500 words. +This entire section is free-form, <= 350 words. ## User preferences Include a dedicated bullet list of actionable user preferences that are likely to matter again, not just inside one task group. This section should be more concrete and easier to apply than `## User Profile`. Prefer preferences that repeatedly save user keystrokes or avoid predictable interruption. -This section may be long. Do not compress it to just a few umbrella bullets when `MEMORY.md` -contains many distinct actionable preferences. +Keep it dense and non-duplicative. Include only stable or high-leverage preferences that would +change future agent behavior across recurring workflows. Treat this as the main actionable payload of `memory_summary.md`. For example, include (when known): @@ -500,16 +531,12 @@ Rules: - Keep each bullet actionable and future-facing. - Default to lifting or lightly adapting strong bullets from `MEMORY.md` `## User preferences` rather than rewriting them into smoother higher-level summaries. -- Preserve more of the user's original point than a terse summary would. Prefer evidence-aware - bullets that still keep some original wording over abstract umbrella summaries. +- Preserve the user's original point when it is compact and behavior-changing; otherwise compress + to the shortest faithful wording. - When a short quoted or near-verbatim phrase makes the preference easier to recognize or grep for later, keep that phrase in the bullet instead of replacing it with an abstraction. -- Do not over-merge adjacent preferences. If several distinct preferences would change different - future defaults, keep them as separate bullets. -- Prefer many narrow actionable bullets over a few broad umbrella bullets. -- Prefer a broad actionable inventory over a short highly deduped list. -- Do not treat 5-10 bullets as an implicit target; long-lived memory sets may justify a much - longer list. +- Merge adjacent preferences unless they would change different future defaults. +- Prefer a compact set of sharp bullets over a broad inventory. - Do not require a preference to be broad across task families. If it is likely to matter again in a recurring workflow, it belongs here. - When deciding whether to include a preference, ask whether omitting it would make the next @@ -542,16 +569,20 @@ For example, include (when known): This is a compact index to help future agents quickly find details in `MEMORY.md`, `skills/`, and `rollout_summaries/`. -Treat it as a routing/index layer, not a mini-handbook: +Treat it as a dense routing/index layer, not a mini-handbook: - tell future agents what to search first, - preserve enough specificity to route into the right `MEMORY.md` block quickly. +- keep topic descriptions brief; delete stale, duplicated, or low-signal topics even if they + existed in the previous summary. Topic selection and quality rules: - Organize the index first by cwd / project scope, then by topic. - Split the index into a recent high-utility window and older topics. - Do not target a fixed topic count. Include informative topics and omit low-signal noise. +- Keep the index current. Feel free to restructure, rename, merge, or delete topics when the + current `MEMORY.md` organization or evidence has changed. - Prefer grouping by task family / workflow intent, not by incidental tool overlap alone. - Order topics by utility, using `updated_at` recency as a strong default proxy unless there is strong contrary evidence. @@ -588,7 +619,7 @@ Recent Active Memory Window behavior (scope-first, then day-ordered): - Within each scope, order day subsections by recency. - If a scope has only one meaningful recent day, include only that day for that scope. - For each recent-day subsection inside a scope, prioritize informative, likely-to-recur topics and make - those entries richer (better keywords, clearer descriptions, and useful recent learnings); + those entries denser (better keywords, brief descriptions, and useful recent learnings); do not spend much space on trivial tasks touched that day. - Preserve routing coverage for `MEMORY.md` in the overall index. If a scope/day includes less useful topics, include shorter/compact entries for routing rather than dropping them. @@ -596,16 +627,16 @@ Recent Active Memory Window behavior (scope-first, then day-ordered): appears; do not duplicate it under multiple day sections. - If a topic spans multiple scopes and retrieval would differ by scope, split it. Otherwise, place it under the dominant scope and mention the secondary scope in the description. -- Recent-day entries should be richer than older-topic entries: stronger keywords, clearer - descriptions, and concise recent learnings/change notes. +- Recent-day entries should be more informative than older-topic entries through stronger + keywords and concise recent learnings/change notes, not longer prose. - Group similar tasks/topics together when it improves routing clarity. - Do not over cluster topics together, especially when they contain distinct task intents. Recent-topic format: - : , , , ... - - desc: - - learnings: + - desc: + - learnings: ### @@ -643,10 +674,9 @@ Notes: caveats, and decision triggers; move cross-task, stable, broadly reusable user defaults to `## User preferences`. - Coverage guardrail: ensure every top-level `# Task Group` in `MEMORY.md` is represented by - at least one topic bullet in this index (either directly or via a clearly subsuming topic). -- Keep descriptions explicit: what is inside, when to use it, and what kind of - outcome/procedure depth is available (for example: runbook, diagnostics, reporting, recovery), - so a future agent can quickly choose which topic/keyword cluster to search first. + at least one topic bullet in this index (either directly or via a clearly subsuming compact topic). +- Keep descriptions explicit but short: enough for a future agent to choose the right + topic/keyword cluster, not enough to replace opening `MEMORY.md`. - `memory_summary.md` should not sound like a second-order executive summary. Prefer concrete, source-faithful wording over polished abstraction, especially in: - `## User preferences` @@ -731,6 +761,9 @@ WORKFLOW ============================================================ 1. Determine mode (INIT vs INCREMENTAL UPDATE) using artifact availability and current run context. + Independently check `memory_summary.md` first line: if it is not exactly `v1`, regenerate + `memory_summary.md` from scratch after the other artifacts are finalized, even when `MEMORY.md` + itself can be updated incrementally. 2. INIT phase behavior: - Read `raw_memories.md` first, then rollout summaries carefully. @@ -747,8 +780,8 @@ WORKFLOW conflicting task families until MEMORY blocks are richer and more useful than raw memories 3. INCREMENTAL UPDATE behavior: - - Read existing `MEMORY.md` and `memory_summary.md` first for continuity and to locate - existing references that may need surgical cleanup. + - Read existing `MEMORY.md` and, only when it starts with exactly `v1`, existing + `memory_summary.md` first for continuity and to locate references that may need surgical cleanup. - Use the injected git-style workspace changes as the first routing pass: - added/modified `raw_memories.md` and `rollout_summaries/*.md` = ingestion queue - deleted `rollout_summaries/*.md` and `extensions/*/resources/*.md` = forgetting / @@ -775,6 +808,8 @@ WORKFLOW - doing light clustering and merging if needed - refreshing `MEMORY.md` top-of-file ordering so recent high-utility task families stay easy to find - rebuilding the `memory_summary.md` recent active window (last 3 memory days) from current `updated_at` coverage + - freely restructuring `memory_summary.md` so it reflects the current memory set without + stale topics, duplicated preference bullets, or obsolete routing labels - updating existing skills or adding new skills only when there is clear new reusable procedure - updating `memory_summary.md` last to reflect the final state of the memory folder - Minimize churn in incremental mode: if an existing `MEMORY.md` block or `## What's in Memory` @@ -816,6 +851,9 @@ WORKFLOW 7. Final pass: - remove duplication in memory_summary, skills/, and MEMORY.md + - verify `memory_summary.md` still begins with exactly `v1` + - verify `memory_summary.md` is dense: brief high-level profile, compact actionable + preferences, compact general tips, and a routing index rather than a second handbook - remove stale or low-signal blocks that are less likely to be useful in the future - remove or rewrite blocks/task sections whose supporting rollout references point only to deleted inputs or missing rollout summary files From da14dd2adde31b7eaf2301c20f29c17d29e33898 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Mon, 18 May 2026 03:11:54 -0700 Subject: [PATCH 08/53] [codex] Add installed-plugin mention API (#22448) ## Summary - add app-server `plugin/installed` for mention-oriented plugin loading - return installed plugins plus explicitly requested install-suggestion rows - keep remote handling on installed-state data instead of the broad catalog listing path ## Why The `@` mention surface only needs plugins that are usable now, plus a small product-approved set of install suggestions. It does not need the full catalog-shaped `plugin/list` payload that the Plugins page uses. ## Validation - `just write-app-server-schema` - `just fmt` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-core-plugins` - `cargo test -p codex-app-server --test all plugin_installed_` ## Notes - The package-wide `cargo test -p codex-app-server` run still hits an existing unrelated stack overflow in `in_process::tests::in_process_start_clamps_zero_channel_capacity`. - Companion webview PR: https://github.com/openai/openai/pull/915672 --- .../schema/json/ClientRequest.json | 49 ++ .../codex_app_server_protocol.schemas.json | 74 +++ .../codex_app_server_protocol.v2.schemas.json | 74 +++ .../schema/json/v2/PluginInstalledParams.json | 33 ++ .../json/v2/PluginInstalledResponse.json | 525 ++++++++++++++++++ .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/PluginInstalledParams.ts | 15 + .../typescript/v2/PluginInstalledResponse.ts | 7 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 14 + .../src/protocol/v2/plugin.rs | 22 + .../src/protocol/v2/tests.rs | 21 + codex-rs/app-server/README.md | 5 +- codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 2 + .../src/request_processors/plugins.rs | 253 ++++++++- .../app-server/tests/common/mcp_process.rs | 10 + .../app-server/tests/suite/v2/plugin_list.rs | 303 +++++++++- codex-rs/core-plugins/src/manager.rs | 36 +- codex-rs/core-plugins/src/manager_tests.rs | 127 +++-- codex-rs/core-plugins/src/remote.rs | 111 +++- .../remote/remote_installed_plugin_sync.rs | 2 +- 22 files changed, 1609 insertions(+), 82 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ad45893bbe..9c2415678f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1589,6 +1589,31 @@ ], "type": "object" }, + "PluginInstalledParams": { + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, "PluginListMarketplaceKind": { "enum": [ "local", @@ -4638,6 +4663,30 @@ "title": "Plugin/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/installed" + ], + "title": "Plugin/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installedRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8bb77fae37..94abf054db 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -757,6 +757,30 @@ "title": "Plugin/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/installed" + ], + "title": "Plugin/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installedRequest", + "type": "object" + }, { "properties": { "id": { @@ -11825,6 +11849,56 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginInstalledParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginInstalledParams", + "type": "object" + }, + "PluginInstalledResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/v2/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/v2/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginInstalledResponse", + "type": "object" + }, "PluginInterface": { "properties": { "brandColor": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 01a8d6a820..601e9dd7fb 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1464,6 +1464,30 @@ "title": "Plugin/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/installed" + ], + "title": "Plugin/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installedRequest", + "type": "object" + }, { "properties": { "id": { @@ -8374,6 +8398,56 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginInstalledParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginInstalledParams", + "type": "object" + }, + "PluginInstalledResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginInstalledResponse", + "type": "object" + }, "PluginInterface": { "properties": { "brandColor": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json new file mode 100644 index 0000000000..f3ec95a5da --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginInstalledParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json new file mode 100644 index 0000000000..ffe20e8c55 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -0,0 +1,525 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "MarketplaceInterface": { + "properties": { + "displayName": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, + "PluginInstallPolicy": { + "enum": [ + "NOT_AVAILABLE", + "AVAILABLE", + "INSTALLED_BY_DEFAULT" + ], + "type": "string" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, + "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshotUrls", + "screenshots" + ], + "type": "object" + }, + "PluginMarketplaceEntry": { + "properties": { + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/MarketplaceInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." + }, + "plugins": { + "items": { + "$ref": "#/definitions/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "name", + "plugins" + ], + "type": "object" + }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, + "sharePrincipals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" + } + }, + "required": [ + "name", + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + }, + { + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "refName": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "git" + ], + "title": "GitPluginSourceType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "GitPluginSource", + "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "$ref": "#/definitions/PluginInstallPolicy" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "authPolicy", + "enabled", + "id", + "installPolicy", + "installed", + "name", + "source" + ], + "type": "object" + } + }, + "properties": { + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginInstalledResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 28371c7106..7f017ed700 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -43,6 +43,7 @@ import type { McpServerToolCallParams } from "./v2/McpServerToolCallParams"; import type { ModelListParams } from "./v2/ModelListParams"; import type { ModelProviderCapabilitiesReadParams } from "./v2/ModelProviderCapabilitiesReadParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; +import type { PluginInstalledParams } from "./v2/PluginInstalledParams"; import type { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginShareCheckoutParams } from "./v2/PluginShareCheckoutParams"; @@ -80,4 +81,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts new file mode 100644 index 0000000000..83a564921a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts @@ -0,0 +1,15 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginInstalledParams = { +/** + * Optional working directories used to discover repo marketplaces. + */ +cwds?: Array | null, +/** + * Additional uninstalled plugin names that should be returned when present locally. + * This is used by mention surfaces that intentionally expose install entrypoints. + */ +installSuggestionPluginNames?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts new file mode 100644 index 0000000000..d9713351ab --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; +import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; + +export type PluginInstalledResponse = { marketplaces: Array, marketplaceLoadErrors: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 5deeccd793..dc400a6f8e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -265,6 +265,8 @@ export type { PluginHookSummary } from "./PluginHookSummary"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; export type { PluginInstallResponse } from "./PluginInstallResponse"; +export type { PluginInstalledParams } from "./PluginInstalledParams"; +export type { PluginInstalledResponse } from "./PluginInstalledResponse"; export type { PluginInterface } from "./PluginInterface"; export type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind"; export type { PluginListParams } from "./PluginListParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 1ab2743d6b..a08de3e528 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -625,6 +625,11 @@ client_request_definitions! { serialization: None, response: v2::PluginListResponse, }, + PluginInstalled => "plugin/installed" { + params: v2::PluginInstalledParams, + serialization: None, + response: v2::PluginInstalledResponse, + }, PluginRead => "plugin/read" { params: v2::PluginReadParams, serialization: None, @@ -1719,6 +1724,15 @@ mod tests { }; assert_eq!(plugin_read.serialization_scope(), None); + let plugin_installed = ClientRequest::PluginInstalled { + request_id: request_id(), + params: v2::PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }, + }; + assert_eq!(plugin_installed.serialization_scope(), None); + let plugin_uninstall = ClientRequest::PluginUninstall { request_id: request_id(), params: v2::PluginUninstallParams { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 3ffab53218..33c2670077 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -125,6 +125,19 @@ pub struct PluginListParams { pub marketplace_kinds: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstalledParams { + /// Optional working directories used to discover repo marketplaces. + #[ts(optional = nullable)] + pub cwds: Option>, + /// Additional uninstalled plugin names that should be returned when present locally. + /// This is used by mention surfaces that intentionally expose install entrypoints. + #[ts(optional = nullable)] + pub install_suggestion_plugin_names: Option>, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[ts(export_to = "v2/")] pub enum PluginListMarketplaceKind { @@ -150,6 +163,15 @@ pub struct PluginListResponse { pub featured_plugin_ids: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstalledResponse { + pub marketplaces: Vec, + #[serde(default)] + pub marketplace_load_errors: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index fb2a59bded..40ec247338 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2790,6 +2790,27 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { ); } +#[test] +fn plugin_installed_params_serializes_install_suggestion_names() { + assert_eq!( + serde_json::to_value(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: Some(vec![ + "computer-use".to_string(), + "chrome".to_string(), + ]), + }) + .unwrap(), + json!({ + "cwds": null, + "installSuggestionPluginNames": [ + "computer-use", + "chrome", + ], + }), + ); +} + #[test] fn plugin_read_params_serialization_uses_install_source_fields() { let marketplace_path = if cfg!(windows) { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9d0a509c4c..d0f423d5c7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -198,6 +198,7 @@ Example with notification opt-out: - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). +- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. @@ -718,7 +719,7 @@ Invoke an app by including `$` in the text input and adding a `mention ### Example: Start a turn (invoke a plugin) -Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://@` path returned by `plugin/list`. +Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://@` path returned by `plugin/installed` or `plugin/list`. ```json { "method": "turn/start", "id": 35, "params": { @@ -1676,7 +1677,7 @@ The server also emits `app/list/updated` notifications whenever either source (a } ``` -Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/list`. +Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/installed` or `plugin/list`. Example: diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fc835b82ec..4e2c6f38cc 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1102,6 +1102,9 @@ impl MessageProcessor { ClientRequest::PluginList { params, .. } => { self.plugin_processor.plugin_list(params).await } + ClientRequest::PluginInstalled { params, .. } => { + self.plugin_processor.plugin_installed(params).await + } ClientRequest::PluginRead { params, .. } => { self.plugin_processor.plugin_read(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index d5853b56c5..e941caca09 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -107,6 +107,8 @@ use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginInstalledParams; +use codex_app_server_protocol::PluginInstalledResponse; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index c290d2091a..199ddf1191 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginSharePrincipalRole; use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; +use codex_core_plugins::remote::RemotePluginScope; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; use codex_mcp::McpOAuthLoginSupport; @@ -122,6 +123,39 @@ fn share_context_for_source( } } +fn convert_configured_marketplace_plugin_to_plugin_summary( + plugin: codex_core_plugins::ConfiguredMarketplacePlugin, + shared_plugin_ids_by_local_path: &std::collections::BTreeMap, +) -> PluginSummary { + let share_context = share_context_for_source(&plugin.source, shared_plugin_ids_by_local_path); + PluginSummary { + id: plugin.id, + remote_plugin_id: None, + local_version: plugin.local_version, + installed: plugin.installed, + enabled: plugin.enabled, + name: plugin.name, + share_context, + source: marketplace_plugin_source_to_info(plugin.source), + install_policy: plugin.policy.installation.into(), + auth_policy: plugin.policy.authentication.into(), + availability: PluginAvailability::Available, + interface: plugin.interface.map(local_plugin_interface_to_info), + keywords: plugin.keywords, + } +} + +fn remote_installed_plugin_visible_scopes(config: &Config) -> Vec { + let mut scopes = Vec::new(); + if config.features.enabled(Feature::RemotePlugin) { + scopes.push(RemotePluginScope::Global); + } + if config.features.enabled(Feature::PluginSharing) { + scopes.push(RemotePluginScope::Workspace); + } + scopes +} + fn remote_plugin_share_discoverability( discoverability: PluginShareDiscoverability, ) -> codex_core_plugins::remote::RemotePluginShareDiscoverability { @@ -268,6 +302,15 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn plugin_installed( + &self, + params: PluginInstalledParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_installed_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn plugin_read( &self, params: PluginReadParams, @@ -487,27 +530,10 @@ impl PluginRequestProcessor { .plugins .into_iter() .map(|plugin| { - let share_context = share_context_for_source( - &plugin.source, + convert_configured_marketplace_plugin_to_plugin_summary( + plugin, &shared_plugin_ids_by_local_path, - ); - PluginSummary { - id: plugin.id, - remote_plugin_id: None, - local_version: plugin.local_version, - installed: plugin.installed, - enabled: plugin.enabled, - name: plugin.name, - share_context, - source: marketplace_plugin_source_to_info(plugin.source), - install_policy: plugin.policy.installation.into(), - auth_policy: plugin.policy.authentication.into(), - availability: PluginAvailability::Available, - interface: plugin - .interface - .map(local_plugin_interface_to_info), - keywords: plugin.keywords, - } + ) }) .collect(), }) @@ -632,6 +658,193 @@ impl PluginRequestProcessor { }) } + async fn plugin_installed_response( + &self, + params: PluginInstalledParams, + ) -> Result { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginInstalledParams { + cwds, + install_suggestion_plugin_names, + } = params; + let roots = cwds.unwrap_or_default(); + let install_suggestion_plugin_names = install_suggestion_plugin_names + .unwrap_or_default() + .into_iter() + .collect::>(); + + let empty_response = || PluginInstalledResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + }; + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Ok(empty_response()); + } + let auth = self.auth_manager.auth().await; + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Ok(empty_response()); + } + + let plugins_input = config.plugins_config_input(); + let remote_installed_plugin_visible_scopes = + remote_installed_plugin_visible_scopes(&config); + plugins_manager.maybe_start_remote_installed_plugin_bundle_sync( + &plugins_input, + auth.clone(), + Some(self.effective_plugins_changed_callback()), + ); + + let (mut data, marketplace_load_errors) = self + .load_local_installed_and_suggested_plugins( + plugins_manager.clone(), + &config, + &plugins_input, + roots, + install_suggestion_plugin_names, + ) + .await?; + + data.extend( + self.load_remote_installed_plugins( + plugins_manager, + &plugins_input, + &remote_installed_plugin_visible_scopes, + auth.as_ref(), + ) + .await, + ); + + Ok(PluginInstalledResponse { + marketplaces: data, + marketplace_load_errors, + }) + } + + async fn load_local_installed_and_suggested_plugins( + &self, + plugins_manager: Arc, + config: &Config, + plugins_input: &codex_core_plugins::PluginsConfigInput, + roots: Vec, + install_suggestion_plugin_names: HashSet, + ) -> Result< + ( + Vec, + Vec, + ), + JSONRPCErrorError, + > { + let config_for_marketplace_listing = plugins_input.clone(); + let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(config)?; + match tokio::task::spawn_blocking(move || { + let outcome = plugins_manager + .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + Ok::< + ( + Vec, + Vec, + ), + MarketplaceError, + >(( + outcome + .marketplaces + .into_iter() + .filter_map(|marketplace| { + let plugins = marketplace + .plugins + .into_iter() + .filter(|plugin| { + plugin.installed + || install_suggestion_plugin_names.contains(&plugin.name) + }) + .map(|plugin| { + convert_configured_marketplace_plugin_to_plugin_summary( + plugin, + &shared_plugin_ids_by_local_path, + ) + }) + .collect::>(); + + (!plugins.is_empty()).then_some(PluginMarketplaceEntry { + name: marketplace.name, + path: Some(marketplace.path), + interface: marketplace.interface.map(|interface| { + MarketplaceInterface { + display_name: interface.display_name, + } + }), + plugins, + }) + }) + .collect(), + outcome + .errors + .into_iter() + .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { + marketplace_path: err.path, + message: err.message, + }) + .collect(), + )) + }) + .await + { + Ok(Ok(outcome)) => Ok(outcome), + Ok(Err(err)) => Err(Self::marketplace_error( + err, + "list installed and suggested marketplace plugins", + )), + Err(err) => Err(internal_error(format!( + "failed to list installed and suggested plugins: {err}" + ))), + } + } + + async fn load_remote_installed_plugins( + &self, + plugins_manager: Arc, + plugins_input: &codex_core_plugins::PluginsConfigInput, + visible_scopes: &[RemotePluginScope], + auth: Option<&CodexAuth>, + ) -> Vec { + let remote_marketplaces = if let Some(remote_marketplaces) = + plugins_manager.build_remote_installed_plugin_marketplaces_from_cache(visible_scopes) + { + Ok(remote_marketplaces) + } else { + plugins_manager + .build_and_cache_remote_installed_plugin_marketplaces( + plugins_input, + auth, + visible_scopes, + Some(self.effective_plugins_changed_callback()), + ) + .await + }; + + match remote_marketplaces { + Ok(remote_marketplaces) => remote_marketplaces + .into_iter() + .map(remote_marketplace_to_info) + .collect(), + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => Vec::new(), + Err(err) => { + warn!( + error = %err, + "plugin/installed remote installed plugin fetch failed; returning local marketplaces only" + ); + Vec::new() + } + } + } + async fn plugin_read_response( &self, params: PluginReadParams, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 981c22f625..3445010a7f 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -57,6 +57,7 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelProviderCapabilitiesReadParams; use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstalledParams; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginSkillReadParams; @@ -684,6 +685,15 @@ impl McpProcess { self.send_request("plugin/list", params).await } + /// Send a `plugin/installed` JSON-RPC request. + pub async fn send_plugin_installed_request( + &mut self, + params: PluginInstalledParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/installed", params).await + } + /// Send a `plugin/read` JSON-RPC request. pub async fn send_plugin_read_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 4ffa3893d0..d439a4585b 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -9,6 +9,8 @@ use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstalledParams; +use codex_app_server_protocol::PluginInstalledResponse; use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; @@ -125,6 +127,95 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul Ok(()) } +#[tokio::test] +async fn plugin_installed_includes_installed_plugins_and_explicit_install_suggestions() -> Result<()> +{ + let codex_home = TempDir::new()?; + write_openai_curated_marketplace( + codex_home.path(), + &["linear", "computer-use", "not-mentioned"], + )?; + write_installed_plugin(&codex_home, "openai-curated", "linear")?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = true +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: Some(vec!["computer-use".to_string()]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "openai-curated"); + assert_eq!( + response.marketplaces[0] + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ("linear@openai-curated".to_string(), true, true), + ("computer-use@openai-curated".to_string(), false, false), + ] + ); + assert_eq!(response.marketplace_load_errors, Vec::new()); + Ok(()) +} + +#[tokio::test] +async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> { + let codex_home = TempDir::new()?; + write_installed_plugin(&codex_home, "openai-curated", "linear")?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = true +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + assert_eq!(response.marketplaces, Vec::new()); + assert_eq!(response.marketplace_load_errors, Vec::new()); + Ok(()) +} + #[tokio::test] async fn plugin_list_rejects_relative_cwds() -> Result<()> { let codex_home = TempDir::new()?; @@ -1360,7 +1451,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; - write_plugins_enabled_config_with_base_url( + write_remote_plugin_catalog_config( codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; @@ -1710,6 +1801,185 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex Ok(()) } +#[tokio::test] +async fn plugin_installed_includes_remote_shared_with_me_plugins() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = false +plugin_sharing = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let mut workspace_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + "PRIVATE", + /*enabled*/ Some(true), + ))?; + let unlisted_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_33333333333333333333333333333333", + "unlisted-linear", + "Unlisted Linear", + "UNLISTED", + /*enabled*/ Some(false), + ))?; + workspace_installed_body["plugins"] + .as_array_mut() + .expect("installed plugins should be an array") + .push(unlisted_installed_body["plugins"][0].clone()); + let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?; + let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true); + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 1); + let marketplace = &response.marketplaces[0]; + assert_eq!(marketplace.name, "workspace-shared-with-me"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Shared with me") + ); + assert_eq!( + marketplace + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ( + "shared-linear@workspace-shared-with-me".to_string(), + true, + true + ), + ( + "unlisted-linear@workspace-shared-with-me".to_string(), + true, + false + ) + ] + ); + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_installed_starts_remote_installed_bundle_sync() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let bundle_url = mount_remote_plugin_bundle( + &server, + "linear", + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + let global_installed_body = + remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true); + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let plugin_installed_request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + let response: PluginInstalledResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(plugin_installed_request_id)), + ) + .await??, + )?; + + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "chatgpt-global"); + assert_eq!( + response.marketplaces[0] + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![("linear@chatgpt-global".to_string(), true, true)] + ); + let installed_path = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear/1.2.3/.codex-plugin/plugin.json"); + wait_for_path_exists(&installed_path).await?; + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag() -> Result<()> { let codex_home = TempDir::new()?; @@ -1987,12 +2257,8 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.discoverability, Some(PluginShareDiscoverability::Unlisted) ); - wait_for_remote_plugin_request_count( - &server, - "/ps/plugins/installed", - /*expected_count*/ 5, - ) - .await?; + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) } @@ -2490,6 +2756,29 @@ async fn wait_for_remote_plugin_request_count( Ok(()) } +async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &str) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + bail!("wiremock did not record requests"); + }; + if requests.iter().any(|request| { + request.method == "GET" + && request.url.path().ends_with("/ps/plugins/installed") + && request + .url + .query_pairs() + .any(|(name, value)| name == "scope" && value == scope) + }) { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> { timeout(DEFAULT_TIMEOUT, async { loop { diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index f6d5a6d88a..56b8c18bda 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -37,6 +37,7 @@ use crate::marketplace_upgrade::configured_git_marketplace_names; use crate::marketplace_upgrade::upgrade_configured_git_marketplaces; use crate::remote::RemoteInstalledPlugin; use crate::remote::RemotePluginCatalogError; +use crate::remote::RemotePluginScope; use crate::remote::RemotePluginServiceConfig; use crate::remote_legacy::RemotePluginFetchError; use crate::remote_legacy::RemotePluginMutationError; @@ -597,6 +598,39 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } + pub fn build_remote_installed_plugin_marketplaces_from_cache( + &self, + visible_scopes: &[RemotePluginScope], + ) -> Option> { + let cache = match self.remote_installed_plugins_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + let plugins = cache.as_ref()?; + Some(crate::remote::group_remote_installed_plugins_by_marketplaces(plugins, visible_scopes)) + } + + pub async fn build_and_cache_remote_installed_plugin_marketplaces( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + visible_scopes: &[RemotePluginScope], + on_effective_plugins_changed: Option>, + ) -> Result, RemotePluginCatalogError> { + let plugins = crate::remote::fetch_remote_installed_plugins( + &remote_plugin_service_config(config), + auth, + ) + .await?; + let marketplaces = + crate::remote::group_remote_installed_plugins_by_marketplaces(&plugins, visible_scopes); + let changed = self.write_remote_installed_plugins_cache(plugins); + if changed && let Some(on_effective_plugins_changed) = on_effective_plugins_changed { + on_effective_plugins_changed(); + } + Ok(marketplaces) + } + fn write_remote_installed_plugins_cache(&self, plugins: Vec) -> bool { let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, @@ -674,7 +708,7 @@ impl PluginsManager { ); } - fn maybe_start_remote_installed_plugin_bundle_sync( + pub fn maybe_start_remote_installed_plugin_bundle_sync( self: &Arc, config: &PluginsConfigInput, auth: Option, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 449c87b186..791a143c4f 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -7,6 +7,7 @@ use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; use crate::marketplace::MarketplacePluginInstallPolicy; use crate::remote::RemoteInstalledPlugin; +use crate::remote::RemotePluginScope; use crate::startup_sync::curated_plugins_repo_path; use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; use crate::test_support::TEST_CURATED_PLUGIN_SHA; @@ -147,6 +148,20 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput { load_plugins_config_input(codex_home, cwd).await } +fn remote_installed_linear_plugin() -> RemoteInstalledPlugin { + RemoteInstalledPlugin { + marketplace_name: "chatgpt-global".to_string(), + id: "plugins~Plugin_linear".to_string(), + name: "linear".to_string(), + enabled: true, + install_policy: codex_app_server_protocol::PluginInstallPolicy::Available, + auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: None, + keywords: Vec::new(), + } +} + #[tokio::test] async fn load_plugins_loads_default_skills_and_mcp_servers() { let codex_home = TempDir::new().unwrap(); @@ -334,38 +349,6 @@ approval_mode = "approve" ); } -#[tokio::test] -async fn remote_installed_cache_adds_plugin_skill_roots_without_remote_plugin_flag() { - let codex_home = TempDir::new().unwrap(); - let plugin_base = codex_home - .path() - .join("plugins/cache/chatgpt-global/linear"); - write_plugin(&plugin_base, "local", "linear"); - write_file( - &codex_home.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); - - let config = load_config(codex_home.path(), codex_home.path()).await; - let manager = PluginsManager::new(codex_home.path().to_path_buf()); - manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin { - marketplace_name: "chatgpt-global".to_string(), - id: "plugins~Plugin_linear".to_string(), - name: "linear".to_string(), - enabled: true, - }]); - - let outcome = manager.plugins_for_config(&config).await; - assert_eq!( - outcome.effective_skill_roots(), - vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()] - ); - assert_eq!(outcome.plugins().len(), 1); - assert_eq!(outcome.plugins()[0].config_name, "linear@chatgpt-global"); -} - #[tokio::test] async fn remote_installed_cache_ignores_plugins_missing_local_cache() { let codex_home = TempDir::new().unwrap(); @@ -379,17 +362,85 @@ remote_plugin = true let config = load_config(codex_home.path(), codex_home.path()).await; let manager = PluginsManager::new(codex_home.path().to_path_buf()); - manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin { - marketplace_name: "chatgpt-global".to_string(), - id: "plugins~Plugin_linear".to_string(), - name: "linear".to_string(), - enabled: true, - }]); + manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); let outcome = manager.plugins_for_config(&config).await; assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mut plugin = remote_installed_linear_plugin(); + plugin.install_policy = codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault; + plugin.auth_policy = codex_app_server_protocol::PluginAuthPolicy::OnInstall; + plugin.interface = Some(codex_app_server_protocol::PluginInterface { + display_name: Some("Linear".to_string()), + short_description: Some("Track remote work".to_string()), + long_description: None, + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: Some("#111111".to_string()), + composer_icon: None, + composer_icon_url: None, + logo: None, + logo_url: None, + screenshots: Vec::new(), + screenshot_urls: Vec::new(), + }); + plugin.keywords = vec!["issues".to_string()]; + manager.write_remote_installed_plugins_cache(vec![plugin]); + + let marketplaces = manager + .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) + .expect("remote installed cache should be present"); + assert_eq!(marketplaces.len(), 1); + assert_eq!(marketplaces[0].name, "chatgpt-global"); + assert_eq!(marketplaces[0].display_name, "ChatGPT Plugins"); + assert_eq!(marketplaces[0].plugins.len(), 1); + let plugin = &marketplaces[0].plugins[0]; + assert_eq!(plugin.id, "linear@chatgpt-global"); + assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear"); + assert_eq!(plugin.name, "linear"); + assert_eq!(plugin.installed, true); + assert_eq!(plugin.enabled, true); + assert_eq!( + plugin.install_policy, + codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault + ); + assert_eq!( + plugin.auth_policy, + codex_app_server_protocol::PluginAuthPolicy::OnInstall + ); + assert_eq!(plugin.keywords, vec!["issues".to_string()]); + assert_eq!( + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Linear") + ); + assert_eq!( + plugin + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()), + Some("Track remote work") + ); + assert_eq!( + manager + .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Workspace]) + .expect("remote installed cache should be present"), + Vec::new() + ); +} + #[tokio::test] async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 4dde224039..56427473a1 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -27,7 +27,7 @@ pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError; pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome; pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard; pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight; -pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; +pub(crate) use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once; pub use share::RemotePluginShareAccessPolicy; pub use share::RemotePluginShareDiscoverability; @@ -63,6 +63,28 @@ const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [ + ( + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME, + ), +]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginServiceConfig { @@ -89,6 +111,11 @@ pub struct RemoteInstalledPlugin { pub id: String, pub name: String, pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + pub availability: PluginAvailability, + pub interface: Option, + pub keywords: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -264,7 +291,7 @@ pub enum RemotePluginCatalogError { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] -enum RemotePluginScope { +pub enum RemotePluginScope { #[serde(rename = "GLOBAL")] Global, #[serde(rename = "WORKSPACE")] @@ -608,13 +635,7 @@ fn build_remote_marketplace( }) .map(|(plugin, installed_plugin)| build_remote_plugin_summary(plugin, installed_plugin)) .collect::, _>>()?; - plugins.sort_by(|left, right| { - remote_plugin_display_name(left) - .to_ascii_lowercase() - .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) - .then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))) - .then_with(|| left.id.cmp(&right.id)) - }); + sort_remote_plugin_summaries_by_display_name(&mut plugins); Ok(Some(RemoteMarketplace { name: name.to_string(), display_name: display_name.to_string(), @@ -622,7 +643,7 @@ fn build_remote_marketplace( })) } -pub async fn fetch_remote_installed_plugins( +pub(crate) async fn fetch_remote_installed_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, ) -> Result, RemotePluginCatalogError> { @@ -642,7 +663,7 @@ pub async fn fetch_remote_installed_plugins( let mut installed_plugins = [global, workspace] .into_iter() .flat_map(|(_scope, plugins)| plugins) - .map(|plugin| remote_installed_plugin_to_info(&plugin)) + .map(|plugin| remote_installed_plugin_to_cache_entry(&plugin)) .collect::, _>>()?; installed_plugins.sort_by(|left, right| { left.marketplace_name @@ -652,6 +673,55 @@ pub async fn fetch_remote_installed_plugins( Ok(installed_plugins) } +pub fn group_remote_installed_plugins_by_marketplaces( + plugins: &[RemoteInstalledPlugin], + visible_scopes: &[RemotePluginScope], +) -> Vec { + let mut plugins_by_marketplace = BTreeMap::>::new(); + + for plugin in plugins { + if !RemotePluginScope::from_marketplace_name(&plugin.marketplace_name) + .is_some_and(|scope| visible_scopes.contains(&scope)) + { + continue; + } + let Ok(plugin_id) = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) + else { + continue; + }; + let plugin_summary = RemotePluginSummary { + id: plugin_id.as_key(), + remote_plugin_id: plugin.id.clone(), + name: plugin.name.clone(), + share_context: None, + installed: true, + enabled: plugin.enabled, + install_policy: plugin.install_policy, + auth_policy: plugin.auth_policy, + availability: plugin.availability, + interface: plugin.interface.clone(), + keywords: plugin.keywords.clone(), + }; + plugins_by_marketplace + .entry(plugin.marketplace_name.clone()) + .or_default() + .push(plugin_summary); + } + + REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER + .into_iter() + .filter_map(|(marketplace_name, display_name)| { + let mut marketplace_plugins = plugins_by_marketplace.remove(marketplace_name)?; + sort_remote_plugin_summaries_by_display_name(&mut marketplace_plugins); + Some(RemoteMarketplace { + name: marketplace_name.to_string(), + display_name: display_name.to_string(), + plugins: marketplace_plugins, + }) + }) + .collect() +} + pub async fn fetch_remote_plugin_detail( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -982,7 +1052,7 @@ fn remote_plugin_share_context( } } -fn remote_installed_plugin_to_info( +fn remote_installed_plugin_to_cache_entry( installed_plugin: &RemotePluginInstalledItem, ) -> Result { let plugin = &installed_plugin.plugin; @@ -994,6 +1064,11 @@ fn remote_installed_plugin_to_info( id: plugin.id.clone(), name: plugin.name.clone(), enabled: installed_plugin.enabled, + install_policy: plugin.installation_policy, + auth_policy: plugin.authentication_policy, + availability: plugin.availability, + interface: remote_plugin_interface_to_info(plugin), + keywords: plugin.release.keywords.clone(), }) } @@ -1068,6 +1143,18 @@ fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str { .unwrap_or(&plugin.name) } +fn sort_remote_plugin_summaries_by_display_name(plugins: &mut [RemotePluginSummary]) { + plugins.sort_by(|left, right| { + let left_display_name = remote_plugin_display_name(left); + let right_display_name = remote_plugin_display_name(right); + left_display_name + .to_ascii_lowercase() + .cmp(&right_display_name.to_ascii_lowercase()) + .then_with(|| left_display_name.cmp(right_display_name)) + .then_with(|| left.id.cmp(&right.id)) + }); +} + fn non_empty_string(value: Option<&str>) -> Option { value.and_then(|value| { let value = value.trim(); diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index b13ca7a630..69cbfe2569 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -78,7 +78,7 @@ pub struct RemotePluginCacheMutationGuard { key: RemotePluginCacheMutationKey, } -pub fn maybe_start_remote_installed_plugin_bundle_sync( +pub(crate) fn maybe_start_remote_installed_plugin_bundle_sync( codex_home: PathBuf, config: RemotePluginServiceConfig, auth: Option, From a80f07ec4aa9dd311091b91d41945043765a1caa Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 13:32:21 +0200 Subject: [PATCH 09/53] chore: goal ext skeleton (#23288) Skeleton of `/goal` in extension Lot's of follow-ups coming --- codex-rs/Cargo.lock | 12 ++ codex-rs/Cargo.toml | 3 + codex-rs/ext/goal/BUILD.bazel | 6 + codex-rs/ext/goal/Cargo.toml | 22 +++ codex-rs/ext/goal/src/accounting.rs | 73 ++++++++ codex-rs/ext/goal/src/extension.rs | 278 ++++++++++++++++++++++++++++ codex-rs/ext/goal/src/lib.rs | 21 +++ codex-rs/ext/goal/src/spec.rs | 89 +++++++++ codex-rs/ext/goal/src/tool.rs | 231 +++++++++++++++++++++++ 9 files changed, 735 insertions(+) create mode 100644 codex-rs/ext/goal/BUILD.bazel create mode 100644 codex-rs/ext/goal/Cargo.toml create mode 100644 codex-rs/ext/goal/src/accounting.rs create mode 100644 codex-rs/ext/goal/src/extension.rs create mode 100644 codex-rs/ext/goal/src/lib.rs create mode 100644 codex-rs/ext/goal/src/spec.rs create mode 100644 codex-rs/ext/goal/src/tool.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 83ca0d1191..850ddbd525 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2946,6 +2946,18 @@ dependencies = [ "walkdir", ] +[[package]] +name = "codex-goal-extension" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-extension-api", + "codex-protocol", + "codex-tools", + "serde", + "serde_json", +] + [[package]] name = "codex-guardian" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 7207fd3aba..efc64577dd 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -45,6 +45,7 @@ members = [ "execpolicy", "execpolicy-legacy", "ext/extension-api", + "ext/goal", "ext/guardian", "ext/memories", "external-agent-migration", @@ -162,6 +163,7 @@ codex-file-system = { path = "file-system" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } +codex-goal-extension = { path = "ext/goal" } codex-guardian = { path = "ext/guardian" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } @@ -470,6 +472,7 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", + "codex-goal-extension", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/ext/goal/BUILD.bazel b/codex-rs/ext/goal/BUILD.bazel new file mode 100644 index 0000000000..037313da37 --- /dev/null +++ b/codex-rs/ext/goal/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "goal", + crate_name = "codex_goal_extension", +) diff --git a/codex-rs/ext/goal/Cargo.toml b/codex-rs/ext/goal/Cargo.toml new file mode 100644 index 0000000000..7f8b9bd308 --- /dev/null +++ b/codex-rs/ext/goal/Cargo.toml @@ -0,0 +1,22 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-goal-extension" +version.workspace = true + +[lib] +name = "codex_goal_extension" +path = "src/lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-extension-api = { workspace = true } +codex-protocol = { workspace = true } +codex-tools = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/codex-rs/ext/goal/src/accounting.rs b/codex-rs/ext/goal/src/accounting.rs new file mode 100644 index 0000000000..712e325849 --- /dev/null +++ b/codex-rs/ext/goal/src/accounting.rs @@ -0,0 +1,73 @@ +use codex_protocol::protocol::TokenUsage; +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::PoisonError; + +#[derive(Debug, Default)] +pub(crate) struct GoalAccountingState { + inner: Mutex, +} + +#[derive(Debug, Default)] +struct GoalAccountingInner { + turns: HashMap, + unflushed_token_delta: i64, +} + +#[derive(Debug, Default)] +struct GoalTurnAccounting { + token_delta: i64, + stopped: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RecordedTokenDelta { + pub(crate) turn_delta: i64, + pub(crate) thread_unflushed_delta: i64, +} + +impl GoalAccountingState { + pub(crate) fn start_turn(&self, turn_id: impl Into) { + let turn_id = turn_id.into(); + self.inner().turns.entry(turn_id).or_default().stopped = false; + } + + pub(crate) fn record_token_usage( + &self, + turn_id: impl Into, + usage: &TokenUsage, + ) -> Option { + let delta = goal_token_delta_for_usage(usage); + if delta <= 0 { + return None; + } + + let turn_id = turn_id.into(); + let mut inner = self.inner(); + let turn = inner.turns.entry(turn_id).or_default(); + turn.token_delta = turn.token_delta.saturating_add(delta); + let turn_delta = turn.token_delta; + inner.unflushed_token_delta = inner.unflushed_token_delta.saturating_add(delta); + Some(RecordedTokenDelta { + turn_delta, + thread_unflushed_delta: inner.unflushed_token_delta, + }) + } + + pub(crate) fn stop_turn(&self, turn_id: &str) { + if let Some(turn) = self.inner().turns.get_mut(turn_id) { + turn.stopped = true; + } + } + + fn inner(&self) -> std::sync::MutexGuard<'_, GoalAccountingInner> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +pub(crate) fn goal_token_delta_for_usage(usage: &TokenUsage) -> i64 { + usage + .input_tokens + .saturating_sub(usage.cached_input_tokens) + .saturating_add(usage.output_tokens.max(0)) +} diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs new file mode 100644 index 0000000000..0d78742962 --- /dev/null +++ b/codex-rs/ext/goal/src/extension.rs @@ -0,0 +1,278 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use codex_extension_api::ConfigContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::TokenUsageContributor; +use codex_extension_api::ToolContributor; +use codex_extension_api::TurnAbortInput; +use codex_extension_api::TurnLifecycleContributor; +use codex_extension_api::TurnStartInput; +use codex_extension_api::TurnStopInput; +use codex_protocol::ThreadId; +use codex_protocol::protocol::ThreadGoal; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnAbortReason; + +use crate::accounting::GoalAccountingState; +use crate::tool::CreateGoalRequest; +use crate::tool::GoalToolExecutor; + +#[derive(Clone, Debug)] +pub struct GoalExtensionConfig { + pub enabled: bool, +} + +impl GoalExtensionConfig { + fn from_enabled(enabled: bool) -> Self { + Self { enabled } + } +} + +#[derive(Clone)] +pub struct GoalExtension { + backend: Arc, + goals_enabled: Arc bool + Send + Sync>, +} + +impl std::fmt::Debug for GoalExtension { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GoalExtension").finish_non_exhaustive() + } +} + +impl GoalExtension { + pub fn new( + backend: Arc, + goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, + ) -> Self { + Self { + backend, + goals_enabled: Arc::new(goals_enabled), + } + } + + pub fn without_backend(goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static) -> Self { + Self::new(Arc::new(NoGoalToolBackend), goals_enabled) + } +} + +#[async_trait] +pub trait GoalToolBackend: Send + Sync { + async fn get_goal(&self, thread_id: ThreadId) -> Result, String>; + + async fn create_goal( + &self, + thread_id: ThreadId, + request: CreateGoalRequest, + ) -> Result; + + async fn complete_goal(&self, thread_id: ThreadId) -> Result; +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct NoGoalToolBackend; + +#[async_trait] +impl GoalToolBackend for NoGoalToolBackend { + async fn get_goal(&self, _thread_id: ThreadId) -> Result, String> { + Err(missing_backend_message()) + } + + async fn create_goal( + &self, + _thread_id: ThreadId, + _request: CreateGoalRequest, + ) -> Result { + Err(missing_backend_message()) + } + + async fn complete_goal(&self, _thread_id: ThreadId) -> Result { + Err(missing_backend_message()) + } +} + +fn missing_backend_message() -> String { + // TODO: replace this fallback with a host-provided goal backend once + // ToolContributor invocations can reach thread-scoped goal persistence and + // the current turn context. + "goal tools are not connected to host goal persistence yet".to_string() +} + +impl ThreadLifecycleContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_thread_start(&self, input: ThreadStartInput<'_, C>) { + input + .thread_store + .insert(GoalExtensionConfig::from_enabled((self.goals_enabled)( + input.config, + ))); + input + .thread_store + .get_or_init::(GoalAccountingState::default); + } +} + +impl ConfigContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &C, + new_config: &C, + ) { + thread_store.insert(GoalExtensionConfig::from_enabled((self.goals_enabled)( + new_config, + ))); + } +} + +impl TurnLifecycleContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_turn_start(&self, input: TurnStartInput<'_>) { + if !goal_enabled(input.thread_store) { + return; + } + + // TODO: TurnStartInput should expose collaboration mode and token usage + // at turn start. Goals need mode to suppress plan-mode accounting and + // the token baseline to account deltas exactly. + accounting_state(input.thread_store).start_turn(input.turn_store.level_id()); + } + + fn on_turn_stop(&self, input: TurnStopInput<'_>) { + if !goal_enabled(input.thread_store) { + return; + } + + // TODO: this should flush wall-clock and any unflushed token usage to + // persisted goal storage, emit ThreadGoalUpdated, and optionally inject + // budget-limit steering through a host event/input capability. + // TODO: the host also needs an idle/next-turn wake capability so an + // active goal can enqueue continuation context after the turn is fully + // cleared, only when there is no pending user or mailbox work. + accounting_state(input.thread_store).stop_turn(input.turn_store.level_id()); + } + + fn on_turn_abort(&self, input: TurnAbortInput<'_>) { + if !goal_enabled(input.thread_store) { + return; + } + + accounting_state(input.thread_store).stop_turn(input.turn_store.level_id()); + if input.reason == TurnAbortReason::Interrupted { + // TODO: interrupted turns should pause the active goal via persisted + // goal storage and emit ThreadGoalUpdated with turn_id None. + } + } +} + +impl TokenUsageContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_token_usage( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + turn_store: &ExtensionData, + token_usage: &TokenUsageInfo, + ) { + if !goal_enabled(thread_store) { + return; + } + + let Some(_recorded) = accounting_state(thread_store) + .record_token_usage(turn_store.level_id(), &token_usage.last_token_usage) + else { + return; + }; + + // TODO: TokenUsageContributor needs a host goal storage capability so + // this recorded delta can be committed to the active persisted goal. + // It also needs an event/input capability to emit ThreadGoalUpdated and + // inject budget-limit steering when accounting changes goal status. + // TODO: if the storage/event path must await, TokenUsageContributor + // either needs to become async or receive a fire-and-forget host sink. + } +} + +// TODO: app-server initiated goal set/clear operations need a contributor or +// backend callback here. They currently happen outside thread/turn/token +// lifecycle, but the goal extension must observe them to account before +// mutation, refresh active-goal accounting, emit objective-update steering, and +// clear runtime state when a goal is removed. + +impl ToolContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn tools( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + ) -> Vec>> { + if !goal_enabled(thread_store) { + return Vec::new(); + } + + let Ok(thread_id) = ThreadId::from_string(thread_store.level_id()) else { + return Vec::new(); + }; + vec![ + Arc::new(GoalToolExecutor::get(thread_id, Arc::clone(&self.backend))), + Arc::new(GoalToolExecutor::create( + thread_id, + Arc::clone(&self.backend), + )), + Arc::new(GoalToolExecutor::update( + thread_id, + Arc::clone(&self.backend), + )), + ] + } +} + +pub fn install( + registry: &mut ExtensionRegistryBuilder, + goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, +) where + C: Send + Sync + 'static, +{ + install_with_backend(registry, Arc::new(NoGoalToolBackend), goals_enabled); +} + +pub fn install_with_backend( + registry: &mut ExtensionRegistryBuilder, + backend: Arc, + goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, +) where + C: Send + Sync + 'static, +{ + let extension = Arc::new(GoalExtension::new(backend, goals_enabled)); + registry.thread_lifecycle_contributor(extension.clone()); + registry.config_contributor(extension.clone()); + registry.turn_lifecycle_contributor(extension.clone()); + registry.token_usage_contributor(extension.clone()); + registry.tool_contributor(extension); +} + +fn goal_enabled(thread_store: &ExtensionData) -> bool { + thread_store + .get::() + .is_some_and(|config| config.enabled) +} + +fn accounting_state(thread_store: &ExtensionData) -> Arc { + thread_store.get_or_init::(GoalAccountingState::default) +} diff --git a/codex-rs/ext/goal/src/lib.rs b/codex-rs/ext/goal/src/lib.rs new file mode 100644 index 0000000000..17a43e4671 --- /dev/null +++ b/codex-rs/ext/goal/src/lib.rs @@ -0,0 +1,21 @@ +//! Extension crate sketch for the `/goal` feature. +//! +//! This crate is intentionally not wired into the host yet. It contains the +//! goal tool specs, extension registration shape, and the parts of runtime +//! accounting that can be represented with today's extension API. + +mod accounting; +mod extension; +mod spec; +mod tool; + +pub use extension::GoalExtension; +pub use extension::GoalExtensionConfig; +pub use extension::GoalToolBackend; +pub use extension::NoGoalToolBackend; +pub use extension::install; +pub use extension::install_with_backend; +pub use spec::CREATE_GOAL_TOOL_NAME; +pub use spec::GET_GOAL_TOOL_NAME; +pub use spec::UPDATE_GOAL_TOOL_NAME; +pub use tool::CreateGoalRequest; diff --git a/codex-rs/ext/goal/src/spec.rs b/codex-rs/ext/goal/src/spec.rs new file mode 100644 index 0000000000..e127846ab5 --- /dev/null +++ b/codex-rs/ext/goal/src/spec.rs @@ -0,0 +1,89 @@ +//! Responses API tool definitions for persisted thread goals. + +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolSpec; +use serde_json::json; +use std::collections::BTreeMap; + +pub const GET_GOAL_TOOL_NAME: &str = "get_goal"; +pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; +pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; + +pub fn create_get_goal_tool() -> ToolSpec { + ToolSpec::Function(ResponsesApiTool { + name: GET_GOAL_TOOL_NAME.to_string(), + description: "Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budget." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())), + output_schema: None, + }) +} + +pub fn create_create_goal_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "objective".to_string(), + JsonSchema::string(Some( + "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails." + .to_string(), + )), + ), + ( + "token_budget".to_string(), + JsonSchema::integer(Some( + "Optional positive token budget for the new active goal.".to_string(), + )), + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: CREATE_GOAL_TOOL_NAME.to_string(), + description: format!( + r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. +Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."# + ), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + /*required*/ Some(vec!["objective".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} + +pub fn create_update_goal_tool() -> ToolSpec { + let properties = BTreeMap::from([( + "status".to_string(), + JsonSchema::string_enum( + vec![json!("complete")], + Some( + "Required. Set to complete only when the objective is achieved and no required work remains." + .to_string(), + ), + ), + )]); + + ToolSpec::Function(ResponsesApiTool { + name: UPDATE_GOAL_TOOL_NAME.to_string(), + description: r#"Update the existing goal. +Use this tool only to mark the goal achieved. +Set status to `complete` only when the objective has actually been achieved and no required work remains. +Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. +You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. +When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + /*required*/ Some(vec!["status".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs new file mode 100644 index 0000000000..d19dc81056 --- /dev/null +++ b/codex-rs/ext/goal/src/tool.rs @@ -0,0 +1,231 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use codex_extension_api::FunctionCallError; +use codex_extension_api::JsonToolOutput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolExecutor; +use codex_extension_api::ToolName; +use codex_extension_api::ToolOutput; +use codex_extension_api::ToolSpec; +use codex_protocol::ThreadId; +use codex_protocol::protocol::ThreadGoal; +use codex_protocol::protocol::ThreadGoalStatus; +use codex_protocol::protocol::validate_thread_goal_objective; +use serde::Deserialize; +use serde::Serialize; + +use crate::extension::GoalToolBackend; +use crate::spec::CREATE_GOAL_TOOL_NAME; +use crate::spec::GET_GOAL_TOOL_NAME; +use crate::spec::UPDATE_GOAL_TOOL_NAME; +use crate::spec::create_create_goal_tool; +use crate::spec::create_get_goal_tool; +use crate::spec::create_update_goal_tool; + +#[derive(Clone)] +pub(crate) struct GoalToolExecutor { + kind: GoalToolKind, + thread_id: ThreadId, + backend: Arc, +} + +#[derive(Clone, Copy)] +enum GoalToolKind { + Get, + Create, + Update, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct CreateGoalRequest { + pub objective: String, + pub token_budget: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct UpdateGoalArgs { + status: ThreadGoalStatus, +} + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct GoalToolResponse { + goal: Option, + remaining_tokens: Option, + completion_budget_report: Option, +} + +#[derive(Clone, Copy)] +enum CompletionBudgetReport { + Include, + Omit, +} + +impl GoalToolExecutor { + pub(crate) fn get(thread_id: ThreadId, backend: Arc) -> Self { + Self { + kind: GoalToolKind::Get, + thread_id, + backend, + } + } + + pub(crate) fn create(thread_id: ThreadId, backend: Arc) -> Self { + Self { + kind: GoalToolKind::Create, + thread_id, + backend, + } + } + + pub(crate) fn update(thread_id: ThreadId, backend: Arc) -> Self { + Self { + kind: GoalToolKind::Update, + thread_id, + backend, + } + } +} + +#[async_trait] +impl ToolExecutor for GoalToolExecutor { + fn tool_name(&self) -> ToolName { + ToolName::plain(match self.kind { + GoalToolKind::Get => GET_GOAL_TOOL_NAME, + GoalToolKind::Create => CREATE_GOAL_TOOL_NAME, + GoalToolKind::Update => UPDATE_GOAL_TOOL_NAME, + }) + } + + fn spec(&self) -> Option { + Some(match self.kind { + GoalToolKind::Get => create_get_goal_tool(), + GoalToolKind::Create => create_create_goal_tool(), + GoalToolKind::Update => create_update_goal_tool(), + }) + } + + async fn handle(&self, invocation: ToolCall) -> Result, FunctionCallError> { + match self.kind { + GoalToolKind::Get => self.handle_get(invocation).await, + GoalToolKind::Create => self.handle_create(invocation).await, + GoalToolKind::Update => self.handle_update(invocation).await, + } + } +} + +impl GoalToolExecutor { + async fn handle_get( + &self, + invocation: ToolCall, + ) -> Result, FunctionCallError> { + let _ = invocation.function_arguments()?; + let goal = self + .backend + .get_goal(self.thread_id) + .await + .map_err(FunctionCallError::RespondToModel)?; + goal_response(goal, CompletionBudgetReport::Omit) + } + + async fn handle_create( + &self, + invocation: ToolCall, + ) -> Result, FunctionCallError> { + let mut request: CreateGoalRequest = parse_arguments(invocation.function_arguments()?)?; + request.objective = request.objective.trim().to_string(); + validate_thread_goal_objective(&request.objective) + .map_err(FunctionCallError::RespondToModel)?; + validate_goal_budget(request.token_budget).map_err(FunctionCallError::RespondToModel)?; + + let goal = self + .backend + .create_goal(self.thread_id, request) + .await + .map_err(FunctionCallError::RespondToModel)?; + goal_response(Some(goal), CompletionBudgetReport::Omit) + } + + async fn handle_update( + &self, + invocation: ToolCall, + ) -> Result, FunctionCallError> { + let args: UpdateGoalArgs = parse_arguments(invocation.function_arguments()?)?; + if args.status != ThreadGoalStatus::Complete { + return Err(FunctionCallError::RespondToModel( + "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + .to_string(), + )); + } + + // TODO: update_goal needs a host callback before completion to flush + // final active-turn accounting with budget steering suppressed. + let goal = self + .backend + .complete_goal(self.thread_id) + .await + .map_err(FunctionCallError::RespondToModel)?; + goal_response(Some(goal), CompletionBudgetReport::Include) + } +} + +fn parse_arguments(arguments: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_str(arguments) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) +} + +fn validate_goal_budget(value: Option) -> Result<(), String> { + if let Some(value) = value + && value <= 0 + { + return Err("goal budgets must be positive when provided".to_string()); + } + Ok(()) +} + +fn goal_response( + goal: Option, + completion_budget_report: CompletionBudgetReport, +) -> Result, FunctionCallError> { + let value = serde_json::to_value(GoalToolResponse::new(goal, completion_budget_report)) + .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + Ok(Box::new(JsonToolOutput::new(value))) +} + +impl GoalToolResponse { + fn new(goal: Option, report_mode: CompletionBudgetReport) -> Self { + let remaining_tokens = goal.as_ref().and_then(|goal| { + goal.token_budget + .map(|budget| (budget - goal.tokens_used).max(0)) + }); + let completion_budget_report = match report_mode { + CompletionBudgetReport::Include => goal + .as_ref() + .filter(|goal| goal.status == ThreadGoalStatus::Complete) + .and_then(completion_budget_report), + CompletionBudgetReport::Omit => None, + }; + Self { + goal, + remaining_tokens, + completion_budget_report, + } + } +} + +fn completion_budget_report(goal: &ThreadGoal) -> Option { + if goal.token_budget.is_none() && goal.time_used_seconds <= 0 { + None + } else { + Some( + "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." + .to_string(), + ) + } +} From 9531e932ef33b3834e5dc23fe71ae7ac84e6e213 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 13:53:58 +0200 Subject: [PATCH 10/53] Make extension lifecycle hooks async (#23291) ## Why Extension lifecycle hooks sit on the host/extension boundary, but the current trait surface only allows synchronous callbacks. That forces extensions that need to seed, rehydrate, observe, or flush extension-owned state during thread and turn transitions to either block inside the callback or move async work into separate host plumbing. This PR makes those lifecycle callbacks awaitable so extension implementations can perform async work directly at the lifecycle point where the host already has the relevant session, thread, or turn stores available. ## What changed - Makes `ThreadLifecycleContributor` and `TurnLifecycleContributor` async in `codex-extension-api`. - Awaits thread start/resume/stop and turn start/stop/abort lifecycle callbacks from `codex-core`. - Updates the guardian and memories extensions to implement the async lifecycle trait surface. - Updates the existing lifecycle tests to use async contributor implementations. - Adds `async-trait` to the crates that now expose or implement these async object-safe lifecycle traits. ## Testing - Existing `codex-core` lifecycle tests were updated to cover async implementations for thread stop and turn abort ordering. --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/codex_thread.rs | 12 ++--- codex-rs/core/src/session/handlers.rs | 16 ++++--- codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/src/session/tests.rs | 9 ++-- codex-rs/core/src/tasks/lifecycle.rs | 44 +++++++++++-------- codex-rs/core/src/tasks/mod.rs | 12 +++-- codex-rs/core/src/thread_manager.rs | 2 +- codex-rs/ext/extension-api/Cargo.toml | 1 + .../ext/extension-api/src/contributors.rs | 16 ++++--- codex-rs/ext/extension-api/src/registry.rs | 12 ++--- codex-rs/ext/goal/src/extension.rs | 10 +++-- codex-rs/ext/guardian/Cargo.toml | 1 + codex-rs/ext/guardian/src/lib.rs | 3 +- codex-rs/ext/memories/src/extension.rs | 3 +- 15 files changed, 86 insertions(+), 59 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 850ddbd525..5ced1803fa 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2829,6 +2829,7 @@ dependencies = [ name = "codex-extension-api" version = "0.0.0" dependencies = [ + "async-trait", "codex-protocol", "codex-tools", ] @@ -2962,6 +2963,7 @@ dependencies = [ name = "codex-guardian" version = "0.0.0" dependencies = [ + "async-trait", "codex-core", "codex-extension-api", "codex-protocol", diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 1a2d5ed710..7e9e6e38cd 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -144,7 +144,7 @@ impl CodexThread { self.codex.session_loop_termination.clone().await; } - pub(crate) fn emit_thread_resume_lifecycle(&self) { + pub(crate) async fn emit_thread_resume_lifecycle(&self) { for contributor in self .codex .session @@ -152,10 +152,12 @@ impl CodexThread { .extensions .thread_lifecycle_contributors() { - contributor.on_thread_resume(codex_extension_api::ThreadResumeInput { - session_store: &self.codex.session.services.session_extension_data, - thread_store: &self.codex.session.services.thread_extension_data, - }); + contributor + .on_thread_resume(codex_extension_api::ThreadResumeInput { + session_store: &self.codex.session.services.session_extension_data, + thread_store: &self.codex.session.services.thread_extension_data, + }) + .await; } } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index a26b9bb3f6..c2ffe2f4e4 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -638,12 +638,14 @@ async fn shutdown_session_runtime(sess: &Arc) { sess.guardian_review_session.shutdown().await; } -fn emit_thread_stop_lifecycle(sess: &Session) { +async fn emit_thread_stop_lifecycle(sess: &Session) { for contributor in sess.services.extensions.thread_lifecycle_contributors() { - contributor.on_thread_stop(codex_extension_api::ThreadStopInput { - session_store: &sess.services.session_extension_data, - thread_store: &sess.services.thread_extension_data, - }); + contributor + .on_thread_stop(codex_extension_api::ThreadStopInput { + session_store: &sess.services.session_extension_data, + thread_store: &sess.services.thread_extension_data, + }) + .await; } } @@ -662,7 +664,7 @@ pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { &[], ); - emit_thread_stop_lifecycle(sess.as_ref()); + emit_thread_stop_lifecycle(sess.as_ref()).await; // Gracefully flush and shutdown thread persistence on session end so tests // that inspect durable state do not race with the background writer. @@ -917,7 +919,7 @@ pub(super) async fn submission_loop( // explicit shutdown op, still run session teardown. if !shutdown_received { shutdown_session_runtime(&sess).await; - emit_thread_stop_lifecycle(sess.as_ref()); + emit_thread_stop_lifecycle(sess.as_ref()).await; } debug!("Agent loop exited"); } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 3f8276d04d..925ee2df59 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -877,7 +877,7 @@ impl Session { config: config.as_ref(), session_store: &session_extension_data, thread_store: &thread_extension_data, - }); + }).await; } let services = SessionServices { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index bb858999e4..511dbfeabb 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5683,8 +5683,9 @@ async fn submission_loop_channel_close_emits_thread_stop_lifecycle() { expected_thread_id: ThreadId, } + #[async_trait::async_trait] impl codex_extension_api::ThreadLifecycleContributor for ThreadStopRecorder { - fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { + async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { assert_eq!( self.expected_thread_id.to_string(), input.thread_store.level_id() @@ -5728,8 +5729,9 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif expected_turn_id: String, } + #[async_trait::async_trait] impl codex_extension_api::ThreadLifecycleContributor for LifecycleRecorder { - fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { + async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { assert_eq!( self.expected_thread_id.to_string(), input.thread_store.level_id() @@ -5741,8 +5743,9 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif } } + #[async_trait::async_trait] impl codex_extension_api::TurnLifecycleContributor for LifecycleRecorder { - fn on_turn_abort(&self, input: codex_extension_api::TurnAbortInput<'_>) { + async fn on_turn_abort(&self, input: codex_extension_api::TurnAbortInput<'_>) { assert_eq!( self.expected_thread_id.to_string(), input.thread_store.level_id() diff --git a/codex-rs/core/src/tasks/lifecycle.rs b/codex-rs/core/src/tasks/lifecycle.rs index 2fb9017df2..94b51647c9 100644 --- a/codex-rs/core/src/tasks/lifecycle.rs +++ b/codex-rs/core/src/tasks/lifecycle.rs @@ -4,38 +4,44 @@ use codex_protocol::protocol::TurnAbortReason; use crate::session::session::Session; impl Session { - pub(super) fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) { + pub(super) async fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) { for contributor in self.services.extensions.turn_lifecycle_contributors() { - contributor.on_turn_start(codex_extension_api::TurnStartInput { - session_store: &self.services.session_extension_data, - thread_store: &self.services.thread_extension_data, - turn_store, - }); + contributor + .on_turn_start(codex_extension_api::TurnStartInput { + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store, + }) + .await; } } - pub(super) fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) { + pub(super) async fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) { for contributor in self.services.extensions.turn_lifecycle_contributors() { - contributor.on_turn_stop(codex_extension_api::TurnStopInput { - session_store: &self.services.session_extension_data, - thread_store: &self.services.thread_extension_data, - turn_store, - }); + contributor + .on_turn_stop(codex_extension_api::TurnStopInput { + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store, + }) + .await; } } - pub(super) fn emit_turn_abort_lifecycle( + pub(super) async fn emit_turn_abort_lifecycle( &self, reason: TurnAbortReason, turn_store: &ExtensionData, ) { for contributor in self.services.extensions.turn_lifecycle_contributors() { - contributor.on_turn_abort(codex_extension_api::TurnAbortInput { - reason: reason.clone(), - session_store: &self.services.session_extension_data, - thread_store: &self.services.thread_extension_data, - turn_store, - }); + contributor + .on_turn_abort(codex_extension_api::TurnAbortInput { + reason: reason.clone(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store, + }) + .await; } } } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 58ead0fd25..7724d5f2d7 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -365,7 +365,8 @@ impl Session { turn_state.push_pending_input(item); } } - self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()); + self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()) + .await; let turn_extension_data = Arc::clone(&turn_context.extension_data); let mut active = self.active_turn.lock().await; @@ -505,7 +506,8 @@ impl Session { } if let Some(turn_context) = turn_context.as_deref() { - self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()); + self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) + .await; } if (aborted_turn || reason == TurnAbortReason::Interrupted) && let Err(err) = self @@ -553,7 +555,8 @@ impl Session { self.handle_task_abort(task, reason.clone()).await; } if let Some(turn_context) = turn_context.as_deref() { - self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()); + self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) + .await; } if let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { @@ -756,7 +759,8 @@ impl Session { .time_to_first_token_ms() .await; if should_clear_active_turn { - self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()); + self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) + .await; } if let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TurnFinished { diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 7b3d0a9921..e5b4fcf6a2 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1228,7 +1228,7 @@ impl ThreadManagerState { .finalize_thread_spawn(codex, thread_id, tracked_session_source) .await?; if is_resumed_thread { - new_thread.thread.emit_thread_resume_lifecycle(); + new_thread.thread.emit_thread_resume_lifecycle().await; if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await { warn!("failed to apply goal resume runtime effects: {err}"); } diff --git a/codex-rs/ext/extension-api/Cargo.toml b/codex-rs/ext/extension-api/Cargo.toml index ceecc48023..15f6e99fa0 100644 --- a/codex-rs/ext/extension-api/Cargo.toml +++ b/codex-rs/ext/extension-api/Cargo.toml @@ -14,5 +14,6 @@ doctest = false workspace = true [dependencies] +async-trait = { workspace = true } codex-protocol = { workspace = true } codex-tools = { workspace = true } diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index a8c2001200..b03846fd31 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -36,16 +36,17 @@ pub trait ContextContributor: Send + Sync { /// Implementations should use these callbacks to seed, rehydrate, or flush /// extension-private thread state. Heavy dependencies belong on the extension /// value created by the host, not in these inputs. -pub trait ThreadLifecycleContributor: Send + Sync { +#[async_trait::async_trait] +pub trait ThreadLifecycleContributor: Send + Sync { /// Called after thread-scoped extension stores are created, before later /// contributors can read from them. - fn on_thread_start(&self, _input: ThreadStartInput<'_, C>) {} + async fn on_thread_start(&self, _input: ThreadStartInput<'_, C>) {} /// Called after the host constructs a runtime from persisted history. - fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {} + async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {} /// Called before the host drops the thread runtime and thread-scoped store. - fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {} + async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {} } /// Contributor for host-owned turn lifecycle gates. @@ -53,16 +54,17 @@ pub trait ThreadLifecycleContributor: Send + Sync { /// Implementations should use these callbacks to seed, observe, or clear /// extension-private turn state. The host exposes stable identifiers and /// extension stores instead of core runtime objects. +#[async_trait::async_trait] pub trait TurnLifecycleContributor: Send + Sync { /// Called after turn-scoped extension stores are created, before the task /// for the turn starts running. - fn on_turn_start(&self, _input: TurnStartInput<'_>) {} + async fn on_turn_start(&self, _input: TurnStartInput<'_>) {} /// Called before the host drops the completed turn runtime and turn store. - fn on_turn_stop(&self, _input: TurnStopInput<'_>) {} + async fn on_turn_stop(&self, _input: TurnStopInput<'_>) {} /// Called after the host aborts a running turn. - fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {} + async fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {} } /// Contributor for host-owned configuration changes. diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index 8193f5c1d1..f00e996315 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -12,7 +12,7 @@ use crate::TurnItemContributor; use crate::TurnLifecycleContributor; /// Mutable registry used while hosts register typed runtime contributions. -pub struct ExtensionRegistryBuilder { +pub struct ExtensionRegistryBuilder { thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -23,7 +23,7 @@ pub struct ExtensionRegistryBuilder { approval_review_contributors: Vec>, } -impl Default for ExtensionRegistryBuilder { +impl Default for ExtensionRegistryBuilder { fn default() -> Self { Self { thread_lifecycle_contributors: Vec::new(), @@ -38,7 +38,7 @@ impl Default for ExtensionRegistryBuilder { } } -impl ExtensionRegistryBuilder { +impl ExtensionRegistryBuilder { /// Creates an empty registry builder. pub fn new() -> Self { Self::default() @@ -103,7 +103,7 @@ impl ExtensionRegistryBuilder { } /// Immutable typed registry produced after extensions are installed. -pub struct ExtensionRegistry { +pub struct ExtensionRegistry { thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -114,7 +114,7 @@ pub struct ExtensionRegistry { approval_review_contributors: Vec>, } -impl ExtensionRegistry { +impl ExtensionRegistry { /// Returns the registered thread-lifecycle contributors. pub fn thread_lifecycle_contributors(&self) -> &[Arc>] { &self.thread_lifecycle_contributors @@ -165,6 +165,6 @@ impl ExtensionRegistry { } /// Creates an empty shared registry for hosts that do not register contributions. -pub fn empty_extension_registry() -> Arc> { +pub fn empty_extension_registry() -> Arc> { Arc::new(ExtensionRegistryBuilder::new().build()) } diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 0d78742962..97d7157298 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -102,11 +102,12 @@ fn missing_backend_message() -> String { "goal tools are not connected to host goal persistence yet".to_string() } +#[async_trait] impl ThreadLifecycleContributor for GoalExtension where C: Send + Sync + 'static, { - fn on_thread_start(&self, input: ThreadStartInput<'_, C>) { + async fn on_thread_start(&self, input: ThreadStartInput<'_, C>) { input .thread_store .insert(GoalExtensionConfig::from_enabled((self.goals_enabled)( @@ -135,11 +136,12 @@ where } } +#[async_trait] impl TurnLifecycleContributor for GoalExtension where C: Send + Sync + 'static, { - fn on_turn_start(&self, input: TurnStartInput<'_>) { + async fn on_turn_start(&self, input: TurnStartInput<'_>) { if !goal_enabled(input.thread_store) { return; } @@ -150,7 +152,7 @@ where accounting_state(input.thread_store).start_turn(input.turn_store.level_id()); } - fn on_turn_stop(&self, input: TurnStopInput<'_>) { + async fn on_turn_stop(&self, input: TurnStopInput<'_>) { if !goal_enabled(input.thread_store) { return; } @@ -164,7 +166,7 @@ where accounting_state(input.thread_store).stop_turn(input.turn_store.level_id()); } - fn on_turn_abort(&self, input: TurnAbortInput<'_>) { + async fn on_turn_abort(&self, input: TurnAbortInput<'_>) { if !goal_enabled(input.thread_store) { return; } diff --git a/codex-rs/ext/guardian/Cargo.toml b/codex-rs/ext/guardian/Cargo.toml index 513254b7cf..53e553ec1c 100644 --- a/codex-rs/ext/guardian/Cargo.toml +++ b/codex-rs/ext/guardian/Cargo.toml @@ -14,6 +14,7 @@ doctest = false workspace = true [dependencies] +async-trait = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/ext/guardian/src/lib.rs b/codex-rs/ext/guardian/src/lib.rs index 809a381471..0591887c25 100644 --- a/codex-rs/ext/guardian/src/lib.rs +++ b/codex-rs/ext/guardian/src/lib.rs @@ -47,11 +47,12 @@ impl GuardianThreadContext { } } +#[async_trait::async_trait] impl ThreadLifecycleContributor for GuardianExtension where S: Send + Sync, { - fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { let Ok(forked_from_thread_id) = ThreadId::from_string(input.thread_store.level_id()) else { return; }; diff --git a/codex-rs/ext/memories/src/extension.rs b/codex-rs/ext/memories/src/extension.rs index 1a52eb2cb9..a50949e919 100644 --- a/codex-rs/ext/memories/src/extension.rs +++ b/codex-rs/ext/memories/src/extension.rs @@ -58,8 +58,9 @@ impl ContextContributor for MemoriesExtension { } } +#[async_trait::async_trait] impl ThreadLifecycleContributor for MemoriesExtension { - fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { input .thread_store .insert(MemoriesExtensionConfig::from_config(input.config)); From 6a8173588c15a519f3bb4e63f0cf5ab392f18053 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 14:08:56 +0200 Subject: [PATCH 11/53] feat: add extension event sink capability (#23293) ## Why Extensions can already expose typed contributions and receive host capabilities such as `AgentSpawner`, but they do not have a typed way to send protocol events back through the host. Extensions that need to surface progress or status should not have to own persistence, ordering, transport fanout, or logging decisions themselves. ## What - Add `ExtensionEventSink`, a host-provided fire-and-forget sink for `codex_protocol::protocol::Event`. - Add `NoopExtensionEventSink` so hosts that do not expose extension event emission keep the existing empty-registry behavior. - Store the sink on `ExtensionRegistryBuilder` / `ExtensionRegistry`, with `with_event_sink(...)` and `event_sink()` accessors, and re-export the new capability from `codex-extension-api`. ## Testing - Not run locally; PR metadata/body update only. --- .../extension-api/src/capabilities/events.rs | 19 +++++++++++++++ .../ext/extension-api/src/capabilities/mod.rs | 3 +++ codex-rs/ext/extension-api/src/lib.rs | 2 ++ codex-rs/ext/extension-api/src/registry.rs | 24 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 codex-rs/ext/extension-api/src/capabilities/events.rs diff --git a/codex-rs/ext/extension-api/src/capabilities/events.rs b/codex-rs/ext/extension-api/src/capabilities/events.rs new file mode 100644 index 0000000000..d8fda1aea7 --- /dev/null +++ b/codex-rs/ext/extension-api/src/capabilities/events.rs @@ -0,0 +1,19 @@ +use codex_protocol::protocol::Event; + +/// Host-provided fire-and-forget sink for extension-generated events. +/// +/// Extensions construct protocol events with the correlation id appropriate for +/// the callback they are handling, then leave persistence, ordering, transport +/// fanout, and logging decisions to the host. +pub trait ExtensionEventSink: Send + Sync { + /// Queue one protocol event for host-owned delivery. + fn emit(&self, event: Event); +} + +/// Event sink used when the host does not expose extension event emission. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopExtensionEventSink; + +impl ExtensionEventSink for NoopExtensionEventSink { + fn emit(&self, _event: Event) {} +} diff --git a/codex-rs/ext/extension-api/src/capabilities/mod.rs b/codex-rs/ext/extension-api/src/capabilities/mod.rs index bcc33da975..cd23146542 100644 --- a/codex-rs/ext/extension-api/src/capabilities/mod.rs +++ b/codex-rs/ext/extension-api/src/capabilities/mod.rs @@ -1,4 +1,7 @@ mod agent; +mod events; pub use agent::AgentSpawnFuture; pub use agent::AgentSpawner; +pub use events::ExtensionEventSink; +pub use events::NoopExtensionEventSink; diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index e8be7e0309..fe33d42128 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -5,6 +5,8 @@ mod state; pub use capabilities::AgentSpawnFuture; pub use capabilities::AgentSpawner; +pub use capabilities::ExtensionEventSink; +pub use capabilities::NoopExtensionEventSink; pub use codex_tools::FunctionCallError; pub use codex_tools::JsonToolOutput; pub use codex_tools::ResponsesApiTool; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index f00e996315..41d0967126 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -5,6 +5,8 @@ use crate::ApprovalReviewFuture; use crate::ConfigContributor; use crate::ContextContributor; use crate::ExtensionData; +use crate::ExtensionEventSink; +use crate::NoopExtensionEventSink; use crate::ThreadLifecycleContributor; use crate::TokenUsageContributor; use crate::ToolContributor; @@ -13,6 +15,7 @@ use crate::TurnLifecycleContributor; /// Mutable registry used while hosts register typed runtime contributions. pub struct ExtensionRegistryBuilder { + event_sink: Arc, thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -26,6 +29,7 @@ pub struct ExtensionRegistryBuilder { impl Default for ExtensionRegistryBuilder { fn default() -> Self { Self { + event_sink: Arc::new(NoopExtensionEventSink), thread_lifecycle_contributors: Vec::new(), turn_lifecycle_contributors: Vec::new(), config_contributors: Vec::new(), @@ -44,6 +48,19 @@ impl ExtensionRegistryBuilder { Self::default() } + /// Creates an empty registry builder with a host-provided event sink. + pub fn with_event_sink(event_sink: Arc) -> Self { + Self { + event_sink, + ..Self::default() + } + } + + /// Returns the host event sink to pass into extension constructors. + pub fn event_sink(&self) -> Arc { + Arc::clone(&self.event_sink) + } + /// Registers one approval-review contributor. pub fn approval_review_contributor(&mut self, contributor: Arc) { self.approval_review_contributors.push(contributor); @@ -90,6 +107,7 @@ impl ExtensionRegistryBuilder { /// Finishes construction and returns the immutable registry. pub fn build(self) -> ExtensionRegistry { ExtensionRegistry { + event_sink: self.event_sink, thread_lifecycle_contributors: self.thread_lifecycle_contributors, turn_lifecycle_contributors: self.turn_lifecycle_contributors, config_contributors: self.config_contributors, @@ -104,6 +122,7 @@ impl ExtensionRegistryBuilder { /// Immutable typed registry produced after extensions are installed. pub struct ExtensionRegistry { + event_sink: Arc, thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -115,6 +134,11 @@ pub struct ExtensionRegistry { } impl ExtensionRegistry { + /// Returns the host event sink retained by this registry. + pub fn event_sink(&self) -> Arc { + Arc::clone(&self.event_sink) + } + /// Returns the registered thread-lifecycle contributors. pub fn thread_lifecycle_contributors(&self) -> &[Arc>] { &self.thread_lifecycle_contributors From 7ee7fe239f8bd2f478a30c369c2566004769a3da Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 14:47:05 +0200 Subject: [PATCH 12/53] chore: isolate thread goal storage behind GoalStore (#23295) ## Why Thread goal persistence is being prepared for a dedicated storage boundary. Before that split, goal-specific reads, writes, accounting, and cleanup were exposed directly on `StateRuntime`, so core and app-server callsites stayed coupled to the full runtime instead of a goal-specific store. This PR introduces that boundary without changing the goal wire API or current persistence behavior. Callers now go through `StateRuntime::thread_goals()` and the new `GoalStore`, while `GoalStore` still uses the existing state DB pool underneath. ## What changed - Added `GoalStore` in `state/src/runtime/goals.rs` and exposed it from `StateRuntime` via `thread_goals()`. - Moved thread-goal reads, writes, status updates, pause, delete, and usage accounting onto `GoalStore`. - Updated core session goal handling, app-server goal RPCs, resume snapshots, and goal tests to use the store boundary. - Kept thread deletion responsible for cascading goal cleanup by deleting the goal through the store only after a thread row is removed. ## Testing - Existing goal persistence, resume, and accounting tests were updated to exercise the new `GoalStore` access path. --- .../thread_goal_processor.rs | 7 + .../request_processors/thread_lifecycle.rs | 2 +- .../tests/suite/v2/thread_resume.rs | 3 + codex-rs/core/src/goals.rs | 47 ++++- codex-rs/core/src/session/tests.rs | 10 ++ codex-rs/core/src/thread_manager_tests.rs | 2 + codex-rs/state/src/lib.rs | 1 + codex-rs/state/src/runtime.rs | 8 +- codex-rs/state/src/runtime/goals.rs | 160 ++++++++++++++---- codex-rs/state/src/runtime/threads.rs | 6 +- 10 files changed, 203 insertions(+), 43 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 57d2575590..a7c9a2c20c 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -150,12 +150,14 @@ impl ThreadGoalRequestProcessor { let (goal, previous_status) = (if let Some(objective) = objective { let existing_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await .map_err(|err| invalid_request(err.to_string()))?; if let Some(goal) = existing_goal.as_ref() { let previous_status = ExternalGoalPreviousStatus::from(goal); state_db + .thread_goals() .update_thread_goal( thread_id, codex_state::ThreadGoalUpdate { @@ -177,6 +179,7 @@ impl ThreadGoalRequestProcessor { } else { let previous_status = ExternalGoalPreviousStatus::NewGoal; state_db + .thread_goals() .replace_thread_goal( thread_id, objective, @@ -188,6 +191,7 @@ impl ThreadGoalRequestProcessor { } } else { let existing_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await .map_err(|err| invalid_request(err.to_string()))?; @@ -198,6 +202,7 @@ impl ThreadGoalRequestProcessor { }; let previous_status = ExternalGoalPreviousStatus::from(&existing_goal); state_db + .thread_goals() .update_thread_goal( thread_id, codex_state::ThreadGoalUpdate { @@ -246,6 +251,7 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; let goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await .map_err(|err| internal_error(format!("failed to read thread goal: {err}")))? @@ -303,6 +309,7 @@ impl ThreadGoalRequestProcessor { thread_state.listener_command_tx() }; let cleared = state_db + .thread_goals() .delete_thread_goal(thread_id) .await .map_err(|err| internal_error(format!("failed to clear thread goal: {err}")))?; diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 4781df8350..c4d5fa6c96 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -676,7 +676,7 @@ pub(super) async fn send_thread_goal_snapshot_notification( thread_id: ThreadId, state_db: &StateDbHandle, ) { - match state_db.get_thread_goal(thread_id).await { + match state_db.thread_goals().get_thread_goal(thread_id).await { Ok(Some(goal)) => { outgoing .send_server_notification(ServerNotification::ThreadGoalUpdated( diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index ff2ccec49c..852b86e562 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -943,10 +943,12 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; let thread_id = ThreadId::from_string(&thread_id)?; let persisted_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await? .expect("goal should exist"); state_db + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 12, @@ -974,6 +976,7 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> .await??; let edit: ThreadGoalSetResponse = to_response(edit_resp)?; let updated_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await? .expect("goal should still exist"); diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 4a7a9a3d0b..ce4c0a809d 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -422,6 +422,7 @@ impl Session { let state_db = self.require_state_db_for_thread_goals().await?; state_db + .thread_goals() .get_thread_goal(self.conversation_id) .await .map(|goal| goal.map(protocol_goal_from_state)) @@ -459,10 +460,14 @@ impl Session { let mut replacing_goal = false; let previous_status; let goal = if let Some(objective) = objective.as_deref() { - let existing_goal = state_db.get_thread_goal(self.conversation_id).await?; + let existing_goal = state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await?; previous_status = existing_goal.as_ref().map(|goal| goal.status); if let Some(existing_goal) = existing_goal.as_ref() { state_db + .thread_goals() .update_thread_goal( self.conversation_id, codex_state::ThreadGoalUpdate { @@ -482,6 +487,7 @@ impl Session { } else { replacing_goal = true; state_db + .thread_goals() .replace_thread_goal( self.conversation_id, objective, @@ -493,11 +499,15 @@ impl Session { .await? } } else { - let existing_goal = state_db.get_thread_goal(self.conversation_id).await?; + let existing_goal = state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await?; previous_status = existing_goal.as_ref().map(|goal| goal.status); let expected_goal_id = existing_goal.map(|goal| goal.goal_id); let status = status.map(state_goal_status_from_protocol); state_db + .thread_goals() .update_thread_goal( self.conversation_id, codex_state::ThreadGoalUpdate { @@ -581,6 +591,7 @@ impl Session { ) .await?; let goal = state_db + .thread_goals() .insert_thread_goal( self.conversation_id, objective, @@ -760,7 +771,10 @@ impl Session { state_db: &StateDbHandle, expected_goal_id: Option<&str>, ) -> anyhow::Result> { - let goal = state_db.get_thread_goal(self.conversation_id).await?; + let goal = state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await?; Ok(goal.and_then(|goal| { expected_goal_id .is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id) @@ -801,7 +815,11 @@ impl Session { return; } }; - match state_db.get_thread_goal(self.conversation_id).await { + match state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await + { Ok(Some(goal)) if matches!( goal.status, @@ -963,6 +981,7 @@ impl Session { .current_goal_status_for_metrics(&state_db, expected_goal_id.as_deref()) .await?; let outcome = state_db + .thread_goals() .account_thread_goal_usage( self.conversation_id, time_delta_seconds, @@ -1085,6 +1104,7 @@ impl Session { .await?; match state_db + .thread_goals() .account_thread_goal_usage( self.conversation_id, time_delta_seconds, @@ -1147,6 +1167,7 @@ impl Session { ) .await?; let Some(goal) = state_db + .thread_goals() .pause_active_thread_goal(self.conversation_id) .await? else { @@ -1192,7 +1213,11 @@ impl Session { let Some(state_db) = self.state_db_for_thread_goals().await? else { return Ok(()); }; - let Some(goal) = state_db.get_thread_goal(self.conversation_id).await? else { + let Some(goal) = state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await? + else { self.clear_stopped_thread_goal_runtime_state().await; return Ok(()); }; @@ -1237,7 +1262,11 @@ impl Session { Arc::clone(&active_turn.turn_state) }; let goal_is_current = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => match state_db.get_thread_goal(self.conversation_id).await { + Ok(Some(state_db)) => match state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await + { Ok(Some(goal)) if goal.goal_id == candidate.goal_id && goal.status == codex_state::ThreadGoalStatus::Active => @@ -1333,7 +1362,11 @@ impl Session { return None; } }; - let goal = match state_db.get_thread_goal(self.conversation_id).await { + let goal = match state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await + { Ok(Some(goal)) => goal, Ok(None) => { tracing::debug!("skipping active goal continuation because no goal is set"); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 511dbfeabb..b0bc6330d4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8592,6 +8592,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh let state_db = goal_test_state_db(sess.as_ref()).await?; let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted after accounting"); @@ -8615,6 +8616,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh .await?; let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted after follow-up accounting"); @@ -8654,6 +8656,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a let state_db = goal_test_state_db(sess.as_ref()).await?; let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted"); @@ -8662,6 +8665,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a let previous_goal = goal.clone(); let goal_id = goal.goal_id.clone(); let updated_goal = state_db + .thread_goals() .update_thread_goal( sess.conversation_id, codex_state::ThreadGoalUpdate { @@ -8683,6 +8687,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a assert!(sess.active_turn.lock().await.is_some()); let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted"); @@ -8709,6 +8714,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { let state_db = goal_test_state_db(sess.as_ref()).await?; let old_goal = state_db + .thread_goals() .replace_thread_goal( sess.conversation_id, "Keep improving the benchmark", @@ -8717,6 +8723,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { ) .await?; let new_goal = state_db + .thread_goals() .replace_thread_goal( sess.conversation_id, "Write a concise benchmark summary", @@ -8774,6 +8781,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: let state_db = goal_test_state_db(sess.as_ref()).await?; let goal = state_db + .thread_goals() .replace_thread_goal( sess.conversation_id, "Keep improving the benchmark", @@ -8807,6 +8815,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: .await?; let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted"); @@ -8905,6 +8914,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a ) .await?; let persisted_goal = state_db + .thread_goals() .get_thread_goal(test.session_configured.thread_id) .await? .expect("goal should be persisted"); diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 956486f182..43ec706f1a 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1561,6 +1561,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { .state_db() .expect("source thread should have a state db"); state_db + .thread_goals() .replace_thread_goal( source.thread_id, "Keep working until the task is done", @@ -1581,6 +1582,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { .await .expect("resume source thread"); let goal = state_db + .thread_goals() .get_thread_goal(resumed.thread_id) .await? .expect("goal should still exist after resume"); diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 43c02faae2..aeb8b2d13b 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -48,6 +48,7 @@ pub use model::ThreadGoalStatus; pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; +pub use runtime::GoalStore; pub use runtime::RemoteControlEnrollmentRecord; pub use runtime::ThreadFilterOptions; pub use runtime::ThreadGoalAccountingMode; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index d6ed7bfcc0..e95cc7cc8a 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -18,7 +18,6 @@ use crate::apply_rollout_item; use crate::migrations::runtime_logs_migrator; use crate::migrations::runtime_state_migrator; use crate::model::AgentJobRow; -use crate::model::ThreadGoalRow; use crate::model::ThreadRow; use crate::model::anchor_from_item; use crate::model::datetime_to_epoch_millis; @@ -65,6 +64,7 @@ mod remote_control; mod test_support; mod threads; +pub use goals::GoalStore; pub use goals::ThreadGoalAccountingMode; pub use goals::ThreadGoalAccountingOutcome; pub use goals::ThreadGoalUpdate; @@ -86,6 +86,7 @@ pub struct StateRuntime { default_provider: String, pool: Arc, logs_pool: Arc, + thread_goals: GoalStore, thread_updated_at_millis: Arc, } @@ -164,6 +165,7 @@ impl StateRuntime { let thread_updated_at_millis = thread_updated_at_millis_result?; let thread_updated_at_millis = thread_updated_at_millis.unwrap_or(0); let runtime = Arc::new(Self { + thread_goals: GoalStore::new(Arc::clone(&pool)), pool, logs_pool, codex_home, @@ -183,6 +185,10 @@ impl StateRuntime { pub fn codex_home(&self) -> &Path { self.codex_home.as_path() } + + pub fn thread_goals(&self) -> &GoalStore { + &self.thread_goals + } } fn base_sqlite_options(path: &Path) -> SqliteConnectOptions { diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index 63dc63557c..b43e8f69d5 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -1,6 +1,18 @@ use super::*; +use crate::model::ThreadGoalRow; use uuid::Uuid; +#[derive(Clone)] +pub struct GoalStore { + pool: Arc, +} + +impl GoalStore { + pub(crate) fn new(pool: Arc) -> Self { + Self { pool } + } +} + pub struct ThreadGoalUpdate { pub objective: Option, pub status: Option, @@ -21,7 +33,7 @@ pub enum ThreadGoalAccountingMode { ActiveOrStopped, } -impl StateRuntime { +impl GoalStore { pub async fn get_thread_goal( &self, thread_id: ThreadId, @@ -308,29 +320,6 @@ WHERE thread_id = ? self.get_thread_goal(thread_id).await } - async fn set_thread_preview_if_empty( - &self, - thread_id: ThreadId, - preview: &str, - ) -> anyhow::Result<()> { - let preview = preview.trim(); - if preview.is_empty() { - return Ok(()); - } - sqlx::query( - r#" -UPDATE threads -SET preview = ? -WHERE id = ? AND preview = '' - "#, - ) - .bind(preview) - .bind(thread_id.to_string()) - .execute(self.pool.as_ref()) - .await?; - Ok(()) - } - pub async fn pause_active_thread_goal( &self, thread_id: ThreadId, @@ -463,6 +452,29 @@ RETURNING let updated = thread_goal_from_row(&row)?; Ok(ThreadGoalAccountingOutcome::Updated(updated)) } + + async fn set_thread_preview_if_empty( + &self, + thread_id: ThreadId, + preview: &str, + ) -> anyhow::Result<()> { + let preview = preview.trim(); + if preview.is_empty() { + return Ok(()); + } + sqlx::query( + r#" +UPDATE threads +SET preview = ? +WHERE id = ? AND preview = '' + "#, + ) + .bind(preview) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(()) + } } fn thread_goal_from_row(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result { @@ -519,6 +531,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let goal = runtime + .thread_goals() .replace_thread_goal( thread_id, "optimize the benchmark", @@ -529,7 +542,11 @@ mod tests { .expect("goal replacement should succeed"); assert_eq!( Some(goal.clone()), - runtime.get_thread_goal(thread_id).await.unwrap() + runtime + .thread_goals() + .get_thread_goal(thread_id) + .await + .unwrap() ); let metadata = runtime .get_thread(thread_id) @@ -539,6 +556,7 @@ mod tests { assert_eq!(metadata.preview.as_deref(), Some("hello")); let updated = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -560,6 +578,7 @@ mod tests { assert_eq!(expected, updated); let replaced = runtime + .thread_goals() .replace_thread_goal( thread_id, "ship the new result", @@ -574,9 +593,28 @@ mod tests { assert_eq!(0, replaced.tokens_used); assert_eq!(0, replaced.time_used_seconds); - assert!(runtime.delete_thread_goal(thread_id).await.unwrap()); - assert_eq!(None, runtime.get_thread_goal(thread_id).await.unwrap()); - assert!(!runtime.delete_thread_goal(thread_id).await.unwrap()); + assert!( + runtime + .thread_goals() + .delete_thread_goal(thread_id) + .await + .unwrap() + ); + assert_eq!( + None, + runtime + .thread_goals() + .get_thread_goal(thread_id) + .await + .unwrap() + ); + assert!( + !runtime + .thread_goals() + .delete_thread_goal(thread_id) + .await + .unwrap() + ); } #[tokio::test] @@ -596,6 +634,7 @@ mod tests { .expect("test thread should be upserted"); runtime + .thread_goals() .replace_thread_goal( thread_id, "optimize the benchmark", @@ -621,6 +660,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let replaced = runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -643,6 +683,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let inserted = runtime + .thread_goals() .insert_thread_goal( thread_id, "optimize the benchmark", @@ -654,6 +695,7 @@ mod tests { .expect("goal should be inserted"); let duplicate = runtime + .thread_goals() .insert_thread_goal( thread_id, "replace the benchmark", @@ -666,7 +708,11 @@ mod tests { assert_eq!(None, duplicate); assert_eq!( Some(inserted), - runtime.get_thread_goal(thread_id).await.unwrap() + runtime + .thread_goals() + .get_thread_goal(thread_id) + .await + .unwrap() ); } @@ -677,6 +723,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let inserted = runtime + .thread_goals() .insert_thread_goal( thread_id, "stay within budget", @@ -700,6 +747,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let original = runtime + .thread_goals() .replace_thread_goal( thread_id, "old objective", @@ -709,6 +757,7 @@ mod tests { .await .expect("goal replacement should succeed"); let replacement = runtime + .thread_goals() .replace_thread_goal( thread_id, "new objective", @@ -719,6 +768,7 @@ mod tests { .expect("goal replacement should succeed"); let stale_update = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -735,12 +785,14 @@ mod tests { assert_eq!( Some(replacement.clone()), runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") ); let fresh_update = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -763,6 +815,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let original = runtime + .thread_goals() .replace_thread_goal( thread_id, "old objective", @@ -772,6 +825,7 @@ mod tests { .await .expect("goal replacement should succeed"); let replacement = runtime + .thread_goals() .replace_thread_goal( thread_id, "new objective", @@ -782,6 +836,7 @@ mod tests { .expect("goal replacement should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 5, @@ -809,6 +864,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "draft the report", @@ -818,6 +874,7 @@ mod tests { .await .expect("goal replacement should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 12, @@ -832,6 +889,7 @@ mod tests { }; let updated = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -860,6 +918,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "optimize the benchmark", @@ -869,7 +928,7 @@ mod tests { .await .expect("goal replacement should succeed"); - let status_update = runtime.update_thread_goal( + let status_update = runtime.thread_goals().update_thread_goal( thread_id, ThreadGoalUpdate { objective: None, @@ -878,7 +937,7 @@ mod tests { expected_goal_id: None, }, ); - let budget_update = runtime.update_thread_goal( + let budget_update = runtime.thread_goals().update_thread_goal( thread_id, ThreadGoalUpdate { objective: None, @@ -892,6 +951,7 @@ mod tests { budget_update.expect("budget update should succeed"); let goal = runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") @@ -906,6 +966,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; let goal = runtime + .thread_goals() .replace_thread_goal( thread_id, "optimize the benchmark", @@ -916,6 +977,7 @@ mod tests { .expect("goal replacement should succeed"); let paused = runtime + .thread_goals() .pause_active_thread_goal(thread_id) .await .expect("active pause should succeed") @@ -928,6 +990,7 @@ mod tests { assert_eq!(expected, paused); let complete = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -941,6 +1004,7 @@ mod tests { .expect("goal update should succeed") .expect("goal should exist"); let pause_result = runtime + .thread_goals() .pause_active_thread_goal(thread_id) .await .expect("terminal pause attempt should succeed"); @@ -948,6 +1012,7 @@ mod tests { assert_eq!( Some(complete), runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") @@ -960,6 +1025,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -970,6 +1036,7 @@ mod tests { .expect("goal replacement should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 7, @@ -987,6 +1054,7 @@ mod tests { assert_eq!(7, goal.time_used_seconds); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 3, @@ -1004,6 +1072,7 @@ mod tests { assert_eq!(10, goal.time_used_seconds); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 5, @@ -1027,6 +1096,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay stopped", @@ -1037,6 +1107,7 @@ mod tests { .expect("goal replacement should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 5, @@ -1060,6 +1131,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stop before overrun", @@ -1069,6 +1141,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .update_thread_goal( thread_id, crate::ThreadGoalUpdate { @@ -1082,6 +1155,7 @@ mod tests { .expect("goal update should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 3, @@ -1105,6 +1179,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -1114,6 +1189,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 1, @@ -1125,6 +1201,7 @@ mod tests { .expect("usage accounting should succeed"); let lowered = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -1149,6 +1226,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -1158,6 +1236,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 1, @@ -1169,6 +1248,7 @@ mod tests { .expect("usage accounting should succeed"); let reactivated = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -1197,6 +1277,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -1206,6 +1287,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 1, @@ -1217,6 +1299,7 @@ mod tests { .expect("usage accounting should succeed"); let paused = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -1241,6 +1324,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "finish the report", @@ -1251,6 +1335,7 @@ mod tests { .expect("goal replacement should succeed"); let active_only = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 30, @@ -1268,6 +1353,7 @@ mod tests { assert_eq!(0, goal.time_used_seconds); let completing_turn = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 30, @@ -1291,6 +1377,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "finish the report", @@ -1300,6 +1387,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -1314,6 +1402,7 @@ mod tests { .expect("goal should exist"); let active_only = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 30, @@ -1331,6 +1420,7 @@ mod tests { assert_eq!(0, goal.time_used_seconds); let in_flight_turn = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 30, @@ -1354,6 +1444,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "count every token", @@ -1363,14 +1454,14 @@ mod tests { .await .expect("goal replacement should succeed"); - let first = runtime.account_thread_goal_usage( + let first = runtime.thread_goals().account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 4, /*token_delta*/ 40, ThreadGoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ); - let second = runtime.account_thread_goal_usage( + let second = runtime.thread_goals().account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 6, /*token_delta*/ 60, @@ -1382,6 +1473,7 @@ mod tests { second.expect("second usage accounting should succeed"); let goal = runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") @@ -1396,6 +1488,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "clean up with the thread", @@ -1413,6 +1506,7 @@ mod tests { assert_eq!( None, runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 925fb45288..c8c48db721 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -947,7 +947,11 @@ ON CONFLICT(thread_id, position) DO NOTHING .bind(thread_id.to_string()) .execute(self.pool.as_ref()) .await?; - Ok(result.rows_affected()) + let rows_affected = result.rows_affected(); + if rows_affected > 0 { + self.thread_goals.delete_thread_goal(thread_id).await?; + } + Ok(rows_affected) } } From 500ef67ed15fae5148d5cfcdf42973ffead19b12 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 15:19:23 +0200 Subject: [PATCH 13/53] chore: goal resumed metrics (#23301) Add metrics for goal resume --- codex-rs/core/src/goals.rs | 8 ++++++++ codex-rs/otel/src/metrics/names.rs | 1 + 2 files changed, 9 insertions(+) diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index ce4c0a809d..717afb65c2 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -19,6 +19,7 @@ use codex_otel::GOAL_BUDGET_LIMITED_METRIC; use codex_otel::GOAL_COMPLETED_METRIC; use codex_otel::GOAL_CREATED_METRIC; use codex_otel::GOAL_DURATION_SECONDS_METRIC; +use codex_otel::GOAL_RESUMED_METRIC; use codex_otel::GOAL_TOKEN_COUNT_METRIC; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; @@ -734,6 +735,12 @@ impl Session { .counter(GOAL_CREATED_METRIC, /*inc*/ 1, &[]); } + fn emit_goal_resumed_metric(&self) { + self.services + .session_telemetry + .counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]); + } + fn emit_goal_terminal_metrics_if_status_changed( &self, previous_status: Option, @@ -1229,6 +1236,7 @@ impl Session { .await .wall_clock .mark_active_goal(goal.goal_id); + self.emit_goal_resumed_metric(); } codex_state::ThreadGoalStatus::Paused | codex_state::ThreadGoalStatus::BudgetLimited diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 82fae06734..2d7dfdf880 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -29,6 +29,7 @@ pub const TURN_MEMORY_METRIC: &str = "codex.turn.memory"; pub const TURN_TOOL_CALL_METRIC: &str = "codex.turn.tool.call"; pub const TURN_TOKEN_USAGE_METRIC: &str = "codex.turn.token_usage"; pub const GOAL_CREATED_METRIC: &str = "codex.goal.created"; +pub const GOAL_RESUMED_METRIC: &str = "codex.goal.resumed"; pub const GOAL_COMPLETED_METRIC: &str = "codex.goal.completed"; pub const GOAL_BUDGET_LIMITED_METRIC: &str = "codex.goal.budget_limited"; pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; From b631d9217025b9960510808443125da3fffc7857 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 15:59:06 +0200 Subject: [PATCH 14/53] chore: make token usage async (#23305) Make the `TokenUsageContributor` async. This will be required for future extension and it's basically free --- codex-rs/core/src/session/mod.rs | 14 ++++++++------ codex-rs/core/src/session/tests.rs | 3 ++- codex-rs/ext/extension-api/src/contributors.rs | 3 ++- codex-rs/ext/goal/src/extension.rs | 5 ++--- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d67e79f794..315dc93c6f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2946,12 +2946,14 @@ impl Session { }; if let Some(token_info) = token_info.as_ref() { for contributor in self.services.extensions.token_usage_contributors() { - contributor.on_token_usage( - &self.services.session_extension_data, - &self.services.thread_extension_data, - turn_context.extension_data.as_ref(), - token_info, - ); + contributor + .on_token_usage( + &self.services.session_extension_data, + &self.services.thread_extension_data, + turn_context.extension_data.as_ref(), + token_info, + ) + .await; } } } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b0bc6330d4..4511df4583 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1874,8 +1874,9 @@ async fn record_token_usage_info_notifies_extension_contributors() { records: Arc>>, } + #[async_trait::async_trait] impl codex_extension_api::TokenUsageContributor for TokenUsageRecorder { - fn on_token_usage( + async fn on_token_usage( &self, session_store: &codex_extension_api::ExtensionData, thread_store: &codex_extension_api::ExtensionData, diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index b03846fd31..4aa28d1049 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -88,9 +88,10 @@ pub trait ConfigContributor: Send + Sync { /// Implementations should keep this callback cheap. The host calls it after /// updating cached token usage and before emitting the corresponding client /// token-count notification. +#[async_trait::async_trait] pub trait TokenUsageContributor: Send + Sync { /// Called each time the host records token usage from a model response. - fn on_token_usage( + async fn on_token_usage( &self, _session_store: &ExtensionData, _thread_store: &ExtensionData, diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 97d7157298..3204b9eb92 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -179,11 +179,12 @@ where } } +#[async_trait] impl TokenUsageContributor for GoalExtension where C: Send + Sync + 'static, { - fn on_token_usage( + async fn on_token_usage( &self, _session_store: &ExtensionData, thread_store: &ExtensionData, @@ -204,8 +205,6 @@ where // this recorded delta can be committed to the active persisted goal. // It also needs an event/input capability to emit ThreadGoalUpdated and // inject budget-limit steering when accounting changes goal status. - // TODO: if the storage/event path must await, TokenUsageContributor - // either needs to become async or receive a fire-and-forget host sink. } } From 4ca60ef9fffe76fb4f86d606f7d4a2f727f6cd25 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 16:14:37 +0200 Subject: [PATCH 15/53] Emit goal update events from goal extension tools (#23306) ## Why Goal creation and completion are moving through the goal extension, but the rest of Codex still observes goal state through `ThreadGoalUpdated` events. Without an event from the extension-owned tool path, a model-initiated `create_goal` or `update_goal` can mutate the backend and return a tool result while app-server and TUI listeners miss the goal state transition. ## What changed - Added `GoalEventEmitter` as a small wrapper around the host `ExtensionEventSink` to build `EventMsg::ThreadGoalUpdated` events for goal updates. - Threaded the registry event sink into `GoalExtension` and the `GoalToolExecutor`s created by the extension. The public `GoalExtension::new` constructor keeps a `NoopExtensionEventSink` fallback for standalone use. - Emitted a goal update after successful `create_goal` and `update_goal` tool calls. Until `ToolCall` exposes the current turn submission id, these events use the tool call id as the event id and leave `turn_id` unset. Relevant code: - [`GoalEventEmitter::thread_goal_updated`](https://github.com/openai/codex/blob/1fe2d73890df9a50996f67f705d4da4cc3d4b866/codex-rs/ext/goal/src/events.rs#L19-L32) - [`GoalToolExecutor` emission points](https://github.com/openai/codex/blob/1fe2d73890df9a50996f67f705d4da4cc3d4b866/codex-rs/ext/goal/src/tool.rs#L161-L190) ## Testing - `cargo test -p codex-goal-extension` --- codex-rs/ext/goal/src/events.rs | 34 ++++++++++++++++++++++++++++ codex-rs/ext/goal/src/extension.rs | 27 ++++++++++++++++++++-- codex-rs/ext/goal/src/lib.rs | 1 + codex-rs/ext/goal/src/tool.rs | 36 +++++++++++++++++++++++++++--- 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 codex-rs/ext/goal/src/events.rs diff --git a/codex-rs/ext/goal/src/events.rs b/codex-rs/ext/goal/src/events.rs new file mode 100644 index 0000000000..ab9eda4056 --- /dev/null +++ b/codex-rs/ext/goal/src/events.rs @@ -0,0 +1,34 @@ +use std::sync::Arc; + +use codex_extension_api::ExtensionEventSink; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ThreadGoal; +use codex_protocol::protocol::ThreadGoalUpdatedEvent; + +#[derive(Clone)] +pub(crate) struct GoalEventEmitter { + sink: Arc, +} + +impl GoalEventEmitter { + pub(crate) fn new(sink: Arc) -> Self { + Self { sink } + } + + pub(crate) fn thread_goal_updated( + &self, + event_id: impl Into, + turn_id: Option, + goal: ThreadGoal, + ) { + self.sink.emit(Event { + id: event_id.into(), + msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: goal.thread_id, + turn_id, + goal, + }), + }); + } +} diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 3204b9eb92..a0ce7117f4 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use async_trait::async_trait; use codex_extension_api::ConfigContributor; use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::NoopExtensionEventSink; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; use codex_extension_api::TokenUsageContributor; @@ -18,6 +20,7 @@ use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnAbortReason; use crate::accounting::GoalAccountingState; +use crate::events::GoalEventEmitter; use crate::tool::CreateGoalRequest; use crate::tool::GoalToolExecutor; @@ -35,6 +38,7 @@ impl GoalExtensionConfig { #[derive(Clone)] pub struct GoalExtension { backend: Arc, + event_emitter: GoalEventEmitter, goals_enabled: Arc bool + Send + Sync>, } @@ -48,9 +52,18 @@ impl GoalExtension { pub fn new( backend: Arc, goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, + ) -> Self { + Self::new_with_event_sink(backend, Arc::new(NoopExtensionEventSink), goals_enabled) + } + + pub fn new_with_event_sink( + backend: Arc, + event_sink: Arc, + goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, ) -> Self { Self { backend, + event_emitter: GoalEventEmitter::new(event_sink), goals_enabled: Arc::new(goals_enabled), } } @@ -231,14 +244,20 @@ where return Vec::new(); }; vec![ - Arc::new(GoalToolExecutor::get(thread_id, Arc::clone(&self.backend))), + Arc::new(GoalToolExecutor::get( + thread_id, + Arc::clone(&self.backend), + self.event_emitter.clone(), + )), Arc::new(GoalToolExecutor::create( thread_id, Arc::clone(&self.backend), + self.event_emitter.clone(), )), Arc::new(GoalToolExecutor::update( thread_id, Arc::clone(&self.backend), + self.event_emitter.clone(), )), ] } @@ -260,7 +279,11 @@ pub fn install_with_backend( ) where C: Send + Sync + 'static, { - let extension = Arc::new(GoalExtension::new(backend, goals_enabled)); + let extension = Arc::new(GoalExtension::new_with_event_sink( + backend, + registry.event_sink(), + goals_enabled, + )); registry.thread_lifecycle_contributor(extension.clone()); registry.config_contributor(extension.clone()); registry.turn_lifecycle_contributor(extension.clone()); diff --git a/codex-rs/ext/goal/src/lib.rs b/codex-rs/ext/goal/src/lib.rs index 17a43e4671..38332aee41 100644 --- a/codex-rs/ext/goal/src/lib.rs +++ b/codex-rs/ext/goal/src/lib.rs @@ -5,6 +5,7 @@ //! accounting that can be represented with today's extension API. mod accounting; +mod events; mod extension; mod spec; mod tool; diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs index d19dc81056..8ad4541a3c 100644 --- a/codex-rs/ext/goal/src/tool.rs +++ b/codex-rs/ext/goal/src/tool.rs @@ -15,6 +15,7 @@ use codex_protocol::protocol::validate_thread_goal_objective; use serde::Deserialize; use serde::Serialize; +use crate::events::GoalEventEmitter; use crate::extension::GoalToolBackend; use crate::spec::CREATE_GOAL_TOOL_NAME; use crate::spec::GET_GOAL_TOOL_NAME; @@ -28,6 +29,7 @@ pub(crate) struct GoalToolExecutor { kind: GoalToolKind, thread_id: ThreadId, backend: Arc, + event_emitter: GoalEventEmitter, } #[derive(Clone, Copy)] @@ -65,27 +67,42 @@ enum CompletionBudgetReport { } impl GoalToolExecutor { - pub(crate) fn get(thread_id: ThreadId, backend: Arc) -> Self { + pub(crate) fn get( + thread_id: ThreadId, + backend: Arc, + event_emitter: GoalEventEmitter, + ) -> Self { Self { kind: GoalToolKind::Get, thread_id, backend, + event_emitter, } } - pub(crate) fn create(thread_id: ThreadId, backend: Arc) -> Self { + pub(crate) fn create( + thread_id: ThreadId, + backend: Arc, + event_emitter: GoalEventEmitter, + ) -> Self { Self { kind: GoalToolKind::Create, thread_id, backend, + event_emitter, } } - pub(crate) fn update(thread_id: ThreadId, backend: Arc) -> Self { + pub(crate) fn update( + thread_id: ThreadId, + backend: Arc, + event_emitter: GoalEventEmitter, + ) -> Self { Self { kind: GoalToolKind::Update, thread_id, backend, + event_emitter, } } } @@ -146,6 +163,7 @@ impl GoalToolExecutor { .create_goal(self.thread_id, request) .await .map_err(FunctionCallError::RespondToModel)?; + self.emit_goal_updated_from_tool_call(&invocation, goal.clone()); goal_response(Some(goal), CompletionBudgetReport::Omit) } @@ -168,8 +186,20 @@ impl GoalToolExecutor { .complete_goal(self.thread_id) .await .map_err(FunctionCallError::RespondToModel)?; + self.emit_goal_updated_from_tool_call(&invocation, goal.clone()); goal_response(Some(goal), CompletionBudgetReport::Include) } + + fn emit_goal_updated_from_tool_call(&self, invocation: &ToolCall, goal: ThreadGoal) { + // TODO: ToolCall should expose the current turn submission id so goal + // tool events can set ThreadGoalUpdatedEvent.turn_id exactly as core + // does today. Until then, correlate the event with the tool call id. + self.event_emitter.thread_goal_updated( + invocation.call_id.clone(), + /*turn_id*/ None, + goal, + ); + } } fn parse_arguments(arguments: &str) -> Result From fce10e009d2b56536d01145a809b8e862dbb671d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 08:52:18 -0700 Subject: [PATCH 16/53] tui: keep cleared Fast tier from reappearing after side-thread resume (#23121) ## Why After turning Fast mode off in the TUI, returning from a side thread could make `Fast` appear again in the main chat widget. The opt-out itself was still persisted; the display was being rebuilt from stale cached `ThreadSessionState` data, which made it look like Fast had been re-enabled. Fixes #23104. ## What changed - Keep the active thread's cached `service_tier` in sync whenever the user persists a service-tier selection. - Update both the primary-thread snapshot and the thread event store so restored TUI state reflects the current tier. - Add a focused regression test for clearing a cached Fast tier. ## Manual repro 1. Start a TUI session where `Fast` is enabled by default. 2. Run `/fast` and turn Fast mode off. Confirm `Fast` disappears from the chat widget display. 3. Re-enter thread navigation via either path: - Run `/side test`, then return to the main thread. - Run `/agent`, enter a child thread, then return to the main thread. 4. Before this fix, `Fast` reappears in the main chat widget display even though the opt-out was already persisted. 5. After this fix, `Fast` stays cleared. ## Verification - `cargo test -p codex-tui app::thread_session_state::tests::service_tier_sync_updates_active_cached_session -- --exact` --- codex-rs/tui/src/app/event_dispatch.rs | 4 +- codex-rs/tui/src/app/thread_session_state.rs | 69 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 9271089c43..efb9a26c63 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1303,8 +1303,10 @@ impl App { } AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); - let profile = self.active_profile.as_deref(); self.config.service_tier = service_tier.clone(); + self.sync_active_thread_service_tier_to_cached_session() + .await; + let profile = self.active_profile.as_deref(); let edits = crate::config_update::build_service_tier_selection_edits( profile, service_tier.as_deref(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 4037a085eb..6f2b3ec7f1 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -8,6 +8,30 @@ use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; impl App { + pub(super) async fn sync_active_thread_service_tier_to_cached_session(&mut self) { + let Some(active_thread_id) = self.active_thread_id else { + return; + }; + + let service_tier = self.chat_widget.current_service_tier().map(str::to_string); + let update_session = |session: &mut ThreadSessionState| { + session.service_tier = service_tier.clone(); + }; + + if self.primary_thread_id == Some(active_thread_id) + && let Some(session) = self.primary_session_configured.as_mut() + { + update_session(session); + } + + if let Some(channel) = self.thread_event_channels.get(&active_thread_id) { + let mut store = channel.store.lock().await; + if let Some(session) = store.session.as_mut() { + update_session(session); + } + } + } + pub(super) async fn sync_active_thread_permission_settings_to_cached_session(&mut self) { let Some(active_thread_id) = self.active_thread_id else { return; @@ -125,6 +149,7 @@ mod tests { use crate::test_support::test_path_buf; use codex_app_server_protocol::AskForApproval; use codex_config::types::ApprovalsReviewer; + use codex_protocol::config_types::ServiceTier; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -316,6 +341,50 @@ mod tests { assert_eq!(store_session, Some(expected_session)); } + #[tokio::test] + async fn service_tier_sync_updates_active_cached_session() { + let mut app = make_test_app().await; + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000406").expect("valid thread"); + let session = ThreadSessionState { + service_tier: Some(ServiceTier::Fast.request_value().to_string()), + ..test_thread_session(thread_id, test_path_buf("/tmp/main")) + }; + + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + app.primary_session_configured = Some(session.clone()); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session(/*capacity*/ 4, session.clone(), Vec::new()), + ); + app.chat_widget.handle_thread_session(session); + app.chat_widget.set_service_tier(/*service_tier*/ None); + + app.sync_active_thread_service_tier_to_cached_session() + .await; + + let expected_session = ThreadSessionState { + service_tier: None, + ..test_thread_session(thread_id, test_path_buf("/tmp/main")) + }; + assert_eq!( + app.primary_session_configured, + Some(expected_session.clone()) + ); + + let store_session = app + .thread_event_channels + .get(&thread_id) + .expect("thread channel") + .store + .lock() + .await + .session + .clone(); + assert_eq!(store_session, Some(expected_session)); + } + #[tokio::test] async fn thread_read_fallback_uses_active_permission_settings() { let mut app = make_test_app().await; From af6ffb6ebb4c1aad5793bb1eff4ea1ac785cd22d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 08:55:22 -0700 Subject: [PATCH 17/53] Support --output-schema for exec resume (#23123) ## Why `codex exec resume` should have the same structured-output support as top-level `codex exec`. Without `--output-schema`, multi-turn automation has to choose between resumed session context and schema-validated JSON output. Fixes #22998. ## What changed - Marked `--output-schema` as a global `codex exec` flag so it can be passed after `resume`. - Reused the existing output schema plumbing so resumed turns attach the schema to the final response request while preserving session context. --- codex-rs/cli/src/main.rs | 8 ++++- codex-rs/exec/src/cli.rs | 2 +- codex-rs/exec/src/cli_tests.rs | 5 ++- codex-rs/exec/tests/suite/resume.rs | 56 +++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6c79c34c99..8274510b9f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2346,7 +2346,7 @@ mod tests { } #[test] - fn exec_resume_accepts_output_last_message_flag_after_subcommand() { + fn exec_resume_accepts_output_flags_after_subcommand() { let cli = MultitoolCli::try_parse_from([ "codex", "exec", @@ -2354,6 +2354,8 @@ mod tests { "session-123", "-o", "/tmp/resume-output.md", + "--output-schema", + "/tmp/schema.json", "re-review", ]) .expect("parse should succeed"); @@ -2369,6 +2371,10 @@ mod tests { exec.last_message_file, Some(std::path::PathBuf::from("/tmp/resume-output.md")) ); + assert_eq!( + exec.output_schema, + Some(std::path::PathBuf::from("/tmp/schema.json")) + ); assert_eq!(args.session_id.as_deref(), Some("session-123")); assert_eq!(args.prompt.as_deref(), Some("re-review")); } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 3a5ebfd1ba..53124c11a5 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -50,7 +50,7 @@ pub struct Cli { pub removed_full_auto: bool, /// Path to a JSON Schema file describing the model's final response shape. - #[arg(long = "output-schema", value_name = "FILE")] + #[arg(long = "output-schema", value_name = "FILE", global = true)] pub output_schema: Option, #[clap(skip)] diff --git a/codex-rs/exec/src/cli_tests.rs b/codex-rs/exec/src/cli_tests.rs index 45f2aa330d..524067dd66 100644 --- a/codex-rs/exec/src/cli_tests.rs +++ b/codex-rs/exec/src/cli_tests.rs @@ -36,7 +36,7 @@ fn resume_parses_prompt_after_global_flags() { } #[test] -fn resume_accepts_output_last_message_flag_after_subcommand() { +fn resume_accepts_output_flags_after_subcommand() { const PROMPT: &str = "echo resume-with-output-file"; let cli = Cli::parse_from([ "codex-exec", @@ -44,6 +44,8 @@ fn resume_accepts_output_last_message_flag_after_subcommand() { "session-123", "-o", "/tmp/resume-output.md", + "--output-schema", + "/tmp/schema.json", PROMPT, ]); @@ -51,6 +53,7 @@ fn resume_accepts_output_last_message_flag_after_subcommand() { cli.last_message_file, Some(PathBuf::from("/tmp/resume-output.md")) ); + assert_eq!(cli.output_schema, Some(PathBuf::from("/tmp/schema.json"))); let Some(Command::Resume(args)) = cli.command else { panic!("expected resume command"); }; diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 2bf6416ccc..904c6a8f19 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -375,6 +375,62 @@ async fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resume_includes_output_schema_in_request() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let test = test_codex_exec(); + let server = MockServer::start().await; + let response_mock = mount_exec_responses(&server, /*count*/ 2).await; + + let schema_contents = serde_json::json!({ + "type": "object", + "properties": { + "answer": { "type": "string" } + }, + "required": ["answer"], + "additionalProperties": false + }); + let schema_path = test.cwd_path().join("schema.json"); + std::fs::write(&schema_path, serde_json::to_vec_pretty(&schema_contents)?)?; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("echo seed-resume-session") + .assert() + .success(); + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("resume") + .arg("--last") + .arg("--json") + .arg("--output-schema") + .arg(&schema_path) + .arg("echo resume-with-schema") + .assert() + .success(); + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let payload: Value = requests[1].body_json(); + let text = payload.get("text").expect("request missing text field"); + let format = text + .get("format") + .expect("request missing text.format field"); + assert_eq!( + format, + &serde_json::json!({ + "name": "codex_output_schema", + "type": "json_schema", + "strict": true, + "schema": schema_contents, + }) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); From deb159d9ffb848b60a9c63ab9d2ce014d7093b0f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 09:00:57 -0700 Subject: [PATCH 18/53] Fix TUI stream cleanup after turn errors (#23128) ## Summary Fixes #22726. After a Responses stream disconnect, the live TUI could keep accepting prompts while leaving partially streamed assistant output in its transient streaming-cell form. That made fenced diffs or SVG/XML-like content appear as raw transcript text until the user closed the TUI and resumed the same session, which rebuilt the transcript from saved history. This change finalizes the active answer stream before generic failed-turn cleanup clears the stream controller, so the live transcript takes the same source-backed markdown consolidation path as a successful turn. ## Reviewer repro 1. Start a local Codex TUI session. 2. Trigger an assistant turn that streams markdown content, especially a fenced diff or SVG/XML-like block. 3. Force or encounter a non-retry stream disconnect before the turn completes. 4. Continue using the same still-open TUI session. 5. Before this fix, the live history can stay raw/plain even though `codex resume` renders the same session normally. 6. After this fix, the failed-turn path consolidates the partial stream before rendering the error, so the live TUI keeps normal transcript rendering. --- .../tui/src/chatwidget/tests/app_server.rs | 34 +++++++++++++++++++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 1 + 2 files changed, 35 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 26e38900ce..688e30bd17 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -636,6 +636,40 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn live_app_server_failed_turn_consolidates_streamed_answer() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + handle_turn_started(&mut chat, "turn-1"); + while rx.try_recv().is_ok() {} + + handle_agent_message_delta(&mut chat, "```diff\n+ streamed patch\n```\n"); + chat.run_commit_tick(); + while rx.try_recv().is_ok() {} + + handle_error( + &mut chat, + "stream disconnected before completion", + /*codex_error_info*/ None, + ); + + let mut saw_consolidate = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::ConsolidateAgentMessage { source, .. } = event { + saw_consolidate = true; + assert!( + source.contains("streamed patch"), + "expected partial stream source to be consolidated, got {source:?}" + ); + } + } + + assert!( + saw_consolidate, + "failed turn should consolidate streamed cells before clearing the stream controller" + ); +} + #[tokio::test] async fn live_app_server_stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index d82eca338f..4868d6a25c 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -339,6 +339,7 @@ impl ChatWidget { pub(super) fn on_error(&mut self, message: String) { self.input_queue.submit_pending_steers_after_interrupt = false; + self.flush_answer_stream_with_separator(); self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); self.set_ambient_pet_notification( From e734cb5713086aa31db81f9180c78c0742a446b9 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 09:02:38 -0700 Subject: [PATCH 19/53] Hide ChatGPT usage link for non-OpenAI status (#23127) Addresses #22778 ## Summary Provider deployments such as Bedrock manage rate limits and billing outside ChatGPT, so the `/status` link to the ChatGPT usage page is irrelevant and confusing for those users. Custom providers that are explicitly configured to use OpenAI/ChatGPT auth still point at OpenAI-backed usage, so they should keep the link. ## Changes - Render the ChatGPT usage note only when the configured provider uses OpenAI auth. - Keep the note hidden when `/status` displays a provider such as Bedrock that manages limits elsewhere. - Add regression coverage for both Bedrock and a custom OpenAI-auth proxy provider. ## Manual Repro 1. Configure Codex with a non-OpenAI-auth provider, for example `model_provider = "amazon-bedrock"`. 2. Start the TUI and run `/status`. 3. Confirm the status card shows the custom provider, for example `Model provider: Amazon Bedrock`, and does not show `https://chatgpt.com/codex/settings/usage`. 4. Configure a custom provider that proxies to OpenAI and has OpenAI/ChatGPT auth enabled. 5. Run `/status` again and confirm the ChatGPT usage link appears for that OpenAI-auth provider. --- codex-rs/tui/src/status/card.rs | 11 +++++++-- codex-rs/tui/src/status/tests.rs | 38 +++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 3d30f800b6..7f7768e2cf 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -106,6 +106,7 @@ struct StatusHistoryCell { agents_summary: Arc>, collaboration_mode: Option, model_provider: Option, + show_chatgpt_usage_link: bool, account: Option, thread_name: Option, session_id: Option, @@ -308,6 +309,7 @@ impl StatusHistoryCell { workspace_root_suffix.as_deref(), ); let model_provider = format_model_provider(config, runtime_model_provider_base_url); + let show_chatgpt_usage_link = config.model_provider.requires_openai_auth; let account = compose_account_display(account_display); let session_id = session_id.as_ref().map(std::string::ToString::to_string); let forked_from = forked_from.map(|id| id.to_string()); @@ -347,6 +349,7 @@ impl StatusHistoryCell { permissions, collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, + show_chatgpt_usage_link, account, thread_name, session_id, @@ -765,8 +768,12 @@ impl HistoryCell for StatusHistoryCell { [note_first_line, note_second_line], RtOptions::new(available_inner_width), ); - lines.extend(note_lines); - lines.push(Line::from(Vec::>::new())); + // The ChatGPT usage page only applies to providers backed by OpenAI auth; + // providers like Bedrock manage limits and billing elsewhere. + if self.show_chatgpt_usage_link { + lines.extend(note_lines); + lines.push(Line::from(Vec::>::new())); + } let mut model_spans = vec![Span::from(self.model_name.clone())]; if !self.model_details.is_empty() { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 3a10e0ed94..286767e647 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -582,7 +582,7 @@ async fn status_snapshot_shows_active_user_defined_profile() { } #[tokio::test] -async fn status_model_provider_uses_bedrock_runtime_base_url() { +async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_link() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model_provider_id = "amazon-bedrock".to_string(); @@ -629,6 +629,42 @@ async fn status_model_provider_uses_bedrock_runtime_base_url() { !rendered.contains("bedrock-mantle.us-east-1"), "expected /status to ignore configured Bedrock base URL, got: {rendered}" ); + assert!( + !rendered.contains("https://chatgpt.com/codex/settings/usage"), + "expected /status to hide ChatGPT usage link for Bedrock, got: {rendered}" + ); + + config.model_provider_id = "openai-proxy".to_string(); + config.model_provider = ModelProviderInfo { + name: "OpenAI Proxy".to_string(), + base_url: Some("https://openai-proxy.example/v1".to_string()), + requires_openai_auth: true, + ..ModelProviderInfo::default() + }; + let (composite, _handle) = new_status_output_with_rate_limits_handle( + &config, + /*runtime_model_provider_base_url*/ None, + test_status_account_display().as_ref(), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ &[], + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + "".to_string(), + /*refreshing_rate_limits*/ false, + ); + let rendered = render_lines(&composite.display_lines(/*width*/ 120)).join("\n"); + + assert!( + rendered.contains("https://chatgpt.com/codex/settings/usage"), + "expected /status to show ChatGPT usage link for OpenAI-auth proxy, got: {rendered}" + ); } #[tokio::test] From adca1b643fd0d2733030ef4fdaf5273036f02d9a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 09:04:02 -0700 Subject: [PATCH 20/53] [1 of 2] Optimize TUI startup terminal probes (#23175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Codex TUI startup still feels slower than 0.117.0 after the app-server move in 0.118.0. A visible chunk of launch-to-input latency comes from serial terminal startup probes: cursor position, keyboard enhancement support, and default foreground/background color queries can each wait on terminal responses before the first usable frame. Refs #16335. ## What This PR batches the terminal startup probes into one bounded probe. It also reuses the probed cursor position and default colors during TUI setup, fast-paths the primary-device-attributes fallback as keyboard enhancement unsupported, and keeps lightweight startup timing logs for future tuning. The startup telemetry is intentionally left in production: it records phase timings for terminal probes and initial-frame scheduling so future startup regressions can be diagnosed from normal logs rather than re-adding one-off debug instrumentation. ## Benchmark In the local pty startup benchmark, the pre-optimization `main` baseline was about 250.5ms median from launch to accepted chat input. This probe-only branch measured about 152ms median, for an approximate savings of 95-100ms. ## Stack 1. [#23175: [1 of 2] Optimize TUI startup terminal probes](https://github.com/openai/codex/pull/23175) — this PR 2. [#23176: [2 of 2] Start fresh TUI thread in background](https://github.com/openai/codex/pull/23176) — layered on this PR ## Verification - `cargo test -p codex-tui` --- codex-rs/tui/src/app.rs | 19 +++ codex-rs/tui/src/lib.rs | 9 +- codex-rs/tui/src/terminal_palette.rs | 25 ++++ codex-rs/tui/src/terminal_probe.rs | 190 ++++++++++++++++++++------- codex-rs/tui/src/tui.rs | 84 ++++++++---- 5 files changed, 254 insertions(+), 73 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 46a7be00dd..07ac30f046 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -660,6 +660,7 @@ impl App { startup_hooks_browser: Option, ) -> Result { use tokio_stream::StreamExt; + let startup_started_at = Instant::now(); let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); @@ -703,7 +704,9 @@ impl App { }); } }; + let bootstrap_started_at = Instant::now(); let bootstrap = app_server.bootstrap(&config).await?; + let bootstrap_ms = bootstrap_started_at.elapsed().as_millis(); let mut model = bootstrap.default_model; let available_models = bootstrap.available_models; let exit_info = handle_model_migration_prompt_if_needed( @@ -760,8 +763,10 @@ impl App { let workspace_command_runner: WorkspaceCommandRunner = Arc::new( AppServerWorkspaceCommandRunner::new(app_server.request_handle()), ); + let runtime_model_provider_started_at = Instant::now(); let runtime_model_provider_base_url = resolve_runtime_model_provider_base_url(&config.model_provider).await; + let runtime_model_provider_ms = runtime_model_provider_started_at.elapsed().as_millis(); let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = @@ -772,6 +777,7 @@ impl App { &initial_prompt, &initial_images, ); + let thread_and_widget_started_at = Instant::now(); let (mut chat_widget, initial_started_thread) = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { let started = app_server.start_thread(&config).await?; @@ -888,6 +894,7 @@ impl App { (ChatWidget::new_with_app_event(init), Some(forked)) } }; + let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis(); if let Some(message) = external_agent_config_migration_message { chat_widget.add_info_message(message, /*hint*/ None); } @@ -958,6 +965,7 @@ See the Codex keymap documentation for supported actions and examples." if let Some(entry) = startup_hooks_browser { app.chat_widget.open_hooks_browser(entry); } + let initial_session_started_at = Instant::now(); if let Some(started) = initial_started_thread { let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) @@ -967,6 +975,7 @@ See the Codex keymap documentation for supported actions and examples." .await; } } + let initial_session_ms = initial_session_started_at.elapsed().as_millis(); // On startup, if a managed filesystem sandbox is active, warn about // world-writable dirs on Windows. @@ -996,10 +1005,20 @@ See the Codex keymap documentation for supported actions and examples." } } + let event_stream_started_at = Instant::now(); let tui_events = tui.event_stream(); tokio::pin!(tui_events); tui.frame_requester().schedule_frame(); + tracing::info!( + duration_ms = %startup_started_at.elapsed().as_millis(), + bootstrap_ms = %bootstrap_ms, + runtime_model_provider_ms = %runtime_model_provider_ms, + thread_and_widget_ms = %thread_and_widget_ms, + initial_session_ms = %initial_session_ms, + event_stream_ms = %event_stream_started_at.elapsed().as_millis(), + "tui startup initial frame scheduled" + ); app.refresh_startup_skills(&app_server); // Kick off a non-blocking rate-limit prefetch so the first `/status` // already has data, without delaying the initial frame render. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 871cdcc0dd..9ece5e3776 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1219,10 +1219,13 @@ async fn run_ratatui_app( tracing::error!("panic: {info}"); prev_hook(info); })); - let mut terminal = tui::init()?; - terminal.clear()?; + let mut initialized_terminal = tui::init()?; + initialized_terminal.terminal.clear()?; - let mut tui = Tui::new(terminal); + let mut tui = Tui::new( + initialized_terminal.terminal, + initialized_terminal.enhanced_keys_supported, + ); let mut terminal_restore_guard = TerminalRestoreGuard::new(); #[cfg(not(debug_assertions))] diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 229a97d947..53c68d96e7 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -68,6 +68,13 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.bg) } +#[cfg(unix)] +pub(crate) fn set_default_colors_from_startup_probe( + colors: Option, +) { + imp::set_default_colors_from_startup_probe(colors); +} + #[cfg(all(unix, not(test)))] mod imp { use super::DefaultColors; @@ -112,6 +119,18 @@ mod imp { cache.get_or_init_with(query_default_colors) } + pub(super) fn set_default_colors_from_startup_probe( + colors: Option, + ) { + if let Ok(mut cache) = default_colors_cache().lock() { + cache.value = colors.map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }); + cache.attempted = true; + } + } + pub(super) fn requery_default_colors() { if let Ok(mut cache) = default_colors_cache().lock() { // Don't try to refresh if the cache is already attempted and failed. @@ -166,6 +185,12 @@ mod imp { None } + #[cfg(unix)] + pub(super) fn set_default_colors_from_startup_probe( + _colors: Option, + ) { + } + pub(super) fn requery_default_colors() {} } diff --git a/codex-rs/tui/src/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs index c4e0f57049..d9927ffa20 100644 --- a/codex-rs/tui/src/terminal_probe.rs +++ b/codex-rs/tui/src/terminal_probe.rs @@ -39,6 +39,21 @@ mod imp { pub(crate) bg: (u8, u8, u8), } + /// Results from the TUI's one-shot startup terminal probe. + #[derive(Debug, Clone, Copy, Eq, PartialEq)] + pub(crate) struct StartupProbe { + pub(crate) cursor_position: Option, + pub(crate) default_colors: Option, + pub(crate) keyboard_enhancement_supported: Option, + } + + /// Whether the startup probe should query keyboard enhancement support. + #[derive(Clone, Copy, Eq, PartialEq)] + pub(crate) enum StartupKeyboardEnhancementProbe { + Query, + Skip, + } + /// Temporary terminal handle used while a startup probe owns terminal input. /// /// The preferred path is duplicated stdin/stdout, because terminal replies are delivered to the @@ -198,19 +213,6 @@ mod imp { Ok(unsafe { File::from_raw_fd(duplicated) }) } - /// Queries the current cursor position and returns a zero-based Ratatui position. - /// - /// A timeout or a non-CPR response is not fatal. Callers should treat `Ok(None)` as "terminal - /// did not answer this optional query" and choose a conservative fallback. - pub(crate) fn cursor_position(timeout: Duration) -> io::Result> { - let mut tty = Tty::open()?; - tty.write_all(b"\x1B[6n")?; - let Some(response) = read_until(&mut tty, timeout, parse_cursor_position)? else { - return Ok(None); - }; - Ok(Some(response)) - } - /// Queries OSC 10 and OSC 11 default colors under one shared deadline. /// /// Foreground and background are only useful as a pair for palette calculations, so a missing @@ -226,16 +228,24 @@ mod imp { Ok(Some(colors)) } - /// Checks whether the terminal reports support for keyboard enhancement flags. + /// Runs the optional terminal queries needed during TUI startup under one shared deadline. /// - /// The probe sends the kitty keyboard-status query followed by primary-device-attributes as a - /// fallback. A PDA response proves that the terminal answered but does not prove that keyboard - /// enhancement is unsupported until the bounded wait has expired; flags that arrive later in - /// the same deadline must still win. - pub(crate) fn keyboard_enhancement_supported(timeout: Duration) -> io::Result> { + /// Keeping these queries batched avoids paying one timeout per unsupported capability before + /// the first frame can render. + pub(crate) fn startup( + timeout: Duration, + keyboard_probe: StartupKeyboardEnhancementProbe, + ) -> io::Result { let mut tty = Tty::open()?; - tty.write_all(b"\x1B[?u\x1B[c")?; - read_keyboard_enhancement_supported(&mut tty, timeout) + match keyboard_probe { + StartupKeyboardEnhancementProbe::Query => { + tty.write_all(b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\\x1B[?u\x1B[c")?; + } + StartupKeyboardEnhancementProbe::Skip => { + tty.write_all(b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?; + } + } + read_startup_probe(&mut tty, timeout, keyboard_probe) } /// Reads available terminal bytes until `parse` recognizes a probe response or time expires. @@ -265,42 +275,95 @@ mod imp { } } - /// Reads keyboard-enhancement responses while giving flags the full bounded window to arrive. - fn read_keyboard_enhancement_supported( + fn read_startup_probe( tty: &mut Tty, timeout: Duration, - ) -> io::Result> { + keyboard_probe: StartupKeyboardEnhancementProbe, + ) -> io::Result { let deadline = Instant::now() + timeout; let mut buffer = Vec::new(); - let mut saw_supported = false; - let mut saw_unsupported_fallback = false; + let mut probe = StartupProbe { + cursor_position: None, + default_colors: None, + keyboard_enhancement_supported: None, + }; + let mut saw_supported_keyboard = false; loop { tty.read_available(&mut buffer)?; - match parse_keyboard_enhancement_support(&buffer) { - KeyboardProbeState::SupportedAndFallback => return Ok(Some(true)), - KeyboardProbeState::Supported => saw_supported = true, - KeyboardProbeState::UnsupportedFallback => saw_unsupported_fallback = true, - KeyboardProbeState::Pending => {} - } - if saw_supported && saw_unsupported_fallback { - return Ok(Some(true)); + update_startup_probe( + &mut probe, + &mut saw_supported_keyboard, + &buffer, + keyboard_probe, + ); + if startup_probe_complete(&probe, keyboard_probe) { + return Ok(probe); } let now = Instant::now(); if now >= deadline { - if saw_supported { - return Ok(Some(true)); - } - return Ok(saw_unsupported_fallback.then_some(false)); + finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard); + return Ok(probe); } if !tty.poll_readable(deadline.saturating_duration_since(now))? { - if saw_supported { - return Ok(Some(true)); - } - return Ok(saw_unsupported_fallback.then_some(false)); + finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard); + return Ok(probe); } } } + fn update_startup_probe( + probe: &mut StartupProbe, + saw_supported_keyboard: &mut bool, + buffer: &[u8], + keyboard_probe: StartupKeyboardEnhancementProbe, + ) { + if probe.cursor_position.is_none() { + probe.cursor_position = parse_cursor_position(buffer); + } + if probe.default_colors.is_none() { + probe.default_colors = parse_default_colors(buffer); + } + if keyboard_probe == StartupKeyboardEnhancementProbe::Skip + || probe.keyboard_enhancement_supported.is_some() + { + return; + } + match parse_keyboard_enhancement_support(buffer) { + KeyboardProbeState::SupportedAndFallback => { + probe.keyboard_enhancement_supported = Some(true); + } + KeyboardProbeState::Supported => { + *saw_supported_keyboard = true; + } + KeyboardProbeState::UnsupportedFallback => { + probe.keyboard_enhancement_supported = Some(false); + } + KeyboardProbeState::Pending => {} + } + } + + fn startup_probe_complete( + probe: &StartupProbe, + keyboard_probe: StartupKeyboardEnhancementProbe, + ) -> bool { + probe.cursor_position.is_some() + && probe.default_colors.is_some() + && (keyboard_probe == StartupKeyboardEnhancementProbe::Skip + || probe.keyboard_enhancement_supported.is_some()) + } + + fn finish_startup_probe( + probe: &mut StartupProbe, + keyboard_probe: StartupKeyboardEnhancementProbe, + saw_supported_keyboard: bool, + ) { + if keyboard_probe == StartupKeyboardEnhancementProbe::Query + && probe.keyboard_enhancement_supported.is_none() + { + probe.keyboard_enhancement_supported = saw_supported_keyboard.then_some(true); + } + } + fn parse_cursor_position(buffer: &[u8]) -> Option { for start in find_all_subslices(buffer, b"\x1B[") { let rest = &buffer[start + 2..]; @@ -382,11 +445,12 @@ mod imp { /// Parser state for the keyboard enhancement probe. /// - /// `UnsupportedFallback` records that a primary-device-attributes response arrived, but the - /// caller should keep waiting until the deadline because a later keyboard-flags response is - /// more specific. `Supported` records that keyboard flags arrived, but the caller should still - /// drain the PDA fallback response if it arrives before the deadline so those bytes do not leak - /// into the normal event stream. + /// `UnsupportedFallback` records that a primary-device-attributes response arrived without + /// keyboard flags. Startup treats that as unsupported immediately, matching crossterm's + /// previous behavior and avoiding a fixed delay in terminals without the keyboard protocol. + /// `Supported` records that keyboard flags arrived, but the caller should still drain the PDA + /// fallback response if it arrives before the deadline so those bytes do not leak into the + /// normal event stream. #[derive(Debug, Clone, Copy, Eq, PartialEq)] enum KeyboardProbeState { Pending, @@ -556,6 +620,38 @@ mod imp { KeyboardProbeState::Pending ); } + + #[test] + fn startup_probe_parses_batched_terminal_responses() { + let mut probe = StartupProbe { + cursor_position: None, + default_colors: None, + keyboard_enhancement_supported: None, + }; + let mut saw_supported_keyboard = false; + update_startup_probe( + &mut probe, + &mut saw_supported_keyboard, + b"\x1B[20;10R\x1B]11;rgb:1111/1111/1111\x07\x1B[?64;1;2c\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B[?7u", + StartupKeyboardEnhancementProbe::Query, + ); + + assert_eq!( + probe, + StartupProbe { + cursor_position: Some(Position { x: 9, y: 19 }), + default_colors: Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17), + }), + keyboard_enhancement_supported: Some(true), + } + ); + assert!(startup_probe_complete( + &probe, + StartupKeyboardEnhancementProbe::Query + )); + } } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 2d22e161fa..32123b787c 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -63,6 +63,11 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; +pub(crate) struct InitializedTerminal { + pub(crate) terminal: Terminal, + pub(crate) enhanced_keys_supported: bool, +} + pub(crate) fn running_in_vscode_terminal() -> bool { keyboard_modes::running_in_vscode_terminal() } @@ -334,7 +339,7 @@ fn flush_terminal_input_buffer() { pub(crate) fn flush_terminal_input_buffer() {} /// Initialize the terminal (inline viewport; history stays in normal scrollback) -pub fn init() -> Result { +pub(crate) fn init() -> Result { if !stdin().is_terminal() { return Err(std::io::Error::other("stdin is not a terminal")); } @@ -351,20 +356,57 @@ pub fn init() -> Result { let backend = CrosstermBackend::new(stdout()); #[cfg(unix)] - let cursor_pos = - match crate::terminal_probe::cursor_position(crate::terminal_probe::DEFAULT_TIMEOUT) { - Ok(Some(pos)) => pos, - Ok(None) => { - tracing::warn!("initial cursor position probe timed out; defaulting to origin"); - Position { x: 0, y: 0 } + let startup_probe = { + use crate::terminal_probe::StartupKeyboardEnhancementProbe; + + let started_at = std::time::Instant::now(); + let keyboard_probe = if keyboard_modes::keyboard_enhancement_disabled() { + StartupKeyboardEnhancementProbe::Skip + } else { + StartupKeyboardEnhancementProbe::Query + }; + match crate::terminal_probe::startup(crate::terminal_probe::DEFAULT_TIMEOUT, keyboard_probe) + { + Ok(probe) => { + tracing::info!( + duration_ms = %started_at.elapsed().as_millis(), + cursor_position = probe.cursor_position.is_some(), + default_colors = probe.default_colors.is_some(), + keyboard_enhancement_supported = ?probe.keyboard_enhancement_supported, + "terminal startup probes completed" + ); + probe } Err(err) => { tracing::warn!( - "failed to read initial cursor position; defaulting to origin: {err}" + duration_ms = %started_at.elapsed().as_millis(), + "terminal startup probes failed: {err}" ); - Position { x: 0, y: 0 } + crate::terminal_probe::StartupProbe { + cursor_position: None, + default_colors: None, + keyboard_enhancement_supported: None, + } } - }; + } + }; + + #[cfg(unix)] + crate::terminal_palette::set_default_colors_from_startup_probe(startup_probe.default_colors); + + #[cfg(unix)] + let cursor_pos = match startup_probe.cursor_position { + Some(pos) => pos, + None => { + tracing::warn!("initial cursor position probe timed out; defaulting to origin"); + Position { x: 0, y: 0 } + } + }; + + #[cfg(unix)] + let enhanced_keys_supported = startup_probe + .keyboard_enhancement_supported + .unwrap_or(/*default*/ false); #[cfg(not(unix))] let mut backend = CrosstermBackend::new(stdout()); @@ -372,8 +414,15 @@ pub fn init() -> Result { #[cfg(not(unix))] let cursor_pos = cursor_position_with_crossterm(&mut backend); + #[cfg(not(unix))] + let enhanced_keys_supported = + !keyboard_modes::keyboard_enhancement_disabled() && detect_keyboard_enhancement_supported(); + let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?; - Ok(tui) + Ok(InitializedTerminal { + terminal: tui, + enhanced_keys_supported, + }) } #[cfg(not(unix))] @@ -384,13 +433,6 @@ fn cursor_position_with_crossterm(backend: &mut CrosstermBackend) -> Pos }) } -#[cfg(unix)] -fn detect_keyboard_enhancement_supported() -> bool { - crate::terminal_probe::keyboard_enhancement_supported(crate::terminal_probe::DEFAULT_TIMEOUT) - .unwrap_or(/*default*/ None) - .unwrap_or(/*default*/ false) -} - #[cfg(not(unix))] fn detect_keyboard_enhancement_supported() -> bool { // Non-Unix startup keeps the existing crossterm path because the bounded probe implementation @@ -461,14 +503,10 @@ where } impl Tui { - pub fn new(terminal: Terminal) -> Self { + pub fn new(terminal: Terminal, enhanced_keys_supported: bool) -> Self { let (draw_tx, _) = broadcast::channel(1); let frame_requester = FrameRequester::new(draw_tx.clone()); - // Detect keyboard enhancement support before any EventStream is created so the - // crossterm poller can acquire its lock without contention. - let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled() - && detect_keyboard_enhancement_supported(); // Cache this to avoid contention with the event reader. supports_color::on_cached(supports_color::Stream::Stdout); let _ = crate::terminal_palette::default_colors(); From 82061660ae2adf10dfd5ea5286f5871a8199fdf3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 09:57:54 -0700 Subject: [PATCH 21/53] [codex] Remove legacy shell output formatting paths (#22706) ## Why The client and tool pipeline still carried compatibility code for legacy structured shell output. Current shell and apply_patch responses are already plain text for model consumption, so keeping a JSON-serialization path plus shell-item rewrite logic makes the request formatter and tests preserve a format we do not need anymore. ## What Changed - Removed the client-side shell output rewrite from `core/src/client_common.rs`. - Removed the structured exec-output formatter and the shell `freeform` switch so tool emitters use one model-facing formatter. - Collapsed apply_patch/shell serialization tests around the remaining plain-text output expectations and removed duplicate one-variant parameterized cases. - Kept the `ApplyPatchModelOutput::ShellCommandViaHeredoc` compatibility input shape, but no longer treats it as a separate output-format mode. ## Validation - `cargo test -p codex-core client_common` - `cargo test -p codex-core shell_serialization` - `cargo test -p codex-core apply_patch_cli` - `just fix -p codex-core` ## Documentation No external Codex documentation update is needed. --- codex-rs/core/src/client_common.rs | 111 +------ codex-rs/core/src/client_common_tests.rs | 63 ---- codex-rs/core/src/tools/events.rs | 16 +- codex-rs/core/src/tools/handlers/shell.rs | 9 +- .../src/tools/handlers/shell/shell_command.rs | 1 - codex-rs/core/src/tools/mod.rs | 42 +-- codex-rs/core/tests/common/responses.rs | 15 - codex-rs/core/tests/common/test_codex.rs | 27 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 305 ++++++++---------- codex-rs/core/tests/suite/hooks.rs | 19 +- .../core/tests/suite/shell_serialization.rs | 285 +++------------- 11 files changed, 186 insertions(+), 707 deletions(-) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 2061d53c80..d9edadbed4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -2,13 +2,10 @@ pub use codex_api::ResponseEvent; use codex_config::types::Personality; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; -use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseItem; use codex_tools::ToolSpec; use futures::Stream; -use serde::Deserialize; use serde_json::Value; -use std::collections::HashSet; use std::pin::Pin; use std::task::Context; use std::task::Poll; @@ -64,116 +61,10 @@ impl Default for Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - let mut input = self.input.clone(); - - // when using the *Freeform* apply_patch tool specifically, tool outputs - // should be structured text, not json. Do NOT reserialize when using - // the Function tool - note that this differs from the check above for - // instructions. We declare the result as a named variable for clarity. - let is_freeform_apply_patch_tool_present = self.tools.iter().any(|tool| match tool { - ToolSpec::Freeform(f) => f.name == "apply_patch", - _ => false, - }); - if is_freeform_apply_patch_tool_present { - reserialize_shell_outputs(&mut input); - } - - input + self.input.clone() } } -fn reserialize_shell_outputs(items: &mut [ResponseItem]) { - let mut shell_call_ids: HashSet = HashSet::new(); - - items.iter_mut().for_each(|item| match item { - ResponseItem::LocalShellCall { call_id, id, .. } => { - if let Some(identifier) = call_id.clone().or_else(|| id.clone()) { - shell_call_ids.insert(identifier); - } - } - ResponseItem::CustomToolCall { - id: _, - status: _, - call_id, - name, - input: _, - } => { - if name == "apply_patch" { - shell_call_ids.insert(call_id.clone()); - } - } - ResponseItem::FunctionCall { name, call_id, .. } - if is_shell_tool_name(name) || name == "apply_patch" => - { - shell_call_ids.insert(call_id.clone()); - } - ResponseItem::FunctionCallOutput { - call_id, output, .. - } - | ResponseItem::CustomToolCallOutput { - call_id, output, .. - } => { - if shell_call_ids.remove(call_id) - && let Some(structured) = output - .text_content() - .and_then(parse_structured_shell_output) - { - output.body = FunctionCallOutputBody::Text(structured); - } - } - _ => {} - }) -} - -fn is_shell_tool_name(name: &str) -> bool { - name == "shell" -} - -#[derive(Deserialize)] -struct ExecOutputJson { - output: String, - metadata: ExecOutputMetadataJson, -} - -#[derive(Deserialize)] -struct ExecOutputMetadataJson { - exit_code: i32, - duration_seconds: f32, -} - -fn parse_structured_shell_output(raw: &str) -> Option { - let parsed: ExecOutputJson = serde_json::from_str(raw).ok()?; - Some(build_structured_output(&parsed)) -} - -fn build_structured_output(parsed: &ExecOutputJson) -> String { - let mut sections = Vec::new(); - sections.push(format!("Exit code: {}", parsed.metadata.exit_code)); - sections.push(format!( - "Wall time: {} seconds", - parsed.metadata.duration_seconds - )); - - let mut output = parsed.output.clone(); - if let Some((stripped, total_lines)) = strip_total_output_header(&parsed.output) { - sections.push(format!("Total output lines: {total_lines}")); - output = stripped.to_string(); - } - - sections.push("Output:".to_string()); - sections.push(output); - - sections.join("\n") -} - -fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { - let after_prefix = output.strip_prefix("Total output lines: ")?; - let (total_segment, remainder) = after_prefix.split_once('\n')?; - let total_lines = total_segment.parse::().ok()?; - let remainder = remainder.strip_prefix('\n').unwrap_or(remainder); - Some((remainder, total_lines)) -} - pub struct ResponseStream { pub(crate) rx_event: mpsc::Receiver>, /// Signals the mapper task that the consumer stopped polling before the diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index f67e4f1fd8..45d4334222 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -3,7 +3,6 @@ use codex_api::ResponsesApiRequest; use codex_api::TextControls; use codex_api::create_text_param_for_request; use codex_protocol::config_types::ServiceTier; -use codex_protocol::models::FunctionCallOutputPayload; use pretty_assertions::assert_eq; use super::*; @@ -166,65 +165,3 @@ fn serializes_flex_service_tier_when_set() { Some("flex") ); } - -#[test] -fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { - let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#; - let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello"; - let mut items = vec![ - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_text(raw_output.to_string()), - }, - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-2".to_string(), - name: "apply_patch".to_string(), - input: "*** Begin Patch".to_string(), - }, - ResponseItem::CustomToolCallOutput { - call_id: "call-2".to_string(), - name: None, - output: FunctionCallOutputPayload::from_text(raw_output.to_string()), - }, - ]; - - reserialize_shell_outputs(&mut items); - - assert_eq!( - items, - vec![ - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_text(expected_output.to_string()), - }, - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-2".to_string(), - name: "apply_patch".to_string(), - input: "*** Begin Patch".to_string(), - }, - ResponseItem::CustomToolCallOutput { - call_id: "call-2".to_string(), - name: None, - output: FunctionCallOutputPayload::from_text(expected_output.to_string()), - }, - ] - ); -} diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 51f1833f07..747143ff69 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -116,7 +116,6 @@ pub(crate) enum ToolEmitter { cwd: AbsolutePathBuf, source: ExecCommandSource, parsed_cmd: Vec, - freeform: bool, }, ApplyPatch { changes: HashMap, @@ -132,19 +131,13 @@ pub(crate) enum ToolEmitter { } impl ToolEmitter { - pub fn shell( - command: Vec, - cwd: AbsolutePathBuf, - source: ExecCommandSource, - freeform: bool, - ) -> Self { + pub fn shell(command: Vec, cwd: AbsolutePathBuf, source: ExecCommandSource) -> Self { let parsed_cmd = parse_command(&command); Self::Shell { command, cwd, source, parsed_cmd, - freeform, } } @@ -328,12 +321,7 @@ impl ToolEmitter { output: &ExecToolCallOutput, ctx: ToolEventCtx<'_>, ) -> String { - match self { - Self::Shell { freeform: true, .. } => { - super::format_exec_output_for_model_freeform(output, ctx.turn.truncation_policy) - } - _ => super::format_exec_output_for_model_structured(output, ctx.turn.truncation_policy), - } + super::format_exec_output_for_model(output, ctx.turn.truncation_policy) } pub async fn finish( diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index c8c93bd6c7..2c69566b0c 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -52,7 +52,6 @@ struct RunExecLikeArgs { turn: Arc, tracker: crate::tools::context::SharedTurnDiffTracker, call_id: String, - freeform: bool, shell_runtime_backend: ShellRuntimeBackend, } @@ -68,7 +67,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { turn, tracker, call_id, - freeform: true, shell_runtime_backend: self.shell_runtime_backend(), }) .await diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 5cd943e199..dd63116353 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -23,7 +23,6 @@ use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_output_truncation::truncate_text; pub use router::ToolRouter; -use serde::Serialize; // Telemetry preview limits: keep log events smaller than model budgets. pub(crate) const TELEMETRY_PREVIEW_MAX_BYTES: usize = 2 * 1024; // 2 KiB @@ -60,46 +59,7 @@ pub(crate) fn tool_user_shell_type( /// Format the combined exec output for sending back to the model. /// Includes exit code and duration metadata; truncates large bodies safely. -pub fn format_exec_output_for_model_structured( - exec_output: &ExecToolCallOutput, - truncation_policy: TruncationPolicy, -) -> String { - let ExecToolCallOutput { - exit_code, - duration, - .. - } = exec_output; - - #[derive(Serialize)] - struct ExecMetadata { - exit_code: i32, - duration_seconds: f32, - } - - #[derive(Serialize)] - struct ExecOutput<'a> { - output: &'a str, - metadata: ExecMetadata, - } - - // round to 1 decimal place - let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0; - - let formatted_output = format_exec_output_str(exec_output, truncation_policy); - - let payload = ExecOutput { - output: &formatted_output, - metadata: ExecMetadata { - exit_code: *exit_code, - duration_seconds, - }, - }; - - #[expect(clippy::expect_used)] - serde_json::to_string(&payload).expect("serialize ExecOutput") -} - -pub fn format_exec_output_for_model_freeform( +pub fn format_exec_output_for_model( exec_output: &ExecToolCallOutput, truncation_policy: TruncationPolicy, ) -> String { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 6c1f581f9f..c917436b6d 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -35,8 +35,6 @@ use wiremock::http::HeaderValue; use wiremock::matchers::method; use wiremock::matchers::path_regex; -use crate::test_codex::ApplyPatchModelOutput; - #[derive(Debug, Clone)] pub struct ResponseMock { requests: Arc>>, @@ -883,19 +881,6 @@ pub fn ev_local_shell_call(call_id: &str, status: &str, command: Vec<&str>) -> V }) } -pub fn ev_apply_patch_call( - call_id: &str, - patch: &str, - output_type: ApplyPatchModelOutput, -) -> Value { - match output_type { - ApplyPatchModelOutput::Freeform => ev_apply_patch_custom_tool_call(call_id, patch), - ApplyPatchModelOutput::ShellCommandViaHeredoc => { - ev_apply_patch_shell_command_call_via_heredoc(call_id, patch) - } - } -} - /// Convenience: SSE event for an `apply_patch` custom tool call with raw patch /// text. This mirrors the payload produced by the Responses API when the model /// invokes `apply_patch` directly. diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 947111d3fc..2007104868 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -184,20 +184,12 @@ fn docker_command_capture_stdout(args: [&str; N]) -> Result String { - // Box the awaited output helpers so callers do not inline request - // capture and response parsing into their own async state. - match output_type { - ApplyPatchModelOutput::Freeform => { - Box::pin(self.custom_tool_call_output(call_id)).await - } - ApplyPatchModelOutput::ShellCommandViaHeredoc => { - Box::pin(self.function_call_stdout(call_id)).await - } - } + pub async fn apply_patch_output(&self, call_id: &str) -> String { + self.custom_tool_call_output(call_id).await } } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 9185b8a7bd..05379517e5 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -3,8 +3,8 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use core_test_support::responses::ev_apply_patch_call; use core_test_support::responses::ev_apply_patch_custom_tool_call; +use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc; use core_test_support::responses::ev_shell_command_call; use core_test_support::test_codex::ApplyPatchModelOutput; use pretty_assertions::assert_eq; @@ -45,7 +45,6 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; use serde_json::json; -use test_case::test_case; use wiremock::Mock; use wiremock::Respond; use wiremock::ResponseTemplate; @@ -182,11 +181,35 @@ pub async fn mount_apply_patch( call_id: &str, patch: &str, assistant_msg: &str, - output_type: ApplyPatchModelOutput, ) { mount_sse_sequence( harness.server(), - apply_patch_responses(call_id, patch, assistant_msg, output_type), + apply_patch_responses( + call_id, + patch, + assistant_msg, + ev_apply_patch_custom_tool_call, + ), + ) + .await; +} + +async fn mount_apply_patch_model_output( + harness: &TestCodexHarness, + call_id: &str, + patch: &str, + assistant_msg: &str, + model_output: ApplyPatchModelOutput, +) { + let apply_patch_call = match model_output { + ApplyPatchModelOutput::ShellCommandViaHeredoc => { + ev_apply_patch_shell_command_call_via_heredoc + } + }; + + mount_sse_sequence( + harness.server(), + apply_patch_responses(call_id, patch, assistant_msg, apply_patch_call), ) .await; } @@ -195,12 +218,12 @@ fn apply_patch_responses( call_id: &str, patch: &str, assistant_msg: &str, - output_type: ApplyPatchModelOutput, + apply_patch_call: fn(&str, &str) -> serde_json::Value, ) -> Vec { vec![ sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_call(call_id, patch, output_type), + apply_patch_call(call_id, patch), ev_completed("resp-1"), ]), sse(vec![ @@ -231,20 +254,11 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() - let patch = "*** Begin Patch\n*** Add File: helper-alias.txt\n+hello\n*** End Patch"; let call_id = "apply-helper-alias"; - mount_apply_patch( - &harness, - call_id, - patch, - "done", - ApplyPatchModelOutput::Freeform, - ) - .await; + mount_apply_patch(&harness, call_id, patch, "done").await; harness.submit("please apply helper alias patch").await?; - let out = harness - .apply_patch_output(call_id, ApplyPatchModelOutput::Freeform) - .await; + let out = harness.apply_patch_output(call_id).await; assert_regex_match( r"(?s)^Exit code: 0.*Success\. Updated the following files:\nA helper-alias\.txt\n?$", &out, @@ -255,10 +269,7 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() - } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_cli_multiple_operations_integration( - output_type: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_multiple_operations_integration() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness_with(|builder| builder.with_model("gpt-5.4")).await?; @@ -270,11 +281,11 @@ async fn apply_patch_cli_multiple_operations_integration( let patch = "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"; let call_id = "apply-multi-ops"; - mount_apply_patch(&harness, call_id, patch, "done", output_type).await; + mount_apply_patch(&harness, call_id, patch, "done").await; harness.submit("please apply multi-ops patch").await?; - let out = harness.apply_patch_output(call_id, output_type).await; + let out = harness.apply_patch_output(call_id).await; let expected = r"(?s)^Exit code: 0 Wall time: [0-9]+(?:\.[0-9]+)? seconds @@ -297,9 +308,7 @@ D delete.txt } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> Result<()> { +async fn apply_patch_cli_multiple_chunks() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -310,7 +319,7 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> let patch = "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"; let call_id = "apply-multi-chunks"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply multi-chunk patch").await?; @@ -322,11 +331,7 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_moves_file_to_new_directory( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_moves_file_to_new_directory() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -335,7 +340,7 @@ async fn apply_patch_cli_moves_file_to_new_directory( let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"; let call_id = "apply-move"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply move patch").await?; @@ -348,11 +353,7 @@ async fn apply_patch_cli_moves_file_to_new_directory( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_updates_file_appends_trailing_newline( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_updates_file_appends_trailing_newline() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -363,7 +364,7 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline( let patch = "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"; let call_id = "apply-append-nl"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply newline patch").await?; @@ -374,11 +375,7 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_insert_only_hunk_modifies_file( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_insert_only_hunk_modifies_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -389,7 +386,7 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file( let patch = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"; let call_id = "apply-insert-only"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("insert lines via apply_patch").await?; @@ -401,11 +398,7 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_move_overwrites_existing_destination( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_move_overwrites_existing_destination() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -417,7 +410,7 @@ async fn apply_patch_cli_move_overwrites_existing_destination( let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"; let call_id = "apply-move-overwrite"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply move overwrite patch").await?; @@ -430,11 +423,7 @@ async fn apply_patch_cli_move_overwrites_existing_destination( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_move_without_content_change_has_no_turn_diff() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -445,7 +434,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/name.txt\n@@\n same\n*** End Patch"; let call_id = "apply-move-no-change"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; submit_without_wait(&harness, "rename without content change").await?; @@ -467,11 +456,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_add_overwrites_existing_file( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_add_overwrites_existing_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -480,7 +465,7 @@ async fn apply_patch_cli_add_overwrites_existing_file( let patch = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"; let call_id = "apply-add-overwrite"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply add overwrite patch").await?; @@ -492,22 +477,18 @@ async fn apply_patch_cli_add_overwrites_existing_file( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_rejects_invalid_hunk_header( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_rejects_invalid_hunk_header() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; let patch = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"; let call_id = "apply-invalid-header"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply invalid header patch").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("apply_patch verification failed"), @@ -521,11 +502,7 @@ async fn apply_patch_cli_rejects_invalid_hunk_header( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_reports_missing_context( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_reports_missing_context() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -535,11 +512,11 @@ async fn apply_patch_cli_reports_missing_context( let patch = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"; let call_id = "apply-missing-context"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply missing context patch").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("apply_patch verification failed"), @@ -554,22 +531,18 @@ async fn apply_patch_cli_reports_missing_context( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_reports_missing_target_file( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_reports_missing_target_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; let patch = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"; let call_id = "apply-missing-file"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness.submit("attempt to update a missing file").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("apply_patch verification failed"), "expected verification failure message" @@ -587,22 +560,18 @@ async fn apply_patch_cli_reports_missing_target_file( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_delete_missing_file_reports_error( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_delete_missing_file_reports_error() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; let patch = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"; let call_id = "apply-delete-missing"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness.submit("attempt to delete missing file").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("apply_patch verification failed"), @@ -621,20 +590,18 @@ async fn apply_patch_cli_delete_missing_file_reports_error( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput) -> Result<()> { +async fn apply_patch_cli_rejects_empty_patch() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; let patch = "*** Begin Patch\n*** End Patch"; let call_id = "apply-empty"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply empty patch").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("patch rejected: empty patch"), "expected rejection for empty patch: {out}" @@ -643,11 +610,7 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_delete_directory_reports_verification_error( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_delete_directory_reports_verification_error() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -656,22 +619,18 @@ async fn apply_patch_cli_delete_directory_reports_verification_error( let patch = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"; let call_id = "apply-delete-dir"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("delete a directory via apply_patch").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!(out.contains("apply_patch verification failed")); assert!(out.contains("Failed to read")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_rejects_path_traversal_outside_workspace( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_rejects_path_traversal_outside_workspace() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -687,7 +646,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( let patch = "*** Begin Patch\n*** Add File: ../escape.txt\n+outside\n*** End Patch"; let call_id = "apply-path-traversal"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness .submit_with_permission_profile( @@ -696,7 +655,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains( "patch rejected: writing outside of the project; rejected by user approval settings" @@ -712,10 +671,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] -async fn intercepted_apply_patch_verification_uses_local_sandbox( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn intercepted_apply_patch_verification_uses_local_sandbox() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_remote!(Ok(()), "symlink setup needs local filesystem link creation"); @@ -735,7 +691,14 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox( *** End Patch"# ); let call_id = "apply-sandboxed-read"; - mount_apply_patch(&harness, call_id, &patch, "fail", model_output).await; + mount_apply_patch_model_output( + &harness, + call_id, + &patch, + "fail", + ApplyPatchModelOutput::ShellCommandViaHeredoc, + ) + .await; harness .submit_with_permission_profile( @@ -744,7 +707,11 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox( ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.function_call_stdout(call_id).await; + assert!( + serde_json::from_str::(&out).is_err(), + "expected heredoc apply_patch output to be plain text" + ); assert!( out.contains("apply_patch verification failed"), "expected sandboxed verification failure: {out}" @@ -762,11 +729,7 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform ; "freeform")] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] -async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_remote!( Ok(()), @@ -810,7 +773,7 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace *** End Patch"# ); let call_id = "apply-symlink-escape"; - mount_apply_patch(&harness, call_id, &patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, &patch, "fail").await; harness .submit_with_permission_profile( @@ -819,7 +782,7 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert_eq!( std::fs::read_to_string(&outside_file)?, original_contents, @@ -831,11 +794,7 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform ; "freeform")] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] -async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_remote!( Ok(()), @@ -871,7 +830,7 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( *** End Patch"# ); let call_id = "apply-hard-link"; - mount_apply_patch(&harness, call_id, &patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, &patch, "ok").await; harness .submit_with_permission_profile( @@ -880,7 +839,7 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; if cfg!(windows) { assert!( out.contains("patch rejected: writing outside of the project"), @@ -933,11 +892,7 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -955,7 +910,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( let patch = "*** Begin Patch\n*** Update File: stay.txt\n*** Move to: ../escape-move.txt\n@@\n-from\n+to\n*** End Patch"; let call_id = "apply-move-traversal"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness .submit_with_permission_profile( @@ -964,7 +919,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains( "patch rejected: writing outside of the project; rejected by user approval settings" @@ -980,11 +935,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_verification_failure_has_no_side_effects( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_verification_failure_has_no_side_effects() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -993,7 +944,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects( let call_id = "apply-partial-no-side-effects"; let patch = "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"; - mount_apply_patch(&harness, call_id, patch, "failed", model_output).await; + mount_apply_patch(&harness, call_id, patch, "failed").await; harness.submit("attempt partial apply patch").await?; @@ -1393,14 +1344,7 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest let call_id = "apply-nested-cwd-repo-relative"; let patch = "*** Begin Patch\n*** Update File: ../repo.txt\n@@\n-before\n+after\n*** End Patch"; - mount_apply_patch( - &harness, - call_id, - patch, - "updated repo-relative path", - ApplyPatchModelOutput::Freeform, - ) - .await; + mount_apply_patch(&harness, call_id, patch, "updated repo-relative path").await; submit_without_wait(&harness, "update file outside nested cwd but inside repo").await?; @@ -1485,10 +1429,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1497,18 +1438,36 @@ async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch( let patch_inner = format!("*** Begin Patch\n*** Add File: {file_name}\n+lenient\n*** End Patch\n"); let call_id = "apply-lenient"; - mount_apply_patch(&harness, call_id, patch_inner.as_str(), "ok", model_output).await; + mount_apply_patch_model_output( + &harness, + call_id, + patch_inner.as_str(), + "ok", + ApplyPatchModelOutput::ShellCommandViaHeredoc, + ) + .await; harness.submit("apply lenient heredoc patch").await?; + let out = harness.function_call_stdout(call_id).await; + assert!( + serde_json::from_str::(&out).is_err(), + "expected heredoc apply_patch output to be plain text" + ); + assert!( + out.contains("Success. Updated the following files:"), + "expected successful apply_patch output: {out}" + ); + assert!( + out.contains(&format!("A {file_name}")), + "expected created file in apply_patch output: {out}" + ); assert_eq!(harness.read_file_text(file_name).await?, "lenient\n"); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) -> Result<()> { +async fn apply_patch_cli_end_of_file_anchor() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1517,7 +1476,7 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) let patch = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"; let call_id = "apply-eof"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply EOF-anchored patch").await?; assert_eq!(harness.read_file_text("tail.txt").await?, "alpha\nend\n"); @@ -1525,11 +1484,7 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_missing_second_chunk_context_rejected( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_missing_second_chunk_context_rejected() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1540,11 +1495,11 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected( let patch = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"; let call_id = "apply-missing-ctx-2nd"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness.submit("apply missing context second chunk").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!(out.contains("apply_patch verification failed")); assert!( out.contains("Failed to find expected lines in"), @@ -1559,11 +1514,7 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_emits_turn_diff_event_with_unified_diff( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_emits_turn_diff_event_with_unified_diff() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1573,7 +1524,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( let call_id = "apply-diff-event"; let file = "udiff.txt"; let patch = format!("*** Begin Patch\n*** Add File: {file}\n+hello\n*** End Patch\n"); - mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch.as_str(), "ok").await; submit_without_wait(&harness, "emit diff").await?; @@ -1789,11 +1740,7 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_change_context_disambiguates_target( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_change_context_disambiguates_target() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1805,7 +1752,7 @@ async fn apply_patch_change_context_disambiguates_target( let patch = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"; let call_id = "apply-ctx"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply with change_context").await?; diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 53649afff8..ef3918e189 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -3650,22 +3650,9 @@ async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<() let tool_response = hook_inputs[0]["tool_response"] .as_str() .context("apply_patch tool_response should be a string")?; - let mut parsed_tool_response = serde_json::from_str::(tool_response)?; - if let Some(metadata) = parsed_tool_response - .get_mut("metadata") - .and_then(Value::as_object_mut) - { - let _ = metadata.remove("duration_seconds"); - } - assert_eq!( - parsed_tool_response, - serde_json::json!({ - "output": "Success. Updated the following files:\nA post_tool_use_apply_patch.txt\n", - "metadata": { - "exit_code": 0, - }, - }) - ); + assert!(tool_response.starts_with("Exit code: 0")); + assert!(tool_response.contains("Success. Updated the following files:")); + assert!(tool_response.contains("A post_tool_use_apply_patch.txt")); Ok(()) } diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index dbe4957ecb..2295798daa 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -12,16 +12,12 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -use core_test_support::test_codex::ApplyPatchModelOutput; -use core_test_support::test_codex::ShellModelOutput; -use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; use regex_lite::Regex; use serde_json::Value; use serde_json::json; use std::fs; -use test_case::test_case; use crate::suite::apply_patch_cli::apply_patch_harness; use crate::suite::apply_patch_cli::mount_apply_patch; @@ -38,135 +34,68 @@ const FIXTURE_JSON: &str = r#"{ } "#; -fn shell_responses( - call_id: &str, - command: Vec<&str>, - output_type: ShellModelOutput, -) -> Result> { - match output_type { - ShellModelOutput::ShellCommand => { - let command = shlex::try_join(command)?; - let parameters = json!({ - "command": command, - "timeout_ms": 2_000, - }); - Ok(vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - "shell_command", - &serde_json::to_string(¶meters)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ]) - } - } -} - -fn configure_shell_model( - builder: TestCodexBuilder, - output_type: ShellModelOutput, -) -> TestCodexBuilder { - match output_type { - ShellModelOutput::ShellCommand => builder.with_model("test-gpt-5-codex"), - } +fn shell_responses(call_id: &str, command: Vec<&str>) -> Result> { + let command = shlex::try_join(command)?; + let parameters = json!({ + "command": command, + "timeout_ms": 2_000, + }); + Ok(vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(¶meters)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::ShellCommand)] -async fn shell_output_is_structured_with_freeform_apply_patch( - output_type: ShellModelOutput, -) -> Result<()> { +async fn shell_output_preserves_fixture_json_as_freeform() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model(test_codex(), output_type); - let test = builder.build(&server).await?; - - let call_id = "shell-structured"; - let responses = shell_responses(call_id, vec!["/bin/echo", "freeform shell"], output_type)?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the structured shell command", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock - .last_request() - .expect("structured shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("structured output string"); - - assert!( - serde_json::from_str::(output).is_err(), - "expected structured shell output to be plain text", - ); - let expected_pattern = r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -freeform shell -?$"; - assert_regex_match(expected_pattern, output); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::ShellCommand)] -async fn shell_output_structures_fixture_with_serialization( - output_type: ShellModelOutput, -) -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = configure_shell_model(test_codex(), output_type); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let test = builder.build(&server).await?; let fixture_path = test.cwd.path().join("fixture.json"); fs::write(&fixture_path, FIXTURE_JSON)?; let fixture_path_str = fixture_path.to_string_lossy().to_string(); - let call_id = "shell-structured-fixture"; + let call_id = "shell-freeform-fixture"; let responses = shell_responses( call_id, vec!["/usr/bin/sed", "-n", "p", fixture_path_str.as_str()], - output_type, )?; let mock = mount_sse_sequence(&server, responses).await; test.submit_turn_with_permission_profile( - "read the fixture JSON with structured output", + "read the fixture JSON with shell output", PermissionProfile::Disabled, ) .await?; - let req = mock - .last_request() - .expect("structured output request recorded"); + let req = mock.last_request().expect("shell output request recorded"); let output_item = req.function_call_output(call_id); let output = output_item .get("output") .and_then(Value::as_str) - .expect("structured output string"); + .expect("shell output string"); assert!( serde_json::from_str::(output).is_err(), - "expected structured output to be plain text" + "expected shell output to be plain text" ); let (header, body) = output .split_once("Output:\n") - .expect("structured output contains an Output section"); + .expect("shell output contains an Output section"); assert_regex_match( r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds$", header.trim_end(), @@ -180,34 +109,26 @@ async fn shell_output_structures_fixture_with_serialization( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::ShellCommand)] -async fn shell_output_for_freeform_tool_records_duration( - output_type: ShellModelOutput, -) -> Result<()> { +async fn shell_output_records_duration() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model(test_codex(), output_type); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let test = builder.build(&server).await?; - let call_id = "shell-structured"; - let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "sleep 0.2"], output_type)?; + let call_id = "shell-freeform"; + let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "sleep 0.2"])?; let mock = mount_sse_sequence(&server, responses).await; - test.submit_turn_with_permission_profile( - "run the structured shell command", - PermissionProfile::Disabled, - ) - .await?; + test.submit_turn_with_permission_profile("run the shell command", PermissionProfile::Disabled) + .await?; - let req = mock - .last_request() - .expect("structured output request recorded"); + let req = mock.last_request().expect("shell output request recorded"); let output_item = req.function_call_output(call_id); let output = output_item .get("output") .and_then(Value::as_str) - .expect("structured output string"); + .expect("shell output string"); let expected_pattern = r#"(?s)^Exit code: 0 Wall time: [0-9]+(?:\.[0-9]+)? seconds @@ -221,7 +142,7 @@ $"#; .captures(output) .and_then(|caps| caps.get(1)) .and_then(|value| value.as_str().parse::().ok()) - .expect("expected structured shell output to contain wall time seconds"); + .expect("expected shell output to contain wall time seconds"); assert!( wall_time_seconds > 0.1, "expected wall time to be greater than zero seconds, got {wall_time_seconds}" @@ -231,53 +152,7 @@ $"#; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_custom_tool_output_is_structured( - output_type: ApplyPatchModelOutput, -) -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = apply_patch_harness().await?; - - let call_id = "apply-patch-structured"; - let file_name = "structured.txt"; - let patch = format!( - r#"*** Begin Patch -*** Add File: {file_name} -+from custom tool -*** End Patch -"# - ); - mount_apply_patch(&harness, call_id, &patch, "done", output_type).await; - - harness - .test() - .submit_turn_with_permission_profile( - "apply the patch via custom tool", - PermissionProfile::Disabled, - ) - .await?; - - let output = harness.apply_patch_output(call_id, output_type).await; - - let expected_pattern = format!( - r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -Success. Updated the following files: -A {file_name} -?$" - ); - assert_regex_match(&expected_pattern, output.as_str()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_custom_tool_call_creates_file( - output_type: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_custom_tool_call_creates_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -287,7 +162,7 @@ async fn apply_patch_custom_tool_call_creates_file( let patch = format!( "*** Begin Patch\n*** Add File: {file_name}\n+custom tool content\n*** End Patch\n" ); - mount_apply_patch(&harness, call_id, &patch, "apply_patch done", output_type).await; + mount_apply_patch(&harness, call_id, &patch, "apply_patch done").await; harness .test() @@ -297,7 +172,7 @@ async fn apply_patch_custom_tool_call_creates_file( ) .await?; - let output = harness.apply_patch_output(call_id, output_type).await; + let output = harness.apply_patch_output(call_id).await; let expected_pattern = format!( r"(?s)^Exit code: 0 @@ -319,10 +194,7 @@ A {file_name} } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_custom_tool_call_updates_existing_file( - output_type: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_custom_tool_call_updates_existing_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -333,14 +205,7 @@ async fn apply_patch_custom_tool_call_updates_existing_file( let patch = format!( "*** Begin Patch\n*** Update File: {file_name}\n@@\n-before\n+after\n*** End Patch\n" ); - mount_apply_patch( - &harness, - call_id, - &patch, - "apply_patch update done", - output_type, - ) - .await; + mount_apply_patch(&harness, call_id, &patch, "apply_patch update done").await; harness .test() @@ -350,7 +215,7 @@ async fn apply_patch_custom_tool_call_updates_existing_file( ) .await?; - let output = harness.apply_patch_output(call_id, output_type).await; + let output = harness.apply_patch_output(call_id).await; let expected_pattern = format!( r"(?s)^Exit code: 0 @@ -369,10 +234,7 @@ M {file_name} } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_custom_tool_call_reports_failure_output( - output_type: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_custom_tool_call_reports_failure_output() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -382,14 +244,7 @@ async fn apply_patch_custom_tool_call_reports_failure_output( let patch = format!( "*** Begin Patch\n*** Update File: {missing_file}\n@@\n-before\n+after\n*** End Patch\n" ); - mount_apply_patch( - &harness, - call_id, - &patch, - "apply_patch failure done", - output_type, - ) - .await; + mount_apply_patch(&harness, call_id, &patch, "apply_patch failure done").await; harness .test() @@ -399,7 +254,7 @@ async fn apply_patch_custom_tool_call_reports_failure_output( ) .await?; - let output = harness.apply_patch_output(call_id, output_type).await; + let output = harness.apply_patch_output(call_id).await; let expected_output = format!( "apply_patch verification failed: Failed to read file to update {}/{missing_file}: No such file or directory (os error 2)", @@ -411,49 +266,7 @@ async fn apply_patch_custom_tool_call_reports_failure_output( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_tool_output_is_structured(output_type: ApplyPatchModelOutput) -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = apply_patch_harness().await?; - - let call_id = "apply-patch-function"; - let file_name = "freeform_apply_patch.txt"; - let patch = - format!("*** Begin Patch\n*** Add File: {file_name}\n+via apply_patch\n*** End Patch\n"); - mount_apply_patch( - &harness, - call_id, - &patch, - "apply_patch function done", - output_type, - ) - .await; - harness - .test() - .submit_turn_with_permission_profile( - "apply the patch via freeform apply_patch", - PermissionProfile::Disabled, - ) - .await?; - - let output = harness.apply_patch_output(call_id, output_type).await; - let expected_pattern = format!( - r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -Success. Updated the following files: -A {file_name} -?$" - ); - assert_regex_match(&expected_pattern, output.as_str()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::ShellCommand)] -async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutput) -> Result<()> { +async fn shell_output_is_freeform_for_nonzero_exit() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -461,7 +274,7 @@ async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutp let test = builder.build(&server).await?; let call_id = "shell-nonzero-exit"; - let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "exit 42"], output_type)?; + let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "exit 42"])?; let mock = mount_sse_sequence(&server, responses).await; test.submit_turn_with_permission_profile( From 3f2b7ede0b1b7d9dfc317bd02f15eeed26ed8d72 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 19:25:27 +0200 Subject: [PATCH 22/53] nit: read prompt (#23332) --- .../memories/read/templates/memories/read_path.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/codex-rs/memories/read/templates/memories/read_path.md b/codex-rs/memories/read/templates/memories/read_path.md index 828b30923e..00639c7e8c 100644 --- a/codex-rs/memories/read/templates/memories/read_path.md +++ b/codex-rs/memories/read/templates/memories/read_path.md @@ -25,7 +25,7 @@ Memory layout (general -> specific): - scripts/ (optional helper scripts) - examples/ (optional example outputs) - templates/ (optional templates) - - {{ base_path }}/rollout_summaries/ (per-rollout recaps + evidence snippets) +- {{ base_path }}/rollout_summaries/ (per-rollout recaps + evidence snippets) - The paths of these entries can be found in {{ base_path }}/MEMORY.md or {{ base_path }}/rollout_summaries/ as `rollout_path` - These files are append-only `jsonl`: `session_meta.payload.id` identifies the session, `turn_context` marks turn boundaries, `event_msg` is the lightweight status stream, and `response_item` contains actual messages, tool calls, and tool outputs. - For efficient lookup, prefer matching the filename suffix or `session_meta.payload.id`; avoid broad full-content scans unless needed. @@ -57,9 +57,6 @@ How to decide whether to verify memory: disruptive, it is acceptable to answer from memory in an interactive turn, but you should say that it is memory-derived, note that it may be stale, and consider offering to refresh it live. -- If a fact is lower-drift and cheap to verify, use judgment: verification is - more important when the fact is central to the answer or especially easy to - confirm. - If a fact is lower-drift and expensive to verify, it is usually fine to answer from memory directly. @@ -72,10 +69,8 @@ When answering from memory without current verification: - If live verification was skipped and a refresh would be useful in the interactive context, consider offering to verify or refresh it live. - Do not present unverified memory-derived facts as confirmed-current. -- For interactive requests, prefer a short refresh offer over silently doing - expensive verification that the user did not ask for. -- When the unverified fact is about prior results, commands, timing, or an - older snapshot, a concrete refresh offer can be especially helpful. +- Prefer a short refresh offer for interactive questions, especially about prior + results, commands, timing, or older snapshots. Memory citation requirements: @@ -88,7 +83,7 @@ Memory citation requirements: MEMORY.md:234-236|note=[responsesapi citation extraction code pointer] -rollout_summaries/2026-02-17T21-23-02-LN3m-weekly_memory_report_pivot_from_git_history.md:10-12|note=[weekly report format] +rollout_summaries/2026-02-17T21-23-02-LN3m-example.md:10-12|note=[weekly report format] 019c6e27-e55b-73d1-87d8-4e01f1f75043 From d335b002127fa849c09beb005e73057c81b80182 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 18 May 2026 10:32:33 -0700 Subject: [PATCH 23/53] windows: link MSVC release binaries with static CRT (#22905) ## Why Windows release artifacts currently import `VCRUNTIME140.dll` and `VCRUNTIME140_1.dll`. That becomes observable on clean Windows machines that do not already have the VC++ runtime available globally: - Desktop Store launches can fail after the app relocates `codex.exe` out of `WindowsApps`, which means an MSIX-level VCLibs dependency does not protect the relocated CLI/app-server process. - The npm CLI path reproduces the same missing-DLL startup failure when `System32\vcruntime140_1.dll` is hidden and `PATH` is stripped of incidental fallback copies. In that setup, the existing Windows binary exits with `0xC0000135` / `-1073741515` before Codex code runs. ## What changed - Add `-C target-feature=+crt-static` to the existing MSVC-only Cargo rustflags in `codex-rs/.cargo/config.toml`. - Preserve the existing `/STACK:8388608` linker setting in the same target block. This keeps the change scoped to Windows MSVC builds and avoids altering non-Windows or GNU target behavior. ## Verification I built an x64 Windows release probe with static CRT linkage and the normal 8 MiB stack reserve, then verified: - `dumpbin /dependents codex.exe` no longer reports `VCRUNTIME140.dll` or `VCRUNTIME140_1.dll`. - `dumpbin /headers codex.exe` reports `800000 size of stack reserve`. - With `System32\vcruntime140_1.dll` hidden and `PATH` stripped to Windows system directories only: - the old npm CLI path exits `-1073741515` - the rebuilt static-CRT `codex.exe --version` succeeds with exit code `0` - the rebuilt TUI starts successfully I also confirmed `codex.exe app-server --listen ws://127.0.0.1:0` starts and binds normally with the static-CRT artifact. --- codex-rs/.cargo/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/.cargo/config.toml b/codex-rs/.cargo/config.toml index 5d5eb8fd6f..8a6f2c11ee 100644 --- a/codex-rs/.cargo/config.toml +++ b/codex-rs/.cargo/config.toml @@ -1,5 +1,5 @@ [target.'cfg(all(windows, target_env = "msvc"))'] -rustflags = ["-C", "link-arg=/STACK:8388608"] +rustflags = ["-C", "link-arg=/STACK:8388608", "-C", "target-feature=+crt-static"] # MSVC emits a warning about code that may trip "Cortex-A53 MPCore processor bug #843419" (see # https://developer.arm.com/documentation/epm048406/latest) which is sometimes emitted by LLVM. From bb43044cbac4351b90d7d3dd52e74f15bc5f4cb0 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 14:41:14 -0300 Subject: [PATCH 24/53] fix(tui): show shutdown feedback on exit (#23323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Ctrl+C can take a noticeable amount of time to finish when the TUI is waiting for the app-server thread shutdown path to complete. Before this change, the UI could look like it had not accepted the shutdown request because the composer and cursor remained in their normal interactive state during that wait. This PR makes the accepted shutdown visible immediately. It does not add an artificial sleep or change the shutdown timeout; it only draws one final feedback frame before continuing through the existing shutdown flow. ## What Changed - On `ExitMode::ShutdownFirst`, the TUI now renders shutdown feedback before awaiting the existing thread shutdown future. - The bottom pane disables composer input, which hides the cursor through the existing disabled-input cursor path. - The composer shows `Shutting down...` as the disabled input hint and suppresses footer content so the shutdown acknowledgement is not competing with shortcut/status text. - The logout path uses the same feedback path before shutting down. ## How to Test 1. Start Codex from this branch. 2. Press `Ctrl+C` to request shutdown. 3. If shutdown takes long enough to observe, confirm the composer changes to `› Shutting down...`, the cursor disappears, and no footer hint is rendered below it. 4. Regression check: repeat with text already typed in the composer and confirm the visible row still switches to `Shutting down...` while the draft remains preserved internally until the process exits. Targeted tests: - `cargo test -p codex-tui shutdown_in_progress_disables_input_and_uses_hint_without_footer` - `cargo test -p codex-tui bottom_pane::footer::tests::` ## Local Validation Note `cargo test -p codex-tui` still aborts in `app::tests::discard_side_thread_removes_agent_navigation_entry` with a stack overflow. That same test also failed when run alone locally, and the failure appears unrelated to this shutdown feedback path. --- codex-rs/tui/src/app.rs | 69 +++++++----- codex-rs/tui/src/app/event_dispatch.rs | 4 + codex-rs/tui/src/app/pets.rs | 16 +++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 102 +++++++++++++----- codex-rs/tui/src/bottom_pane/mod.rs | 6 ++ ...composer__tests__shutdown_in_progress.snap | 9 ++ codex-rs/tui/src/chatwidget.rs | 4 + 7 files changed, 159 insertions(+), 51 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 07ac30f046..1d72d8d46b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1195,30 +1195,8 @@ See the Codex keymap documentation for supported actions and examples." } // Allow widgets to process any pending timers before rendering. self.chat_widget.pre_draw_tick(); - let desired_height = - self.chat_widget.desired_height(tui.terminal.size()?.width); - let mut rendered_area = Rect::default(); - if terminal_resize_reflow_enabled { - tui.draw_with_resize_reflow(desired_height, |frame| { - let area = frame.area(); - rendered_area = area; - self.chat_widget.render(area, frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(area) { - frame.set_cursor_style(self.chat_widget.cursor_style(area)); - frame.set_cursor_position((x, y)); - } - })?; - } else { - tui.draw(desired_height, |frame| { - let area = frame.area(); - rendered_area = area; - self.chat_widget.render(area, frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(area) { - frame.set_cursor_style(self.chat_widget.cursor_style(area)); - frame.set_cursor_position((x, y)); - } - })?; - } + let rendered_area = + self.render_chat_widget_frame(tui, terminal_resize_reflow_enabled)?; if self.chat_widget.ambient_pet_image_enabled() { let terminal_size = tui.terminal.size()?; let ambient_pet_area = Rect::new( @@ -1253,6 +1231,49 @@ See the Codex keymap documentation for supported actions and examples." } Ok(AppRunControl::Continue) } + + pub(super) fn show_shutdown_feedback(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.disable_ambient_pet_before_shutdown(tui)?; + self.chat_widget.show_shutdown_in_progress(); + let terminal_resize_reflow_enabled = self.terminal_resize_reflow_enabled(); + if terminal_resize_reflow_enabled { + self.handle_draw_pre_render(tui)?; + } + self.chat_widget.pre_draw_tick(); + self.render_chat_widget_frame(tui, terminal_resize_reflow_enabled)?; + Ok(()) + } + + fn render_chat_widget_frame( + &mut self, + tui: &mut tui::Tui, + terminal_resize_reflow_enabled: bool, + ) -> Result { + let desired_height = self.chat_widget.desired_height(tui.terminal.size()?.width); + let mut rendered_area = Rect::default(); + if terminal_resize_reflow_enabled { + tui.draw_with_resize_reflow(desired_height, |frame| { + let area = frame.area(); + rendered_area = area; + self.chat_widget.render(area, frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(area) { + frame.set_cursor_style(self.chat_widget.cursor_style(area)); + frame.set_cursor_position((x, y)); + } + })?; + } else { + tui.draw(desired_height, |frame| { + let area = frame.area(); + rendered_area = area; + self.chat_widget.render(area, frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(area) { + frame.set_cursor_style(self.chat_widget.cursor_style(area)); + frame.set_cursor_position((x, y)); + } + })?; + } + Ok(rendered_area) + } } impl Drop for App { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index efb9a26c63..d1ce849c04 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -296,10 +296,14 @@ impl App { self.chat_widget.on_commit_tick(); } AppEvent::Exit(mode) => { + if mode == ExitMode::ShutdownFirst { + self.show_shutdown_feedback(tui)?; + } return Ok(self.handle_exit_mode(app_server, mode).await); } AppEvent::Logout => match app_server.logout_account().await { Ok(()) => { + self.show_shutdown_feedback(tui)?; return Ok(self .handle_exit_mode(app_server, ExitMode::ShutdownFirst) .await); diff --git a/codex-rs/tui/src/app/pets.rs b/codex-rs/tui/src/app/pets.rs index fe6e90673b..eef38b894e 100644 --- a/codex-rs/tui/src/app/pets.rs +++ b/codex-rs/tui/src/app/pets.rs @@ -3,6 +3,22 @@ use super::*; impl App { + pub(super) fn disable_ambient_pet_before_shutdown(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.chat_widget.disable_ambient_pet_for_session(); + if let Err(clear_err) = tui.clear_ambient_pet_image() { + match clear_err { + crate::pets::PetImageRenderError::Terminal(err) => return Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!( + error = %err, + "failed to clear ambient pet image before shutdown feedback" + ); + } + } + } + Ok(()) + } + pub(super) fn handle_ambient_pet_image_render_error( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e76b4c0b96..2176e7762e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4130,6 +4130,15 @@ impl ChatComposer { } } + pub(crate) fn show_shutdown_in_progress(&mut self) { + self.set_input_enabled(/*enabled*/ false, Some("Shutting down...".to_string())); + self.footer.quit_shortcut_expires_at = None; + self.footer.mode = FooterMode::ComposerEmpty; + self.footer.hint_override = Some(Vec::new()); + self.footer.plan_mode_nudge_visible = false; + self.footer.flash = None; + } + pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; } @@ -4627,36 +4636,38 @@ impl ChatComposer { let mut state = self.draft.textarea_state.borrow_mut(); let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode; - if let Some(mask_char) = mask_char { - self.draft - .textarea - .render_ref_masked(textarea_rect, buf, &mut state, mask_char); - } else { - let highlight_ranges = self.history_search_highlight_ranges(); - if highlight_ranges.is_empty() { - StatefulWidgetRef::render_ref( - &(&self.draft.textarea), - textarea_rect, - buf, - &mut state, - ); + if self.draft.input_enabled { + if let Some(mask_char) = mask_char { + self.draft + .textarea + .render_ref_masked(textarea_rect, buf, &mut state, mask_char); } else { - let highlight_style = - Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); - let highlights = highlight_ranges - .into_iter() - .map(|range| (range, highlight_style)) - .collect::>(); - self.draft.textarea.render_ref_styled_with_highlights( - textarea_rect, - buf, - &mut state, - Style::default(), - &highlights, - ); + let highlight_ranges = self.history_search_highlight_ranges(); + if highlight_ranges.is_empty() { + StatefulWidgetRef::render_ref( + &(&self.draft.textarea), + textarea_rect, + buf, + &mut state, + ); + } else { + let highlight_style = + Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); + let highlights = highlight_ranges + .into_iter() + .map(|range| (range, highlight_style)) + .collect::>(); + self.draft.textarea.render_ref_styled_with_highlights( + textarea_rect, + buf, + &mut state, + Style::default(), + &highlights, + ); + } } } - if textarea_is_empty { + if !self.draft.input_enabled || textarea_is_empty { let text = if self.draft.input_enabled { self.placeholder_text.as_str().to_string() } else { @@ -10684,4 +10695,41 @@ mod tests { }; assert_eq!(composer.cursor_pos(area), None); } + + #[test] + fn shutdown_in_progress_disables_input_and_uses_hint_without_footer() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); + composer.show_shutdown_in_progress(); + + assert!(!composer.input_enabled()); + assert_eq!(composer.current_text(), "hello"); + assert_eq!(composer.custom_footer_height(), Some(0)); + + let area = Rect { + x: 0, + y: 0, + width: 40, + height: 5, + }; + assert_eq!(composer.cursor_pos(area), None); + + let mut terminal = Terminal::new(TestBackend::new(40, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap(); + insta::assert_snapshot!("shutdown_in_progress", terminal.backend()); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 165169c456..3f93c13898 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -789,6 +789,12 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn show_shutdown_in_progress(&mut self) { + self.view_stack.clear(); + self.composer.show_shutdown_in_progress(); + self.request_redraw(); + } + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { if let Some(text) = self.composer.clear_for_ctrl_c() { if let Some(thread_id) = self.thread_id { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap new file mode 100644 index 0000000000..15703f0ade --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Shutting down... " +" " +" " +" " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a37371bf73..8c84748dde 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1277,6 +1277,10 @@ impl ChatWidget { .send(AppEvent::Exit(ExitMode::ShutdownFirst)); } + pub(crate) fn show_shutdown_in_progress(&mut self) { + self.bottom_pane.show_shutdown_in_progress(); + } + fn request_redraw(&mut self) { self.frame_requester.schedule_frame(); } From 9286ff2805c15d5ce031b016056fc6c5d0d75c4f Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 18 May 2026 10:53:49 -0700 Subject: [PATCH 25/53] Fix remote turn diff display roots (#23261) ## Why `TurnDiffTracker` computes a display root so turn diffs can be rendered repo-relative. For remote exec-server turns, the selected turn `cwd` may exist only inside the selected environment, but `run_turn` was discovering the git root through the local host filesystem. When that lookup failed, nested remote-session diffs fell back to the nested `cwd` and showed `/tmp/...`-prefixed paths instead of repo-relative paths. ## What changed - Resolve the diff display root from the primary selected turn environment when one exists, using that environment's filesystem and `cwd`. - Add `codex_git_utils::get_git_repo_root_with_fs(...)` so git-root discovery can run against an `ExecutorFileSystem`, including remote environments. - Reuse that helper from `resolve_root_git_project_for_trust(...)` and add coverage for `.git` gitdir-pointer detection. ## Validation - Devbox Bazel: `//codex-rs/core:core-unit-tests --test_filter=get_git_repo_root_with_fs_detects_gitdir_pointer` - Devbox Docker-backed remote-env repro: `//codex-rs/core:core-all-test --test_filter=apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested` --- codex-rs/core/src/git_info_tests.rs | 15 +++++++++++++++ codex-rs/core/src/session/turn.rs | 14 ++++++++++++-- codex-rs/git-utils/src/info.rs | 24 +++++++++++++++++++----- codex-rs/git-utils/src/lib.rs | 1 + 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index af62816f72..946779d06b 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -2,6 +2,7 @@ use codex_exec_server::LOCAL_FS; use codex_git_utils::GitInfo; use codex_git_utils::GitSha; use codex_git_utils::collect_git_info; +use codex_git_utils::get_git_repo_root_with_fs; use codex_git_utils::get_has_changes; use codex_git_utils::git_diff_to_remote; use codex_git_utils::recent_commits; @@ -526,6 +527,20 @@ async fn resolve_root_git_project_for_trust_returns_none_outside_repo() { ); } +#[tokio::test] +async fn get_git_repo_root_with_fs_detects_gitdir_pointer() { + let tmp = TempDir::new().expect("tempdir"); + let proj = tmp.path().join("proj"); + let nested = proj.join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(proj.join(".git"), "gitdir: /tmp/fake-worktree\n").unwrap(); + + assert_eq!( + get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &nested.abs()).await, + Some(proj.abs()) + ); +} + #[tokio::test] async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index aee4bd360f..96569d0bfa 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -71,6 +71,7 @@ use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; use codex_features::Feature; use codex_git_utils::get_git_repo_root; +use codex_git_utils::get_git_repo_root_with_fs; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; @@ -370,8 +371,17 @@ pub(crate) async fn run_turn( // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. #[allow(deprecated)] - let display_root = get_git_repo_root(turn_context.cwd.as_path()) - .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()); + let display_root = match turn_context.environments.primary() { + Some(turn_environment) => get_git_repo_root_with_fs( + turn_environment.environment.get_filesystem().as_ref(), + &turn_environment.cwd, + ) + .await + .unwrap_or_else(|| turn_environment.cwd.clone()) + .into_path_buf(), + None => get_git_repo_root(turn_context.cwd.as_path()) + .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()), + }; let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root( display_root, ))); diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index 722d556b37..a188d9a876 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -38,6 +38,23 @@ pub fn get_git_repo_root(base_dir: &Path) -> Option { find_ancestor_git_entry(base).map(|(repo_root, _)| repo_root) } +/// Return the repository root for `cwd` using the provided filesystem. +/// +/// This mirrors [`get_git_repo_root`] for local paths, but works when `cwd` +/// only exists inside a selected remote environment. +pub async fn get_git_repo_root_with_fs( + fs: &dyn ExecutorFileSystem, + cwd: &AbsolutePathBuf, +) -> Option { + let base = match fs.get_metadata(cwd, /*sandbox*/ None).await { + Ok(metadata) if metadata.is_directory => cwd.clone(), + _ => cwd.parent()?, + }; + find_ancestor_git_entry_with_fs(fs, &base) + .await + .map(|(repo_root, _)| repo_root) +} + /// Timeout for git commands to prevent freezing on large repositories const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5); const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" }; @@ -729,11 +746,8 @@ pub async fn resolve_root_git_project_for_trust( fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, ) -> Option { - let base = match fs.get_metadata(cwd, /*sandbox*/ None).await { - Ok(metadata) if metadata.is_directory => cwd.clone(), - _ => cwd.parent()?, - }; - let (repo_root, dot_git) = find_ancestor_git_entry_with_fs(fs, &base).await?; + let repo_root = get_git_repo_root_with_fs(fs, cwd).await?; + let dot_git = repo_root.join(".git"); if fs .get_metadata(&dot_git, /*sandbox*/ None) .await diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index bcd1a8b5c9..986a7434d2 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -31,6 +31,7 @@ pub use info::default_branch_name; pub use info::get_git_remote_urls; pub use info::get_git_remote_urls_assume_git_repo; pub use info::get_git_repo_root; +pub use info::get_git_repo_root_with_fs; pub use info::get_has_changes; pub use info::get_head_commit_hash; pub use info::git_diff_to_remote; From b1c13b6fe535aeda00ed95b52c80f02e7d6ec745 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 18 May 2026 11:00:03 -0700 Subject: [PATCH 26/53] Simplify legacy Windows sandbox ACL persistence (#22569) ## Why The legacy Windows sandbox still carried a `persist_aces` mode switch, even though the only path that meaningfully applies filesystem ACEs today is `workspace-write`, which already uses the persistent behavior. Legacy read-only sessions rely on the read-only capability SID rather than per-command filesystem ACE mutation, so the temporary cleanup branch had become conceptual overhead without a corresponding behavioral need. Removing that split makes the ACL lifecycle match the current sandbox model more directly and trims the guard/revocation plumbing from the legacy launcher paths. ## What changed - Removed the `persist_aces` parameter from legacy ACL preparation. - Made legacy deny-read handling always use the persistent reconciliation path. - Dropped guard tracking and post-exit ACE revocation from both capture and unified-exec legacy flows. - Kept workspace `.codex` / `.agents` protection tied directly to `WorkspaceWrite` instead of an intermediate persistence flag. ## Verification - `cargo fmt -p codex-windows-sandbox` - `git diff --check` - `cargo test -p codex-windows-sandbox` - 85 passed, 2 ignored, 2 (unrelated) failed locally. --- codex-rs/windows-sandbox-rs/src/lib.rs | 25 +------ codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 74 +++++-------------- .../src/unified_exec/backends/legacy.rs | 25 +------ 3 files changed, 21 insertions(+), 103 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index db958c9975..da2a2d3634 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -279,7 +279,6 @@ pub use stub::run_windows_sandbox_legacy_preflight; #[cfg(target_os = "windows")] mod windows_impl { - use super::acl::revoke_ace; use super::logging::log_failure; use super::logging::log_success; use super::policy::SandboxPolicy; @@ -292,7 +291,6 @@ mod windows_impl { use super::spawn_prep::prepare_legacy_session_security; use super::spawn_prep::prepare_legacy_spawn_context; use super::spawn_prep::root_capability_sids; - use super::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; @@ -424,8 +422,7 @@ mod windows_impl { ); let security = prepare_legacy_session_security(&policy, codex_home, cwd, capability_roots)?; allow_null_device_for_workspace_write(is_workspace_write); - let persist_aces = is_workspace_write; - let guards = apply_legacy_session_acl_rules( + apply_legacy_session_acl_rules( &policy, sandbox_policy_cwd, codex_home, @@ -438,7 +435,6 @@ mod windows_impl { readonly_sid_str: security.readonly_sid_str.as_deref(), write_root_sids: &security.write_root_sids, }, - persist_aces, )?; let (stdin_pair, stdout_pair, stderr_pair) = unsafe { setup_stdio_pipes()? }; let ((in_r, in_w), (out_r, out_w), (err_r, err_w)) = (stdin_pair, stdout_pair, stderr_pair); @@ -463,13 +459,6 @@ mod windows_impl { CloseHandle(out_w); CloseHandle(err_r); CloseHandle(err_w); - if !persist_aces { - for (p, sid_str) in &guards { - if let Ok(sid) = LocalSid::from_string(sid_str) { - revoke_ace(p, sid.as_ptr()); - } - } - } CloseHandle(security.h_token); } return Err(err); @@ -570,15 +559,6 @@ mod windows_impl { log_failure(&command, &format!("exit code {exit_code}"), logs_base_dir); } - if !persist_aces { - unsafe { - for (p, sid_str) in guards { - if let Ok(sid) = LocalSid::from_string(&sid_str) { - revoke_ace(&p, sid.as_ptr()); - } - } - } - } Ok(CaptureResult { exit_code, stdout, @@ -609,7 +589,7 @@ mod windows_impl { codex_home, ); let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; - let _guards = apply_legacy_session_acl_rules( + apply_legacy_session_acl_rules( sandbox_policy, sandbox_policy_cwd, codex_home, @@ -622,7 +602,6 @@ mod windows_impl { readonly_sid_str: None, write_root_sids: &write_root_sids, }, - /*persist_aces*/ true, )?; Ok(()) diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index 331df4f438..b27fa822fa 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -8,7 +8,6 @@ use crate::cap::workspace_write_cap_sid_for_root; use crate::cap::workspace_write_root_contains_path; use crate::cap::workspace_write_root_overlaps_path; use crate::cap::workspace_write_root_specificity; -use crate::deny_read_acl::apply_deny_read_acls; use crate::deny_read_state::sync_persistent_deny_read_acls; use crate::env::apply_no_network_to_env; use crate::env::ensure_non_interactive_pager; @@ -283,11 +282,9 @@ pub(crate) fn apply_legacy_session_acl_rules( additional_deny_read_paths: &[PathBuf], additional_deny_write_paths: &[PathBuf], acl_sids: LegacyAclSids<'_>, - persist_aces: bool, -) -> Result> { +) -> Result<()> { let AllowDenyPaths { allow, mut deny } = compute_allow_paths(policy, sandbox_policy_cwd, current_dir, env_map); - let mut guards: Vec<(PathBuf, String)> = Vec::new(); unsafe { for path in additional_deny_write_paths { // Explicit carveouts must exist before the command starts so the @@ -300,31 +297,19 @@ pub(crate) fn apply_legacy_session_acl_rules( } if let Some(readonly_sid) = acl_sids.readonly_sid { for p in &allow { - if matches!(add_allow_ace(p, readonly_sid.as_ptr()), Ok(true)) - && !persist_aces - && let Some(readonly_sid_str) = acl_sids.readonly_sid_str - { - guards.push((p.clone(), readonly_sid_str.to_string())); - } + let _ = add_allow_ace(p, readonly_sid.as_ptr()); } } else { for p in &allow { let Some(root_sid) = matching_root_capability(p, acl_sids.write_root_sids) else { continue; }; - if matches!(add_allow_ace(p, root_sid.sid.as_ptr()), Ok(true)) && !persist_aces { - guards.push((p.clone(), root_sid.sid_str.clone())); - } + let _ = add_allow_ace(p, root_sid.sid.as_ptr()); } } for p in &deny { for root_sid in deny_root_capabilities_for_path(p, acl_sids.write_root_sids) { - if let Ok(added) = add_deny_write_ace(p, root_sid.sid.as_ptr()) - && added - && !persist_aces - { - guards.push((p.clone(), root_sid.sid_str.clone())); - } + let _ = add_deny_write_ace(p, root_sid.sid.as_ptr()); } } if !additional_deny_read_paths.is_empty() { @@ -332,42 +317,20 @@ pub(crate) fn apply_legacy_session_acl_rules( let Some(readonly_sid_str) = acl_sids.readonly_sid_str else { anyhow::bail!("readonly capability SID string missing"); }; - let applied_deny_read_paths = if persist_aces { - sync_persistent_deny_read_acls( - codex_home, - readonly_sid_str, - additional_deny_read_paths, - readonly_sid.as_ptr(), - )? - } else { - apply_deny_read_acls(additional_deny_read_paths, readonly_sid.as_ptr())? - }; - if !persist_aces { - guards.extend( - applied_deny_read_paths - .into_iter() - .map(|path| (path, readonly_sid_str.to_string())), - ); - } + sync_persistent_deny_read_acls( + codex_home, + readonly_sid_str, + additional_deny_read_paths, + readonly_sid.as_ptr(), + )?; } else { for root_sid in acl_sids.write_root_sids { - let applied_deny_read_paths = if persist_aces { - sync_persistent_deny_read_acls( - codex_home, - &root_sid.sid_str, - additional_deny_read_paths, - root_sid.sid.as_ptr(), - )? - } else { - apply_deny_read_acls(additional_deny_read_paths, root_sid.sid.as_ptr())? - }; - if !persist_aces { - guards.extend( - applied_deny_read_paths - .into_iter() - .map(|path| (path, root_sid.sid_str.clone())), - ); - } + sync_persistent_deny_read_acls( + codex_home, + &root_sid.sid_str, + additional_deny_read_paths, + root_sid.sid.as_ptr(), + )?; } } } @@ -377,8 +340,7 @@ pub(crate) fn apply_legacy_session_acl_rules( if let Some(readonly_sid) = acl_sids.readonly_sid { allow_null_device(readonly_sid.as_ptr()); } - if persist_aces - && matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) + if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) && let Some(workspace_sid) = matching_root_capability(current_dir, acl_sids.write_root_sids) { @@ -389,7 +351,7 @@ pub(crate) fn apply_legacy_session_acl_rules( } } } - Ok(guards) + Ok(()) } #[allow(clippy::too_many_arguments)] diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs index 2f76147ce9..2cb12bfd71 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs @@ -1,6 +1,5 @@ use super::windows_common::finish_driver_spawn; use super::windows_common::normalize_windows_tty_input; -use crate::acl::revoke_ace; use crate::conpty::ConptyInstance; use crate::conpty::spawn_conpty_process_as_user; use crate::desktop::LaunchDesktop; @@ -16,7 +15,6 @@ use crate::spawn_prep::apply_legacy_session_acl_rules; use crate::spawn_prep::legacy_session_capability_roots; use crate::spawn_prep::prepare_legacy_session_security; use crate::spawn_prep::prepare_legacy_spawn_context; -use crate::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::ProcessDriver; @@ -24,7 +22,6 @@ use codex_utils_pty::SpawnedProcess; use codex_utils_pty::TerminalSize; use std::collections::HashMap; use std::path::Path; -use std::path::PathBuf; use std::ptr; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -206,7 +203,6 @@ fn finalize_exit( process_handle: Arc>>, thread_handle: HANDLE, output_join: std::thread::JoinHandle<()>, - guards: Vec<(PathBuf, String)>, logs_base_dir: Option<&Path>, command: Vec, ) { @@ -242,14 +238,6 @@ fn finalize_exit( } else { log_failure(&command, &format!("exit code {exit_code}"), logs_base_dir); } - - unsafe { - for (path, cap_sid) in guards { - if let Ok(sid) = LocalSid::from_string(&cap_sid) { - revoke_ace(&path, sid.as_ptr()); - } - } - } } fn resize_conpty_handle(hpc: &Arc>>, size: TerminalSize) -> Result<()> { @@ -325,8 +313,7 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( prepare_legacy_session_security(&common.policy, codex_home, cwd, capability_roots)?; allow_null_device_for_workspace_write(common.is_workspace_write); - let persist_aces = common.is_workspace_write; - let guards = apply_legacy_session_acl_rules( + apply_legacy_session_acl_rules( &common.policy, sandbox_policy_cwd, codex_home, @@ -339,7 +326,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( readonly_sid_str: security.readonly_sid_str.as_deref(), write_root_sids: &security.write_root_sids, }, - persist_aces, )?; let (writer_tx, writer_rx) = mpsc::channel::>(128); @@ -375,13 +361,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( Ok(handles) => handles, Err(err) => { unsafe { - if !persist_aces { - for (path, cap_sid) in &guards { - if let Ok(sid) = LocalSid::from_string(cap_sid) { - revoke_ace(path, sid.as_ptr()); - } - } - } CloseHandle(security.h_token); } return Err(err); @@ -392,7 +371,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( let process_handle = Arc::new(StdMutex::new(Some(pi.hProcess))); let wait_handle = Arc::clone(&process_handle); let command_for_wait = command.clone(); - let guards_for_wait = if persist_aces { Vec::new() } else { guards }; let hpc_for_wait = hpc_handle.clone(); std::thread::spawn(move || { let _desktop = desktop; @@ -423,7 +401,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( wait_handle, pi.hThread, output_join, - guards_for_wait, common.logs_base_dir.as_deref(), command_for_wait, ); From 8c14b08dd1e4de6a3e9e73de5c735433ff5c55a3 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 18 May 2026 11:10:37 -0700 Subject: [PATCH 27/53] Upload rust full CI JUnit reports (#23273) ## Why `rust-ci-full` failures currently leave downstream investigation reconstructing basic test facts from raw logs. `cargo nextest` can emit standard JUnit XML for each lane, which gives us a small structured artifact for post-run failure analysis without changing the test execution model. ## What changed - enable nextest JUnit output in `codex-rs/.config/nextest.toml` - upload the lane-scoped JUnit XML artifact from each `rust-ci-full` test lane ## Verification - `rust-ci-full` run `26018931531` on head `52d77c60e79b36859d944ef28a36b014055c5c48` produced JUnit artifacts for macOS, Linux x64 remote, Windows x64, and Windows ARM64 test lanes - `rust-ci-full` run `26021241006` on the same head produced the missing Linux ARM JUnit artifact after the first run lost that runner before export - downloaded all five lane JUnit artifacts and verified each contains non-empty test counters and failure data --- .github/workflows/rust-ci-full.yml | 8 ++++++++ codex-rs/.config/nextest.toml | 3 +++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index de37faa4ca..697d4b9c37 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -688,6 +688,14 @@ jobs: RUST_MIN_STACK: "8388608" # 8 MiB NEXTEST_STATUS_LEVEL: leak + - name: Upload nextest JUnit report + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: nextest-junit-rust-ci-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }} + path: codex-rs/target/nextest/default/junit.xml + if-no-files-found: warn + - name: Upload Cargo timings (nextest) if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index 86d00e4637..cc86ee0874 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -2,6 +2,9 @@ # Do not increase, fix your test instead slow-timeout = { period = "15s", terminate-after = 2 } +[profile.default.junit] +path = "junit.xml" + [test-groups.app_server_protocol_codegen] max-threads = 1 From d32cb2c6aca2626d1b1d05c4537a5b6c2eec20f2 Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Mon, 18 May 2026 11:22:42 -0700 Subject: [PATCH 28/53] fix: harden plugin creator sharing validation (#22893) # Summary Before this change, the sample plugin creator could emit placeholder-heavy manifests that fail workspace sharing, and it chose a repo-local marketplace implicitly whenever it ran from inside a git checkout. This PR makes generated plugins share-ready by default. It switches creation to the personal marketplace unless the caller explicitly opts into repo-local paths, adds a validator that mirrors the workspace plugin ingestion contract, and updates the skill prompt and docs to describe the real flow. The goal is to stop malformed generated plugins before they reach sharing and to make the default placement match the personal marketplace behavior users expect. ## Changes - Generate share-safe plugin manifests instead of `[TODO: ...]` placeholder payloads. - Default plugin and marketplace creation to `~/plugins` and `~/.agents/plugins/marketplace.json`. - Keep repo-local marketplace creation available through explicit `--path` and `--marketplace-path` arguments. - Add `validate_plugin.py` to check manifests, companion files, skill frontmatter, skill agent YAML, asset paths, and backend-shaped contracts before sharing. - Refresh the plugin creator skill text, reference docs, and default prompt to describe validation and the personal default. ## Design decisions - The validator tracks the workspace ingestion schema directly, including the required `defaultPrompt` alias handling and skill `agents/openai.yaml` checks. - The validator keeps one intentional extra preflight rule: leftover `[TODO: ...]` placeholders are rejected before sharing even when a single placeholder would not independently violate backend type validation. - Repo-local creation stays possible, but it is now explicit instead of cwd-sensitive. ## Testing Tests: targeted Python syntax checks, plugin skill validation, staged diff whitespace validation, 15 generated plugin smoke runs, backend manifest-schema acceptance for all 15 generated bundles, and a git-repo cwd regression proving the creator still writes to the personal marketplace by default. --- .../assets/samples/plugin-creator/SKILL.md | 49 +- .../samples/plugin-creator/agents/openai.yaml | 2 +- .../references/plugin-json-spec.md | 24 +- .../scripts/create_basic_plugin.py | 77 +-- .../plugin-creator/scripts/validate_plugin.py | 586 ++++++++++++++++++ 5 files changed, 673 insertions(+), 65 deletions(-) create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md index 4a90165c07..dde5779e6c 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: plugin-creator -description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, and baseline placeholders you can edit before publishing or testing. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update personal or repo-root `.agents/plugins/marketplace.json` entries for plugin ordering and availability metadata. +description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update marketplace entries for plugin ordering and availability metadata. --- # Plugin Creator @@ -17,7 +17,8 @@ description: Create and scaffold plugin directories for Codex with a required `. python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py ``` -2. Open `/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders. +2. Edit `/.codex-plugin/plugin.json` when the request gives specific metadata. + The scaffold starts with valid defaults and must not contain `[TODO: ...]` placeholders. 3. Generate or update the personal marketplace entry when the plugin should appear in Codex UI ordering: @@ -26,14 +27,12 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py /plugins \ + --marketplace-path /.agents/plugins/marketplace.json \ --with-marketplace ``` @@ -48,17 +47,21 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ `` is the directory where the plugin folder `` will be created (for example `~/code/plugins`). +5. Before handing back a generated plugin, run: + +```bash +python3 .agents/skills/plugin-creator/scripts/validate_plugin.py +``` + ## What this skill creates - Default marketplace-backed scaffolds are personal: `~/plugins//` plus `~/.agents/plugins/marketplace.json`. -- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said - personal vs team, ask which marketplace to update before generating a marketplace entry. - Creates plugin root at `///`. - Always creates `///.codex-plugin/plugin.json`. -- Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section. -- Creates or updates the selected marketplace when `--with-marketplace` is set. - - If the marketplace file does not exist yet, seed top-level `name` plus `interface.displayName` placeholders before adding the first plugin entry. +- Fills the manifest with the validated schema shape that the ingestion path accepts. +- Creates or updates `~/.agents/plugins/marketplace.json` when `--with-marketplace` is set. + - If the marketplace file does not exist yet, seed a personal marketplace root before adding the first plugin entry. - `` is normalized using skill-creator naming rules: - `My Plugin` → `my-plugin` - `My--Plugin` → `my-plugin` @@ -74,8 +77,10 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ ## Marketplace workflow -- Personal plugins use `~/.agents/plugins/marketplace.json`. -- Repo/team plugins use `/.agents/plugins/marketplace.json`. +- Personal creation defaults to `~/.agents/plugins/marketplace.json`. +- Repo/team marketplace creation is opt-in through both `--path` and `--marketplace-path`, only + when the user specifically requests it. +- In either location, the generated source path remains `./plugins/`. - Marketplace root metadata supports top-level `name` plus optional `interface.displayName`. - Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list. - `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries. @@ -113,15 +118,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ ``` - Use `--force` only when intentionally replacing an existing marketplace entry for the same plugin name. -- If the selected marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry. +- If the target marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry. - For a brand-new marketplace file, the root object should look like: ```json { - "name": "[TODO: marketplace-name]", + "name": "personal", "interface": { - "displayName": "[TODO: Marketplace Display Name]" + "displayName": "Personal" }, "plugins": [ { @@ -144,7 +149,9 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ - Outer folder name and `plugin.json` `"name"` are always the same normalized plugin name. - Do not remove required structure; keep `.codex-plugin/plugin.json` present. -- Keep manifest values as placeholders until a human or follow-up step explicitly fills them. +- Do not leave `[TODO: ...]` placeholders in plugin manifests. +- Keep `apps` and `mcpServers` out of `plugin.json` unless their companion files are actually created. +- Omit unsupported plugin manifest fields that validation rejects, including `hooks`. - If creating files inside an existing plugin path, use `--force` only when overwrite is intentional. - Preserve any existing marketplace `interface.displayName`. - When generating marketplace entries, always write `policy.installation`, `policy.authentication`, and `category` even if their values are defaults. @@ -176,3 +183,9 @@ After editing `SKILL.md`, run: ```bash python3 /scripts/quick_validate.py .agents/skills/plugin-creator ``` + +Before handing back a generated plugin, run: + +```bash +python3 .agents/skills/plugin-creator/scripts/validate_plugin.py +``` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml b/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml index 979b49859f..19a9a6fc5b 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml +++ b/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Plugin Creator" short_description: "Scaffold plugins and marketplace entries" - default_prompt: "Use $plugin-creator to scaffold a plugin with placeholder plugin.json, optional structure, and a marketplace.json entry." + default_prompt: "Use $plugin-creator to scaffold a valid plugin in the personal marketplace, then validate it before handing it back." icon_small: "./assets/plugin-creator-small.svg" icon_large: "./assets/plugin-creator.png" diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index ee66dfe4f6..0fd444e64b 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -97,7 +97,8 @@ # Marketplace JSON sample spec -`marketplace.json` depends on where the plugin should live: +`marketplace.json` depends on where the plugin should live. New plugin creation defaults to the +personal marketplace unless the caller explicitly requests a repo-local destination: - Personal plugin: `~/.agents/plugins/marketplace.json` - Repo/team plugin: `/.agents/plugins/marketplace.json` @@ -166,8 +167,25 @@ - Append new entries unless the user explicitly requests reordering. - Replace an existing entry for the same plugin only when overwrite is intentional. - Default new plugin creation to the personal marketplace. -- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said - personal or team, ask which marketplace to update before creating the entry. +- Use a repo/team marketplace only when the user specifically requests that destination. - Choose marketplace location to match the selected destination: - Personal plugin: `~/.agents/plugins/marketplace.json` - Repo/team plugin: `/.agents/plugins/marketplace.json` + +### Plugin validation notes + +- The validator mirrors the workspace plugin ingestion schema so generated plugins follow the same + manifest contract from the start. +- Plugin manifests must include real values for `name`, `version`, `description`, + `author.name`, and the required `interface` fields. +- `version` must use strict semver. +- `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` must be absolute `https://` URLs when + present. +- `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when + present. +- `apps` and `mcpServers` should appear in `plugin.json` only when `.app.json` and `.mcp.json` + actually exist. +- Validation rejects unsupported manifest fields such as `hooks`, so the scaffold keeps them out of + generated manifests. +- Run `scripts/validate_plugin.py ` before handing back a generated plugin. It adds one + intentional preflight check that rejects leftover `[TODO: ...]` placeholders. diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py index dcb24562e8..fb882ce99a 100755 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -16,7 +16,8 @@ DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.js DEFAULT_INSTALL_POLICY = "AVAILABLE" DEFAULT_AUTH_POLICY = "ON_INSTALL" DEFAULT_CATEGORY = "Productivity" -DEFAULT_MARKETPLACE_DISPLAY_NAME = "[TODO: Marketplace Display Name]" +DEFAULT_MARKETPLACE_NAME = "personal" +DEFAULT_MARKETPLACE_DISPLAY_NAME = "Personal" VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"} VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"} @@ -40,49 +41,35 @@ def validate_plugin_name(plugin_name: str) -> None: ) -def build_plugin_json(plugin_name: str) -> dict: - return { +def display_name_from_plugin_name(plugin_name: str) -> str: + return " ".join(part.capitalize() for part in plugin_name.split("-")) + + +def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> dict[str, Any]: + display_name = display_name_from_plugin_name(plugin_name) + payload: dict[str, Any] = { "name": plugin_name, - "version": "[TODO: 1.2.0]", - "description": "[TODO: Brief plugin description]", + "version": "0.1.0", + "description": f"{display_name} plugin", "author": { - "name": "[TODO: Author Name]", - "email": "[TODO: author@example.com]", - "url": "[TODO: https://github.com/author]", + "name": "Local developer", }, - "homepage": "[TODO: https://docs.example.com/plugin]", - "repository": "[TODO: https://github.com/author/plugin]", - "license": "[TODO: MIT]", - "keywords": ["[TODO: keyword1]", "[TODO: keyword2]"], - "skills": "[TODO: ./skills/]", - "hooks": "[TODO: ./hooks.json]", - "mcpServers": "[TODO: ./.mcp.json]", - "apps": "[TODO: ./.app.json]", + "skills": "./skills/", "interface": { - "displayName": "[TODO: Plugin Display Name]", - "shortDescription": "[TODO: Short description for subtitle]", - "longDescription": "[TODO: Long description for details page]", - "developerName": "[TODO: OpenAI]", - "category": "[TODO: Productivity]", - "capabilities": ["[TODO: Interactive]", "[TODO: Write]"], - "websiteURL": "[TODO: https://openai.com/]", - "privacyPolicyURL": "[TODO: https://openai.com/policies/row-privacy-policy/]", - "termsOfServiceURL": "[TODO: https://openai.com/policies/row-terms-of-use/]", - "defaultPrompt": [ - "[TODO: Summarize my inbox and draft replies for me.]", - "[TODO: Find open bugs and turn them into tickets.]", - "[TODO: Review today's meetings and flag gaps.]", - ], - "brandColor": "[TODO: #3B82F6]", - "composerIcon": "[TODO: ./assets/icon.png]", - "logo": "[TODO: ./assets/logo.png]", - "screenshots": [ - "[TODO: ./assets/screenshot1.png]", - "[TODO: ./assets/screenshot2.png]", - "[TODO: ./assets/screenshot3.png]", - ], + "displayName": display_name, + "shortDescription": f"Use {display_name} in Codex.", + "longDescription": f"{display_name} adds a local Codex plugin scaffold.", + "developerName": "Local developer", + "category": DEFAULT_CATEGORY, + "capabilities": [], + "defaultPrompt": f"Help me use {display_name}.", }, } + if with_mcp: + payload["mcpServers"] = "./.mcp.json" + if with_apps: + payload["apps"] = "./.app.json" + return payload def build_marketplace_entry( @@ -112,7 +99,7 @@ def load_json(path: Path) -> dict[str, Any]: def build_default_marketplace() -> dict[str, Any]: return { - "name": "[TODO: marketplace-name]", + "name": DEFAULT_MARKETPLACE_NAME, "interface": { "displayName": DEFAULT_MARKETPLACE_DISPLAY_NAME, }, @@ -185,7 +172,7 @@ def create_stub_file(path: Path, payload: dict, force: bool) -> None: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Create a plugin skeleton with placeholder plugin.json." + description="Create a plugin skeleton with a validation-ready plugin.json." ) parser.add_argument("plugin_name") parser.add_argument( @@ -193,7 +180,7 @@ def parse_args() -> argparse.Namespace: default=str(DEFAULT_PLUGIN_PARENT), help=( "Parent directory for plugin creation (defaults to /plugins). " - "Use /plugins when creating a repo/team plugin." + "Pass an explicit repo path only when a repo/team plugin is intended." ), ) parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory") @@ -216,7 +203,7 @@ def parse_args() -> argparse.Namespace: default=str(DEFAULT_MARKETPLACE_PATH), help=( "Path to marketplace.json (defaults to /.agents/plugins/marketplace.json). " - "Use /.agents/plugins/marketplace.json for a repo/team plugin." + "Pass a repo-rooted marketplace path only when a repo/team plugin is intended." ), ) parser.add_argument( @@ -252,7 +239,11 @@ def main() -> None: plugin_root.mkdir(parents=True, exist_ok=True) plugin_json_path = plugin_root / ".codex-plugin" / "plugin.json" - write_json(plugin_json_path, build_plugin_json(plugin_name), args.force) + write_json( + plugin_json_path, + build_plugin_json(plugin_name, with_mcp=args.with_mcp, with_apps=args.with_apps), + args.force, + ) optional_directories = { "skills": args.with_skills, diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py new file mode 100644 index 0000000000..64518064e5 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py @@ -0,0 +1,586 @@ +#!/usr/bin/env python3 +"""Validate a generated plugin against the plugin ingestion contract.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.parse import urlparse + +import yaml + + +TODO_MARKER = "[TODO:" +SEMVER_RE = re.compile( + r"^(0|[1-9]\d*)\." + r"(0|[1-9]\d*)\." + r"(0|[1-9]\d*)" + r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\." + r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) +HEX_COLOR_RE = re.compile(r"^#[0-9A-F]{6}$", re.IGNORECASE) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate a local Codex plugin.") + parser.add_argument("plugin_path", help="Path to the plugin root directory") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + plugin_root = Path(args.plugin_path).expanduser().resolve() + errors = validate_plugin(plugin_root) + if errors: + print("Plugin validation failed:") + for error in errors: + print(f"- {error}") + raise SystemExit(1) + print(f"Plugin validation passed: {plugin_root}") + + +def validate_plugin(plugin_root: Path) -> list[str]: + errors: list[str] = [] + manifest_path = plugin_root / ".codex-plugin" / "plugin.json" + manifest = load_json_object(manifest_path, errors) + if manifest is None: + return errors + + reject_todo_markers(manifest, "$", errors) + validate_manifest_shape(plugin_root, manifest, errors) + return errors + + +def load_json_object(path: Path, errors: list[str]) -> dict[str, Any] | None: + if not path.is_file(): + errors.append("missing `.codex-plugin/plugin.json`") + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except OSError: + errors.append("unable to read `.codex-plugin/plugin.json`") + return None + except json.JSONDecodeError: + errors.append("`.codex-plugin/plugin.json` must be valid JSON") + return None + if not isinstance(payload, dict): + errors.append("`.codex-plugin/plugin.json` must contain a JSON object") + return None + return payload + + +def reject_todo_markers(value: Any, path: str, errors: list[str]) -> None: + if isinstance(value, str): + if TODO_MARKER in value: + errors.append(f"{path} still contains a `[TODO: ...]` placeholder") + return + if isinstance(value, list): + for index, item in enumerate(value): + reject_todo_markers(item, f"{path}[{index}]", errors) + return + if isinstance(value, dict): + for key, item in value.items(): + reject_todo_markers(item, f"{path}.{key}", errors) + + +def validate_manifest_shape( + plugin_root: Path, + manifest: dict[str, Any], + errors: list[str], +) -> None: + allowed_keys = { + "id", + "name", + "version", + "description", + "skills", + "apps", + "mcpServers", + "interface", + "author", + "homepage", + "repository", + "license", + "keywords", + } + for key in sorted(set(manifest) - allowed_keys): + errors.append(f"plugin.json field `{key}` is not accepted by plugin validation") + + validate_optional_non_empty_string(manifest, "id", errors) + require_non_empty_string(manifest, "name", errors) + version = require_non_empty_string(manifest, "version", errors) + if version is not None and SEMVER_RE.fullmatch(version) is None: + errors.append("plugin.json field `version` must be strict semver") + require_non_empty_string(manifest, "description", errors) + + author = require_object(manifest, "author", errors) + if author is not None: + reject_unknown_fields(author, {"name", "email", "url"}, "author", errors) + require_non_empty_string(author, "name", errors, prefix="author") + validate_optional_non_empty_string(author, "email", errors, prefix="author") + validate_optional_https_url(author, "url", errors, prefix="author") + + validate_optional_contract_path(manifest, "skills", "skills", errors) + validate_optional_contract_path(manifest, "apps", ".app.json", errors) + validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors) + + if manifest.get("apps") is not None: + validate_app_manifest( + plugin_root / ".app.json", + errors, + ) + if manifest.get("mcpServers") is not None: + validate_mcp_manifest( + plugin_root / ".mcp.json", + errors, + ) + validate_skill_manifests(plugin_root, errors) + + interface = require_object(manifest, "interface", errors) + if interface is None: + return + reject_unknown_fields( + interface, + { + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + "capabilities", + "websiteURL", + "privacyPolicyURL", + "termsOfServiceURL", + "brandColor", + "composerIcon", + "logo", + "screenshots", + "defaultPrompt", + "default_prompt", + }, + "interface", + errors, + ) + for field in ( + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + ): + require_non_empty_string(interface, field, errors, prefix="interface") + if "defaultPrompt" not in interface and "default_prompt" not in interface: + errors.append( + "plugin.json field `interface.defaultPrompt` or `interface.default_prompt` is required" + ) + capabilities = interface.get("capabilities") + if not isinstance(capabilities, list) or not all( + isinstance(value, str) and value.strip() for value in capabilities + ): + errors.append("plugin.json field `interface.capabilities` must be an array of strings") + for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"): + validate_optional_https_url(interface, field, errors, prefix="interface") + brand_color = interface.get("brandColor") + if brand_color is not None and ( + not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None + ): + errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`") + for field in ("composerIcon", "logo"): + validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors) + screenshots = interface.get("screenshots", []) + if not isinstance(screenshots, list): + errors.append("plugin.json field `interface.screenshots` must be an array") + else: + for index, raw_path in enumerate(screenshots): + validate_asset_path( + plugin_root, + plugin_root, + raw_path, + f"interface.screenshots[{index}]", + errors, + ) + + +def require_object( + payload: dict[str, Any], + key: str, + errors: list[str], +) -> dict[str, Any] | None: + value = payload.get(key) + if not isinstance(value, dict): + errors.append(f"plugin.json field `{key}` must be an object") + return None + return value + + +def require_non_empty_string( + payload: dict[str, Any], + key: str, + errors: list[str], + *, + prefix: str | None = None, +) -> str | None: + value = payload.get(key) + field = f"{prefix}.{key}" if prefix is not None else key + if not isinstance(value, str) or not value.strip(): + errors.append(f"plugin.json field `{field}` must be a non-empty string") + return None + return value + + +def validate_optional_non_empty_string( + payload: dict[str, Any], + key: str, + errors: list[str], + *, + prefix: str | None = None, +) -> None: + value = payload.get(key) + if value is None: + return + field = f"{prefix}.{key}" if prefix is not None else key + if not isinstance(value, str) or not value.strip(): + errors.append(f"plugin.json field `{field}` must be a non-empty string") + + +def reject_unknown_fields( + payload: dict[str, Any], + allowed_keys: set[str], + prefix: str, + errors: list[str], +) -> None: + for key in sorted(set(payload) - allowed_keys): + errors.append(f"plugin.json field `{prefix}.{key}` is not accepted by plugin validation") + + +def validate_optional_https_url( + payload: dict[str, Any], + key: str, + errors: list[str], + *, + prefix: str, +) -> None: + value = payload.get(key) + if value is None: + return + parsed = urlparse(value) if isinstance(value, str) else None + if parsed is None or parsed.scheme != "https" or not parsed.netloc: + errors.append(f"plugin.json field `{prefix}.{key}` must be an absolute `https://` URL") + + +def validate_optional_contract_path( + payload: dict[str, Any], + key: str, + expected: str, + errors: list[str], +) -> None: + value = payload.get(key) + if value is None: + return + normalized = normalize_contract_path(value) if isinstance(value, str) else None + if normalized != expected: + errors.append(f"plugin.json field `{key}` must resolve to `{expected}`") + + +def normalize_contract_path(raw_path: str) -> str | None: + path = Path(raw_path) + if path.is_absolute(): + return None + normalized = path.as_posix().rstrip("/") + return normalized or None + + +def validate_app_manifest(path: Path, errors: list[str]) -> None: + payload = load_companion_json_object(path, "`.app.json`", errors) + if payload is None: + return + reject_companion_unknown_fields(payload, {"apps"}, "`.app.json`", errors) + apps = payload.get("apps") + if not isinstance(apps, dict): + errors.append("`.app.json` field `apps` must be an object") + return + for key, value in apps.items(): + if not isinstance(value, dict): + errors.append(f"`.app.json` app `{key}` must be an object") + continue + reject_companion_unknown_fields(value, {"id"}, f"`.app.json` app `{key}`", errors) + app_id = value.get("id") + if not isinstance(app_id, str) or not app_id.strip(): + errors.append(f"`.app.json` app `{key}` field `id` must be a non-empty string") + + +def validate_mcp_manifest(path: Path, errors: list[str]) -> None: + payload = load_companion_json_object(path, "`.mcp.json`", errors) + if payload is None: + return + reject_companion_unknown_fields(payload, {"mcpServers"}, "`.mcp.json`", errors) + servers = payload.get("mcpServers") + if not isinstance(servers, dict): + errors.append("`.mcp.json` field `mcpServers` must be an object") + return + for key, value in servers.items(): + if not isinstance(key, str) or not key.strip(): + errors.append("`.mcp.json` server names must be non-empty strings") + if not isinstance(value, dict): + errors.append(f"`.mcp.json` server `{key}` must be an object") + + +def load_companion_json_object( + path: Path, + label: str, + errors: list[str], +) -> dict[str, Any] | None: + if not path.is_file(): + errors.append(f"{label} is required when its plugin.json field is present") + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + errors.append(f"{label} must contain valid JSON") + return None + if not isinstance(payload, dict): + errors.append(f"{label} must contain a JSON object") + return None + return payload + + +def reject_companion_unknown_fields( + payload: dict[str, Any], + allowed_keys: set[str], + prefix: str, + errors: list[str], +) -> None: + for key in sorted(set(payload) - allowed_keys): + errors.append(f"{prefix} field `{key}` is not accepted by plugin validation") + + +def validate_skill_manifests(plugin_root: Path, errors: list[str]) -> None: + skills_root = plugin_root / "skills" + if not skills_root.is_dir(): + return + for skill_root in sorted(skills_root.iterdir(), key=lambda path: path.name): + if skill_root.name.startswith(".") or not skill_root.is_dir(): + continue + validate_skill_manifest(skill_root, errors) + + +def validate_skill_manifest(skill_root: Path, errors: list[str]) -> None: + skill_md_path = skill_root / "SKILL.md" + if not skill_md_path.is_file(): + errors.append(f"skill `{skill_root.name}` is missing `SKILL.md`") + return + try: + contents = skill_md_path.read_text(encoding="utf-8") + except OSError: + errors.append(f"unable to read skill `{skill_root.name}`") + return + if not contents.startswith("---\n"): + errors.append(f"skill `{skill_root.name}` must start with YAML frontmatter") + return + frontmatter_end = contents.find("\n---", 4) + if frontmatter_end == -1: + errors.append(f"skill `{skill_root.name}` frontmatter is not closed") + return + try: + frontmatter = yaml.safe_load(contents[4:frontmatter_end]) + except yaml.YAMLError: + errors.append(f"skill `{skill_root.name}` frontmatter must be valid YAML") + return + if not isinstance(frontmatter, dict): + errors.append(f"skill `{skill_root.name}` frontmatter must be an object") + return + skill_name = frontmatter.get("name") + if not isinstance(skill_name, str) or not skill_name.strip(): + errors.append(f"skill `{skill_root.name}` frontmatter field `name` must be non-empty") + description = frontmatter.get("description") + if not isinstance(description, str) or not description.strip(): + errors.append( + f"skill `{skill_root.name}` frontmatter field `description` must be non-empty" + ) + disable_model_invocation = frontmatter.get("disable-model-invocation") + if disable_model_invocation is None: + disable_model_invocation = frontmatter.get("disable_model_invocation") + if disable_model_invocation not in (None, False): + errors.append( + f"skill `{skill_root.name}` frontmatter field `disable-model-invocation` must be false" + ) + agent_yaml_path = skill_root / "agents" / "openai.yaml" + if agent_yaml_path.is_file(): + validate_skill_agent_manifest( + plugin_root=skill_root.parent.parent, + skill_root=skill_root, + agent_yaml_path=agent_yaml_path, + errors=errors, + ) + + +def validate_skill_agent_manifest( + *, + plugin_root: Path, + skill_root: Path, + agent_yaml_path: Path, + errors: list[str], +) -> None: + try: + payload = yaml.safe_load(agent_yaml_path.read_text(encoding="utf-8")) + except OSError: + errors.append(f"unable to read skill `{skill_root.name}` agent YAML") + return + except yaml.YAMLError: + errors.append(f"skill `{skill_root.name}` agent YAML must be valid YAML") + return + if not isinstance(payload, dict): + errors.append(f"skill `{skill_root.name}` agent YAML must be an object") + return + + reject_skill_agent_unknown_fields( + payload, + {"interface", "policy", "dependencies"}, + skill_root, + errors, + ) + interface = payload.get("interface") + if not isinstance(interface, dict): + errors.append(f"skill `{skill_root.name}` agent field `interface` must be an object") + return + reject_skill_agent_unknown_fields( + interface, + { + "display_name", + "short_description", + "icon_small", + "icon_large", + "brand_color", + "default_prompt", + }, + skill_root, + errors, + prefix="interface", + ) + for field in ("display_name", "short_description"): + value = interface.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append( + f"skill `{skill_root.name}` agent field `interface.{field}` must be non-empty" + ) + for field in ("icon_small", "icon_large"): + validate_optional_asset_path( + skill_root, + plugin_root, + interface, + field, + errors, + prefix=f"skill `{skill_root.name}` agent field `interface", + ) + brand_color = interface.get("brand_color") + if brand_color is not None and ( + not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None + ): + errors.append( + f"skill `{skill_root.name}` agent field `interface.brand_color` must use `#RRGGBB`" + ) + default_prompt = interface.get("default_prompt") + if default_prompt is not None and ( + not isinstance(default_prompt, str) or not default_prompt.strip() + ): + errors.append( + f"skill `{skill_root.name}` agent field `interface.default_prompt` must be non-empty" + ) + + policy = payload.get("policy") + if policy is not None: + if not isinstance(policy, dict): + errors.append(f"skill `{skill_root.name}` agent field `policy` must be an object") + else: + reject_skill_agent_unknown_fields( + policy, + {"allow_implicit_invocation"}, + skill_root, + errors, + prefix="policy", + ) + allow_implicit_invocation = policy.get("allow_implicit_invocation") + if allow_implicit_invocation is not None and not isinstance( + allow_implicit_invocation, + bool, + ): + errors.append( + f"skill `{skill_root.name}` agent field " + "`policy.allow_implicit_invocation` must be a boolean" + ) + + dependencies = payload.get("dependencies") + if dependencies is not None: + if not isinstance(dependencies, dict): + errors.append( + f"skill `{skill_root.name}` agent field `dependencies` must be an object" + ) + else: + reject_skill_agent_unknown_fields( + dependencies, + {"tools"}, + skill_root, + errors, + prefix="dependencies", + ) + + +def reject_skill_agent_unknown_fields( + payload: dict[str, Any], + allowed_keys: set[str], + skill_root: Path, + errors: list[str], + *, + prefix: str | None = None, +) -> None: + for key in sorted(set(payload) - allowed_keys): + field = f"{prefix}.{key}" if prefix is not None else key + errors.append( + f"skill `{skill_root.name}` agent field `{field}` is not accepted by plugin validation" + ) + + +def validate_optional_asset_path( + base_dir: Path, + allowed_root: Path, + payload: dict[str, Any], + key: str, + errors: list[str], + *, + prefix: str = "interface", +) -> None: + raw_path = payload.get(key) + if raw_path is None: + return + validate_asset_path(base_dir, allowed_root, raw_path, f"{prefix}.{key}", errors) + + +def validate_asset_path( + base_dir: Path, + allowed_root: Path, + raw_path: Any, + field: str, + errors: list[str], +) -> None: + label = field if field.startswith("skill `") else f"plugin.json field `{field}`" + if not isinstance(raw_path, str) or not raw_path.strip(): + errors.append(f"{label} must be a non-empty relative path") + return + candidate = PurePosixPath(raw_path.replace("\\", "/")) + if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts): + errors.append(f"{label} must stay inside the plugin archive") + return + resolved_path = (base_dir / candidate.as_posix()).resolve() + if not resolved_path.is_relative_to(allowed_root.resolve()): + errors.append(f"{label} must stay inside the plugin archive") + return + if not resolved_path.is_file(): + errors.append(f"{label} points to a missing file") + + +if __name__ == "__main__": + main() From 0d344aca9b0caee4e5a508ee10d8a72f4d416896 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:28:53 -0700 Subject: [PATCH 29/53] goal: pause continuation loops on usage limits and blockers (#23094) Addresses #22833, #22245, #23067 ## Why `/goal` can keep synthesizing turns even when the next turn cannot make meaningful progress. Hard usage exhaustion can replay failing turns, and repeated permission or external-resource blockers can keep burning tokens while waiting for user or system intervention. ## What changed - Add resumable `blocked` and `usageLimited` goal states. As with `paused`, goal continuation stops with these states. - Move to `usageLimited` after usage-limit failures. - Allow the built-in `update_goal` tool to set `blocked` only under explicit repeated-impasse guidance. Updated goal continuation prompt to specify that agent should use `blocked` only when it has made at least three attempts to get past an impasse. Most of the files touched by this PR are because of the small app server protocol update. ## Validation I manually reproduced a number of situations where an agent can run into a true impasse and verified that it properly enters `blocked` state. I then resumed and verified that it once again entered `blocked` state several turns later if the impasse still exists. I also manually reproduced the usage-limit condition by creating a simulated responses API endpoint that returns 429 errors with the appropriate error message. Verified that the goal runtime properly moves the goal into `usageLimited` state and TUI UI updates appropriately. Verified that `/goal resume` resumes (and immediately goes back into `ussageLImited` state if appropriate). ## Follow-up PRs Small changes will be needed to the GUI clients to properly handle the two new states. --- .../schema/json/ClientRequest.json | 2 + .../schema/json/ServerNotification.json | 2 + .../codex_app_server_protocol.schemas.json | 2 + .../codex_app_server_protocol.v2.schemas.json | 2 + .../v2/ThreadGoalUpdatedNotification.json | 2 + .../schema/typescript/v2/ThreadGoalStatus.ts | 2 +- .../src/protocol/v2/thread.rs | 2 + codex-rs/app-server/README.md | 6 +- .../thread_goal_processor.rs | 4 + .../tests/suite/v2/thread_resume.rs | 86 +++++++++ codex-rs/core/src/goals.rs | 109 +++++++++++- codex-rs/core/src/session/tests.rs | 167 +++++++++++++++++- codex-rs/core/src/session/turn.rs | 36 +++- codex-rs/core/src/tools/handlers/goal.rs | 6 +- .../src/tools/handlers/goal/update_goal.rs | 16 +- codex-rs/core/src/tools/handlers/goal_spec.rs | 19 +- codex-rs/core/templates/goals/continuation.md | 10 +- codex-rs/otel/src/metrics/names.rs | 2 + codex-rs/protocol/src/protocol.rs | 2 + .../0033_thread_goal_stopped_statuses.sql | 48 +++++ codex-rs/state/src/model/thread_goal.rs | 6 + codex-rs/state/src/runtime/goals.rs | 150 +++++++++++++++- codex-rs/tui/src/app/thread_goal_actions.rs | 5 +- codex-rs/tui/src/bottom_pane/footer.rs | 4 + codex-rs/tui/src/chatwidget/goal_menu.rs | 10 +- codex-rs/tui/src/chatwidget/goal_status.rs | 2 + codex-rs/tui/src/chatwidget/interaction.rs | 22 +++ ..._chatwidget__tests__goal_menu_blocked.snap | 11 ++ ...idget__tests__goal_menu_usage_limited.snap | 11 ++ .../tui/src/chatwidget/tests/goal_menu.rs | 64 +++++++ .../src/chatwidget/tests/status_and_layout.rs | 60 +++++++ codex-rs/tui/src/goal_display.rs | 2 + 32 files changed, 837 insertions(+), 35 deletions(-) create mode 100644 codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9c2415678f..6678d9c0d1 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3124,6 +3124,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6e8b5d8a04..5d4af3d936 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3257,6 +3257,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 94abf054db..c4bb4a2ff4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15561,6 +15561,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 601e9dd7fb..c176c2a4b8 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -13385,6 +13385,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json index 52a2e905a2..cbb04eaab2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json @@ -51,6 +51,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts index 7a4bf332fb..46ec7ddd69 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadGoalStatus = "active" | "paused" | "budgetLimited" | "complete"; +export type ThreadGoalStatus = "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index ffe2c353d1..53f942cf80 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -555,6 +555,8 @@ v2_enum_from_core! { pub enum ThreadGoalStatus from CoreThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d0f423d5c7..5fa61b3c17 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -496,7 +496,7 @@ Experimental: use `memory/reset` to clear local memory artifacts and sqlite-back ### Example: Set and update a thread goal -Use `thread/goal/set` to create or update the current goal for a materialized thread. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget. +Use `thread/goal/set` to create or update the current goal for a materialized thread. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted, `blocked` when progress is waiting on outside intervention, and `usageLimited` when usage availability stops further work. The system also sets `budgetLimited` when accounting crosses a configured token budget and `usageLimited` when a turn ends on a hard usage-limit error. ```json { "method": "thread/goal/set", "id": 27, "params": { @@ -529,12 +529,12 @@ Use `thread/goal/set` to create or update the current goal for a materialized th ```json { "method": "thread/goal/set", "id": 28, "params": { "threadId": "thr_123", - "status": "paused" + "status": "blocked" } } { "id": 28, "result": { "goal": { "threadId": "thr_123", "objective": "Keep improving the benchmark until p95 latency is under 120ms", - "status": "paused", + "status": "blocked", "tokenBudget": 200000, "tokensUsed": 10000, "timeUsedSeconds": 60, diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index a7c9a2c20c..0133114b84 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -454,6 +454,8 @@ fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadG match status { ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, } @@ -463,6 +465,8 @@ fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> Threa match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 852b86e562..c9a5ae1485 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -893,6 +893,92 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> Ok(()) } +#[tokio::test] +async fn thread_goal_set_persists_resumable_stopped_statuses() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + for (wire_status, expected_status) in [ + ("blocked", ThreadGoalStatus::Blocked), + ("usageLimited", ThreadGoalStatus::UsageLimited), + ] { + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id.clone(), + "objective": "keep polishing", + "status": wire_status, + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let goal: ThreadGoalSetResponse = to_response(goal_resp)?; + assert_eq!(goal.goal.status, expected_status); + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ThreadGoalUpdated(notification) = notification else { + anyhow::bail!("expected thread goal update notification"); + }; + assert_eq!(notification.goal.status, expected_status); + } + + Ok(()) +} + #[tokio::test] async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 717afb65c2..0f3b8e36eb 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -15,12 +15,14 @@ use crate::tasks::RegularTask; use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; use anyhow::Context; use codex_features::Feature; +use codex_otel::GOAL_BLOCKED_METRIC; use codex_otel::GOAL_BUDGET_LIMITED_METRIC; use codex_otel::GOAL_COMPLETED_METRIC; use codex_otel::GOAL_CREATED_METRIC; use codex_otel::GOAL_DURATION_SECONDS_METRIC; use codex_otel::GOAL_RESUMED_METRIC; use codex_otel::GOAL_TOKEN_COUNT_METRIC; +use codex_otel::GOAL_USAGE_LIMITED_METRIC; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -156,6 +158,9 @@ pub(crate) enum GoalRuntimeEvent<'a> { turn_context: Option<&'a TurnContext>, reason: TurnAbortReason, }, + UsageLimitReached { + turn_context: &'a TurnContext, + }, ExternalMutationStarting, ExternalSet { external_set: ExternalGoalSet, @@ -393,6 +398,11 @@ impl Session { .await; Ok(()) }), + GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move { + self.usage_limit_active_thread_goal_for_turn(turn_context) + .await?; + Ok(()) + }), GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move { if let Err(err) = self.account_thread_goal_before_external_mutation().await { tracing::warn!( @@ -537,6 +547,7 @@ impl Session { if replacing_goal { self.emit_goal_created_metric(); } + self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status); self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal); let goal = protocol_goal_from_state(goal); *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; @@ -653,6 +664,7 @@ impl Session { let previous_status = previous_goal .as_ref() .and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status)); + self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status); self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone())); let goal_id = goal.goal_id; @@ -681,7 +693,10 @@ impl Session { self.clear_stopped_thread_goal_runtime_state().await; } } - codex_state::ThreadGoalStatus::Paused | codex_state::ThreadGoalStatus::Complete => { + codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited + | codex_state::ThreadGoalStatus::Complete => { self.clear_stopped_thread_goal_runtime_state().await; } } @@ -741,6 +756,25 @@ impl Session { .counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]); } + fn emit_goal_resumed_metric_if_status_changed( + &self, + previous_status: Option, + goal_status: codex_state::ThreadGoalStatus, + ) { + if goal_status == codex_state::ThreadGoalStatus::Active + && matches!( + previous_status, + Some( + codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited + ) + ) + { + self.emit_goal_resumed_metric(); + } + } + fn emit_goal_terminal_metrics_if_status_changed( &self, previous_status: Option, @@ -751,6 +785,8 @@ impl Session { } let counter = match goal.status { + codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC, + codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC, codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC, codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC, codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => { @@ -1011,6 +1047,8 @@ impl Session { matches!(budget_limit_steering, BudgetLimitSteering::Suppressed) } codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited | codex_state::ThreadGoalStatus::Complete => true, }; { @@ -1200,6 +1238,59 @@ impl Session { Ok(()) } + async fn usage_limit_active_thread_goal_for_turn( + &self, + turn_context: &TurnContext, + ) -> anyhow::Result<()> { + if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { + return Ok(()); + } + + if !self.enabled(Feature::Goals) { + return Ok(()); + } + + let _continuation_guard = self + .goal_runtime + .continuation_lock + .acquire() + .await + .context("goal continuation semaphore closed")?; + let Some(state_db) = self.state_db_for_thread_goals().await? else { + return Ok(()); + }; + self.account_thread_goal_progress( + turn_context, + BudgetLimitSteering::Suppressed, + TerminalMetricEmission::Emit, + ) + .await?; + let previous_status = self + .current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None) + .await?; + let Some(goal) = state_db + .thread_goals() + .usage_limit_active_thread_goal(self.conversation_id) + .await? + else { + return Ok(()); + }; + self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); + let goal = protocol_goal_from_state(goal); + *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; + self.clear_active_goal_accounting(turn_context).await; + self.send_event( + turn_context, + EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: self.conversation_id, + turn_id: Some(turn_context.sub_id.clone()), + goal, + }), + ) + .await; + Ok(()) + } + async fn restore_thread_goal_runtime_after_resume(&self) -> anyhow::Result<()> { if !self.enabled(Feature::Goals) { return Ok(()); @@ -1239,6 +1330,8 @@ impl Session { self.emit_goal_resumed_metric(); } codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited | codex_state::ThreadGoalStatus::BudgetLimited | codex_state::ThreadGoalStatus::Complete => { self.clear_stopped_thread_goal_runtime_state().await; @@ -1594,6 +1687,8 @@ pub(crate) fn protocol_goal_status_from_state( match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, } @@ -1605,6 +1700,8 @@ pub(crate) fn state_goal_status_from_protocol( match status { ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, } @@ -1683,7 +1780,7 @@ mod tests { } #[test] - fn continuation_prompt_only_tells_model_to_update_goal_when_complete() { + fn continuation_prompt_allows_complete_and_strict_blocked_updates() { let prompt = continuation_prompt(&ThreadGoal { thread_id: ThreadId::new(), objective: "finish the stack".to_string(), @@ -1700,9 +1797,11 @@ mod tests { assert!(prompt.contains("\nfinish the stack\n")); assert!(prompt.contains("Token budget: 10000")); assert!(prompt.contains("call update_goal with status \"complete\"")); - assert!(!prompt.contains( - "explain the blocker or next required input to the user and wait for new input" - )); + assert!(prompt.contains("status \"blocked\"")); + assert!(prompt.contains("at least three consecutive goal turns")); + assert!(prompt.contains("same blocking condition")); + assert!(prompt.contains("original/user-triggered turn")); + assert!(prompt.contains("truly at an impasse")); assert!(!prompt.contains("budgetLimited")); assert!(!prompt.contains("status \"paused\"")); } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4511df4583..d3f6917775 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8629,6 +8629,57 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation() -> anyhow::Result<()> +{ + let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + sess.set_thread_goal( + tc.as_ref(), + SetGoalRequest { + objective: Some("Keep improving the benchmark".to_string()), + status: None, + token_budget: Some(Some(50)), + }, + ) + .await?; + sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted { + turn_context: tc.as_ref(), + token_usage: TokenUsage::default(), + }) + .await?; + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + set_total_token_usage(&sess, post_goal_token_usage()).await; + + sess.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: tc.as_ref(), + }) + .await?; + + let state_db = goal_test_state_db(sess.as_ref()).await?; + let goal = state_db + .thread_goals() + .get_thread_goal(sess.conversation_id) + .await? + .expect("goal should remain persisted after usage limiting"); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + assert_eq!(70, goal.tokens_used); + + sess.abort_all_tasks(TurnAbortReason::Replaced).await; + sess.goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) + .await?; + assert!(sess.active_turn.lock().await.is_none()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> { let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; @@ -9550,7 +9601,121 @@ async fn update_goal_tool_rejects_pausing_goal() { }; assert_eq!( output, - "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" + ); + + let goal = session + .get_thread_goal() + .await + .expect("read thread goal") + .expect("goal should still exist"); + assert_eq!(goal.status, ThreadGoalStatus::Active); +} + +#[tokio::test] +async fn update_goal_tool_marks_goal_blocked() { + let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; + + create_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::clone(&tracker), + call_id: "create-goal".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Keep the watcher alive", + "token_budget": 123, + }) + .to_string(), + }, + }) + .await + .expect("initial create_goal should succeed"); + + update_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker, + call_id: "block-goal".to_string(), + tool_name: codex_tools::ToolName::plain("update_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "status": "blocked", + }) + .to_string(), + }, + }) + .await + .expect("update_goal should mark the goal blocked"); + + let goal = session + .get_thread_goal() + .await + .expect("read thread goal") + .expect("goal should still exist"); + assert_eq!(goal.status, ThreadGoalStatus::Blocked); +} + +#[tokio::test] +async fn update_goal_tool_rejects_usage_limited_goal() { + let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; + + create_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::clone(&tracker), + call_id: "create-goal".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Keep the watcher alive", + }) + .to_string(), + }, + }) + .await + .expect("initial create_goal should succeed"); + + let response = update_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker, + call_id: "usage-limit-goal".to_string(), + tool_name: codex_tools::ToolName::plain("update_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "status": "usageLimited", + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = response else { + panic!("expected update_goal to reject usage-limiting a goal"); + }; + assert_eq!( + output, + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" ); let goal = session diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 96569d0bfa..ab316440e5 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -20,6 +20,7 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_ use crate::connectors; use crate::context::ContextualUserFragment; use crate::feedback_tags; +use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::PendingInputHookDisposition; use crate::hook_runtime::emit_hook_completed_events; use crate::hook_runtime::inspect_pending_input; @@ -162,7 +163,16 @@ pub(crate) async fn run_turn( let pre_sampling_compact = match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { Ok(pre_sampling_compact) => pre_sampling_compact, - Err(_) => { + Err(err) => { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } error!("Failed to run pre-sampling compact"); return None; } @@ -517,7 +527,20 @@ pub(crate) async fn run_turn( .await { Ok(reset_client_session) => reset_client_session, - Err(_) => return None, + Err(err) => { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!( + "failed to usage-limit active goal after usage-limit error: {err}" + ); + } + return None; + } }; if reset_client_session { client_session.reset_websocket_session(); @@ -673,6 +696,15 @@ pub(crate) async fn run_turn( } Err(e) => { info!("Turn error: {e:#}"); + if e.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; // let the user continue the conversation diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs index e50095e524..694eafca65 100644 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ b/codex-rs/core/src/tools/handlers/goal.rs @@ -1,8 +1,8 @@ //! Built-in model tool handlers for persisted thread goals. //! -//! The public tool contract intentionally splits goal creation from completion: -//! `create_goal` starts an active objective, while `update_goal` can only mark -//! the existing goal complete. +//! The public tool contract intentionally splits goal creation from stopped +//! status updates: `create_goal` starts an active objective, while +//! `update_goal` can only mark the existing goal complete or blocked. use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs index 8cf8ce546a..16883e0755 100644 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/update_goal.rs @@ -51,9 +51,12 @@ impl ToolExecutor for UpdateGoalHandler { }; let args: UpdateGoalArgs = parse_arguments(&arguments)?; - if args.status != ThreadGoalStatus::Complete { + if !matches!( + args.status, + ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked + ) { return Err(FunctionCallError::RespondToModel( - "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" .to_string(), )); } @@ -68,13 +71,18 @@ impl ToolExecutor for UpdateGoalHandler { turn.as_ref(), SetGoalRequest { objective: None, - status: Some(ThreadGoalStatus::Complete), + status: Some(args.status), token_budget: None, }, ) .await .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(Some(goal), CompletionBudgetReport::Include).map(boxed_tool_output) + let completion_budget_report = if args.status == ThreadGoalStatus::Complete { + CompletionBudgetReport::Include + } else { + CompletionBudgetReport::Omit + }; + goal_response(Some(goal), completion_budget_report).map(boxed_tool_output) } } diff --git a/codex-rs/core/src/tools/handlers/goal_spec.rs b/codex-rs/core/src/tools/handlers/goal_spec.rs index a5ea0ad2f4..a3ae8703ce 100644 --- a/codex-rs/core/src/tools/handlers/goal_spec.rs +++ b/codex-rs/core/src/tools/handlers/goal_spec.rs @@ -63,9 +63,9 @@ pub fn create_update_goal_tool() -> ToolSpec { let properties = BTreeMap::from([( "status".to_string(), JsonSchema::string_enum( - vec![json!("complete")], + vec![json!("complete"), json!("blocked")], Some( - "Required. Set to complete only when the objective is achieved and no required work remains." + "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit." .to_string(), ), ), @@ -74,10 +74,14 @@ pub fn create_update_goal_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: UPDATE_GOAL_TOOL_NAME.to_string(), description: r#"Update the existing goal. -Use this tool only to mark the goal achieved. +Use this tool only to mark the goal achieved or genuinely blocked. Set status to `complete` only when the objective has actually been achieved and no required work remains. +Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change. +If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again. +Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`. +Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. -You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. +You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# .to_string(), strict: false, @@ -96,7 +100,7 @@ mod tests { use super::*; #[test] - fn update_goal_tool_only_exposes_complete_status() { + fn update_goal_tool_exposes_complete_and_blocked_statuses() { let ToolSpec::Function(tool) = create_update_goal_tool() else { panic!("update_goal should be a function tool"); }; @@ -107,6 +111,9 @@ mod tests { .and_then(|properties| properties.get("status")) .expect("status property should exist"); - assert_eq!(status.enum_values, Some(vec![json!("complete")])); + assert_eq!( + status.enum_values, + Some(vec![json!("complete"), json!("blocked")]) + ); } } diff --git a/codex-rs/core/templates/goals/continuation.md b/codex-rs/core/templates/goals/continuation.md index 5b4bd774ba..904d15c266 100644 --- a/codex-rs/core/templates/goals/continuation.md +++ b/codex-rs/core/templates/goals/continuation.md @@ -40,4 +40,12 @@ Before deciding that the goal is achieved, treat completion as unproven and veri Do not rely on intent, partial progress, memory of earlier work, or a plausible final answer as proof of completion. Marking the goal complete is a claim that the full objective has been finished and can withstand requirement-by-requirement scrutiny. Only mark the goal achieved when current evidence proves every requirement has been satisfied and no required work remains. If the evidence is incomplete, weak, indirect, merely consistent with completion, or leaves any requirement missing, incomplete, or unverified, keep working instead of marking the goal complete. If the objective is achieved, call update_goal with status "complete" so usage accounting is preserved. If the achieved goal has a token budget, report the final consumed token budget to the user after update_goal succeeds. -Do not call update_goal unless the goal is complete. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work. +Blocked audit: +- Do not call update_goal with status "blocked" the first time a blocker appears. +- Only use status "blocked" when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic goal continuations. +- If the user resumes a goal that was previously marked "blocked", treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, call update_goal with status "blocked" again. +- Use status "blocked" only when you are truly at an impasse and cannot make meaningful progress without user input or an external-state change. +- Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; call update_goal with status "blocked". +- Never use status "blocked" merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. + +Do not call update_goal unless the goal is complete or the strict blocked audit above is satisfied. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work. diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 2d7dfdf880..17a39816a7 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -32,6 +32,8 @@ pub const GOAL_CREATED_METRIC: &str = "codex.goal.created"; pub const GOAL_RESUMED_METRIC: &str = "codex.goal.resumed"; pub const GOAL_COMPLETED_METRIC: &str = "codex.goal.completed"; pub const GOAL_BUDGET_LIMITED_METRIC: &str = "codex.goal.budget_limited"; +pub const GOAL_USAGE_LIMITED_METRIC: &str = "codex.goal.usage_limited"; +pub const GOAL_BLOCKED_METRIC: &str = "codex.goal.blocked"; pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; pub const GOAL_DURATION_SECONDS_METRIC: &str = "codex.goal.duration_s"; pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage"; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 94753780cb..98635ee30a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3561,6 +3561,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { pub enum ThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } diff --git a/codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql b/codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql new file mode 100644 index 0000000000..4e89f7469b --- /dev/null +++ b/codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql @@ -0,0 +1,48 @@ +PRAGMA foreign_keys=OFF; + +CREATE TABLE thread_goals_new ( + thread_id TEXT PRIMARY KEY NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + goal_id TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ( + 'active', + 'paused', + 'blocked', + 'usage_limited', + 'budget_limited', + 'complete' + )), + token_budget INTEGER, + tokens_used INTEGER NOT NULL DEFAULT 0, + time_used_seconds INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +INSERT INTO thread_goals_new ( + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms +) +SELECT + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms +FROM thread_goals; + +DROP TABLE thread_goals; +ALTER TABLE thread_goals_new RENAME TO thread_goals; + +PRAGMA foreign_keys=ON; diff --git a/codex-rs/state/src/model/thread_goal.rs b/codex-rs/state/src/model/thread_goal.rs index e943c145f8..4806250069 100644 --- a/codex-rs/state/src/model/thread_goal.rs +++ b/codex-rs/state/src/model/thread_goal.rs @@ -12,6 +12,8 @@ use super::epoch_millis_to_datetime; pub enum ThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } @@ -21,6 +23,8 @@ impl ThreadGoalStatus { match self { Self::Active => "active", Self::Paused => "paused", + Self::Blocked => "blocked", + Self::UsageLimited => "usage_limited", Self::BudgetLimited => "budget_limited", Self::Complete => "complete", } @@ -42,6 +46,8 @@ impl TryFrom<&str> for ThreadGoalStatus { match value { "active" => Ok(Self::Active), "paused" => Ok(Self::Paused), + "blocked" => Ok(Self::Blocked), + "usage_limited" => Ok(Self::UsageLimited), "budget_limited" => Ok(Self::BudgetLimited), "complete" => Ok(Self::Complete), other => Err(anyhow!("unknown thread goal status `{other}`")), diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index b43e8f69d5..9de561ef2d 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -195,7 +195,7 @@ UPDATE thread_goals SET objective = COALESCE(?, objective), status = CASE - WHEN status = ? AND ? = ? THEN status + WHEN status = ? AND ? IN (?, ?) THEN status WHEN ? = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ? ELSE ? END, @@ -209,6 +209,7 @@ WHERE thread_id = ? .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) .bind(status.as_str()) .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(crate::ThreadGoalStatus::Blocked.as_str()) .bind(status.as_str()) .bind(token_budget) .bind(token_budget) @@ -229,7 +230,7 @@ UPDATE thread_goals SET objective = COALESCE(?, objective), status = CASE - WHEN status = ? AND ? = ? THEN status + WHEN status = ? AND ? IN (?, ?) THEN status WHEN ? = 'active' AND token_budget IS NOT NULL AND tokens_used >= token_budget THEN ? ELSE ? END, @@ -242,6 +243,7 @@ WHERE thread_id = ? .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) .bind(status.as_str()) .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(crate::ThreadGoalStatus::Blocked.as_str()) .bind(status.as_str()) .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) .bind(status.as_str()) @@ -323,6 +325,23 @@ WHERE thread_id = ? pub async fn pause_active_thread_goal( &self, thread_id: ThreadId, + ) -> anyhow::Result> { + self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::Paused) + .await + } + + pub async fn usage_limit_active_thread_goal( + &self, + thread_id: ThreadId, + ) -> anyhow::Result> { + self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::UsageLimited) + .await + } + + async fn update_active_thread_goal_status( + &self, + thread_id: ThreadId, + status: crate::ThreadGoalStatus, ) -> anyhow::Result> { let now_ms = datetime_to_epoch_millis(Utc::now()); let result = sqlx::query( @@ -332,12 +351,19 @@ SET status = ?, updated_at_ms = ? WHERE thread_id = ? - AND status = 'active' + AND ( + status = 'active' + OR ( + ? = 'usage_limited' + AND status = 'budget_limited' + ) + ) "#, ) - .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(status.as_str()) .bind(now_ms) .bind(thread_id.to_string()) + .bind(status.as_str()) .execute(self.pool.as_ref()) .await?; @@ -386,7 +412,7 @@ WHERE thread_id = ? "status IN ('active', 'budget_limited', 'complete')" } ThreadGoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'budget_limited')" + "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" } }; let budget_limit_status_filter = match mode { @@ -394,7 +420,7 @@ WHERE thread_id = ? | ThreadGoalAccountingMode::ActiveOnly | ThreadGoalAccountingMode::ActiveOrComplete => "status = 'active'", ThreadGoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'budget_limited')" + "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" } }; let goal_id_filter = if expected_goal_id.is_some() { @@ -1019,6 +1045,66 @@ mod tests { ); } + #[tokio::test] + async fn usage_limit_active_thread_goal_updates_active_or_budget_limited_goals() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + let goal = runtime + .thread_goals() + .replace_thread_goal( + thread_id, + "optimize the benchmark", + crate::ThreadGoalStatus::Active, + /*token_budget*/ None, + ) + .await + .expect("goal replacement should succeed"); + + let usage_limited = runtime + .thread_goals() + .usage_limit_active_thread_goal(thread_id) + .await + .expect("usage limiting should succeed") + .expect("active goal should become usage limited"); + let expected = crate::ThreadGoal { + status: crate::ThreadGoalStatus::UsageLimited, + updated_at: usage_limited.updated_at, + ..goal + }; + assert_eq!(expected, usage_limited); + + let second_update = runtime + .thread_goals() + .usage_limit_active_thread_goal(thread_id) + .await + .expect("repeated usage limiting should succeed"); + assert_eq!(None, second_update); + + let budget_limited = runtime + .thread_goals() + .replace_thread_goal( + thread_id, + "keep the usage failure visible", + crate::ThreadGoalStatus::BudgetLimited, + /*token_budget*/ Some(1), + ) + .await + .expect("goal replacement should succeed"); + let usage_limited = runtime + .thread_goals() + .usage_limit_active_thread_goal(thread_id) + .await + .expect("usage limiting should succeed") + .expect("budget-limited goal should become usage limited"); + let expected = crate::ThreadGoal { + status: crate::ThreadGoalStatus::UsageLimited, + updated_at: usage_limited.updated_at, + ..budget_limited + }; + assert_eq!(expected, usage_limited); + } + #[tokio::test] async fn usage_accounting_updates_active_goals_and_accounts_budget_limited_in_flight_usage() { let runtime = test_runtime().await; @@ -1318,6 +1404,58 @@ mod tests { assert_eq!(50, paused.tokens_used); } + #[tokio::test] + async fn blocking_budget_limited_goal_preserves_terminal_status() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .thread_goals() + .replace_thread_goal( + thread_id, + "stay within budget", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(40), + ) + .await + .expect("goal replacement should succeed"); + let outcome = runtime + .thread_goals() + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 1, + /*token_delta*/ 50, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Updated(budget_limited) = outcome else { + panic!("budget crossing should update the goal"); + }; + + let blocked = runtime + .thread_goals() + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + objective: None, + status: Some(crate::ThreadGoalStatus::Blocked), + token_budget: None, + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed") + .expect("goal should exist"); + + let expected = crate::ThreadGoal { + updated_at: blocked.updated_at, + ..budget_limited + }; + assert_eq!(expected, blocked); + } + #[tokio::test] async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() { let runtime = test_runtime().await; diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index e43183ffc7..89b5465a96 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -63,7 +63,10 @@ impl App { let Some(goal) = response.goal else { return; }; - if goal.status == ThreadGoalStatus::Paused { + if matches!( + goal.status, + ThreadGoalStatus::Paused | ThreadGoalStatus::Blocked | ThreadGoalStatus::UsageLimited + ) { self.chat_widget .show_resume_paused_goal_prompt(thread_id, goal.objective); } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 0b6aabf5a9..8035881e8e 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -98,6 +98,8 @@ pub(crate) enum CollaborationModeIndicator { pub(crate) enum GoalStatusIndicator { Active { usage: Option }, Paused, + Blocked, + UsageLimited, BudgetLimited { usage: Option }, Complete { usage: Option }, } @@ -547,6 +549,8 @@ pub(crate) fn goal_status_indicator_line( } } GoalStatusIndicator::Paused => "Goal paused (/goal resume)".to_string(), + GoalStatusIndicator::Blocked => "Goal blocked (/goal resume)".to_string(), + GoalStatusIndicator::UsageLimited => "Goal hit usage limits (/goal resume)".to_string(), GoalStatusIndicator::BudgetLimited { usage } => { if let Some(usage) = usage { format!("Goal unmet ({usage})") diff --git a/codex-rs/tui/src/chatwidget/goal_menu.rs b/codex-rs/tui/src/chatwidget/goal_menu.rs index 19966d325f..ed48b5840d 100644 --- a/codex-rs/tui/src/chatwidget/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/goal_menu.rs @@ -103,7 +103,9 @@ fn goal_summary_lines(goal: &AppThreadGoal) -> Vec> { } let command_hint = match goal.status { AppThreadGoalStatus::Active => "Commands: /goal edit, /goal pause, /goal clear", - AppThreadGoalStatus::Paused => "Commands: /goal edit, /goal resume, /goal clear", + AppThreadGoalStatus::Paused + | AppThreadGoalStatus::Blocked + | AppThreadGoalStatus::UsageLimited => "Commands: /goal edit, /goal resume, /goal clear", AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => { "Commands: /goal edit, /goal clear" } @@ -117,6 +119,8 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str { match status { AppThreadGoalStatus::Active => "active", AppThreadGoalStatus::Paused => "paused", + AppThreadGoalStatus::Blocked => "blocked", + AppThreadGoalStatus::UsageLimited => "usage limited", AppThreadGoalStatus::BudgetLimited => "limited by budget", AppThreadGoalStatus::Complete => "complete", } @@ -125,7 +129,9 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str { fn edited_goal_status(status: AppThreadGoalStatus) -> AppThreadGoalStatus { match status { AppThreadGoalStatus::Active => AppThreadGoalStatus::Active, - AppThreadGoalStatus::Paused => AppThreadGoalStatus::Paused, + AppThreadGoalStatus::Paused + | AppThreadGoalStatus::Blocked + | AppThreadGoalStatus::UsageLimited => status, AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => { AppThreadGoalStatus::Active } diff --git a/codex-rs/tui/src/chatwidget/goal_status.rs b/codex-rs/tui/src/chatwidget/goal_status.rs index e641159a3b..e64c5be376 100644 --- a/codex-rs/tui/src/chatwidget/goal_status.rs +++ b/codex-rs/tui/src/chatwidget/goal_status.rs @@ -50,6 +50,8 @@ pub(super) fn goal_status_indicator_from_app_goal( usage: active_goal_usage(goal.token_budget, goal.tokens_used, goal.time_used_seconds), }), AppThreadGoalStatus::Paused => Some(GoalStatusIndicator::Paused), + AppThreadGoalStatus::Blocked => Some(GoalStatusIndicator::Blocked), + AppThreadGoalStatus::UsageLimited => Some(GoalStatusIndicator::UsageLimited), AppThreadGoalStatus::BudgetLimited => Some(GoalStatusIndicator::BudgetLimited { usage: stopped_goal_budget_usage(goal.token_budget, goal.tokens_used), }), diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 2a88711f48..0819bd6c03 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -375,6 +375,7 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); + self.pause_active_goal_for_interrupt(); self.submit_op(AppCommand::interrupt()); } else { self.request_quit_without_confirmation(); @@ -392,6 +393,7 @@ impl ChatWidget { self.arm_quit_shortcut(key); if self.is_cancellable_work_active() { + self.pause_active_goal_for_interrupt(); self.submit_op(AppCommand::interrupt()); } } @@ -452,4 +454,24 @@ impl ChatWidget { fn is_cancellable_work_active(&self) -> bool { self.bottom_pane.is_task_running() || self.review.is_review_mode } + + fn pause_active_goal_for_interrupt(&self) { + if !self.turn_lifecycle.agent_turn_running { + return; + } + if !self + .current_goal_status + .as_ref() + .is_some_and(GoalStatusState::is_active) + { + return; + } + let Some(thread_id) = self.thread_id else { + return; + }; + self.app_event_tx.send(AppEvent::SetThreadGoalStatus { + thread_id, + status: AppThreadGoalStatus::Paused, + }); + } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap new file mode 100644 index 0000000000..40de732645 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/goal_menu.rs +expression: rendered_goal_summary(&mut rx) +--- +Goal +Status: blocked +Objective: Keep improving the bare goal command until it feels calm and useful. +Time used: 1m +Tokens used: 12.5K + +Commands: /goal edit, /goal resume, /goal clear diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap new file mode 100644 index 0000000000..747ed25d27 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/goal_menu.rs +expression: rendered_goal_summary(&mut rx) +--- +Goal +Status: usage limited +Objective: Keep improving the bare goal command until it feels calm and useful. +Time used: 1m +Tokens used: 12.5K + +Commands: /goal edit, /goal resume, /goal clear diff --git a/codex-rs/tui/src/chatwidget/tests/goal_menu.rs b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs index e7f3077513..6ccc0220bf 100644 --- a/codex-rs/tui/src/chatwidget/tests/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs @@ -28,6 +28,34 @@ async fn goal_menu_paused_snapshot() { assert_chatwidget_snapshot!("goal_menu_paused", rendered_goal_summary(&mut rx)); } +#[tokio::test] +async fn goal_menu_blocked_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_goal_summary(test_goal( + thread_id, + AppThreadGoalStatus::Blocked, + /*token_budget*/ None, + )); + + assert_chatwidget_snapshot!("goal_menu_blocked", rendered_goal_summary(&mut rx)); +} + +#[tokio::test] +async fn goal_menu_usage_limited_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_goal_summary(test_goal( + thread_id, + AppThreadGoalStatus::UsageLimited, + /*token_budget*/ None, + )); + + assert_chatwidget_snapshot!("goal_menu_usage_limited", rendered_goal_summary(&mut rx)); +} + #[tokio::test] async fn goal_menu_budget_limited_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -117,6 +145,42 @@ async fn goal_edit_prompt_submits_preserved_status_and_budget() { assert!(chat.no_modal_or_popup_active()); } +#[tokio::test] +async fn goal_edit_prompt_preserves_resumable_stopped_statuses() { + for stopped_status in [ + AppThreadGoalStatus::Blocked, + AppThreadGoalStatus::UsageLimited, + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_goal_edit_prompt( + thread_id, + test_goal( + thread_id, + stopped_status, + /*token_budget*/ Some(80_000), + ), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::SetThreadGoalObjective { + mode: + crate::app_event::ThreadGoalSetMode::UpdateExisting { + status, + token_budget, + }, + .. + }) => { + assert_eq!(status, stopped_status); + assert_eq!(token_budget, Some(80_000)); + } + other => panic!("expected SetThreadGoalObjective event, got {other:?}"), + } + } +} + #[tokio::test] async fn goal_edit_prompt_resets_terminal_status_to_active() { let cases = [ diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index acd6b7111b..ac3e236822 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1136,6 +1136,45 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); } +#[tokio::test] +async fn ctrl_c_interrupt_pauses_active_goal_turn() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.thread_id = Some(thread_id); + let mut goal = test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Active, + /*token_budget*/ Some(50_000), + /*tokens_used*/ 40_000, + ); + goal.thread_id = thread_id.to_string(); + chat.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal, + }, + ), + /*replay_kind*/ None, + ); + chat.on_task_started(); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SetThreadGoalStatus { + thread_id: event_thread_id, + status: AppThreadGoalStatus::Paused, + }) if event_thread_id == thread_id + ); +} + #[tokio::test] async fn idle_commit_ticks_do_not_restore_status_without_commentary_completion() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -2293,6 +2332,22 @@ fn goal_status_indicator_formats_statuses_and_budgets() { usage: Some("30m".to_string()), }) ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Blocked, + /*token_budget*/ None, + /*tokens_used*/ 0, + )), + Some(GoalStatusIndicator::Blocked) + ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::UsageLimited, + /*token_budget*/ None, + /*tokens_used*/ 0, + )), + Some(GoalStatusIndicator::UsageLimited) + ); assert_eq!( goal_status_indicator_from_app_goal(&test_thread_goal( codex_app_server_protocol::ThreadGoalStatus::BudgetLimited, @@ -2339,6 +2394,11 @@ fn goal_status_indicator_line_formats_goal_text() { "Goal unmet (4K / 5K tokens)", ), (GoalStatusIndicator::Paused, "Goal paused (/goal resume)"), + (GoalStatusIndicator::Blocked, "Goal blocked (/goal resume)"), + ( + GoalStatusIndicator::UsageLimited, + "Goal hit usage limits (/goal resume)", + ), ( GoalStatusIndicator::BudgetLimited { usage: None }, "Goal abandoned", diff --git a/codex-rs/tui/src/goal_display.rs b/codex-rs/tui/src/goal_display.rs index d0455054b7..961b87f16e 100644 --- a/codex-rs/tui/src/goal_display.rs +++ b/codex-rs/tui/src/goal_display.rs @@ -32,6 +32,8 @@ pub(crate) fn goal_status_label(status: ThreadGoalStatus) -> &'static str { match status { ThreadGoalStatus::Active => "active", ThreadGoalStatus::Paused => "paused", + ThreadGoalStatus::Blocked => "blocked", + ThreadGoalStatus::UsageLimited => "usage limited", ThreadGoalStatus::BudgetLimited => "limited by budget", ThreadGoalStatus::Complete => "complete", } From 4ac3ea20a28d8b5239e57ca01f0f4a0acf59baaa Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:32:02 -0700 Subject: [PATCH 30/53] Clarify resume hints for renamed threads (#23234) Addresses #23181 ## Why Renamed threads can share names, so hints that suggest resuming directly by name are ambiguous. Issue #23181 asks for the picker hint to include the thread name and thread ID in parens so users can disambiguate safely. ## What - Adds a shared resume hint formatter for named threads: run `codex resume`, then select ` ()`. - Uses that hint for /rename confirmations, TUI session summaries, and CLI/TUI exit messages. - Keeps direct `codex resume ` guidance for unnamed threads. ## Verification Manually verified that message after `/rename` and after `/exit` include session ID in parens. --------- Co-authored-by: Felipe Coury --- codex-rs/cli/src/main.rs | 10 ++--- codex-rs/tui/src/app.rs | 13 ++++--- codex-rs/tui/src/app/event_dispatch.rs | 2 +- codex-rs/tui/src/app/session_lifecycle.rs | 4 +- codex-rs/tui/src/app/tests/session_summary.rs | 11 ++++-- codex-rs/tui/src/chatwidget.rs | 14 +++---- ...tests__thread_name_update_resume_hint.snap | 5 +++ .../tui/src/chatwidget/tests/app_server.rs | 6 +-- codex-rs/tui/src/main.rs | 5 ++- codex-rs/tui/src/resume_picker.rs | 16 ++++---- codex-rs/utils/cli/src/lib.rs | 1 + codex-rs/utils/cli/src/resume_command.rs | 39 +++++++++++++++++++ 12 files changed, 88 insertions(+), 38 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8274510b9f..4b0d2330a6 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -38,7 +38,7 @@ use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::ProfileV2Name; -use codex_utils_cli::resume_command; +use codex_utils_cli::resume_hint; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -629,6 +629,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec, ) -> Option { let usage_line = (!token_usage.is_zero()).then(|| token_usage.to_string()); - let thread_id = - resumable_thread(thread_id, thread_name, rollout_path).map(|thread| thread.thread_id); - let resume_command = codex_utils_cli::resume_command(/*thread_name*/ None, thread_id); + let resumable_thread = resumable_thread(thread_id, thread_name, rollout_path); + let resume_hint = resumable_thread.as_ref().and_then(|thread| { + codex_utils_cli::resume_hint(thread.thread_name.as_deref(), Some(thread.thread_id)) + }); - if usage_line.is_none() && resume_command.is_none() { + if usage_line.is_none() && resume_hint.is_none() { return None; } Some(SessionSummary { usage_line, - resume_command, + resume_hint, }) } @@ -459,7 +460,7 @@ fn errors_for_cwd(cwd: &Path, response: &SkillsListResponse) -> Vec, - resume_command: Option, + resume_hint: Option, } #[derive(Debug, Default)] diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index d1ce849c04..ddb11c4520 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -152,7 +152,7 @@ impl App { if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } - if let Some(command) = summary.resume_command { + if let Some(command) = summary.resume_hint { let spans = vec![ "To continue this session, run ".into(), command.cyan(), diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 75af2f1da9..1a57b596b0 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -473,7 +473,7 @@ impl App { if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } - if let Some(command) = summary.resume_command { + if let Some(command) = summary.resume_hint { let spans = vec!["To continue this session, run ".into(), command.cyan()]; lines.push(spans.into()); } @@ -701,7 +701,7 @@ impl App { if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } - if let Some(command) = summary.resume_command { + if let Some(command) = summary.resume_hint { let spans = vec!["To continue this session, run ".into(), command.cyan()]; lines.push(spans.into()); diff --git a/codex-rs/tui/src/app/tests/session_summary.rs b/codex-rs/tui/src/app/tests/session_summary.rs index 2a96f9ad3d..63ff92a097 100644 --- a/codex-rs/tui/src/app/tests/session_summary.rs +++ b/codex-rs/tui/src/app/tests/session_summary.rs @@ -57,13 +57,13 @@ async fn session_summary_includes_resume_hint_for_persisted_rollout() { Some("Token usage: total=12 input=10 output=2".to_string()) ); assert_eq!( - summary.resume_command, + summary.resume_hint, Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) ); } #[tokio::test] -async fn session_summary_uses_id_even_when_thread_has_name() { +async fn session_summary_names_picker_item_when_thread_has_name() { let usage = TokenUsage { input_tokens: 10, output_tokens: 2, @@ -83,7 +83,10 @@ async fn session_summary_uses_id_even_when_thread_has_name() { ) .expect("summary"); assert_eq!( - summary.resume_command, - Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + summary.resume_hint, + Some( + "codex resume, then select my-session (123e4567-e89b-12d3-a456-426614174000)" + .to_string() + ) ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8c84748dde..d420162914 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -166,7 +166,7 @@ use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_cli::resume_command; +use codex_utils_cli::resume_hint; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -1440,16 +1440,14 @@ impl ChatWidget { } fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { - let resume_cmd = - resume_command(Some(name), thread_id).unwrap_or_else(|| format!("codex resume {name}")); - let name = name.to_string(); - let line = vec![ + let mut line = vec![ "• ".into(), "Thread renamed to ".into(), - name.cyan(), - ", to resume this thread run ".into(), - resume_cmd.cyan(), + name.to_string().cyan(), ]; + if let Some(hint) = resume_hint(Some(name), thread_id) { + line.extend([". To resume this thread run ".into(), hint.cyan()]); + } PlainHistoryCell::new(vec![line.into()]) } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap new file mode 100644 index 0000000000..b3cdf89da3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: rendered +--- +• Thread renamed to review-fix. To resume this thread run codex resume, then select review-fix (123e4567-e89b-12d3-a456-426614174000) diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 688e30bd17..91c5b1d1fc 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -861,7 +861,8 @@ async fn live_app_server_invalid_thread_name_update_is_ignored() { #[tokio::test] async fn live_app_server_thread_name_update_shows_resume_hint() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let thread_id = ThreadId::new(); + let thread_id = + ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id"); chat.thread_id = Some(thread_id); chat.handle_server_notification( @@ -878,8 +879,7 @@ async fn live_app_server_thread_name_update_shows_resume_hint() { let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); let rendered = lines_to_single_string(&cells[0]); - assert!(rendered.contains("Thread renamed to review-fix")); - assert!(rendered.contains("codex resume review-fix")); + assert_chatwidget_snapshot!("thread_name_update_resume_hint", rendered); } #[tokio::test] diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index e41d5f5a7d..9c69c7c81a 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -7,13 +7,14 @@ use codex_tui::Cli; use codex_tui::ExitReason; use codex_tui::run_main; use codex_utils_cli::CliConfigOverrides; -use codex_utils_cli::resume_command; +use codex_utils_cli::resume_hint; use supports_color::Stream; fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { let AppExitInfo { token_usage, thread_id, + thread_name, .. } = exit_info; @@ -22,7 +23,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec> { let reference = state.relative_time_reference.unwrap_or_else(Utc::now); - let session = row - .thread_name - .as_deref() - .map(str::to_string) - .or_else(|| row.thread_id.map(|thread_id| thread_id.to_string())) - .unwrap_or_else(|| "-".to_string()); + let session = match (row.thread_name.as_deref(), row.thread_id) { + (Some(thread_name), Some(thread_id)) => format!("{thread_name} ({thread_id})"), + (Some(thread_name), None) => thread_name.to_string(), + (None, Some(thread_id)) => thread_id.to_string(), + (None, None) => "-".to_string(), + }; let directory = row .cwd .as_ref() @@ -3387,7 +3387,9 @@ mod tests { let expected_directory = format_directory_display(row.cwd.as_deref().expect("cwd"), /*max_width*/ None); - assert!(rendered.contains("Session: feat(tui): add raw scrollback mode")); + assert!(rendered.contains( + "Session: feat(tui): add raw scrollback mode (019dabc1-0ef5-7431-b81c-03037f51f62c)" + )); assert!(rendered.contains("Created: 17 minutes ago · 2026-05-02 14:31:08")); assert!(rendered.contains("Updated: now · 2026-05-02 14:48:19")); assert!(rendered.contains(&format!("Directory: {expected_directory}"))); diff --git a/codex-rs/utils/cli/src/lib.rs b/codex-rs/utils/cli/src/lib.rs index 32ef52e095..633638e72d 100644 --- a/codex-rs/utils/cli/src/lib.rs +++ b/codex-rs/utils/cli/src/lib.rs @@ -10,5 +10,6 @@ pub use codex_protocol::config_types::ProfileV2Name; pub use config_override::CliConfigOverrides; pub use format_env_display::format_env_display; pub use resume_command::resume_command; +pub use resume_command::resume_hint; pub use sandbox_mode_cli_arg::SandboxModeCliArg; pub use shared_options::SharedCliOptions; diff --git a/codex-rs/utils/cli/src/resume_command.rs b/codex-rs/utils/cli/src/resume_command.rs index 52b88e0e33..d3c6839057 100644 --- a/codex-rs/utils/cli/src/resume_command.rs +++ b/codex-rs/utils/cli/src/resume_command.rs @@ -19,6 +19,16 @@ pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> }) } +pub fn resume_hint(thread_name: Option<&str>, thread_id: Option) -> Option { + let thread_id = thread_id?; + match thread_name.filter(|name| !name.is_empty()) { + Some(thread_name) => Some(format!( + "codex resume, then select {thread_name} ({thread_id})" + )), + None => resume_command(/*thread_name*/ None, Some(thread_id)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -61,4 +71,33 @@ mod tests { let command = resume_command(Some("quote'case"), /*thread_id*/ None); assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); } + + #[test] + fn resume_hint_names_picker_item_with_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let hint = resume_hint(Some("my-thread"), Some(thread_id)); + assert_eq!( + hint, + Some( + "codex resume, then select my-thread (123e4567-e89b-12d3-a456-426614174000)" + .to_string() + ) + ); + } + + #[test] + fn resume_hint_uses_direct_id_command_without_name() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let hint = resume_hint(/*thread_name*/ None, Some(thread_id)); + assert_eq!( + hint, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); + } + + #[test] + fn resume_hint_requires_thread_id() { + let hint = resume_hint(Some("my-thread"), /*thread_id*/ None); + assert_eq!(hint, None); + } } From 53a1f4c29eeef868202030bc8b117152d98f7b57 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:33:13 -0700 Subject: [PATCH 31/53] TUI: route elicitation responses to request thread (#23241) ## Why Fixes #21894. When the TUI handles an MCP elicitation, the request payload already includes the thread that generated the elicitation. `ChatWidget::handle_elicitation_request_now` was ignoring that value and using the currently visible chat thread instead. In a multi-session TUI, that can send `resolve_elicitation` to an older visible thread rather than the session that owns the pending elicitation, producing `elicitation request not found` and leaving the prompt unresolved. ## What changed - Parse `McpServerElicitationRequestParams.thread_id` in the ChatWidget elicitation handler and use it for app-link, form, fallback approval, and auto-decline resolution paths. - Keep the existing visible-thread fallback only for malformed request payloads with an invalid thread id. - Update the invalid URL elicitation regression test so the visible thread and request thread intentionally differ. --- codex-rs/tui/src/chatwidget/tests/app_server.rs | 9 +++++---- codex-rs/tui/src/chatwidget/tool_requests.rs | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 91c5b1d1fc..0b5f989141 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -4,13 +4,14 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn invalid_url_elicitation_is_declined() { let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; - let thread_id = ThreadId::new(); - chat.thread_id = Some(thread_id); + let visible_thread_id = ThreadId::new(); + let request_thread_id = ThreadId::new(); + chat.thread_id = Some(visible_thread_id); chat.handle_elicitation_request_now( codex_app_server_protocol::RequestId::Integer(9), codex_app_server_protocol::McpServerElicitationRequestParams { - thread_id: thread_id.to_string(), + thread_id: request_thread_id.to_string(), turn_id: Some("turn-auth".to_string()), server_name: "payments".to_string(), request: codex_app_server_protocol::McpServerElicitationRequest::Url { @@ -33,7 +34,7 @@ async fn invalid_url_elicitation_is_declined() { content: None, meta: None, }, - }) if op_thread_id == thread_id && server_name == "payments" + }) if op_thread_id == request_thread_id && server_name == "payments" ); } diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index de8fe6a704..c5c425b64f 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -341,7 +341,8 @@ impl ChatWidget { server_name: params.server_name.clone(), }); - let thread_id = self.thread_id.unwrap_or_default(); + let thread_id = ThreadId::from_string(¶ms.thread_id) + .unwrap_or_else(|_| self.thread_id.unwrap_or_default()); if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( thread_id, ¶ms.server_name, From ae03d073b3e0d4227b8646e09872baa157408cdf Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:34:31 -0700 Subject: [PATCH 32/53] TUI: replay in-progress MCP calls as started (#23236) Fixes #22300. ## Summary MCP tool calls can appear in thread history while still in progress. During replay, `handle_thread_item` routed every `ThreadItem::McpToolCall` to the completion handler, so an in-progress item with no result or error was rendered as `MCP tool call completed without a result`. This updates replay handling to mirror command executions: `InProgress` MCP calls go through `on_mcp_tool_call_started`, while completed and failed calls continue through the completion path. ## Validation - `cargo test -p codex-tui replayed_in_progress_mcp_tool_call_stays_active` --- codex-rs/tui/src/chatwidget/replay.rs | 4 +++ .../src/chatwidget/tests/history_replay.rs | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index 294e07e33a..f0211aaca1 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -133,6 +133,10 @@ impl ChatWidget { .. } => {} item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item), + item @ ThreadItem::McpToolCall { + status: codex_app_server_protocol::McpToolCallStatus::InProgress, + .. + } => self.on_mcp_tool_call_started(item), item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), ThreadItem::WebSearch { id, query, action } => { self.on_web_search_begin(id.clone()); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 36b3e0189e..628c5abb43 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -826,6 +826,33 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { assert!(rendered.contains("Raw reasoning")); } +#[tokio::test] +async fn replayed_in_progress_mcp_tool_call_stays_active() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let _ = drain_insert_history(&mut rx); + + chat.replay_thread_item( + AppServerThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "copilot-bridge".to_string(), + tool: "copilot".to_string(), + status: codex_app_server_protocol::McpToolCallStatus::InProgress, + arguments: json!({"action": "wait"}), + mcp_app_resource_uri: None, + result: None, + error: None, + duration_ms: None, + }, + "turn-1".to_string(), + ReplayKind::ThreadSnapshot, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); + let active = active_blob(&chat); + assert!(active.contains("Calling")); + assert!(!active.contains("MCP tool call completed without a result")); +} + #[tokio::test] async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 55f6bbc6672a97efe1321318120b2054bf6b841f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:58:40 -0700 Subject: [PATCH 33/53] goals: keep pause transitions explicit (#23088) ## Problem This addresses several user-reported cases where active goals were paused even though the user had not explicitly asked for that transition: - the guardian approval-review circuit breaker interrupted a turn and implicitly paused the goal - a shutdown in one app-server instance could pause a goal while a second instance was still actively running the same thread - steering-style interrupts could also pause the goal even though they are meant to redirect work, not stop the goal lifecycle The common problem was that core treated `TurnAbortReason::Interrupted` as an implicit request to transition the persisted goal to `paused`. That made unrelated interrupt paths mutate goal state as a side effect, and in the multi-app-server case it allowed stale process teardown to pause a live goal owned by another running client. After this change, transitioning a goal to `paused` is always an explicit action performed by a client or another intentional goal-state mutation. It is never an implicit transition triggered by generic interrupt handling. Refs #22884. ## What changed - Remove the goal runtime path that paused active goals after interrupted task aborts. - Drop the now-unused abort reason from `GoalRuntimeEvent::TaskAborted`. - Update the focused regression coverage so an interrupted active goal still accounts usage but remains `active`. --- codex-rs/core/src/goals.rs | 78 ++---------------------------- codex-rs/core/src/session/mod.rs | 1 - codex-rs/core/src/session/tests.rs | 32 +++++++++++- codex-rs/core/src/tasks/mod.rs | 2 - 4 files changed, 35 insertions(+), 78 deletions(-) diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 0f3b8e36eb..18b7c77bda 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -26,13 +26,11 @@ use codex_otel::GOAL_USAGE_LIMITED_METRIC; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; -use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; use codex_protocol::protocol::TokenUsage; -use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::validate_thread_goal_objective; use codex_rollout::state_db::reconcile_rollout; use codex_thread_store::LocalThreadStore; @@ -156,7 +154,6 @@ pub(crate) enum GoalRuntimeEvent<'a> { MaybeContinueIfIdle, TaskAborted { turn_context: Option<&'a TurnContext>, - reason: TurnAbortReason, }, UsageLimitReached { turn_context: &'a TurnContext, @@ -337,8 +334,8 @@ impl Session { /// starts capture the active goal and token baseline, tool completions /// account usage and may inject budget steering, completion accounting /// suppresses that steering, external mutations account best-effort before - /// changing state, interrupts pause active goals, thread resumes restore - /// runtime state for already-active goals, explicit maybe-continue events + /// changing state, thread resumes restore runtime state for already-active + /// goals, explicit maybe-continue events /// start idle goal continuation turns, and continuation turns with no counted /// autonomous activity suppress the next automatic continuation until /// user/tool/external activity resets it. @@ -390,12 +387,8 @@ impl Session { self.maybe_continue_goal_if_idle_runtime().await; Ok(()) }), - GoalRuntimeEvent::TaskAborted { - turn_context, - reason, - } => Box::pin(async move { - self.handle_thread_goal_task_abort(turn_context, reason) - .await; + GoalRuntimeEvent::TaskAborted { turn_context } => Box::pin(async move { + self.handle_thread_goal_task_abort(turn_context).await; Ok(()) }), GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move { @@ -947,11 +940,7 @@ impl Session { } } - async fn handle_thread_goal_task_abort( - &self, - turn_context: Option<&TurnContext>, - reason: TurnAbortReason, - ) { + async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) { if let Some(turn_context) = turn_context { self.take_thread_goal_continuation_turn(&turn_context.sub_id) .await; @@ -974,12 +963,6 @@ impl Session { accounting.turn = None; } } - - if reason == TurnAbortReason::Interrupted - && let Err(err) = self.pause_active_thread_goal_for_interrupt().await - { - tracing::warn!("failed to pause active thread goal after interrupt: {err}"); - } } async fn account_thread_goal_progress( @@ -1187,57 +1170,6 @@ impl Session { } } - async fn pause_active_thread_goal_for_interrupt(&self) -> anyhow::Result<()> { - if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) { - return Ok(()); - } - - if !self.enabled(Feature::Goals) { - return Ok(()); - } - - let _continuation_guard = self - .goal_runtime - .continuation_lock - .acquire() - .await - .context("goal continuation semaphore closed")?; - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::ThreadGoalAccountingMode::ActiveStatusOnly, - TerminalMetricEmission::Emit, - ) - .await?; - let Some(goal) = state_db - .thread_goals() - .pause_active_thread_goal(self.conversation_id) - .await? - else { - return Ok(()); - }; - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .clear_active_goal(); - self.send_event_raw(Event { - id: uuid::Uuid::new_v4().to_string(), - msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.conversation_id, - turn_id: None, - goal, - }), - }) - .await; - Ok(()) - } - async fn usage_limit_active_thread_goal_for_turn( &self, turn_context: &TurnContext, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 315dc93c6f..02d9e75d4d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3355,7 +3355,6 @@ impl Session { pub async fn interrupt_task(self: &Arc) { info!("interrupt received: abort current task, if any"); let had_active_turn = self.active_turn.lock().await.is_some(); - // Even without an active task, interrupt handling pauses any active goal. self.abort_all_tasks(TurnAbortReason::Interrupted).await; if !had_active_turn { self.cancel_mcp_startup().await; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d3f6917775..dbdc8aa8dd 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8243,7 +8243,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { } #[tokio::test] -async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> { +async fn interrupt_accounts_active_goal_without_pausing() -> anyhow::Result<()> { let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; sess.set_thread_goal( tc.as_ref(), @@ -8273,7 +8273,7 @@ async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> { .await? .expect("goal should remain persisted after interrupt"); assert_eq!( - codex_protocol::protocol::ThreadGoalStatus::Paused, + codex_protocol::protocol::ThreadGoalStatus::Active, goal.status ); assert_eq!(70, goal.tokens_used); @@ -8283,6 +8283,34 @@ async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn shutdown_without_active_turn_keeps_active_goal_active() -> anyhow::Result<()> { + let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + sess.set_thread_goal( + tc.as_ref(), + SetGoalRequest { + objective: Some("Keep improving the benchmark".to_string()), + status: None, + token_budget: None, + }, + ) + .await?; + + assert!(sess.active_turn.lock().await.is_none()); + assert!(handlers::shutdown(&sess, "shutdown".to_string()).await); + + let goal = sess + .get_thread_goal() + .await? + .expect("goal should remain persisted after shutdown"); + assert_eq!( + codex_protocol::protocol::ThreadGoalStatus::Active, + goal.status + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 7724d5f2d7..18f650e925 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -513,7 +513,6 @@ impl Session { && let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { turn_context: turn_context.as_deref(), - reason: reason.clone(), }) .await { @@ -561,7 +560,6 @@ impl Session { if let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { turn_context: turn_context.as_deref(), - reason: reason.clone(), }) .await { From 8e52578e665b525940adc8fd62e71446baf25b41 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 16:04:41 -0300 Subject: [PATCH 34/53] feat(tui): handle paste in session picker (#23338) ## Why The session picker already supports typed search, but it ignored bracketed paste events entirely. On macOS terminals this makes pasted text look like a no-op on the resume screen, which is especially noticeable when a user wants to paste part of a thread name, branch, or path into the search field. ## What Changed - route `TuiEvent::Paste(String)` into the session picker instead of dropping it - normalize pasted search text into a single-line query by collapsing whitespace - ignore whitespace-only pastes - reuse the existing `set_query(...)` path so pasted searches keep the same filtering and pagination behavior as typed input - add focused tests for append behavior, whitespace normalization, whitespace-only paste, and the existing search-loading path This PR is stacked on top of #23234 and contains only the net change relative to `etraut/clarify-resume-hints`. ## How to Test 1. Start Codex in a terminal that emits bracketed paste, for example iTerm2 on macOS. 2. Open the resume picker so the search UI is visible. 3. Copy a term that should match one of the visible sessions, then paste it into the picker. 4. Confirm the query updates immediately and the list filters as if the text had been typed. 5. Also verify that pasting text with newlines or tabs still produces a usable single-line search query. 6. Also verify that normal typed search still works and that `Esc` still clears the query / exits as before. Targeted tests: - `cargo test -p codex-tui` --------- Co-authored-by: Eric Traut --- codex-rs/tui/src/resume_picker.rs | 105 +++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 655c0abd9a..c7be652f85 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -477,6 +477,9 @@ async fn run_session_picker_with_loader( return Ok(sel); } } + TuiEvent::Paste(pasted) => { + state.handle_paste(pasted); + } TuiEvent::Draw | TuiEvent::Resize => { if let Ok(size) = alt.tui.terminal.size() { let list_height = @@ -489,7 +492,6 @@ async fn run_session_picker_with_loader( state.open_pending_transcript_if_ready(); } } - _ => {} } } Some(event) = background_events.next() => { @@ -543,6 +545,11 @@ fn picker_cwd_filter( } } +fn normalize_pasted_query(pasted: &str) -> Option { + let normalized = pasted.split_whitespace().collect::>().join(" "); + (!normalized.is_empty()).then_some(normalized) +} + fn spawn_app_server_page_loader( app_server: AppServerSession, include_non_interactive: bool, @@ -1227,6 +1234,21 @@ impl PickerState { Ok(None) } + fn handle_paste(&mut self, pasted: String) { + if self.is_transcript_loading() { + return; + } + let Some(pasted) = normalize_pasted_query(&pasted) else { + return; + }; + let mut new_query = self.query.clone(); + if !new_query.is_empty() && !new_query.ends_with(char::is_whitespace) { + new_query.push(' '); + } + new_query.push_str(&pasted); + self.set_query(new_query); + } + fn start_initial_load(&mut self) { self.relative_time_reference = Some(Utc::now()); self.reset_pagination(); @@ -6218,6 +6240,87 @@ session_picker_view = "dense" assert!(state.pagination.reached_scan_cap); } + #[tokio::test] + async fn paste_appends_to_existing_query() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("resize"); + + state.handle_paste(String::from("results")); + + assert_eq!(state.query, "resize results"); + } + + #[test] + fn normalize_pasted_query_collapses_whitespace() { + assert_eq!( + normalize_pasted_query(" alpha\n\tbeta\r\n gamma "), + Some(String::from("alpha beta gamma")) + ); + } + + #[tokio::test] + async fn whitespace_only_paste_is_ignored() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("resize"); + + state.handle_paste(String::from(" \n\t ")); + + assert_eq!(state.query, "resize"); + } + + #[tokio::test] + async fn paste_uses_existing_search_loading_path() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.reset_pagination(); + state.ingest_page(page( + vec![make_row( + "/tmp/start.jsonl", + "2025-01-01T00:00:00Z", + "alpha", + )], + Some("2025-01-02T00:00:00Z"), + /*num_scanned_files*/ 1, + /*reached_scan_cap*/ false, + )); + recorded_requests.lock().unwrap().clear(); + + state.handle_paste(String::from("target")); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(state.query, "target"); + assert_eq!(guard.len(), 1); + assert!(guard[0].search_token.is_some()); + } + #[tokio::test] async fn esc_with_empty_query_starts_fresh() { let loader = page_only_loader(|_| {}); From 139365a4bbe7b956b6be428d5bfeb6f658a3156d Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 18 May 2026 12:12:14 -0700 Subject: [PATCH 35/53] feat(app-server): add optional thread_id to experimentalFeature/list (#23335) ## Why `experimentalFeature/list` reports effective feature enablement, but currently does not resolve it against a working directory where project-local config.toml files can exist and toggle on/off features when merged into the effective config after resolving the various config layers. That means we effectively (and incorrectly) ignore features set in project-local config. To address that, this PR exposes an optional `thread_id` param which allows us to load the thread's `cwd. ## Testing - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server experimental_feature_list` --- .../schema/json/ClientRequest.json | 7 ++ .../codex_app_server_protocol.schemas.json | 7 ++ .../codex_app_server_protocol.v2.schemas.json | 7 ++ .../v2/ExperimentalFeatureListParams.json | 7 ++ .../v2/ExperimentalFeatureListParams.ts | 8 +- .../src/protocol/common.rs | 28 ++++- .../src/protocol/v2/experimental_feature.rs | 5 + codex-rs/app-server/README.md | 2 +- .../request_processors/catalog_processor.rs | 24 ++++- .../suite/v2/experimental_feature_list.rs | 101 ++++++++++++++++++ 10 files changed, 191 insertions(+), 5 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6678d9c0d1..b976c9e4ef 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -611,6 +611,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c4bb4a2ff4..fdaaf3e857 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8194,6 +8194,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c176c2a4b8..e413d79234 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4583,6 +4583,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json index ab562edbf2..13fa2efb98 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json @@ -16,6 +16,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts index 0a1555d998..c98425b849 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts @@ -10,4 +10,10 @@ cursor?: string | null, /** * Optional page size; defaults to a reasonable server-side value. */ -limit?: number | null, }; +limit?: number | null, +/** + * Optional loaded thread id. Pass this when showing feature state for an + * existing thread so enablement is computed from that thread's refreshed + * config, including project-local config for the thread's cwd. + */ +threadId?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index a08de3e528..4e6cef308e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2721,7 +2721,33 @@ mod tests { "id": 8, "params": { "cursor": null, - "limit": null + "limit": null, + "threadId": null + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_list_experimental_features_with_thread_id() -> Result<()> { + let request = ClientRequest::ExperimentalFeatureList { + request_id: RequestId::Integer(8), + params: v2::ExperimentalFeatureListParams { + cursor: Some("3".to_string()), + limit: Some(2), + thread_id: Some("00000000-0000-4000-8000-000000000001".to_string()), + }, + }; + assert_eq!( + json!({ + "method": "experimentalFeature/list", + "id": 8, + "params": { + "cursor": "3", + "limit": 2, + "threadId": "00000000-0000-4000-8000-000000000001" } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs index 6adc21b6ef..a16f45216b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs @@ -14,6 +14,11 @@ pub struct ExperimentalFeatureListParams { /// Optional page size; defaults to a reasonable server-side value. #[ts(optional = nullable)] pub limit: Option, + /// Optional loaded thread id. Pass this when showing feature state for an + /// existing thread so enablement is computed from that thread's refreshed + /// config, including project-local config for the thread's cwd. + #[ts(optional = nullable)] + pub thread_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5fa61b3c17..4f9cb7c344 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -188,7 +188,7 @@ Example with notification opt-out: - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. -- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 91fabf7616..834f70654d 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -285,8 +285,28 @@ impl CatalogRequestProcessor { &self, params: ExperimentalFeatureListParams, ) -> Result { - let ExperimentalFeatureListParams { cursor, limit } = params; - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let ExperimentalFeatureListParams { + cursor, + limit, + thread_id, + } = params; + let config = match thread_id.as_deref() { + Some(thread_id) => { + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + let thread_config = thread.config().await; + self.config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))? + } + None => self.load_latest_config(/*fallback_cwd*/ None).await?, + }; let auth = self.auth_manager.auth().await; let workspace_codex_plugins_enabled = self .workspace_codex_plugins_enabled(&config, auth.as_ref()) diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index d50683cf80..279fa60da1 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -3,6 +3,7 @@ use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::ConfigReadParams; @@ -16,6 +17,8 @@ use codex_app_server_protocol::ExperimentalFeatureStage; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; @@ -156,6 +159,104 @@ async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_ Ok(()) } +#[tokio::test] +async fn experimental_feature_list_resolves_thread_project_config() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let server_uri = server.uri(); + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "mock_provider" + +[projects."{workspace_key}"] +trust_level = "trusted" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + )?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#"[features] +memories = true +"#, + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let ThreadStartResponse { thread, .. } = + read_response::(&mut mcp, thread_start_id).await?; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams { + cursor: None, + limit: None, + thread_id: Some(thread.id), + }) + .await?; + + let actual = read_response::(&mut mcp, request_id).await?; + let memories = actual + .data + .iter() + .find(|feature| feature.name == "memories") + .expect("memories feature should be present"); + assert!(memories.enabled); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_list_rejects_unknown_thread_id() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams { + cursor: None, + limit: None, + thread_id: Some("00000000-0000-4000-8000-000000000001".to_string()), + }) + .await?; + let JSONRPCError { error, .. } = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.code, -32600); + assert!( + error + .message + .contains("thread not found: 00000000-0000-4000-8000-000000000001"), + "{}", + error.message + ); + + Ok(()) +} + #[tokio::test] async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() -> Result<()> { From 10f7dc6eb544a076af0cd45748bf43636ed3e0e0 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 18 May 2026 12:26:10 -0700 Subject: [PATCH 36/53] codex: route global AGENTS reads through LOCAL_FS (#23343) ## Summary - make `load_global_instructions` read through an `ExecutorFileSystem` - call global AGENTS reads with explicit `LOCAL_FS` so they stay tied to local codex-home state ## Validation - `bazel test --bes_backend= --bes_results_url= --test_filter=instruction_sources_include_global_before_agents_md_docs //codex-rs/core:core-unit-tests` on `dev` --- codex-rs/core/src/agents_md.rs | 14 +++++++++----- codex-rs/core/src/config/mod.rs | 6 ++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 7a9fd74932..2c635e7002 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -23,6 +23,7 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize as normalize_path; @@ -58,13 +59,14 @@ impl<'a> AgentsMdManager<'a> { Self { config } } - pub(crate) fn load_global_instructions( + pub(crate) async fn load_global_instructions( + fs: &dyn ExecutorFileSystem, codex_dir: Option<&AbsolutePathBuf>, ) -> Option { let base = codex_dir?; for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] { let path = base.join(candidate); - if let Ok(contents) = std::fs::read_to_string(&path) { + if let Ok(contents) = fs.read_file_text(&path, /*sandbox*/ None).await { let trimmed = contents.trim(); if !trimmed.is_empty() { return Some(LoadedAgentsMd { @@ -128,9 +130,11 @@ impl<'a> AgentsMdManager<'a> { /// Returns all instruction source files included in the current config. pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut paths = Self::load_global_instructions(Some(&self.config.codex_home)) - .map(|loaded| vec![loaded.path]) - .unwrap_or_default(); + let mut paths = + Self::load_global_instructions(LOCAL_FS.as_ref(), Some(&self.config.codex_home)) + .await + .map(|loaded| vec![loaded.path]) + .unwrap_or_default(); match self.agents_md_paths(fs).await { Ok(agents_md_paths) => paths.extend(agents_md_paths), Err(err) => { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 71a55b98dc..6eb66c4543 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2417,8 +2417,10 @@ impl Config { guardian_policy_config_source: _, } = config_layer_stack.requirements().clone(); - let user_instructions = AgentsMdManager::load_global_instructions(Some(&codex_home)) - .map(|loaded| loaded.contents); + let user_instructions = + AgentsMdManager::load_global_instructions(LOCAL_FS.as_ref(), Some(&codex_home)) + .await + .map(|loaded| loaded.contents); let mut startup_warnings = config_layer_stack .startup_warnings() .unwrap_or_default() From 4dbca61e200d0e1f048e875e87dd3f12a69f95c3 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 18 May 2026 12:41:10 -0700 Subject: [PATCH 37/53] fix: default unknown tool schemas to empty schemas (#22380) ## Why Some tool providers, especially MCP servers and dynamic tool sources, can supply schema nodes that omit `type` and have no recognized JSON Schema shape hints. Previously, `sanitize_json_schema` filled those unknown nodes in as `string`, which made the schema parseable but invented a scalar constraint that the provider did not specify. For description-only fields, that could incorrectly steer tool arguments away from the provider's actual accepted shape. The Responses API accepts permissive empty schemas such as `{}` at nested property positions, so Codex should preserve that permissive meaning instead of coercing unknown schema nodes into a misleading scalar type. ## What Changed - Changed the no-hints fallback in `codex-rs/tools/src/json_schema.rs` to clear unrecognized object schema nodes to `{}`. - Empty schemas now remain `{}` rather than becoming `type: "string"`. - Description-only or otherwise metadata-only nested property schemas now become `{}` while surrounding object/array/string/number inference still applies when recognized hints are present. - Updated `codex-tools` and `codex-core` tests to cover top-level empty schemas, nested empty schemas, metadata-only malformed schemas, dynamic tools, and MCP tool specs. ## Verification - `cargo test -p codex-tools` - `cargo test -p codex-core test_mcp_tool_property_missing_type_defaults_to_empty_schema` - Manually verified the real Responses API behavior for both empty-schema positions: - Top-level function `parameters: {}` is accepted and echoed back as `{"type":"object","properties":{}}`; when forced to call the tool, Responses emitted empty object arguments: `"arguments": "{}"`. - Nested property schema `{}` is accepted and preserved as `{}`; when forced to call a tool with `metadata.extra`, Responses emitted `"arguments": "{\"metadata\":{\"extra\":\"codex schema sanitizer behavior\"}}"`. --- .../core/src/tools/spec_plan_model_tests.rs | 7 +- codex-rs/tools/src/dynamic_tool_tests.rs | 5 +- codex-rs/tools/src/json_schema.rs | 4 +- codex-rs/tools/src/json_schema_tests.rs | 82 +++++++++++++++++-- 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs index 86692e48df..3390d26e0d 100644 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -907,7 +907,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio } #[tokio::test] -async fn test_mcp_tool_property_missing_type_defaults_to_string() { +async fn test_mcp_tool_property_missing_type_defaults_to_empty_schema() { let config = test_config().await; let model_info = construct_model_info_offline("gpt-5.4", &config); let mut features = Features::with_defaults(); @@ -950,10 +950,7 @@ async fn test_mcp_tool_property_missing_type_defaults_to_string() { name: "search".to_string(), parameters: JsonSchema::object( /*properties*/ - BTreeMap::from([( - "query".to_string(), - JsonSchema::string(Some("search query".to_string())), - )]), + BTreeMap::from([("query".to_string(), JsonSchema::default())]), /*required*/ None, /*additional_properties*/ None ), diff --git a/codex-rs/tools/src/dynamic_tool_tests.rs b/codex-rs/tools/src/dynamic_tool_tests.rs index 077a0622ce..284ce4853b 100644 --- a/codex-rs/tools/src/dynamic_tool_tests.rs +++ b/codex-rs/tools/src/dynamic_tool_tests.rs @@ -27,10 +27,7 @@ fn parse_dynamic_tool_sanitizes_input_schema() { name: "lookup_ticket".to_string(), description: "Fetch a ticket".to_string(), input_schema: JsonSchema::object( - BTreeMap::from([( - "id".to_string(), - JsonSchema::string(Some("Ticket identifier".to_string()),), - )]), + BTreeMap::from([("id".to_string(), JsonSchema::default(),)]), /*required*/ None, /*additional_properties*/ None ), diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 22a641491e..ecd880cc7a 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -166,6 +166,7 @@ pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { @@ -228,7 +229,8 @@ fn sanitize_json_schema(value: &mut JsonValue) { { schema_types.push(JsonSchemaPrimitiveType::Number); } else { - schema_types.push(JsonSchemaPrimitiveType::String); + map.clear(); + return; } } diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 3f13df7638..5daaf048d0 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -34,7 +34,8 @@ fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { // // Expected normalization behavior: // - `properties` implies an object schema when `type` is omitted. - // - The child property keeps its description and defaults to a string type. + // - The child property has no recognized schema hints, so it is coerced to + // an empty permissive schema. let schema = parse_tool_input_schema(&serde_json::json!({ "properties": { "query": {"description": "search query"} @@ -45,16 +46,33 @@ fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { assert_eq!( schema, JsonSchema::object( - BTreeMap::from([( - "query".to_string(), - JsonSchema::string(Some("search query".to_string())), - )]), + BTreeMap::from([("query".to_string(), JsonSchema::default())]), /*required*/ None, /*additional_properties*/ None ) ); } +#[test] +fn parse_tool_input_schema_coerces_unrecognized_object_schema_to_empty_schema() { + // Example schema shape: + // { + // "description": "Ticket identifier", + // "title": "Ticket ID" + // } + // + // Expected normalization behavior: + // - Object schemas with no recognized schema hints are treated as + // malformed and coerced to the empty permissive schema. + let schema = parse_tool_input_schema(&serde_json::json!({ + "description": "Ticket identifier", + "title": "Ticket ID" + })) + .expect("parse schema"); + + assert_eq!(schema, JsonSchema::default()); +} + #[test] fn parse_tool_input_schema_preserves_integer_and_defaults_array_items() { // Example schema shape: @@ -250,16 +268,62 @@ fn parse_tool_input_schema_infers_string_from_enum_const_and_format_keywords() { } #[test] -fn parse_tool_input_schema_defaults_empty_schema_to_string() { +fn parse_tool_input_schema_preserves_empty_schema() { // Example schema shape: // {} // // Expected normalization behavior: - // - With no structural hints at all, the normalizer falls back to a - // permissive string schema. + // - An empty JSON Schema is already a valid permissive schema, so it stays + // empty rather than being rewritten as an object schema. let schema = parse_tool_input_schema(&serde_json::json!({})).expect("parse schema"); - assert_eq!(schema, JsonSchema::string(/*description*/ None)); + assert_eq!(schema, JsonSchema::default()); +} + +#[test] +fn parse_tool_input_schema_preserves_nested_empty_schema() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "metadata": { + // "properties": { + // "extra": {} + // } + // } + // } + // } + // + // Expected normalization behavior: + // - The sanitizer recurses through nested object properties. + // - The innermost `extra` field is an empty JSON Schema and stays empty. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "metadata": { + "properties": { + "extra": {} + } + } + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema::object( + BTreeMap::from([( + "metadata".to_string(), + JsonSchema::object( + BTreeMap::from([("extra".to_string(), JsonSchema::default())]), + /*required*/ None, + /*additional_properties*/ None, + ) + )]), + /*required*/ None, + /*additional_properties*/ None, + ) + ); } #[test] From c69cde3547c87c3423434ff37273dcadbcce8817 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 21:55:57 +0200 Subject: [PATCH 38/53] Add tool lifecycle extension contributor (#23309) ## Why Extensions that need to track runtime progress currently have no typed host signal for tool execution. The goal extension in particular needs to observe tool attempts without inspecting tool payloads, owning tool implementations, or staying coupled to core-only runtime plumbing. This adds a narrow lifecycle contributor API for host-owned tool execution: extensions can observe when an accepted tool call starts and how it finishes, while policy hooks and tool handlers continue to own payload rewriting, blocking, and execution. Relevant code: - [`ToolLifecycleContributor`](https://github.com/openai/codex/blob/3ad2850ffc7d8a1da19c65a92425637a59098f1b/codex-rs/ext/extension-api/src/contributors.rs#L119) defines the extension-facing observer contract. - [`tool_lifecycle.rs`](https://github.com/openai/codex/blob/3ad2850ffc7d8a1da19c65a92425637a59098f1b/codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs) defines the typed start/finish inputs, source, and outcome enums. - [`notify_tool_start` / `notify_tool_finish`](https://github.com/openai/codex/blob/3ad2850ffc7d8a1da19c65a92425637a59098f1b/codex-rs/core/src/tools/lifecycle.rs) bridges core tool dispatch into the extension registry. ## What Changed - Added `ToolLifecycleContributor` to `codex-extension-api`, including: - `ToolStartInput` - `ToolFinishInput` - `ToolCallSource` - `ToolCallOutcome` - Added registration and lookup support on `ExtensionRegistryBuilder` / `ExtensionRegistry`. - Wired core tool dispatch to notify lifecycle contributors for: - accepted tool starts - completed tool calls, including the tool output success marker - pre-tool-use blocks - failures before or after the handler runs - cancellation/abort in the parallel tool path - Registered the goal extension as a lifecycle contributor and added the outcome filter it will use for goal progress accounting. ## Test Coverage - Added `dispatch_notifies_tool_lifecycle_contributors` to cover lifecycle notification ordering and outcomes for successful and handler-failed tool calls. --- codex-rs/core/src/tools/lifecycle.rs | 98 ++++++++ codex-rs/core/src/tools/mod.rs | 1 + codex-rs/core/src/tools/parallel.rs | 234 +++++++++++++++--- codex-rs/core/src/tools/registry.rs | 66 ++++- codex-rs/core/src/tools/registry_tests.rs | 194 +++++++++++++++ codex-rs/core/src/tools/router.rs | 53 +++- .../ext/extension-api/src/contributors.rs | 23 ++ .../src/contributors/tool_lifecycle.rs | 82 ++++++ codex-rs/ext/extension-api/src/lib.rs | 6 + codex-rs/ext/extension-api/src/registry.rs | 15 ++ codex-rs/ext/goal/src/extension.rs | 39 +++ 11 files changed, 777 insertions(+), 34 deletions(-) create mode 100644 codex-rs/core/src/tools/lifecycle.rs create mode 100644 codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs diff --git a/codex-rs/core/src/tools/lifecycle.rs b/codex-rs/core/src/tools/lifecycle.rs new file mode 100644 index 0000000000..ad8b492cce --- /dev/null +++ b/codex-rs/core/src/tools/lifecycle.rs @@ -0,0 +1,98 @@ +use codex_extension_api::ToolCallOutcome; +use codex_extension_api::ToolCallSource as ExtensionToolCallSource; +use codex_extension_api::ToolFinishInput; +use codex_extension_api::ToolStartInput; +use codex_tools::ToolName; + +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use crate::tools::context::ToolCallSource; +use crate::tools::context::ToolInvocation; + +pub(crate) async fn notify_tool_start(invocation: &ToolInvocation) { + for contributor in invocation + .session + .services + .extensions + .tool_lifecycle_contributors() + { + contributor + .on_tool_start(ToolStartInput { + session_store: &invocation.session.services.session_extension_data, + thread_store: &invocation.session.services.thread_extension_data, + turn_store: invocation.turn.extension_data.as_ref(), + turn_id: invocation.turn.sub_id.as_str(), + call_id: invocation.call_id.as_str(), + tool_name: &invocation.tool_name, + source: extension_tool_call_source(invocation.source.clone()), + }) + .await; + } +} + +pub(crate) async fn notify_tool_finish(invocation: &ToolInvocation, outcome: ToolCallOutcome) { + notify_tool_finish_parts( + invocation.session.as_ref(), + invocation.turn.as_ref(), + invocation.call_id.as_str(), + &invocation.tool_name, + invocation.source.clone(), + outcome, + ) + .await; +} + +pub(crate) async fn notify_tool_aborted( + session: &Session, + turn: &TurnContext, + call_id: &str, + tool_name: &ToolName, + source: ToolCallSource, +) { + notify_tool_finish_parts( + session, + turn, + call_id, + tool_name, + source, + ToolCallOutcome::Aborted, + ) + .await; +} + +async fn notify_tool_finish_parts( + session: &Session, + turn: &TurnContext, + call_id: &str, + tool_name: &ToolName, + source: ToolCallSource, + outcome: ToolCallOutcome, +) { + for contributor in session.services.extensions.tool_lifecycle_contributors() { + contributor + .on_tool_finish(ToolFinishInput { + session_store: &session.services.session_extension_data, + thread_store: &session.services.thread_extension_data, + turn_store: turn.extension_data.as_ref(), + turn_id: turn.sub_id.as_str(), + call_id, + tool_name, + source: extension_tool_call_source(source.clone()), + outcome, + }) + .await; + } +} + +fn extension_tool_call_source(source: ToolCallSource) -> ExtensionToolCallSource { + match source { + ToolCallSource::Direct => ExtensionToolCallSource::Direct, + ToolCallSource::CodeMode { + cell_id, + runtime_tool_call_id, + } => ExtensionToolCallSource::CodeMode { + cell_id, + runtime_tool_call_id, + }, + } +} diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index dd63116353..5b7a17f428 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod events; pub(crate) mod handlers; pub(crate) mod hook_names; pub(crate) mod hosted_spec; +pub(crate) mod lifecycle; pub(crate) mod network_approval; pub(crate) mod orchestrator; pub(crate) mod parallel; diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 4c79e4b168..15954869e2 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -1,7 +1,10 @@ use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Instant; use tokio::sync::RwLock; +use tokio::task::JoinError; use tokio_util::either::Either; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; @@ -15,6 +18,7 @@ use crate::session::turn_context::TurnContext; use crate::tools::context::AbortedToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolPayload; +use crate::tools::lifecycle::notify_tool_aborted; use crate::tools::registry::AnyToolResult; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolCall; @@ -89,6 +93,12 @@ impl ToolCallRuntime { let lock = Arc::clone(&self.parallel_execution); let invocation_cancellation_token = cancellation_token.clone(); let started = Instant::now(); + let abort_session = Arc::clone(&session); + let abort_source = source.clone(); + let abort_turn = Arc::clone(&turn); + let terminal_outcome_reached = Arc::new(AtomicBool::new(false)); + let dispatch_terminal_outcome_reached = Arc::clone(&terminal_outcome_reached); + let dispatch_call = call.clone(); let dispatch_span = trace_span!( "dispatch_tool_call_with_code_mode_result", @@ -97,47 +107,69 @@ impl ToolCallRuntime { call_id = call.call_id.as_str(), aborted = false, ); + let abort_dispatch_span = dispatch_span.clone(); - let handle: AbortOnDropHandle> = + let mut handle: AbortOnDropHandle> = AbortOnDropHandle::new(tokio::spawn(async move { - tokio::select! { - _ = cancellation_token.cancelled() => { - let secs = started.elapsed().as_secs_f32().max(0.1); - dispatch_span.record("aborted", true); - Ok(Self::aborted_response(&call, secs)) - }, - res = async { - let _guard = if supports_parallel { - Either::Left(lock.read().await) - } else { - Either::Right(lock.write().await) - }; + let _guard = if supports_parallel { + Either::Left(lock.read().await) + } else { + Either::Right(lock.write().await) + }; - router - .dispatch_tool_call_with_code_mode_result( - session, - turn, - invocation_cancellation_token, - tracker, - call.clone(), - source, - ) - .instrument(dispatch_span.clone()) - .await - } => res, - } + router + .dispatch_tool_call_with_terminal_outcome( + session, + turn, + invocation_cancellation_token, + tracker, + dispatch_call, + source, + dispatch_terminal_outcome_reached, + ) + .instrument(dispatch_span.clone()) + .await })); async move { - handle.await.map_err(|err| { - FunctionCallError::Fatal(format!("tool task failed to receive: {err:?}")) - })? + tokio::select! { + res = &mut handle => res.map_err(Self::tool_task_join_error)?, + _ = cancellation_token.cancelled() => { + if terminal_outcome_reached.load(Ordering::Acquire) || handle.is_finished() { + handle.await.map_err(Self::tool_task_join_error)? + } else { + let secs = started.elapsed().as_secs_f32().max(0.1); + abort_dispatch_span.record("aborted", true); + handle.abort(); + match handle.await { + Ok(result) => result, + Err(err) if err.is_cancelled() => { + let response = Self::aborted_response(&call, secs); + notify_tool_aborted( + abort_session.as_ref(), + abort_turn.as_ref(), + call.call_id.as_str(), + &call.tool_name, + abort_source, + ) + .await; + Ok(response) + } + Err(err) => Err(Self::tool_task_join_error(err)), + } + } + }, + } } .in_current_span() } } impl ToolCallRuntime { + fn tool_task_join_error(err: JoinError) -> FunctionCallError { + FunctionCallError::Fatal(format!("tool task failed to receive: {err:?}")) + } + fn failure_response(call: ToolCall, err: FunctionCallError) -> ResponseInputItem { let message = err.to_string(); match call.payload { @@ -189,3 +221,147 @@ impl ToolCallRuntime { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + use crate::tools::context::FunctionToolOutput; + use crate::tools::context::ToolInvocation; + use crate::tools::registry::CoreToolRuntime; + use crate::tools::registry::ToolExecutor; + use crate::tools::registry::ToolRegistry; + use crate::turn_diff_tracker::TurnDiffTracker; + use codex_extension_api::ToolCallOutcome; + use codex_protocol::models::FunctionCallOutputBody; + use codex_protocol::models::FunctionCallOutputPayload; + use pretty_assertions::assert_eq; + use tokio::sync::Notify; + use tokio::sync::oneshot; + + struct ImmediateHandler { + tool_name: codex_tools::ToolName, + } + + #[async_trait::async_trait] + impl ToolExecutor for ImmediateHandler { + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + Ok(Box::new(FunctionToolOutput::from_text( + "ok".to_string(), + Some(true), + ))) + } + } + + impl CoreToolRuntime for ImmediateHandler {} + + struct BlockingFinishContributor { + records: Arc>>, + finish_started: std::sync::Mutex>>, + allow_finish: Arc, + } + + impl codex_extension_api::ToolLifecycleContributor for BlockingFinishContributor { + fn on_tool_finish<'a>( + &'a self, + input: codex_extension_api::ToolFinishInput<'a>, + ) -> codex_extension_api::ToolLifecycleFuture<'a> { + let records = Arc::clone(&self.records); + let allow_finish = Arc::clone(&self.allow_finish); + let finish_started = self + .finish_started + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + let outcome = input.outcome; + Box::pin(async move { + if let Some(finish_started) = finish_started { + let _ = finish_started.send(()); + } + allow_finish.notified().await; + records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(outcome); + }) + } + } + + #[tokio::test] + async fn cancellation_after_handler_finishes_preserves_completed_lifecycle() + -> anyhow::Result<()> { + let (mut session, turn_context) = crate::session::tests::make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let (finish_started_tx, finish_started_rx) = oneshot::channel(); + let allow_finish = Arc::new(Notify::new()); + let mut builder = + codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.tool_lifecycle_contributor(Arc::new(BlockingFinishContributor { + records: Arc::clone(&records), + finish_started: std::sync::Mutex::new(Some(finish_started_tx)), + allow_finish: Arc::clone(&allow_finish), + })); + session.services.extensions = Arc::new(builder.build()); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let tool_name = codex_tools::ToolName::plain("test_tool"); + let handler = Arc::new(ImmediateHandler { + tool_name: tool_name.clone(), + }) as Arc; + let router = Arc::new(ToolRouter::from_parts( + ToolRegistry::from_tools([handler]), + Vec::new(), + )); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let runtime = ToolCallRuntime::new(router, session, turn_context, tracker); + let cancellation_token = CancellationToken::new(); + let call = ToolCall { + tool_name, + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + + let response_task = + tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone())); + tokio::time::timeout(Duration::from_secs(1), finish_started_rx) + .await + .expect("timed out waiting for lifecycle notification to start") + .expect("lifecycle notification should start"); + cancellation_token.cancel(); + tokio::time::sleep(Duration::from_millis(10)).await; + allow_finish.notify_waiters(); + + let response = tokio::time::timeout(Duration::from_secs(1), response_task) + .await + .expect("timed out waiting for tool response") + .expect("tool response task should join")?; + let expected_response = ResponseInputItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload { + body: FunctionCallOutputBody::Text("ok".to_string()), + success: Some(true), + }, + }; + assert_eq!(expected_response, response); + + let actual = records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + .collect::>(); + assert_eq!(vec![ToolCallOutcome::Completed { success: true }], actual); + + Ok(()) + } +} diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 96b686a83a..363c3f2a01 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use crate::function_tool::FunctionCallError; @@ -18,9 +20,12 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; +use crate::tools::lifecycle::notify_tool_finish; +use crate::tools::lifecycle::notify_tool_start; use crate::tools::tool_dispatch_trace::ToolDispatchTrace; use crate::tools::tool_search_entry::ToolSearchInfo; use crate::util::error_or_panic; +use codex_extension_api::ToolCallOutcome; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; use codex_tools::ToolName; @@ -298,13 +303,23 @@ impl ToolRegistry { Some(tool.supports_parallel_tool_calls()) } + #[allow(dead_code)] + pub(crate) async fn dispatch_any( + &self, + invocation: ToolInvocation, + ) -> Result { + self.dispatch_any_with_terminal_outcome(invocation, /*terminal_outcome_reached*/ None) + .await + } + #[expect( clippy::await_holding_invalid_type, reason = "tool dispatch must keep active-turn accounting atomic" )] - pub(crate) async fn dispatch_any( + pub(crate) async fn dispatch_any_with_terminal_outcome( &self, mut invocation: ToolInvocation, + terminal_outcome_reached: Option>, ) -> Result { let tool_name = invocation.tool_name.clone(); let tool_name_flat = flat_tool_name(&tool_name); @@ -389,6 +404,8 @@ impl ToolRegistry { return Err(err); } + notify_tool_start(&invocation).await; + if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) { match run_pre_tool_use_hooks( &invocation.session, @@ -402,13 +419,33 @@ impl ToolRegistry { PreToolUseHookResult::Blocked(message) => { let err = FunctionCallError::RespondToModel(message); dispatch_trace.record_failed(&err); + if let Some(terminal_outcome_reached) = &terminal_outcome_reached { + terminal_outcome_reached.store(true, Ordering::Release); + } + notify_tool_finish(&invocation, ToolCallOutcome::Blocked).await; return Err(err); } PreToolUseHookResult::Continue { updated_input: Some(updated_input), - } => { - invocation = tool.with_updated_hook_input(invocation, updated_input)?; - } + } => match tool.with_updated_hook_input(invocation.clone(), updated_input) { + Ok(updated_invocation) => { + invocation = updated_invocation; + } + Err(err) => { + dispatch_trace.record_failed(&err); + if let Some(terminal_outcome_reached) = &terminal_outcome_reached { + terminal_outcome_reached.store(true, Ordering::Release); + } + notify_tool_finish( + &invocation, + ToolCallOutcome::Failed { + handler_executed: false, + }, + ) + .await; + return Err(err); + } + }, PreToolUseHookResult::Continue { updated_input: None, } => {} @@ -503,6 +540,27 @@ impl ToolRegistry { } } + let lifecycle_outcome = match &result { + Ok(_) => { + let guard = response_cell.lock().await; + match guard.as_ref() { + Some(result) => ToolCallOutcome::Completed { + success: result.result.success_for_logging(), + }, + None => ToolCallOutcome::Failed { + handler_executed: true, + }, + } + } + Err(_) => ToolCallOutcome::Failed { + handler_executed: true, + }, + }; + if let Some(terminal_outcome_reached) = &terminal_outcome_reached { + terminal_outcome_reached.store(true, Ordering::Release); + } + notify_tool_finish(&invocation, lifecycle_outcome).await; + if let Err(err) = invocation .session .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index defacf33c0..e3ecfc8f98 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -23,6 +23,97 @@ impl ToolExecutor for TestHandler { impl CoreToolRuntime for TestHandler {} +#[derive(Clone)] +enum LifecycleTestResult { + Ok { success: bool }, + Err, +} + +struct LifecycleTestHandler { + tool_name: codex_tools::ToolName, + result: LifecycleTestResult, +} + +#[async_trait::async_trait] +impl ToolExecutor for LifecycleTestHandler { + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + match self.result.clone() { + LifecycleTestResult::Ok { success } => Ok(Box::new( + crate::tools::context::FunctionToolOutput::from_text( + "ok".to_string(), + Some(success), + ), + )), + LifecycleTestResult::Err => Err(FunctionCallError::RespondToModel( + "handler failed".to_string(), + )), + } + } +} + +impl CoreToolRuntime for LifecycleTestHandler {} + +#[derive(Debug, PartialEq, Eq)] +enum RecordedToolLifecycle { + Start { + call_id: String, + tool_name: codex_tools::ToolName, + }, + Finish { + call_id: String, + tool_name: codex_tools::ToolName, + outcome: codex_extension_api::ToolCallOutcome, + }, +} + +struct ToolLifecycleRecorder { + records: Arc>>, +} + +impl codex_extension_api::ToolLifecycleContributor for ToolLifecycleRecorder { + fn on_tool_start<'a>( + &'a self, + input: codex_extension_api::ToolStartInput<'a>, + ) -> codex_extension_api::ToolLifecycleFuture<'a> { + let records = Arc::clone(&self.records); + let record = RecordedToolLifecycle::Start { + call_id: input.call_id.to_string(), + tool_name: input.tool_name.clone(), + }; + Box::pin(async move { + records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(record); + }) + } + + fn on_tool_finish<'a>( + &'a self, + input: codex_extension_api::ToolFinishInput<'a>, + ) -> codex_extension_api::ToolLifecycleFuture<'a> { + let records = Arc::clone(&self.records); + let record = RecordedToolLifecycle::Finish { + call_id: input.call_id.to_string(), + tool_name: input.tool_name.clone(), + outcome: input.outcome, + }; + Box::pin(async move { + records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(record); + }) + } +} + #[test] fn handler_looks_up_namespaced_aliases_explicitly() { let namespace = "mcp__codex_apps__gmail"; @@ -61,3 +152,106 @@ fn handler_looks_up_namespaced_aliases_explicitly() { .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) ); } + +#[tokio::test] +async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> { + let (mut session, turn) = crate::session::tests::make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.tool_lifecycle_contributor(Arc::new(ToolLifecycleRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + + let ok_tool = codex_tools::ToolName::plain("ok_tool"); + let failing_tool = codex_tools::ToolName::plain("failing_tool"); + let ok_handler = Arc::new(LifecycleTestHandler { + tool_name: ok_tool.clone(), + result: LifecycleTestResult::Ok { success: false }, + }) as Arc; + let failing_handler = Arc::new(LifecycleTestHandler { + tool_name: failing_tool.clone(), + result: LifecycleTestResult::Err, + }) as Arc; + let registry = ToolRegistry::new(HashMap::from([ + (ok_tool.clone(), ok_handler), + (failing_tool.clone(), failing_handler), + ])); + let session = Arc::new(session); + let turn = Arc::new(turn); + + registry + .dispatch_any(test_invocation( + Arc::clone(&session), + Arc::clone(&turn), + "ok-call", + ok_tool.clone(), + )) + .await?; + let err = match registry + .dispatch_any(test_invocation( + Arc::clone(&session), + Arc::clone(&turn), + "failing-call", + failing_tool.clone(), + )) + .await + { + Ok(_) => panic!("failing handler should return an error"), + Err(err) => err, + }; + assert_eq!(err.to_string(), "handler failed"); + + let expected = vec![ + RecordedToolLifecycle::Start { + call_id: "ok-call".to_string(), + tool_name: ok_tool.clone(), + }, + RecordedToolLifecycle::Finish { + call_id: "ok-call".to_string(), + tool_name: ok_tool, + outcome: codex_extension_api::ToolCallOutcome::Completed { success: false }, + }, + RecordedToolLifecycle::Start { + call_id: "failing-call".to_string(), + tool_name: failing_tool.clone(), + }, + RecordedToolLifecycle::Finish { + call_id: "failing-call".to_string(), + tool_name: failing_tool, + outcome: codex_extension_api::ToolCallOutcome::Failed { + handler_executed: true, + }, + }, + ]; + let actual = records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + .collect::>(); + assert_eq!(expected, actual); + + Ok(()) +} + +fn test_invocation( + session: Arc, + turn: Arc, + call_id: &str, + tool_name: codex_tools::ToolName, +) -> ToolInvocation { + ToolInvocation { + session, + turn, + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(tokio::sync::Mutex::new( + crate::turn_diff_tracker::TurnDiffTracker::new(), + )), + call_id: call_id.to_string(), + tool_name, + source: crate::tools::context::ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + } +} diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 2477ba347c..a279ec88d9 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -19,6 +19,7 @@ use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use tokio_util::sync::CancellationToken; use tracing::instrument; @@ -123,6 +124,7 @@ impl ToolRouter { } } + #[allow(dead_code)] #[instrument(level = "trace", skip_all, err)] pub async fn dispatch_tool_call_with_code_mode_result( &self, @@ -132,6 +134,53 @@ impl ToolRouter { tracker: SharedTurnDiffTracker, call: ToolCall, source: ToolCallSource, + ) -> Result { + self.dispatch_tool_call_with_code_mode_result_inner( + session, + turn, + cancellation_token, + tracker, + call, + source, + /*terminal_outcome_reached*/ None, + ) + .await + } + + #[instrument(level = "trace", skip_all, err)] + #[allow(clippy::too_many_arguments)] + pub(crate) async fn dispatch_tool_call_with_terminal_outcome( + &self, + session: Arc, + turn: Arc, + cancellation_token: CancellationToken, + tracker: SharedTurnDiffTracker, + call: ToolCall, + source: ToolCallSource, + terminal_outcome_reached: Arc, + ) -> Result { + self.dispatch_tool_call_with_code_mode_result_inner( + session, + turn, + cancellation_token, + tracker, + call, + source, + Some(terminal_outcome_reached), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn dispatch_tool_call_with_code_mode_result_inner( + &self, + session: Arc, + turn: Arc, + cancellation_token: CancellationToken, + tracker: SharedTurnDiffTracker, + call: ToolCall, + source: ToolCallSource, + terminal_outcome_reached: Option>, ) -> Result { let ToolCall { tool_name, @@ -150,7 +199,9 @@ impl ToolRouter { payload, }; - self.registry.dispatch_any(invocation).await + self.registry + .dispatch_any_with_terminal_outcome(invocation, terminal_outcome_reached) + .await } } diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 4aa28d1049..8968e08573 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -11,6 +11,7 @@ use crate::ExtensionData; mod prompt; mod thread_lifecycle; +mod tool_lifecycle; mod turn_lifecycle; pub use prompt::PromptFragment; @@ -18,6 +19,11 @@ pub use prompt::PromptSlot; pub use thread_lifecycle::ThreadResumeInput; pub use thread_lifecycle::ThreadStartInput; pub use thread_lifecycle::ThreadStopInput; +pub use tool_lifecycle::ToolCallOutcome; +pub use tool_lifecycle::ToolCallSource; +pub use tool_lifecycle::ToolFinishInput; +pub use tool_lifecycle::ToolLifecycleFuture; +pub use tool_lifecycle::ToolStartInput; pub use turn_lifecycle::TurnAbortInput; pub use turn_lifecycle::TurnStartInput; pub use turn_lifecycle::TurnStopInput; @@ -111,6 +117,23 @@ pub trait ToolContributor: Send + Sync { ) -> Vec>>; } +/// Contributor for host-owned tool lifecycle gates. +/// +/// Implementations should use these callbacks to observe tool execution without +/// inspecting or rewriting tool input/output. Use `ToolContributor` for owning a +/// tool implementation and hooks for policy that needs tool payloads. +pub trait ToolLifecycleContributor: Send + Sync { + /// Called once the host has accepted a tool call for execution. + fn on_tool_start<'a>(&'a self, _input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> { + Box::pin(std::future::ready(())) + } + + /// Called after the tool call returns, is blocked, fails, or is cancelled. + fn on_tool_finish<'a>(&'a self, _input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> { + Box::pin(std::future::ready(())) + } +} + /// Future returned by one claimed approval-review contribution. pub type ApprovalReviewFuture<'a> = std::pin::Pin + Send + 'a>>; diff --git a/codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs new file mode 100644 index 0000000000..486bca2643 --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs @@ -0,0 +1,82 @@ +use std::future::Future; +use std::pin::Pin; + +use codex_tools::ToolName; + +use crate::ExtensionData; + +/// Future returned by one tool-lifecycle callback. +pub type ToolLifecycleFuture<'a> = Pin + Send + 'a>>; + +/// Host-visible source for a model tool call. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ToolCallSource { + /// The model invoked the tool directly. + Direct, + /// Code mode invoked the tool while executing a runtime cell. + CodeMode { + /// Runtime cell that issued the nested tool request. + cell_id: String, + /// Code-mode's per-cell tool invocation id. + runtime_tool_call_id: String, + }, +} + +/// Extension-facing outcome for a finished tool call. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ToolCallOutcome { + /// The tool returned a normal output. + Completed { + /// The tool output's own success marker for telemetry/logging. + success: bool, + }, + /// The tool was blocked by host policy before the handler ran. + Blocked, + /// The tool did not produce a normal output. + Failed { + /// Whether the host reached the tool handler before the failure. + handler_executed: bool, + }, + /// The host cancelled the tool before normal completion. Cancellation can + /// win before the dispatch path accepts the call, so contributors should not + /// assume a matching start callback exists. + Aborted, +} + +/// Input supplied when the host starts executing one tool call. +pub struct ToolStartInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn runtime. + pub turn_store: &'a ExtensionData, + /// Current turn submission id. + pub turn_id: &'a str, + /// Model-visible tool call id. + pub call_id: &'a str, + /// Tool name as routed by the host. + pub tool_name: &'a ToolName, + /// Source that issued the tool call. + pub source: ToolCallSource, +} + +/// Input supplied when the host finishes executing one tool call. +pub struct ToolFinishInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn runtime. + pub turn_store: &'a ExtensionData, + /// Current turn submission id. + pub turn_id: &'a str, + /// Model-visible tool call id. + pub call_id: &'a str, + /// Tool name as routed by the host. + pub tool_name: &'a ToolName, + /// Source that issued the tool call. + pub source: ToolCallSource, + /// Host-observed result of the tool call. + pub outcome: ToolCallOutcome, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index fe33d42128..373f3735a4 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -28,7 +28,13 @@ pub use contributors::ThreadResumeInput; pub use contributors::ThreadStartInput; pub use contributors::ThreadStopInput; pub use contributors::TokenUsageContributor; +pub use contributors::ToolCallOutcome; +pub use contributors::ToolCallSource; pub use contributors::ToolContributor; +pub use contributors::ToolFinishInput; +pub use contributors::ToolLifecycleContributor; +pub use contributors::ToolLifecycleFuture; +pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; pub use contributors::TurnItemContributionFuture; pub use contributors::TurnItemContributor; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index 41d0967126..4577ddc048 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -10,6 +10,7 @@ use crate::NoopExtensionEventSink; use crate::ThreadLifecycleContributor; use crate::TokenUsageContributor; use crate::ToolContributor; +use crate::ToolLifecycleContributor; use crate::TurnItemContributor; use crate::TurnLifecycleContributor; @@ -22,6 +23,7 @@ pub struct ExtensionRegistryBuilder { token_usage_contributors: Vec>, context_contributors: Vec>, tool_contributors: Vec>, + tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, approval_review_contributors: Vec>, } @@ -37,6 +39,7 @@ impl Default for ExtensionRegistryBuilder { approval_review_contributors: Vec::new(), context_contributors: Vec::new(), tool_contributors: Vec::new(), + tool_lifecycle_contributors: Vec::new(), turn_item_contributors: Vec::new(), } } @@ -99,6 +102,11 @@ impl ExtensionRegistryBuilder { self.tool_contributors.push(contributor); } + /// Registers one tool-lifecycle contributor. + pub fn tool_lifecycle_contributor(&mut self, contributor: Arc) { + self.tool_lifecycle_contributors.push(contributor); + } + /// Registers one ordered turn-item contributor. pub fn turn_item_contributor(&mut self, contributor: Arc) { self.turn_item_contributors.push(contributor); @@ -115,6 +123,7 @@ impl ExtensionRegistryBuilder { approval_review_contributors: self.approval_review_contributors, context_contributors: self.context_contributors, tool_contributors: self.tool_contributors, + tool_lifecycle_contributors: self.tool_lifecycle_contributors, turn_item_contributors: self.turn_item_contributors, } } @@ -129,6 +138,7 @@ pub struct ExtensionRegistry { token_usage_contributors: Vec>, context_contributors: Vec>, tool_contributors: Vec>, + tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, approval_review_contributors: Vec>, } @@ -182,6 +192,11 @@ impl ExtensionRegistry { &self.tool_contributors } + /// Returns the registered tool-lifecycle contributors. + pub fn tool_lifecycle_contributors(&self) -> &[Arc] { + &self.tool_lifecycle_contributors + } + /// Returns the registered ordered turn-item contributors. pub fn turn_item_contributors(&self) -> &[Arc] { &self.turn_item_contributors diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index a0ce7117f4..a8d4f5c289 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -9,7 +9,11 @@ use codex_extension_api::NoopExtensionEventSink; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; use codex_extension_api::TokenUsageContributor; +use codex_extension_api::ToolCallOutcome; use codex_extension_api::ToolContributor; +use codex_extension_api::ToolFinishInput; +use codex_extension_api::ToolLifecycleContributor; +use codex_extension_api::ToolLifecycleFuture; use codex_extension_api::TurnAbortInput; use codex_extension_api::TurnLifecycleContributor; use codex_extension_api::TurnStartInput; @@ -21,6 +25,7 @@ use codex_protocol::protocol::TurnAbortReason; use crate::accounting::GoalAccountingState; use crate::events::GoalEventEmitter; +use crate::spec::UPDATE_GOAL_TOOL_NAME; use crate::tool::CreateGoalRequest; use crate::tool::GoalToolExecutor; @@ -221,6 +226,25 @@ where } } +impl ToolLifecycleContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_tool_finish<'a>(&'a self, input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> { + Box::pin(async move { + let _should_count_for_goal_progress = goal_enabled(input.thread_store) + && tool_attempt_counts_for_goal_progress(input.outcome) + && !(input.tool_name.namespace.is_none() + && input.tool_name.name == UPDATE_GOAL_TOOL_NAME); + + // TODO: commit active goal progress through host goal storage and emit + // ThreadGoalUpdated when the persisted goal changes. This replaces + // GoalRuntimeEvent::ToolCompleted once the goal extension owns runtime + // accounting. + }) + } +} + // TODO: app-server initiated goal set/clear operations need a contributor or // backend callback here. They currently happen outside thread/turn/token // lifecycle, but the goal extension must observe them to account before @@ -288,6 +312,7 @@ pub fn install_with_backend( registry.config_contributor(extension.clone()); registry.turn_lifecycle_contributor(extension.clone()); registry.token_usage_contributor(extension.clone()); + registry.tool_lifecycle_contributor(extension.clone()); registry.tool_contributor(extension); } @@ -300,3 +325,17 @@ fn goal_enabled(thread_store: &ExtensionData) -> bool { fn accounting_state(thread_store: &ExtensionData) -> Arc { thread_store.get_or_init::(GoalAccountingState::default) } + +fn tool_attempt_counts_for_goal_progress(outcome: ToolCallOutcome) -> bool { + match outcome { + ToolCallOutcome::Completed { .. } => true, + ToolCallOutcome::Failed { + handler_executed: true, + } => true, + ToolCallOutcome::Blocked + | ToolCallOutcome::Failed { + handler_executed: false, + } + | ToolCallOutcome::Aborted => false, + } +} From 732b12b1ef57f8db60db6cf8f1f343f088f08a3a Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 18 May 2026 13:06:39 -0700 Subject: [PATCH 39/53] Reduce rust-ci-full Windows nextest timeout flakes (#23253) ## Why Recent `rust-ci-full` failures were dominated by transient Windows timeout clusters in process-heavy tests such as `suite::resume`, `suite::cli_stream`, `suite::auth_env`, `start_thread_uses_all_default_environments_from_codex_home`, and `connect_stdio_command_initializes_json_rpc_client_on_windows`. The goal here is to make those known flaky paths less likely to fail full CI without relaxing the global nextest timeout policy. ## What changed - Enable one global nextest retry with `retries = 1` so a single transient failure can recover. - Add a `windows_process_heavy` test group with `max-threads = 2` for the recurring Windows subprocess/session-heavy timeout families. - Add Windows-only slow-timeout overrides for that process-heavy group. - Add a narrower Windows-only timeout override for `start_thread_uses_all_default_environments_from_codex_home`, which still exceeded the broader Windows bucket in both Windows full-CI lanes. - Increase the `rust-ci-full` nextest job timeout from `45m` to `60m` so Windows ARM64 still has job-level headroom after retries and targeted per-test timeout increases. - Keep the global `slow-timeout` unchanged at `15s`. ## Validation Validated through `rust-ci-full` GitHub Actions reruns on this PR. Observed improvement on the tuned Windows lanes: - Windows x64 went from `5 timed out` to `0 timed out`. - Windows ARM64 went from `2 timed out` to `0 timed out`. - `start_thread_uses_all_default_environments_from_codex_home` recovered as a flaky pass on Windows ARM64 instead of timing out. The remaining failing tests in those runs were unrelated hard failures outside this nextest timeout tuning. --- .github/workflows/rust-ci-full.yml | 7 +++---- codex-rs/.config/nextest.toml | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 697d4b9c37..b3c25b3f03 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -524,10 +524,9 @@ jobs: tests: name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} runs-on: ${{ matrix.runs_on || matrix.runner }} - # Perhaps we can bring this back down to 30m once we finish the cutover - # from tui_app_server/ to tui/. Incidentally, windows-arm64 was the main - # offender for exceeding the timeout. - timeout-minutes: 45 + # Windows ARM64 is the long pole here, and nextest retries plus targeted + # Windows timeout headroom need more than 45m to finish reliably. + timeout-minutes: 60 defaults: run: working-directory: codex-rs diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index cc86ee0874..399caae16a 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -1,6 +1,7 @@ [profile.default] -# Do not increase, fix your test instead +# Retry once so one transient failure does not fail full-CI outright. slow-timeout = { period = "15s", terminate-after = 2 } +retries = 1 [profile.default.junit] path = "junit.xml" @@ -17,6 +18,9 @@ max-threads = 1 [test-groups.windows_sandbox_legacy_sessions] max-threads = 1 +[test-groups.windows_process_heavy] +max-threads = 2 + [[profile.default.overrides]] # Do not add new tests here filter = 'test(rmcp_client) | test(humanlike_typing_1000_chars_appears_live_no_placeholder)' @@ -47,3 +51,18 @@ test-group = 'core_apply_patch_cli_integration' # Serialize them to avoid exhausting Windows session/global desktop resources in CI. filter = 'package(codex-windows-sandbox) & test(legacy_)' test-group = 'windows_sandbox_legacy_sessions' + +[[profile.default.overrides]] +# This Codex-home startup path still exceeded the broader Windows-heavy ceiling +# in both Windows full-CI lanes after contention was reduced. +platform = 'cfg(windows)' +filter = 'test(start_thread_uses_all_default_environments_from_codex_home)' +slow-timeout = { period = "1m", terminate-after = 2 } + +[[profile.default.overrides]] +# These Windows-heavy tests spawn subprocesses, session files, or JSON-RPC +# clients and have been the dominant source of 30s full-CI timeouts. +platform = 'cfg(windows)' +filter = 'test(suite::resume::) | test(suite::cli_stream::) | test(suite::auth_env::) | test(start_thread_uses_all_default_environments_from_codex_home) | test(connect_stdio_command_initializes_json_rpc_client_on_windows)' +test-group = 'windows_process_heavy' +slow-timeout = { period = "45s", terminate-after = 2 } From 1752f374a80b5c667efe1273bb99af9b3da27918 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 18 May 2026 13:39:02 -0700 Subject: [PATCH 40/53] Improve `codex remote-control` CLI UX (#22878) ## Description This PR makes `codex remote-control` behave like a foreground CLI command by default. Running it now starts remote control, waits for readiness, prints a clear status message with the machine name, and stays alive until Ctrl-C. Users who want daemon behavior can use `codex remote-control start`, and `codex remote-control stop` now prints concise human-readable output. `--json` remains available for scripts. Implementation-wise, this now verifies the real app-server state instead of just assuming startup worked. The CLI starts or connects to app-server, probes its control socket, calls the `remoteControl/enable` API, and waits for the remote-control status response/notification before printing success. For daemon mode, `codex remote-control start` also reports which managed app-server binary was used, including its path and best-effort `codex --version`, so failures are easier to diagnose. ## Examples Example output: ``` > codex remote-control Starting app-server with remote control enabled... This machine is available for remote control as com-97826. Press Ctrl-C to stop. ``` Error case using daemon (currently expected based on our publicly released CLI version): ``` > ./target/debug/codex remote-control start Starting app-server daemon with remote control enabled... Error: app server did not become ready on /Users/owen/.codex/app-server-control/app-server-control.sock Daemon used app-server: path: /Users/owen/.codex/packages/standalone/current/codex version: 0.130.0 Managed app-server stderr (/Users/owen/.codex/app-server-daemon/app-server.stderr.log): error: unexpected argument '--remote-control' found Usage: codex app-server [OPTIONS] [COMMAND] For more information, try '--help'. Caused by: 0: failed to connect to /Users/owen/.codex/app-server-control/app-server-control.sock 1: No such file or directory (os error 2) ``` ## What changed - `codex remote-control` now runs remote control in the foreground and prints a Ctrl-C stop hint. - `codex remote-control start` starts the daemon and waits for remote control readiness before reporting success. - `codex remote-control stop` reports stopped/not-running status in plain language. - Startup failures now include recent managed app-server stderr to make daemon issues easier to diagnose. - Added coverage for CLI output, readiness waiting, foreground shutdown, and stderr log tailing. --- codex-rs/app-server-daemon/src/backend/mod.rs | 13 + codex-rs/app-server-daemon/src/backend/pid.rs | 104 ++- .../src/backend/pid_tests.rs | 24 + codex-rs/app-server-daemon/src/client.rs | 140 ++-- codex-rs/app-server-daemon/src/lib.rs | 270 +++++-- .../src/remote_control_client.rs | 459 ++++++++++++ codex-rs/app-server/src/lib.rs | 5 +- codex-rs/cli/src/main.rs | 59 +- codex-rs/cli/src/remote_control_cmd.rs | 695 ++++++++++++++++++ 9 files changed, 1611 insertions(+), 158 deletions(-) create mode 100644 codex-rs/app-server-daemon/src/remote_control_client.rs create mode 100644 codex-rs/cli/src/remote_control_cmd.rs diff --git a/codex-rs/app-server-daemon/src/backend/mod.rs b/codex-rs/app-server-daemon/src/backend/mod.rs index 5e92dd5261..d40df07a0e 100644 --- a/codex-rs/app-server-daemon/src/backend/mod.rs +++ b/codex-rs/app-server-daemon/src/backend/mod.rs @@ -1,5 +1,6 @@ mod pid; +use std::path::Path; use std::path::PathBuf; use serde::Serialize; @@ -31,3 +32,15 @@ pub(crate) fn pid_backend(paths: BackendPaths) -> PidBackend { pub(crate) fn pid_update_loop_backend(paths: BackendPaths) -> PidBackend { PidBackend::new_update_loop(paths.codex_bin, paths.update_pid_file) } + +pub(crate) async fn append_stderr_log_tail_context(pid_file: &Path, context: &mut String) { + match pid::read_stderr_log_tail(pid_file).await { + Ok(Some(tail)) => tail.append_to_context(context), + Ok(None) => {} + Err(err) => { + context.push_str(&format!( + "\n\nFailed to read managed app-server stderr log: {err:#}" + )); + } + } +} diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs index 7f8aa1660d..f5f4fc5b66 100644 --- a/codex-rs/app-server-daemon/src/backend/pid.rs +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -1,3 +1,4 @@ +use std::io::SeekFrom; use std::path::Path; use std::path::PathBuf; #[cfg(unix)] @@ -10,6 +11,8 @@ use anyhow::bail; use serde::Deserialize; use serde::Serialize; use tokio::fs; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncSeekExt; #[cfg(unix)] use tokio::process::Command; use tokio::time::sleep; @@ -18,6 +21,7 @@ const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50); const STOP_GRACE_PERIOD: Duration = Duration::from_secs(60); const STOP_TIMEOUT: Duration = Duration::from_secs(70); const START_TIMEOUT: Duration = Duration::from_secs(10); +const STDERR_LOG_TAIL_BYTES: u64 = 4096; #[derive(Debug)] #[cfg_attr(not(unix), allow(dead_code))] @@ -35,6 +39,25 @@ struct PidRecord { process_start_time: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PidLogTail { + pub(crate) path: PathBuf, + pub(crate) contents: String, +} + +impl PidLogTail { + pub(crate) fn append_to_context(&self, context: &mut String) { + context.push_str(&format!( + "\n\nManaged app-server stderr ({}):", + self.path.display() + )); + for line in self.contents.lines() { + context.push_str("\n "); + context.push_str(line); + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum PidFileState { Missing, @@ -129,11 +152,18 @@ impl PidBackend { } }; let mut command = Command::new(&self.codex_bin); + let stderr_log = match self.open_stderr_log().await { + Ok(stderr_log) => stderr_log, + Err(err) => { + let _ = fs::remove_file(&self.pid_file).await; + return Err(err); + } + }; command .args(self.command_args()) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()); + .stderr(Stdio::from(stderr_log.into_std().await)); #[cfg(unix)] { @@ -169,8 +199,11 @@ impl PidBackend { }, Err(err) => { let _ = self.terminate_process(pid); + let mut context = + format!("failed to record pid-managed app-server process {pid} startup"); + super::append_stderr_log_tail_context(&self.pid_file, &mut context).await; let _ = fs::remove_file(&self.pid_file).await; - return Err(err); + return Err(err).context(context); } }; let contents = serde_json::to_vec(&record).context("failed to serialize pid record")?; @@ -344,6 +377,23 @@ impl PidBackend { Ok(reservation_lock) } + #[cfg(unix)] + async fn open_stderr_log(&self) -> Result { + let stderr_log_file = stderr_log_file_for_pid_file(&self.pid_file); + fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&stderr_log_file) + .await + .with_context(|| { + format!( + "failed to open stderr log for pid-managed app server {}", + stderr_log_file.display() + ) + }) + } + #[cfg(unix)] fn command_args(&self) -> Vec<&'static str> { match self.command_kind { @@ -376,6 +426,56 @@ impl PidBackend { } } +pub(crate) async fn read_stderr_log_tail(pid_file: &Path) -> Result> { + let path = stderr_log_file_for_pid_file(pid_file); + let Some(contents) = read_log_tail(&path, STDERR_LOG_TAIL_BYTES).await? else { + return Ok(None); + }; + Ok(Some(PidLogTail { path, contents })) +} + +fn stderr_log_file_for_pid_file(pid_file: &Path) -> PathBuf { + pid_file.with_extension("stderr.log") +} + +async fn read_log_tail(path: &Path, byte_limit: u64) -> Result> { + let mut file = match fs::File::open(path).await { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(err) + .with_context(|| format!("failed to open stderr log {}", path.display())); + } + }; + let len = file + .metadata() + .await + .with_context(|| format!("failed to inspect stderr log {}", path.display()))? + .len(); + if len == 0 { + return Ok(None); + } + + let start = len.saturating_sub(byte_limit); + file.seek(SeekFrom::Start(start)) + .await + .with_context(|| format!("failed to seek stderr log {}", path.display()))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .await + .with_context(|| format!("failed to read stderr log {}", path.display()))?; + if start > 0 + && let Some(newline_index) = bytes.iter().position(|byte| *byte == b'\n') + { + bytes.drain(..=newline_index); + } + let contents = String::from_utf8_lossy(&bytes).trim_end().to_string(); + if contents.is_empty() { + return Ok(None); + } + Ok(Some(contents)) +} + #[cfg(unix)] fn process_exists(pid: u32) -> bool { let Ok(pid) = libc::pid_t::try_from(pid) else { diff --git a/codex-rs/app-server-daemon/src/backend/pid_tests.rs b/codex-rs/app-server-daemon/src/backend/pid_tests.rs index 305c2a36a3..4c3a0e4414 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_tests.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_tests.rs @@ -6,7 +6,10 @@ use tempfile::TempDir; use super::PidBackend; use super::PidCommandKind; use super::PidFileState; +use super::PidLogTail; use super::PidRecord; +use super::read_stderr_log_tail; +use super::stderr_log_file_for_pid_file; use super::try_lock_file; #[tokio::test] @@ -170,3 +173,24 @@ fn app_server_remote_control_uses_runtime_flag() { vec!["app-server", "--remote-control", "--listen", "unix://"] ); } + +#[tokio::test] +async fn read_stderr_log_tail_returns_recent_complete_lines() { + let temp_dir = TempDir::new().expect("temp dir"); + let pid_file = temp_dir.path().join("app-server.pid"); + let log_file = stderr_log_file_for_pid_file(&pid_file); + let contents = format!("{}\nrecent error\nusage", "x".repeat(4100)); + tokio::fs::write(&log_file, contents) + .await + .expect("write stderr log"); + + assert_eq!( + read_stderr_log_tail(&pid_file) + .await + .expect("read stderr log"), + Some(PidLogTail { + path: log_file, + contents: "recent error\nusage".to_string(), + }) + ); +} diff --git a/codex-rs/app-server-daemon/src/client.rs b/codex-rs/app-server-daemon/src/client.rs index 44fccda394..8c771ba1f2 100644 --- a/codex-rs/app-server-daemon/src/client.rs +++ b/codex-rs/app-server-daemon/src/client.rs @@ -5,6 +5,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCMessage; @@ -14,12 +15,16 @@ use codex_app_server_protocol::RequestId; use codex_uds::UnixStream; use futures::SinkExt; use futures::StreamExt; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::client_async; use tokio_tungstenite::tungstenite::Message; -const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +pub(crate) const CONTROL_SOCKET_RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); const CLIENT_NAME: &str = "codex_app_server_daemon"; +const INITIALIZE_REQUEST_ID: RequestId = RequestId::Integer(1); #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ProbeInfo { @@ -27,7 +32,7 @@ pub(crate) struct ProbeInfo { } pub(crate) async fn probe(socket_path: &Path) -> Result { - timeout(PROBE_TIMEOUT, probe_inner(socket_path)) + timeout(CONTROL_SOCKET_RESPONSE_TIMEOUT, probe_inner(socket_path)) .await .with_context(|| { format!( @@ -38,54 +43,14 @@ pub(crate) async fn probe(socket_path: &Path) -> Result { } async fn probe_inner(socket_path: &Path) -> Result { - let stream = UnixStream::connect(socket_path) - .await - .with_context(|| format!("failed to connect to {}", socket_path.display()))?; - let (mut websocket, _response) = client_async("ws://localhost/", stream) - .await - .with_context(|| format!("failed to upgrade {}", socket_path.display()))?; - - let initialize = JSONRPCMessage::Request(JSONRPCRequest { - id: RequestId::Integer(1), - method: "initialize".to_string(), - params: Some(serde_json::to_value(InitializeParams { - client_info: ClientInfo { - name: CLIENT_NAME.to_string(), - title: Some("Codex App Server Daemon".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: None, - })?), - trace: None, - }); - websocket - .send(Message::Text(serde_json::to_string(&initialize)?.into())) - .await - .context("failed to send initialize request")?; - - let response = loop { - let frame = websocket - .next() - .await - .ok_or_else(|| anyhow!("app-server closed before initialize response"))??; - let Message::Text(payload) = frame else { - continue; - }; - let message = serde_json::from_str::(&payload)?; - if let JSONRPCMessage::Response(response) = message - && response.id == RequestId::Integer(1) - { - break response; - } - }; - let initialize_response = serde_json::from_value::(response.result)?; + let mut websocket = connect(socket_path).await?; + let initialize_response = initialize(&mut websocket, /*experimental_api*/ false).await?; let initialized = JSONRPCMessage::Notification(JSONRPCNotification { method: "initialized".to_string(), params: None, }); - websocket - .send(Message::Text(serde_json::to_string(&initialized)?.into())) + send_message(&mut websocket, &initialized) .await .context("failed to send initialized notification")?; websocket.close(None).await.ok(); @@ -95,6 +60,91 @@ async fn probe_inner(socket_path: &Path) -> Result { }) } +pub(crate) async fn connect(socket_path: &Path) -> Result> { + let stream = UnixStream::connect(socket_path) + .await + .with_context(|| format!("failed to connect to {}", socket_path.display()))?; + let (websocket, _response) = client_async("ws://localhost/", stream) + .await + .with_context(|| format!("failed to upgrade {}", socket_path.display()))?; + Ok(websocket) +} + +pub(crate) async fn initialize( + websocket: &mut WebSocketStream, + experimental_api: bool, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let initialize = JSONRPCMessage::Request(JSONRPCRequest { + id: INITIALIZE_REQUEST_ID, + method: "initialize".to_string(), + params: Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: CLIENT_NAME.to_string(), + title: Some("Codex App Server Daemon".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + capabilities: if experimental_api { + Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }) + } else { + None + }, + })?), + trace: None, + }); + send_message(websocket, &initialize) + .await + .context("failed to send initialize request")?; + + let response = loop { + let message = timeout(CONTROL_SOCKET_RESPONSE_TIMEOUT, read_message(websocket)) + .await + .context("timed out waiting for initialize response")??; + if let JSONRPCMessage::Response(response) = message + && response.id == INITIALIZE_REQUEST_ID + { + break response; + } + }; + serde_json::from_value::(response.result) + .context("failed to parse initialize response") +} + +pub(crate) async fn send_message( + websocket: &mut WebSocketStream, + message: &JSONRPCMessage, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + websocket + .send(Message::Text(serde_json::to_string(message)?.into())) + .await?; + Ok(()) +} + +pub(crate) async fn read_message(websocket: &mut WebSocketStream) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + loop { + let frame = websocket + .next() + .await + .ok_or_else(|| anyhow!("app-server closed the control socket"))??; + let Message::Text(payload) = frame else { + continue; + }; + return serde_json::from_str::(&payload) + .context("failed to parse app-server JSON-RPC message"); + } +} + fn parse_version_from_user_agent(user_agent: &str) -> Result { let (_originator, rest) = user_agent .split_once('/') diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 4ab223051c..62864fd5b7 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -1,6 +1,7 @@ mod backend; mod client; mod managed_install; +mod remote_control_client; mod settings; mod update_loop; @@ -13,6 +14,7 @@ use anyhow::Result; use anyhow::anyhow; pub use backend::BackendKind; use backend::BackendPaths; +use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_transport::app_server_control_socket_path; use codex_utils_home_dir::find_codex_home; use managed_install::managed_codex_bin; @@ -58,6 +60,8 @@ pub struct LifecycleOutput { pub backend: Option, #[serde(skip_serializing_if = "Option::is_none")] pub pid: Option, + pub managed_codex_path: PathBuf, + pub managed_codex_version: Option, pub socket_path: PathBuf, #[serde(skip_serializing_if = "Option::is_none")] pub cli_version: Option, @@ -84,6 +88,7 @@ pub struct BootstrapOutput { pub auto_update_enabled: bool, pub remote_control_enabled: bool, pub managed_codex_path: PathBuf, + pub managed_codex_version: Option, pub socket_path: PathBuf, pub cli_version: String, pub app_server_version: String, @@ -96,6 +101,20 @@ pub enum RemoteControlStartOutput { Start(LifecycleOutput), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteControlReadyStatus { + pub status: RemoteControlConnectionStatus, + pub server_name: String, + pub environment_id: Option, + pub timed_out: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteControlReadyOutput { + pub daemon: RemoteControlStartOutput, + pub remote_control: RemoteControlReadyStatus, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoteControlMode { Enabled, @@ -179,6 +198,27 @@ pub async fn ensure_remote_control_started() -> Result .await } +pub async fn ensure_remote_control_ready() -> Result { + ensure_supported_platform()?; + Daemon::from_environment()? + .ensure_remote_control_ready() + .await +} + +pub async fn enable_remote_control_on_socket( + socket_path: &Path, + connect_timeout: Duration, + connect_retry_delay: Duration, +) -> Result { + ensure_supported_platform()?; + remote_control_client::enable_remote_control_with_connect_retry( + socket_path, + connect_timeout, + connect_retry_delay, + ) + .await +} + pub async fn set_remote_control(mode: RemoteControlMode) -> Result { ensure_supported_platform()?; Daemon::from_environment()?.set_remote_control(mode).await @@ -248,33 +288,39 @@ impl Daemon { async fn start(&self) -> Result { let settings = self.load_settings().await?; if let Ok(info) = client::probe(&self.socket_path).await { - return Ok(self.output( - LifecycleStatus::AlreadyRunning, - self.running_backend(&settings).await?, - /*pid*/ None, - Some(info.app_server_version), - )); + return Ok(self + .output( + LifecycleStatus::AlreadyRunning, + self.running_backend(&settings).await?, + /*pid*/ None, + Some(info.app_server_version), + ) + .await); } if self.running_backend_instance(&settings).await?.is_some() { let info = self.wait_until_ready().await?; - return Ok(self.output( - LifecycleStatus::AlreadyRunning, - Some(BackendKind::Pid), - /*pid*/ None, - Some(info.app_server_version), - )); + return Ok(self + .output( + LifecycleStatus::AlreadyRunning, + Some(BackendKind::Pid), + /*pid*/ None, + Some(info.app_server_version), + ) + .await); } self.ensure_managed_codex_bin()?; let pid = self.start_managed_backend(&settings).await?; let info = self.wait_until_ready().await?; - Ok(self.output( - LifecycleStatus::Started, - Some(BackendKind::Pid), - pid, - Some(info.app_server_version), - )) + Ok(self + .output( + LifecycleStatus::Started, + Some(BackendKind::Pid), + pid, + Some(info.app_server_version), + ) + .await) } async fn restart(&self) -> Result { @@ -294,12 +340,14 @@ impl Daemon { let pid = self.start_managed_backend(&settings).await?; let info = self.wait_until_ready().await?; - Ok(self.output( - LifecycleStatus::Restarted, - Some(BackendKind::Pid), - pid, - Some(info.app_server_version), - )) + Ok(self + .output( + LifecycleStatus::Restarted, + Some(BackendKind::Pid), + pid, + Some(info.app_server_version), + ) + .await) } #[cfg(unix)] @@ -352,12 +400,14 @@ impl Daemon { let settings = self.load_settings().await?; if let Some(backend) = self.running_backend_instance(&settings).await? { backend.stop().await?; - return Ok(self.output( - LifecycleStatus::Stopped, - Some(BackendKind::Pid), - /*pid*/ None, - /*app_server_version*/ None, - )); + return Ok(self + .output( + LifecycleStatus::Stopped, + Some(BackendKind::Pid), + /*pid*/ None, + /*app_server_version*/ None, + ) + .await); } if client::probe(&self.socket_path).await.is_ok() { @@ -366,23 +416,27 @@ impl Daemon { )); } - Ok(self.output( - LifecycleStatus::NotRunning, - /*backend*/ None, - /*pid*/ None, - /*app_server_version*/ None, - )) + Ok(self + .output( + LifecycleStatus::NotRunning, + /*backend*/ None, + /*pid*/ None, + /*app_server_version*/ None, + ) + .await) } async fn version(&self) -> Result { let settings = self.load_settings().await?; let info = client::probe(&self.socket_path).await?; - Ok(self.output( - LifecycleStatus::Running, - self.running_backend(&settings).await?, - /*pid*/ None, - Some(info.app_server_version), - )) + Ok(self + .output( + LifecycleStatus::Running, + self.running_backend(&settings).await?, + /*pid*/ None, + Some(info.app_server_version), + ) + .await) } async fn wait_until_ready(&self) -> Result { @@ -395,17 +449,34 @@ impl Daemon { sleep(START_POLL_INTERVAL).await; } Err(err) => { - return Err(err).with_context(|| { - format!( - "app server did not become ready on {}", - self.socket_path.display() - ) - }); + let context = self.app_server_not_ready_context().await; + return Err(err).context(context); } } } } + async fn app_server_not_ready_context(&self) -> String { + let mut context = format!( + "app server did not become ready on {}", + self.socket_path.display() + ); + self.append_daemon_app_server_context(&mut context).await; + backend::append_stderr_log_tail_context(&self.pid_file, &mut context).await; + context + } + + async fn append_daemon_app_server_context(&self, context: &mut String) { + let managed_codex_version = self + .managed_codex_version_best_effort() + .await + .unwrap_or_else(|| "unknown".to_string()); + context.push_str(&format!( + "\n\nDaemon used app-server:\n path: {}\n version: {managed_codex_version}", + self.managed_codex_bin.display() + )); + } + async fn bootstrap(&self, options: BootstrapOptions) -> Result { let _operation_lock = self.acquire_operation_lock().await?; self.bootstrap_locked(options).await @@ -430,6 +501,16 @@ impl Daemon { Ok(RemoteControlStartOutput::Bootstrap(output)) } + async fn ensure_remote_control_ready(&self) -> Result { + let daemon = self.ensure_remote_control_started().await?; + let remote_control = + remote_control_client::enable_remote_control(&self.socket_path).await?; + Ok(RemoteControlReadyOutput { + daemon, + remote_control, + }) + } + async fn set_remote_control(&self, mode: RemoteControlMode) -> Result { let _operation_lock = self.acquire_operation_lock().await?; self.set_remote_control_locked(mode).await @@ -512,12 +593,14 @@ impl Daemon { updater.start().await?; let info = self.wait_until_ready().await?; + let managed_codex_version = self.managed_codex_version_best_effort().await; Ok(BootstrapOutput { status: BootstrapStatus::Bootstrapped, backend: BackendKind::Pid, auto_update_enabled: true, remote_control_enabled: settings.remote_control_enabled, managed_codex_path: self.managed_codex_bin.clone(), + managed_codex_version, socket_path: self.socket_path.clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), app_server_version: info.app_server_version, @@ -577,6 +660,16 @@ impl Daemon { )) } + #[cfg(unix)] + async fn managed_codex_version_best_effort(&self) -> Option { + managed_codex_version(&self.managed_codex_bin).await.ok() + } + + #[cfg(not(unix))] + async fn managed_codex_version_best_effort(&self) -> Option { + None + } + fn backend_paths(&self, settings: &DaemonSettings) -> BackendPaths { self.backend_paths_with_bin(settings, &self.managed_codex_bin) } @@ -636,17 +729,20 @@ impl Daemon { }) } - fn output( + async fn output( &self, status: LifecycleStatus, backend: Option, pid: Option, app_server_version: Option, ) -> LifecycleOutput { + let managed_codex_version = self.managed_codex_version_best_effort().await; LifecycleOutput { status, backend, pid, + managed_codex_path: self.managed_codex_bin.clone(), + managed_codex_version, socket_path: self.socket_path.clone(), cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), app_server_version, @@ -735,10 +831,12 @@ fn try_lock_file(_file: &tokio::fs::File) -> Result { #[cfg(all(test, unix))] mod tests { use pretty_assertions::assert_eq; + use tempfile::TempDir; use super::BackendKind; use super::BootstrapOutput; use super::BootstrapStatus; + use super::Daemon; use super::LifecycleOutput; use super::LifecycleStatus; use super::RemoteControlStartOutput; @@ -751,22 +849,6 @@ mod tests { use super::should_reexec_updater; use crate::client::ProbeInfo; - #[test] - fn lifecycle_status_uses_camel_case_json() { - assert_eq!( - serde_json::to_string(&LifecycleStatus::AlreadyRunning).expect("serialize"), - "\"alreadyRunning\"" - ); - } - - #[test] - fn bootstrap_status_uses_camel_case_json() { - assert_eq!( - serde_json::to_string(&BootstrapStatus::Bootstrapped).expect("serialize"), - "\"bootstrapped\"" - ); - } - #[test] fn remote_control_status_uses_camel_case_json() { assert_eq!( @@ -847,12 +929,26 @@ mod tests { status: LifecycleStatus::AlreadyRunning, backend: Some(BackendKind::Pid), pid: None, + managed_codex_path: "codex".into(), + managed_codex_version: Some("1.2.3".to_string()), socket_path: "codex.sock".into(), cli_version: Some("1.2.3".to_string()), app_server_version: Some("1.2.4".to_string()), }; let output = RemoteControlStartOutput::Start(lifecycle_output.clone()); + assert_eq!( + serde_json::to_value(&lifecycle_output).expect("serialize"), + serde_json::json!({ + "status": "alreadyRunning", + "backend": "pid", + "managedCodexPath": "codex", + "managedCodexVersion": "1.2.3", + "socketPath": "codex.sock", + "cliVersion": "1.2.3", + "appServerVersion": "1.2.4", + }) + ); assert_eq!( serde_json::to_value(output).expect("serialize"), serde_json::to_value(lifecycle_output).expect("serialize") @@ -864,15 +960,59 @@ mod tests { auto_update_enabled: true, remote_control_enabled: true, managed_codex_path: "codex".into(), + managed_codex_version: Some("1.2.3".to_string()), socket_path: "codex.sock".into(), cli_version: "1.2.3".to_string(), app_server_version: "1.2.4".to_string(), }; let output = RemoteControlStartOutput::Bootstrap(bootstrap_output.clone()); + assert_eq!( + serde_json::to_value(&bootstrap_output).expect("serialize"), + serde_json::json!({ + "status": "bootstrapped", + "backend": "pid", + "autoUpdateEnabled": true, + "remoteControlEnabled": true, + "managedCodexPath": "codex", + "managedCodexVersion": "1.2.3", + "socketPath": "codex.sock", + "cliVersion": "1.2.3", + "appServerVersion": "1.2.4", + }) + ); assert_eq!( serde_json::to_value(output).expect("serialize"), serde_json::to_value(bootstrap_output).expect("serialize") ); } + + #[tokio::test] + async fn not_ready_context_reports_daemon_app_server_before_stderr() { + let temp_dir = TempDir::new().expect("temp dir"); + let daemon = Daemon { + socket_path: temp_dir.path().join("app-server-control.sock"), + pid_file: temp_dir.path().join("app-server.pid"), + update_pid_file: temp_dir.path().join("app-server-updater.pid"), + operation_lock_file: temp_dir.path().join("daemon.lock"), + settings_file: temp_dir.path().join("settings.json"), + managed_codex_bin: temp_dir.path().join("missing-codex"), + }; + let stderr_log = daemon.pid_file.with_extension("stderr.log"); + tokio::fs::write(&stderr_log, "unexpected argument") + .await + .expect("write stderr log"); + + assert_eq!( + daemon.app_server_not_ready_context().await, + format!( + "app server did not become ready on {}\n\n\ + Daemon used app-server:\n path: {}\n version: unknown\n\n\ + Managed app-server stderr ({}):\n unexpected argument", + daemon.socket_path.display(), + daemon.managed_codex_bin.display(), + stderr_log.display() + ) + ); + } } diff --git a/codex-rs/app-server-daemon/src/remote_control_client.rs b/codex-rs/app-server-daemon/src/remote_control_client.rs new file mode 100644 index 0000000000..cc32e50dde --- /dev/null +++ b/codex-rs/app-server-daemon/src/remote_control_client.rs @@ -0,0 +1,459 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusChangedNotification; +use codex_app_server_protocol::RequestId; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; + +use crate::RemoteControlReadyStatus; +use crate::client; + +const REMOTE_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_CONTROL_ENABLE_REQUEST_ID: RequestId = RequestId::Integer(2); + +pub(crate) async fn enable_remote_control(socket_path: &Path) -> Result { + let mut websocket = client::connect(socket_path).await?; + enable_remote_control_with_timeout(&mut websocket, REMOTE_CONTROL_READY_TIMEOUT).await +} + +pub(crate) async fn enable_remote_control_with_connect_retry( + socket_path: &Path, + connect_timeout: Duration, + connect_retry_delay: Duration, +) -> Result { + let mut websocket = + connect_with_retry(socket_path, connect_timeout, connect_retry_delay).await?; + enable_remote_control_with_timeout(&mut websocket, REMOTE_CONTROL_READY_TIMEOUT).await +} + +async fn enable_remote_control_with_timeout( + websocket: &mut WebSocketStream, + ready_timeout: Duration, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + client::initialize(websocket, /*experimental_api*/ true).await?; + let initialized = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + client::send_message(websocket, &initialized) + .await + .context("failed to send initialized notification")?; + + let enable = JSONRPCMessage::Request(JSONRPCRequest { + id: REMOTE_CONTROL_ENABLE_REQUEST_ID, + method: "remoteControl/enable".to_string(), + params: None, + trace: None, + }); + client::send_message(websocket, &enable) + .await + .context("failed to send remoteControl/enable request")?; + + let mut latest = read_enable_response(websocket).await?; + if latest.status == RemoteControlConnectionStatus::Connecting { + latest = wait_for_remote_control_status(websocket, latest, ready_timeout).await?; + } + websocket.close(None).await.ok(); + Ok(latest) +} + +async fn connect_with_retry( + socket_path: &Path, + connect_timeout: Duration, + connect_retry_delay: Duration, +) -> Result> { + let deadline = Instant::now() + connect_timeout; + loop { + match client::connect(socket_path).await { + Ok(websocket) => return Ok(websocket), + Err(_) if Instant::now() < deadline => { + sleep(connect_retry_delay).await; + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "app server did not become ready on {}", + socket_path.display() + ) + }); + } + } + } +} + +async fn read_enable_response( + websocket: &mut WebSocketStream, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + loop { + let message = timeout( + client::CONTROL_SOCKET_RESPONSE_TIMEOUT, + client::read_message(websocket), + ) + .await + .context("timed out waiting for remoteControl/enable response")??; + match message { + JSONRPCMessage::Response(response) + if response.id == REMOTE_CONTROL_ENABLE_REQUEST_ID => + { + let response = + serde_json::from_value::(response.result) + .context("failed to parse remoteControl/enable response")?; + return Ok(RemoteControlReadyStatus::from(response)); + } + JSONRPCMessage::Error(err) if err.id == REMOTE_CONTROL_ENABLE_REQUEST_ID => { + return Err(anyhow!( + "remoteControl/enable failed: {}", + err.error.message + )); + } + JSONRPCMessage::Notification(notification) + if remote_control_status_notification(¬ification).is_some() => + { + continue; + } + _ => {} + } + } +} + +async fn wait_for_remote_control_status( + websocket: &mut WebSocketStream, + mut latest: RemoteControlReadyStatus, + ready_timeout: Duration, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let deadline = tokio::time::Instant::now() + ready_timeout; + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = match timeout(remaining, client::read_message(websocket)).await { + Ok(Ok(message)) => message, + Ok(Err(err)) => return Err(err), + Err(_) => { + latest.timed_out = true; + return Ok(latest); + } + }; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + let Some(status) = remote_control_status_notification(¬ification) else { + continue; + }; + latest = RemoteControlReadyStatus::from(status); + if latest.status != RemoteControlConnectionStatus::Connecting { + return Ok(latest); + } + } + latest.timed_out = true; + Ok(latest) +} + +fn remote_control_status_notification( + notification: &JSONRPCNotification, +) -> Option { + if notification.method != "remoteControl/status/changed" { + return None; + } + let params = notification.params.clone()?; + serde_json::from_value(params).ok() +} + +impl From for RemoteControlReadyStatus { + fn from(response: RemoteControlEnableResponse) -> Self { + let RemoteControlEnableResponse { + status, + server_name, + installation_id: _, + environment_id, + } = response; + Self { + status, + server_name, + environment_id, + timed_out: false, + } + } +} + +impl From for RemoteControlReadyStatus { + fn from(notification: RemoteControlStatusChangedNotification) -> Self { + let RemoteControlStatusChangedNotification { + status, + server_name, + installation_id: _, + environment_id, + } = notification; + Self { + status, + server_name, + environment_id, + timed_out: false, + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use anyhow::Result; + use codex_app_server_protocol::JSONRPCResponse; + use codex_uds::UnixListener; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use tokio_tungstenite::accept_async; + + use super::*; + + const INITIALIZE_REQUEST_ID: RequestId = RequestId::Integer(1); + const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + const TEST_SERVER_NAME: &str = "owen-mbp"; + const TEST_CODEX_HOME: &str = "/tmp/codex-home"; + + #[tokio::test] + async fn enable_remote_control_uses_connected_enable_response_without_later_notification() + -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: Some(remote_control_status( + RemoteControlConnectionStatus::Connected, + Some("env_test"), + )), + enable_response: remote_control_status( + RemoteControlConnectionStatus::Connected, + Some("env_test"), + ), + after_enable_notification: None, + ready_timeout: Duration::from_millis(20), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Connected, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: Some("env_test".to_string()), + timed_out: false, + } + ); + Ok(()) + } + + #[tokio::test] + async fn enable_remote_control_waits_for_connected_notification() -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: None, + enable_response: remote_control_status( + RemoteControlConnectionStatus::Connecting, + /*environment_id*/ None, + ), + after_enable_notification: Some(remote_control_status( + RemoteControlConnectionStatus::Connected, + Some("env_test"), + )), + ready_timeout: Duration::from_secs(1), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Connected, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: Some("env_test".to_string()), + timed_out: false, + } + ); + Ok(()) + } + + #[tokio::test] + async fn enable_remote_control_reports_connecting_after_timeout() -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: None, + enable_response: remote_control_status( + RemoteControlConnectionStatus::Connecting, + /*environment_id*/ None, + ), + after_enable_notification: None, + ready_timeout: Duration::from_millis(20), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Connecting, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: None, + timed_out: true, + } + ); + Ok(()) + } + + #[tokio::test] + async fn enable_remote_control_returns_errored_enable_response() -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: None, + enable_response: remote_control_status( + RemoteControlConnectionStatus::Errored, + /*environment_id*/ None, + ), + after_enable_notification: None, + ready_timeout: Duration::from_millis(20), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Errored, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: None, + timed_out: false, + } + ); + Ok(()) + } + + struct EnableScenario { + initial_notification: Option, + enable_response: RemoteControlStatusChangedNotification, + after_enable_notification: Option, + ready_timeout: Duration, + } + + async fn run_enable_remote_control_scenario( + scenario: EnableScenario, + ) -> Result { + let dir = TempDir::new()?; + let socket_path = dir.path().join("app-server.sock"); + let listener = UnixListener::bind(&socket_path).await?; + let ready_timeout = scenario.ready_timeout; + let server_task = tokio::spawn(serve_enable_remote_control_scenario(listener, scenario)); + + let mut websocket = client::connect(&socket_path).await?; + let status = enable_remote_control_with_timeout(&mut websocket, ready_timeout).await?; + server_task.await??; + Ok(status) + } + + async fn serve_enable_remote_control_scenario( + mut listener: UnixListener, + scenario: EnableScenario, + ) -> Result<()> { + let stream = listener.accept().await?; + let mut websocket = accept_async(stream).await?; + + let initialize = client::read_message(&mut websocket).await?; + let JSONRPCMessage::Request(initialize) = initialize else { + panic!("expected initialize request"); + }; + assert_eq!(initialize.id, INITIALIZE_REQUEST_ID); + assert_eq!(initialize.method, "initialize"); + let Some(initialize_params) = initialize.params else { + panic!("expected initialize params"); + }; + assert_eq!( + initialize_params["capabilities"]["experimentalApi"], + serde_json::Value::Bool(true) + ); + client::send_message( + &mut websocket, + &JSONRPCMessage::Response(JSONRPCResponse { + id: INITIALIZE_REQUEST_ID, + result: serde_json::json!({ + "userAgent": "codex_app_server/1.2.3", + "codexHome": TEST_CODEX_HOME, + "platformFamily": "unix", + "platformOs": "macos", + }), + }), + ) + .await?; + + let initialized = client::read_message(&mut websocket).await?; + let JSONRPCMessage::Notification(initialized) = initialized else { + panic!("expected initialized notification"); + }; + assert_eq!(initialized.method, "initialized"); + + if let Some(status) = scenario.initial_notification { + send_remote_control_status(&mut websocket, status).await?; + } + + let enable = client::read_message(&mut websocket).await?; + let JSONRPCMessage::Request(enable) = enable else { + panic!("expected remoteControl/enable request"); + }; + assert_eq!(enable.id, REMOTE_CONTROL_ENABLE_REQUEST_ID); + assert_eq!(enable.method, "remoteControl/enable"); + client::send_message( + &mut websocket, + &JSONRPCMessage::Response(JSONRPCResponse { + id: REMOTE_CONTROL_ENABLE_REQUEST_ID, + result: serde_json::to_value(RemoteControlEnableResponse::from( + scenario.enable_response, + ))?, + }), + ) + .await?; + + if let Some(status) = scenario.after_enable_notification { + send_remote_control_status(&mut websocket, status).await?; + } else { + tokio::time::sleep(Duration::from_millis(50)).await; + } + + Ok(()) + } + + async fn send_remote_control_status( + websocket: &mut WebSocketStream, + status: RemoteControlStatusChangedNotification, + ) -> Result<()> + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + client::send_message( + websocket, + &JSONRPCMessage::Notification(JSONRPCNotification { + method: "remoteControl/status/changed".to_string(), + params: Some(serde_json::to_value(status)?), + }), + ) + .await + } + + fn remote_control_status( + status: RemoteControlConnectionStatus, + environment_id: Option<&str>, + ) -> RemoteControlStatusChangedNotification { + RemoteControlStatusChangedNotification { + status, + server_name: TEST_SERVER_NAME.to_string(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: environment_id.map(str::to_string), + } + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 4482d326f6..142df216b5 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -400,6 +400,7 @@ pub enum PluginStartupTasks { pub struct AppServerRuntimeOptions { pub plugin_startup_tasks: PluginStartupTasks, pub remote_control_enabled: bool, + pub install_shutdown_signal_handler: bool, } impl Default for AppServerRuntimeOptions { @@ -407,6 +408,7 @@ impl Default for AppServerRuntimeOptions { Self { plugin_startup_tasks: PluginStartupTasks::Start, remote_control_enabled: false, + install_shutdown_signal_handler: true, } } } @@ -645,7 +647,8 @@ pub async fn run_main_with_transport_options( let single_client_mode = matches!(&transport, AppServerTransport::Stdio); let shutdown_when_no_connections = single_client_mode; - let graceful_signal_restart_enabled = !single_client_mode; + let graceful_signal_restart_enabled = + runtime_options.install_shutdown_signal_handler && !single_client_mode; let mut app_server_client_name_rx = None; match &transport { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 4b0d2330a6..1b71ab15ae 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -52,6 +52,7 @@ mod doctor; mod marketplace_cmd; mod mcp_cmd; mod plugin_cmd; +mod remote_control_cmd; mod state_db_recovery; #[cfg(not(windows))] mod wsl_paths; @@ -59,6 +60,7 @@ mod wsl_paths; use crate::mcp_cmd::McpCli; use crate::plugin_cmd::PluginCli; use crate::plugin_cmd::PluginSubcommand; +use crate::remote_control_cmd::RemoteControlCommand; use doctor::DoctorCommand; use state_db_recovery as local_state_db; @@ -565,21 +567,6 @@ struct AppServerBootstrapCommand { remote_control: bool, } -#[derive(Debug, Args)] -struct RemoteControlCommand { - #[command(subcommand)] - subcommand: Option, -} - -#[derive(Debug, Clone, Copy, clap::Subcommand)] -enum RemoteControlSubcommand { - /// Start the app-server daemon with remote control enabled. - Start, - - /// Stop the app-server daemon. - Stop, -} - #[derive(Debug, Args)] struct GenerateTsCommand { /// Output directory where .ts files will be written @@ -1070,24 +1057,18 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } Some(Subcommand::RemoteControl(remote_control_cli)) => { - let subcommand_name = remote_control_subcommand_name(&remote_control_cli); + let subcommand_name = remote_control_cli.subcommand_name(); reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), subcommand_name, )?; - match remote_control_cli - .subcommand - .unwrap_or(RemoteControlSubcommand::Start) - { - RemoteControlSubcommand::Start => { - let output = codex_app_server_daemon::ensure_remote_control_started().await?; - println!("{}", serde_json::to_string(&output)?); - } - RemoteControlSubcommand::Stop => { - print_app_server_daemon_output(AppServerLifecycleCommand::Stop).await?; - } - } + remote_control_cmd::run( + remote_control_cli, + arg0_paths.clone(), + root_config_overrides, + ) + .await?; } #[cfg(any(target_os = "macos", target_os = "windows"))] Some(Subcommand::App(app_cli)) => { @@ -1892,9 +1873,7 @@ fn unsupported_subcommand_name_for_strict_config( Some(Subcommand::AppServer(app_server)) => { Some(app_server_subcommand_name(app_server.subcommand.as_ref())) } - Some(Subcommand::RemoteControl(remote_control)) => { - Some(remote_control_subcommand_name(remote_control)) - } + Some(Subcommand::RemoteControl(remote_control)) => Some(remote_control.subcommand_name()), Some(Subcommand::Mcp(_)) => Some("mcp"), Some(Subcommand::Plugin(_)) => Some("plugin"), #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -1947,14 +1926,6 @@ fn reject_remote_mode_for_app_server_subcommand( reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) } -fn remote_control_subcommand_name(command: &RemoteControlCommand) -> &'static str { - match command.subcommand { - None => "remote-control", - Some(RemoteControlSubcommand::Start) => "remote-control start", - Some(RemoteControlSubcommand::Stop) => "remote-control stop", - } -} - fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str { match subcommand { None => "app-server", @@ -2974,12 +2945,10 @@ mod tests { fn reject_remote_flag_for_remote_control() { let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"]) .expect("parse"); - assert_matches!( - cli.subcommand, - Some(Subcommand::RemoteControl(RemoteControlCommand { - subcommand: None - })) - ); + let Some(Subcommand::RemoteControl(remote_control)) = &cli.subcommand else { + panic!("expected remote-control subcommand"); + }; + assert_eq!(remote_control.subcommand_name(), "remote-control"); let err = reject_remote_mode_for_subcommand( cli.remote.remote.as_deref(), diff --git a/codex-rs/cli/src/remote_control_cmd.rs b/codex-rs/cli/src/remote_control_cmd.rs new file mode 100644 index 0000000000..1ff8bd7595 --- /dev/null +++ b/codex-rs/cli/src/remote_control_cmd.rs @@ -0,0 +1,695 @@ +use std::io::Write; +use std::time::Duration; + +use anyhow::Context; +use clap::Args; +use codex_app_server::AppServerRuntimeOptions; +use codex_app_server::AppServerTransport; +use codex_app_server::AppServerWebsocketAuthSettings; +use codex_app_server_daemon::LifecycleCommand as AppServerLifecycleCommand; +use codex_app_server_daemon::LifecycleOutput as AppServerLifecycleOutput; +use codex_app_server_daemon::LifecycleStatus as AppServerLifecycleStatus; +use codex_app_server_daemon::RemoteControlReadyOutput as AppServerRemoteControlReadyOutput; +use codex_app_server_daemon::RemoteControlReadyStatus as AppServerRemoteControlReadyStatus; +use codex_app_server_daemon::RemoteControlStartOutput as AppServerRemoteControlStartOutput; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_arg0::Arg0DispatchPaths; +use codex_config::LoaderOverrides; +use codex_protocol::protocol::SessionSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; +use tokio::sync::watch; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const FOREGROUND_SOCKET_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const FOREGROUND_SOCKET_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(50); +const FOREGROUND_APP_SERVER_ABORT_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Debug, Args)] +pub(crate) struct RemoteControlCommand { + /// Emit machine-readable JSON. + #[arg(long = "json", global = true)] + json: bool, + + #[command(subcommand)] + subcommand: Option, +} + +impl RemoteControlCommand { + pub(crate) fn subcommand_name(&self) -> &'static str { + match self.subcommand { + None => "remote-control", + Some(RemoteControlSubcommand::Start) => "remote-control start", + Some(RemoteControlSubcommand::Stop) => "remote-control stop", + } + } +} + +#[derive(Debug, Clone, Copy, clap::Subcommand)] +enum RemoteControlSubcommand { + /// Start the app-server daemon with remote control enabled. + Start, + + /// Stop the app-server daemon. + Stop, +} + +pub(crate) async fn run( + command: RemoteControlCommand, + arg0_paths: Arg0DispatchPaths, + root_config_overrides: CliConfigOverrides, +) -> anyhow::Result<()> { + match command.subcommand { + None => { + print_remote_control_progress( + command.json, + "Starting app-server with remote control enabled...", + )?; + run_foreground_remote_control(command.json, arg0_paths, root_config_overrides).await?; + } + Some(RemoteControlSubcommand::Start) => { + print_remote_control_progress( + command.json, + "Starting app-server daemon with remote control enabled...", + )?; + let output = codex_app_server_daemon::ensure_remote_control_ready().await?; + print_remote_control_start_output(&output, command.json)?; + } + Some(RemoteControlSubcommand::Stop) => { + print_remote_control_progress(command.json, "Stopping remote control...")?; + let output = codex_app_server_daemon::run(AppServerLifecycleCommand::Stop).await?; + print_remote_control_stop_output(&output, command.json)?; + } + } + Ok(()) +} + +fn print_remote_control_progress(json: bool, message: &str) -> anyhow::Result<()> { + if json { + return Ok(()); + } + + println!("{message}"); + std::io::stdout() + .flush() + .context("failed to flush remote-control progress message")?; + Ok(()) +} + +async fn run_foreground_remote_control( + json: bool, + arg0_paths: Arg0DispatchPaths, + root_config_overrides: CliConfigOverrides, +) -> anyhow::Result<()> { + let socket_dir = tempfile::Builder::new() + .prefix("codex-rc-") + .tempdir_in("/tmp") + .or_else(|_| tempfile::tempdir()) + .context("failed to create private app-server socket directory")?; + let socket_path = socket_dir.path().join("rc.sock"); + let socket_path = AbsolutePathBuf::from_absolute_path(&socket_path) + .context("private app-server socket path was not absolute")?; + let transport = AppServerTransport::UnixSocket { + socket_path: socket_path.clone(), + }; + let runtime_options = AppServerRuntimeOptions { + remote_control_enabled: true, + install_shutdown_signal_handler: false, + ..Default::default() + }; + let (stop_rx, stop_signal_task) = foreground_stop_signal(); + let mut app_server_task = tokio::spawn(codex_app_server::run_main_with_transport_options( + arg0_paths, + root_config_overrides, + LoaderOverrides::default(), + /*strict_config*/ false, + /*default_analytics_enabled*/ false, + transport, + SessionSource::VSCode, + AppServerWebsocketAuthSettings::default(), + runtime_options, + )); + + let summary = match wait_for_foreground_remote_control_start( + &mut app_server_task, + wait_for_foreground_remote_control_ready(socket_path), + stop_rx.clone(), + ) + .await + { + ForegroundStartupResult::Ready(summary) => summary, + ForegroundStartupResult::Stopped => { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Ok(()); + } + ForegroundStartupResult::ReadyFailed(error) => { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Err(error); + } + ForegroundStartupResult::AppServerExited(error) => { + stop_signal_task.abort(); + return Err(error); + } + }; + + if *stop_rx.borrow() { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Ok(()); + } + + if let Err(error) = print_foreground_ready_output(&summary, json) { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Err(error); + } + + let result = wait_for_foreground_app_server(app_server_task, stop_rx).await; + stop_signal_task.abort(); + result +} + +fn foreground_stop_signal() -> (watch::Receiver, JoinHandle<()>) { + let (stop_tx, stop_rx) = watch::channel(false); + let task = tokio::spawn(async move { + if let Err(err) = tokio::signal::ctrl_c().await { + eprintln!("failed to listen for Ctrl-C: {err}"); + } + let _ = stop_tx.send(true); + }); + (stop_rx, task) +} + +enum ForegroundStartupResult { + Ready(AppServerRemoteControlReadyStatus), + Stopped, + ReadyFailed(anyhow::Error), + AppServerExited(anyhow::Error), +} + +async fn wait_for_foreground_remote_control_start( + app_server_task: &mut JoinHandle>, + ready: impl std::future::Future>, + mut stop_rx: watch::Receiver, +) -> ForegroundStartupResult { + tokio::pin!(ready); + + tokio::select! { + ready_result = &mut ready => match ready_result { + Ok(summary) => ForegroundStartupResult::Ready(summary), + Err(error) => ForegroundStartupResult::ReadyFailed(error), + }, + app_server_result = app_server_task => { + ForegroundStartupResult::AppServerExited( + foreground_app_server_exited_before_ready(app_server_result) + ) + } + _ = wait_for_stop_signal(&mut stop_rx) => ForegroundStartupResult::Stopped, + } +} + +async fn wait_for_foreground_app_server( + mut app_server_task: JoinHandle>, + mut stop_rx: watch::Receiver, +) -> anyhow::Result<()> { + tokio::select! { + app_server_result = &mut app_server_task => { + app_server_result + .context("foreground app-server task failed to join")? + .context("foreground app-server exited with an error")?; + } + _ = wait_for_stop_signal(&mut stop_rx) => { + abort_foreground_app_server(app_server_task).await; + } + } + + Ok(()) +} + +async fn wait_for_stop_signal(stop_rx: &mut watch::Receiver) { + if *stop_rx.borrow() { + return; + } + let _ = stop_rx.wait_for(|stopped| *stopped).await; +} + +fn foreground_app_server_exited_before_ready( + result: Result, tokio::task::JoinError>, +) -> anyhow::Error { + match result { + Ok(Ok(())) => { + anyhow::anyhow!("foreground app-server exited before remote control became ready") + } + Ok(Err(error)) => anyhow::Error::new(error) + .context("foreground app-server exited before remote control became ready"), + Err(error) => anyhow::Error::new(error) + .context("foreground app-server task failed before remote control became ready"), + } +} + +async fn abort_foreground_app_server(app_server_task: JoinHandle>) { + app_server_task.abort(); + let _ = timeout(FOREGROUND_APP_SERVER_ABORT_TIMEOUT, app_server_task).await; +} + +async fn wait_for_foreground_remote_control_ready( + socket_path: AbsolutePathBuf, +) -> anyhow::Result { + codex_app_server_daemon::enable_remote_control_on_socket( + socket_path.as_path(), + FOREGROUND_SOCKET_CONNECT_TIMEOUT, + FOREGROUND_SOCKET_CONNECT_RETRY_DELAY, + ) + .await +} + +fn print_remote_control_start_output( + output: &AppServerRemoteControlReadyOutput, + json: bool, +) -> anyhow::Result<()> { + ensure_remote_control_startable(&output.remote_control)?; + if json { + println!( + "{}", + serde_json::to_string(&RemoteControlStartJsonOutput::daemon(output))? + ); + return Ok(()); + } + + for line in remote_control_start_human_lines( + &output.remote_control, + RemoteControlHumanOutputMode::Daemon, + )? { + println!("{line}"); + } + for line in daemon_app_server_human_lines(&output.daemon) { + println!("{line}"); + } + Ok(()) +} + +fn print_foreground_ready_output( + summary: &AppServerRemoteControlReadyStatus, + json: bool, +) -> anyhow::Result<()> { + if json { + ensure_remote_control_startable(summary)?; + println!( + "{}", + serde_json::to_string(&RemoteControlStartJsonOutput::foreground(summary))? + ); + return Ok(()); + } + + for line in remote_control_start_human_lines(summary, RemoteControlHumanOutputMode::Foreground)? + { + println!("{line}"); + } + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RemoteControlStartJsonOutput<'a> { + mode: RemoteControlModeJson, + status: RemoteControlConnectionStatus, + server_name: &'a str, + environment_id: Option<&'a str>, + timed_out: bool, + #[serde(skip_serializing_if = "Option::is_none")] + daemon: Option<&'a AppServerRemoteControlStartOutput>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum RemoteControlModeJson { + Foreground, + Daemon, +} + +impl<'a> RemoteControlStartJsonOutput<'a> { + fn foreground(summary: &'a AppServerRemoteControlReadyStatus) -> Self { + Self { + mode: RemoteControlModeJson::Foreground, + status: summary.status, + server_name: &summary.server_name, + environment_id: summary.environment_id.as_deref(), + timed_out: summary.timed_out, + daemon: None, + } + } + + fn daemon(output: &'a AppServerRemoteControlReadyOutput) -> Self { + let remote_control = &output.remote_control; + Self { + mode: RemoteControlModeJson::Daemon, + status: remote_control.status, + server_name: &remote_control.server_name, + environment_id: remote_control.environment_id.as_deref(), + timed_out: remote_control.timed_out, + daemon: Some(&output.daemon), + } + } +} + +fn remote_control_start_human_message( + output: &AppServerRemoteControlReadyStatus, +) -> anyhow::Result { + ensure_remote_control_startable(output)?; + match output.status { + RemoteControlConnectionStatus::Connected => Ok(format!( + "This machine is available for remote control as {}.", + output.server_name + )), + RemoteControlConnectionStatus::Connecting => Ok(format!( + "Remote control is enabled on {} and still connecting.", + output.server_name + )), + RemoteControlConnectionStatus::Errored | RemoteControlConnectionStatus::Disabled => { + unreachable!("errored and disabled statuses are rejected before formatting") + } + } +} + +fn ensure_remote_control_startable( + output: &AppServerRemoteControlReadyStatus, +) -> anyhow::Result<()> { + match output.status { + RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting => { + Ok(()) + } + RemoteControlConnectionStatus::Errored => { + anyhow::bail!( + "Remote control is enabled on {} but the connection is errored.", + output.server_name + ); + } + RemoteControlConnectionStatus::Disabled => { + anyhow::bail!("Remote control is disabled on {}.", output.server_name); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteControlHumanOutputMode { + Foreground, + Daemon, +} + +fn remote_control_start_human_lines( + summary: &AppServerRemoteControlReadyStatus, + mode: RemoteControlHumanOutputMode, +) -> anyhow::Result> { + let mut lines = vec![remote_control_start_human_message(summary)?]; + match mode { + RemoteControlHumanOutputMode::Foreground => { + lines.push("Press Ctrl-C to stop.".to_string()); + } + RemoteControlHumanOutputMode::Daemon => {} + } + Ok(lines) +} + +fn daemon_app_server_human_lines(output: &AppServerRemoteControlStartOutput) -> Vec { + let (managed_codex_path, managed_codex_version) = daemon_app_server_identity(output); + vec![ + "Daemon used app-server:".to_string(), + format!(" path: {}", managed_codex_path.display()), + format!(" version: {}", managed_codex_version.unwrap_or("unknown")), + ] +} + +fn daemon_app_server_identity( + output: &AppServerRemoteControlStartOutput, +) -> (&std::path::Path, Option<&str>) { + match output { + AppServerRemoteControlStartOutput::Bootstrap(output) => ( + &output.managed_codex_path, + output.managed_codex_version.as_deref(), + ), + AppServerRemoteControlStartOutput::Start(output) => ( + &output.managed_codex_path, + output.managed_codex_version.as_deref(), + ), + } +} + +fn print_remote_control_stop_output( + output: &AppServerLifecycleOutput, + json: bool, +) -> anyhow::Result<()> { + if json { + println!("{}", serde_json::to_string(output)?); + return Ok(()); + } + + println!("{}", remote_control_stop_human_message(output)); + Ok(()) +} + +fn remote_control_stop_human_message(output: &AppServerLifecycleOutput) -> String { + match output.status { + AppServerLifecycleStatus::Stopped => "Remote control stopped.".to_string(), + AppServerLifecycleStatus::NotRunning => "Remote control is not running.".to_string(), + AppServerLifecycleStatus::Started + | AppServerLifecycleStatus::Restarted + | AppServerLifecycleStatus::AlreadyRunning + | AppServerLifecycleStatus::Running => { + format!( + "Remote control stop completed with status {:?}.", + output.status + ) + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + use std::path::PathBuf; + + use super::*; + + fn remote_control_status( + status: RemoteControlConnectionStatus, + ) -> AppServerRemoteControlReadyStatus { + AppServerRemoteControlReadyStatus { + status, + server_name: "owen-mbp".to_string(), + environment_id: Some("env_test".to_string()), + timed_out: status == RemoteControlConnectionStatus::Connecting, + } + } + + fn daemon_ready_output( + status: RemoteControlConnectionStatus, + ) -> AppServerRemoteControlReadyOutput { + AppServerRemoteControlReadyOutput { + daemon: AppServerRemoteControlStartOutput::Start(AppServerLifecycleOutput { + status: AppServerLifecycleStatus::Started, + backend: None, + pid: Some(42), + managed_codex_path: PathBuf::from("/opt/codex/bin/codex"), + managed_codex_version: Some("1.0.0".to_string()), + socket_path: PathBuf::from("/tmp/app-server-control.sock"), + cli_version: Some("1.0.0".to_string()), + app_server_version: Some("2.0.0".to_string()), + }), + remote_control: AppServerRemoteControlReadyStatus { + status, + server_name: "owen-mbp".to_string(), + environment_id: Some("env_test".to_string()), + timed_out: status == RemoteControlConnectionStatus::Connecting, + }, + } + } + + #[test] + fn remote_control_human_start_messages_use_server_name() { + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Connected + )) + .expect("connected message"), + "This machine is available for remote control as owen-mbp." + ); + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Connecting + )) + .expect("connecting message"), + "Remote control is enabled on owen-mbp and still connecting." + ); + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Errored + )) + .expect_err("errored status should fail") + .to_string(), + "Remote control is enabled on owen-mbp but the connection is errored." + ); + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Disabled + )) + .expect_err("disabled status should fail") + .to_string(), + "Remote control is disabled on owen-mbp." + ); + } + + #[test] + fn remote_control_human_lines_include_foreground_stop_hint_only() { + let summary = remote_control_status(RemoteControlConnectionStatus::Connected); + + assert_eq!( + remote_control_start_human_lines(&summary, RemoteControlHumanOutputMode::Foreground) + .expect("foreground lines"), + vec![ + "This machine is available for remote control as owen-mbp.".to_string(), + "Press Ctrl-C to stop.".to_string(), + ] + ); + assert_eq!( + remote_control_start_human_lines(&summary, RemoteControlHumanOutputMode::Daemon) + .expect("daemon lines"), + vec!["This machine is available for remote control as owen-mbp.".to_string()] + ); + } + + #[test] + fn daemon_app_server_human_lines_include_path_and_version() { + assert_eq!( + daemon_app_server_human_lines( + &daemon_ready_output(RemoteControlConnectionStatus::Connected).daemon + ), + vec![ + "Daemon used app-server:".to_string(), + " path: /opt/codex/bin/codex".to_string(), + " version: 1.0.0".to_string(), + ] + ); + } + + #[test] + fn remote_control_json_output_marks_foreground_or_daemon() { + let foreground_summary = remote_control_status(RemoteControlConnectionStatus::Connected); + assert_eq!( + serde_json::to_value(RemoteControlStartJsonOutput::foreground( + &foreground_summary + )) + .expect("foreground JSON"), + json!({ + "mode": "foreground", + "status": "connected", + "serverName": "owen-mbp", + "environmentId": "env_test", + "timedOut": false, + }) + ); + + let daemon_output = daemon_ready_output(RemoteControlConnectionStatus::Connected); + assert_eq!( + serde_json::to_value(RemoteControlStartJsonOutput::daemon(&daemon_output)) + .expect("daemon JSON"), + json!({ + "mode": "daemon", + "status": "connected", + "serverName": "owen-mbp", + "environmentId": "env_test", + "timedOut": false, + "daemon": { + "status": "started", + "pid": 42, + "managedCodexPath": "/opt/codex/bin/codex", + "managedCodexVersion": "1.0.0", + "socketPath": "/tmp/app-server-control.sock", + "cliVersion": "1.0.0", + "appServerVersion": "2.0.0", + }, + }) + ); + } + + #[test] + fn remote_control_daemon_json_rejects_unstartable_status() { + assert_eq!( + print_remote_control_start_output( + &daemon_ready_output(RemoteControlConnectionStatus::Errored), + /*json*/ true + ) + .expect_err("errored daemon status should fail") + .to_string(), + "Remote control is enabled on owen-mbp but the connection is errored." + ); + } + + #[tokio::test] + async fn foreground_wait_aborts_app_server_on_stop_signal() { + let app_server_task = tokio::spawn(std::future::pending::>()); + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + stop_tx.send(true).expect("send stop signal"); + + tokio::time::timeout( + std::time::Duration::from_secs(1), + wait_for_foreground_app_server(app_server_task, stop_rx), + ) + .await + .expect("foreground wait should return after stop signal") + .expect("stop signal should shut down cleanly"); + } + + #[tokio::test] + async fn foreground_start_wait_stops_before_ready() { + let mut app_server_task = tokio::spawn(std::future::pending::>()); + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + stop_tx.send(true).expect("send stop signal"); + + let startup = tokio::time::timeout( + std::time::Duration::from_secs(1), + wait_for_foreground_remote_control_start( + &mut app_server_task, + std::future::pending::>(), + stop_rx, + ), + ) + .await + .expect("foreground startup wait should return after stop signal"); + + assert!(matches!(startup, ForegroundStartupResult::Stopped)); + app_server_task.abort(); + let _ = app_server_task.await; + } + + #[tokio::test] + async fn foreground_start_wait_reports_app_server_exit_before_ready() { + let mut app_server_task = + tokio::spawn(async { Err(std::io::Error::other("startup failed before socket bind")) }); + let (_stop_tx, stop_rx) = tokio::sync::watch::channel(false); + + let startup = tokio::time::timeout( + std::time::Duration::from_secs(1), + wait_for_foreground_remote_control_start( + &mut app_server_task, + std::future::pending::>(), + stop_rx, + ), + ) + .await + .expect("foreground startup wait should return after app-server exits"); + + let ForegroundStartupResult::AppServerExited(error) = startup else { + panic!("expected app-server exit before ready"); + }; + + assert_eq!( + error.to_string(), + "foreground app-server exited before remote control became ready" + ); + } +} From c95a70fb420214761de942427d870aca1c71718a Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 18 May 2026 14:09:25 -0700 Subject: [PATCH 41/53] Publish Linux runtime wheels with glibc-compatible tags (#21812) ## Why The Python SDK depends on `openai-codex-cli-bin` runtime wheels being installable on the Linux hosts our users actually run. The release workflow currently tags the Linux runtime artifacts as `musllinux_*`, which makes pip ignore them on normal glibc distributions even though the bundled Rust executables are intended to run there. ## What changed - Tag the Linux runtime wheels as `manylinux_2_17_aarch64` and `manylinux_2_17_x86_64` instead of `musllinux_1_1_*`. - Keep the existing runtime wheel build and publish flow unchanged otherwise. ## Verification - Confirmed the wheel-tag issue against the PyPA platform-tag rules for `manylinux` vs `musllinux`. - This PR is now intentionally scoped to the tag correction only; the broader Python runtime release workflow has already landed on `main` through the merged stack. ## Follow-up After publishing the next alpha from this branch, install the SDK/runtime in a fresh glibc Linux environment and confirm pip resolves the tagged Linux wheel as expected. Co-authored-by: Codex --- .github/workflows/rust-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index b6c293d6cd..50953506d3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -533,10 +533,10 @@ jobs: platform_tag="macosx_10_9_x86_64" ;; aarch64-unknown-linux-musl) - platform_tag="musllinux_1_1_aarch64" + platform_tag="manylinux_2_17_aarch64" ;; x86_64-unknown-linux-musl) - platform_tag="musllinux_1_1_x86_64" + platform_tag="manylinux_2_17_x86_64" ;; *) echo "No Python runtime wheel platform tag for ${{ matrix.target }}" From f2368b7de61c6a5adadec7cba3597097f1e8e520 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 14:54:36 -0700 Subject: [PATCH 42/53] [codex] Trim unused TurnContextItem fields (#22709) ## Why `TurnContextItem` is the durable baseline used to reconstruct context diffs across resume/fork. Most of the old persisted-only fields on it are no longer read, so keeping them in rollout snapshots adds schema surface and state that can drift without affecting reconstruction. `summary` is the exception: older Codex versions require it to deserialize `turn_context` records, so keep writing a default compatibility value until that schema surface can be removed safely. ## What changed - Removed the unused persisted fields from `TurnContextItem`: trace ids, user/developer instructions, output schema, and truncation policy. - Kept `summary` with a compatibility comment and made `TurnContext::to_turn_context_item` write `ReasoningSummary::Auto` instead of live turn state. - Updated rollout/context reconstruction fixtures for the retained summary field. ## Verification - `cargo test -p codex-protocol --lib turn_context_item` - `cargo test -p codex-rollout resume_candidate_matches_cwd_reads_latest_turn_context` - `cargo test -p codex-state turn_context` - `cargo test -p codex-core --lib new_default_turn_captures_current_span_trace_id` - `cargo test -p codex-core --lib record_initial_history_resumed_turn_context_after_compaction_reestablishes_reference_context_item` - `cargo test -p codex-core --test all emits_warning_when_resumed_model_differs` - `git diff --check` --- .../core/src/context_manager/history_tests.rs | 8 +-- .../session/rollout_reconstruction_tests.rs | 56 +++---------------- codex-rs/core/src/session/tests.rs | 16 ++---- codex-rs/core/src/session/turn_context.rs | 7 +-- codex-rs/core/tests/suite/resume_warning.rs | 5 -- codex-rs/protocol/src/protocol.rs | 21 ++----- codex-rs/rollout/src/recorder_tests.rs | 8 +-- codex-rs/state/src/extract.rs | 22 +------- 8 files changed, 24 insertions(+), 119 deletions(-) diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 74f4d29bfb..8ba5d56e32 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -2,7 +2,6 @@ use super::*; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::AgentPath; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; @@ -121,7 +120,6 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { fn reference_context_item() -> TurnContextItem { TurnContextItem { turn_id: Some("reference-turn".to_string()), - trace_id: None, cwd: PathBuf::from("/tmp/reference-cwd"), current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), @@ -135,11 +133,7 @@ fn reference_context_item() -> TurnContextItem { collaboration_mode: None, realtime_active: Some(false), effort: None, - summary: ReasoningSummary::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(codex_protocol::protocol::TruncationPolicy::Tokens(10_000)), + summary: codex_protocol::config_types::ReasoningSummary::Auto, } } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index aa403316e0..fc2811710a 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -59,7 +59,6 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -74,11 +73,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; @@ -101,7 +96,6 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let previous_model = "previous-rollout-model"; let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -116,11 +110,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item .turn_id @@ -925,7 +915,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -940,11 +929,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item .turn_id @@ -1005,7 +990,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis .expect("serialize seeded reference context item"), serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1020,11 +1004,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, })) .expect("serialize expected reference context item") ); @@ -1037,7 +1017,6 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1052,11 +1031,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item .turn_id @@ -1155,7 +1130,6 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let unmatched_abort_turn_id = "other-turn".to_string(); let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1170,11 +1144,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let rollout_items = vec![ @@ -1272,7 +1242,6 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1287,11 +1256,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item .turn_id @@ -1428,7 +1393,6 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1443,11 +1407,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item .turn_id diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index dbdc8aa8dd..e3f038eff7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2317,7 +2317,6 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_model = "forked-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -2332,11 +2331,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item .turn_id @@ -5125,7 +5120,7 @@ async fn new_default_turn_captures_current_span_trace_id() { &request_parent )); - let turn_context_item = async { + let turn_trace_id = async { let expected_trace_id = Span::current() .context() .span() @@ -5133,15 +5128,14 @@ async fn new_default_turn_captures_current_span_trace_id() { .trace_id() .to_string(); let turn_context = session.new_default_turn().await; - let turn_context_item = turn_context.to_turn_context_item(); - assert_eq!(turn_context_item.trace_id, Some(expected_trace_id)); - turn_context_item + assert_eq!(turn_context.trace_id, Some(expected_trace_id)); + turn_context.trace_id.clone() } .instrument(request_span) .await; assert_eq!( - turn_context_item.trace_id.as_deref(), + turn_trace_id.as_deref(), Some("00000000000000000000000000000011") ); } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8f0ce2d5fd..b14999d2cc 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -368,7 +368,6 @@ impl TurnContext { pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { TurnContextItem { turn_id: Some(self.sub_id.clone()), - trace_id: self.trace_id.clone(), #[allow(deprecated)] cwd: self.cwd.to_path_buf(), current_date: self.current_date.clone(), @@ -383,11 +382,7 @@ impl TurnContext { collaboration_mode: Some(self.collaboration_mode.clone()), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort, - summary: self.reasoning_summary, - user_instructions: self.user_instructions.clone(), - developer_instructions: self.developer_instructions.clone(), - final_output_json_schema: self.final_output_json_schema.clone(), - truncation_policy: Some(self.truncation_policy), + summary: ReasoningSummaryConfig::Auto, } } diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index ac916ba66d..d2bdcd1d32 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -27,7 +27,6 @@ fn resume_history( let turn_id = "resume-warning-seed-turn".to_string(); let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), - trace_id: None, cwd: config.cwd.to_path_buf(), current_date: None, timezone: None, @@ -44,10 +43,6 @@ fn resume_history( summary: config .model_reasoning_summary .unwrap_or(ReasoningSummary::Auto), - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, }; InitialHistory::Resumed(ResumedHistory { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 98635ee30a..ec780d60c3 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2824,8 +2824,6 @@ pub struct TurnContextNetworkItem { pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trace_id: Option, pub cwd: PathBuf, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_date: Option, @@ -2848,15 +2846,11 @@ pub struct TurnContextItem { pub realtime_active: Option, #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, + // Compatibility-only field written with a default value so older Codex + // versions can deserialize turn-context rollout items. It is no longer + // read by context reconstruction and should be removed in a future schema + // cleanup. pub summary: ReasoningSummaryConfig, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub developer_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub final_output_json_schema: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub truncation_policy: Option, } impl TurnContextItem { @@ -5246,7 +5240,6 @@ mod tests { "summary": "auto", }))?; - assert_eq!(item.trace_id, None); assert_eq!(item.network, None); assert_eq!(item.file_system_sandbox_policy, None); Ok(()) @@ -5256,7 +5249,6 @@ mod tests { fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { turn_id: None, - trace_id: None, cwd: test_path_buf("/tmp"), current_date: None, timezone: None, @@ -5281,10 +5273,6 @@ mod tests { realtime_active: None, effort: None, summary: ReasoningSummaryConfig::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, }; let value = serde_json::to_value(item)?; @@ -5308,6 +5296,7 @@ mod tests { }] }) ); + assert_eq!(value["summary"], json!("auto")); Ok(()) } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 8199f290d8..90fe15c312 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -4,7 +4,6 @@ use super::*; use crate::config::RolloutConfig; use chrono::TimeZone; use codex_protocol::ThreadId; -use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::AskForApproval; @@ -1127,7 +1126,6 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re timestamp: "2025-01-03T13:00:01Z".to_string(), item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - trace_id: None, cwd: latest_cwd.clone(), current_date: None, timezone: None, @@ -1141,11 +1139,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re collaboration_mode: None, realtime_active: None, effort: None, - summary: ReasoningSummaryConfig::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, }), }; writeln!(file, "{}", serde_json::to_string(&turn_context)?)?; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 886d267e4f..2369addf23 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -156,7 +156,6 @@ mod tests { use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; - use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; @@ -339,7 +338,6 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - trace_id: None, cwd: PathBuf::from("/parent/workspace"), current_date: None, timezone: None, @@ -353,11 +351,7 @@ mod tests { collaboration_mode: None, realtime_active: None, effort: None, - summary: ReasoningSummary::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", ); @@ -379,7 +373,6 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - trace_id: None, cwd: PathBuf::from("/fallback/workspace"), current_date: None, timezone: None, @@ -393,11 +386,7 @@ mod tests { collaboration_mode: None, realtime_active: None, effort: Some(ReasoningEffort::High), - summary: ReasoningSummary::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", ); @@ -413,7 +402,6 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - trace_id: None, cwd: PathBuf::from("/fallback/workspace"), current_date: None, timezone: None, @@ -427,11 +415,7 @@ mod tests { collaboration_mode: None, realtime_active: None, effort: Some(ReasoningEffort::High), - summary: ReasoningSummary::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", ); From a66e0e9c4b2978121ed1cd4242f7f62dd027423f Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 18 May 2026 15:33:33 -0700 Subject: [PATCH 43/53] Include plugin id in plugin MCP tool metadata (#23353) Adding the id of the plugin that contains the MCP (if any) so we can apply filters at plugin level. ## Summary - carry the plugin owner into MCP runtime provenance - attach `plugin_id` to outbound plugin-backed MCP tool-call `_meta` - avoid misattributing user-configured MCP servers that shadow plugin server names ## Testing - `just fmt` - `just fix -p codex-mcp` - `just fix -p codex-core` - `cargo test -p codex-mcp` - `cargo test -p codex-core plugin_mcp_tool_call_request_meta_includes_plugin_id` - `cargo test -p codex-core to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp` - `cargo test -p codex-core rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config` - `git diff --check` ## Notes - Attempted `cargo test -p codex-core`; it aborted in `agent::control::tests::resume_agent_from_rollout_skips_descendants_when_parent_resume_fails` with a stack overflow before the full suite completed. --- codex-rs/codex-mcp/src/connection_manager.rs | 8 +++ codex-rs/codex-mcp/src/mcp/mod.rs | 17 ++++-- codex-rs/codex-mcp/src/mcp/mod_tests.rs | 18 ++++++- codex-rs/core/src/config/config_tests.rs | 57 ++++++++++++++++++++ codex-rs/core/src/config/mod.rs | 10 +++- codex-rs/core/src/mcp_tool_call.rs | 21 +++++--- codex-rs/core/src/mcp_tool_call_tests.rs | 37 +++++++++++++ 7 files changed, 155 insertions(+), 13 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index e8dc36b9b2..8144f5b227 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -71,6 +71,7 @@ use tracing::warn; pub struct McpConnectionManager { clients: HashMap, server_metadata: HashMap, + tool_plugin_provenance: Arc, host_owned_codex_apps_enabled: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, @@ -91,6 +92,7 @@ impl McpConnectionManager { Self { clients: HashMap::new(), server_metadata: HashMap::new(), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), host_owned_codex_apps_enabled: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), @@ -136,6 +138,11 @@ impl McpConnectionManager { .is_none_or(|metadata| metadata.pollutes_memory) } + pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { + self.tool_plugin_provenance + .plugin_id_for_mcp_server_name(server_name) + } + pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME } @@ -283,6 +290,7 @@ impl McpConnectionManager { let manager = Self { clients, server_metadata, + tool_plugin_provenance, host_owned_codex_apps_enabled, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: cancel_token.clone(), diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 2ce03c9317..3d1969de0c 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -138,6 +138,8 @@ pub struct McpConfig { /// /// Runtime-only additions are merged later by [`effective_mcp_servers`]. pub configured_mcp_servers: HashMap, + /// Winning plugin owner for plugin-provided MCP servers, keyed by server name. + pub plugin_ids_by_mcp_server_name: HashMap, /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. pub plugin_capability_summaries: Vec, } @@ -146,6 +148,7 @@ pub struct McpConfig { pub struct ToolPluginProvenance { plugin_display_names_by_connector_id: HashMap>, plugin_display_names_by_mcp_server_name: HashMap>, + plugin_ids_by_mcp_server_name: HashMap, } impl ToolPluginProvenance { @@ -163,9 +166,15 @@ impl ToolPluginProvenance { .unwrap_or(&[]) } - fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self { + pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { + self.plugin_ids_by_mcp_server_name + .get(server_name) + .map(String::as_str) + } + + fn from_config(config: &McpConfig) -> Self { let mut tool_plugin_provenance = Self::default(); - for plugin in capability_summaries { + for plugin in &config.plugin_capability_summaries { for connector_id in &plugin.app_connector_ids { tool_plugin_provenance .plugin_display_names_by_connector_id @@ -195,6 +204,8 @@ impl ToolPluginProvenance { plugin_names.sort_unstable(); plugin_names.dedup(); } + tool_plugin_provenance.plugin_ids_by_mcp_server_name = + config.plugin_ids_by_mcp_server_name.clone(); tool_plugin_provenance } @@ -244,7 +255,7 @@ pub fn effective_mcp_servers_from_configured( } pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance { - ToolPluginProvenance::from_capability_summaries(&config.plugin_capability_summaries) + ToolPluginProvenance::from_config(config) } pub async fn read_mcp_resource( diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 99622d7f07..7566bf9bde 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -30,6 +30,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { apps_enabled: false, client_elicitation_capability: ElicitationCapability::default(), configured_mcp_servers: HashMap::new(), + plugin_ids_by_mcp_server_name: HashMap::new(), plugin_capability_summaries: Vec::new(), } } @@ -124,7 +125,10 @@ fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() { #[test] fn tool_plugin_provenance_collects_app_and_mcp_sources() { - let provenance = ToolPluginProvenance::from_capability_summaries(&[ + let mut config = test_mcp_config(PathBuf::new()); + config.plugin_ids_by_mcp_server_name = + HashMap::from([("alpha".to_string(), "alpha@test".to_string())]); + config.plugin_capability_summaries = vec![ PluginCapabilitySummary { display_name: "alpha-plugin".to_string(), app_connector_ids: vec![AppConnectorId("connector_example".to_string())], @@ -140,7 +144,8 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { mcp_server_names: vec!["beta".to_string()], ..PluginCapabilitySummary::default() }, - ]); + ]; + let provenance = tool_plugin_provenance(&config); assert_eq!( provenance, @@ -159,8 +164,17 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { ("alpha".to_string(), vec!["alpha-plugin".to_string()]), ("beta".to_string(), vec!["beta-plugin".to_string()]), ]), + plugin_ids_by_mcp_server_name: HashMap::from([( + "alpha".to_string(), + "alpha@test".to_string(), + )]), } ); + assert_eq!( + provenance.plugin_id_for_mcp_server_name("alpha"), + Some("alpha@test") + ); + assert_eq!(provenance.plugin_id_for_mcp_server_name("beta"), None); } #[test] diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 21d291b309..50b03b5051 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3849,6 +3849,63 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() mcp_config.configured_mcp_servers.get("sample"), Some(&http_mcp("https://sample.example/mcp")) ); + assert_eq!( + mcp_config.plugin_ids_by_mcp_server_name, + HashMap::from([("sample".to_string(), "sample@test".to_string())]) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://plugin.example/mcp" + } + } +}"#, + )?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +plugins = true + +[mcp_servers.sample] +url = "https://user.example/mcp" + +[plugins."sample@test"] +enabled = true +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config.configured_mcp_servers.get("sample"), + Some(&http_mcp("https://user.example/mcp")) + ); + assert!(mcp_config.plugin_ids_by_mcp_server_name.is_empty()); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6eb66c4543..583c2a42b6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -107,6 +107,7 @@ use serde::Serialize; use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::hash_map::Entry; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; @@ -1243,6 +1244,7 @@ impl Config { let plugins_input = self.plugins_config_input(); let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await; let mut configured_mcp_servers = self.mcp_servers.get().clone(); + let mut plugin_ids_by_mcp_server_name = HashMap::new(); for plugin in loaded_plugins .plugins() .iter() @@ -1255,7 +1257,10 @@ impl Config { self.config_layer_stack.requirements().plugins.as_ref(), ); for (name, plugin_server) in plugin_mcp_servers { - configured_mcp_servers.entry(name).or_insert(plugin_server); + if let Entry::Vacant(entry) = configured_mcp_servers.entry(name.clone()) { + entry.insert(plugin_server); + plugin_ids_by_mcp_server_name.insert(name, plugin.config_name.clone()); + } } } if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref() @@ -1265,6 +1270,8 @@ impl Config { // above. filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements)); } + plugin_ids_by_mcp_server_name + .retain(|server_name, _| configured_mcp_servers.contains_key(server_name)); McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), @@ -1292,6 +1299,7 @@ impl Config { ElicitationCapability::default() }, configured_mcp_servers, + plugin_ids_by_mcp_server_name, plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(), } } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 8f6e3afaf5..902ede574e 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -974,6 +974,7 @@ pub(crate) struct McpToolApprovalMetadata { connector_id: Option, connector_name: Option, connector_description: Option, + plugin_id: Option, tool_title: Option, tool_description: Option, mcp_app_resource_uri: Option, @@ -983,6 +984,7 @@ pub(crate) struct McpToolApprovalMetadata { const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri"; +const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id"; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; async fn custom_mcp_tool_approval_mode( @@ -1068,6 +1070,12 @@ fn build_mcp_tool_call_request_meta( serde_json::Value::Object(codex_apps_meta), ); } + if let Some(plugin_id) = metadata.and_then(|metadata| metadata.plugin_id.as_ref()) { + request_meta.insert( + MCP_TOOL_PLUGIN_ID_META_KEY.to_string(), + serde_json::Value::String(plugin_id.clone()), + ); + } (!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta)) } @@ -1480,13 +1488,11 @@ pub(crate) async fn lookup_mcp_tool_metadata( server: &str, tool_name: &str, ) -> Option { - let tools = sess - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; + let manager = sess.services.mcp_connection_manager.read().await; + let plugin_id = manager + .plugin_id_for_mcp_server_name(server) + .map(str::to_string); + let tools = manager.list_all_tools().await; let tool_info = tools .into_iter() .find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?; @@ -1519,6 +1525,7 @@ pub(crate) async fn lookup_mcp_tool_metadata( connector_id: tool_info.connector_id, connector_name: tool_info.connector_name, connector_description, + plugin_id, tool_title: tool_info.tool.title, tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned), mcp_app_resource_uri: get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index d8b6326cd0..25f19c87dd 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -76,6 +76,7 @@ fn approval_metadata( connector_id: connector_id.map(str::to_string), connector_name: connector_name.map(str::to_string), connector_description: connector_description.map(str::to_string), + plugin_id: None, tool_title: tool_title.map(str::to_string), tool_description: tool_description.map(str::to_string), mcp_app_resource_uri: None, @@ -1099,6 +1100,29 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() { ); } +#[tokio::test] +async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() { + let (_, turn_context) = make_session_and_context().await; + let expected_turn_metadata = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) + .expect("turn metadata"); + let mut metadata = approval_metadata( + /*connector_id*/ None, /*connector_name*/ None, + /*connector_description*/ None, /*tool_title*/ None, + /*tool_description*/ None, + ); + metadata.plugin_id = Some("sample@test".to_string()); + + assert_eq!( + build_mcp_tool_call_request_meta(&turn_context, "sample", "call-plugin", Some(&metadata),), + Some(serde_json::json!({ + crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata, + MCP_TOOL_PLUGIN_ID_META_KEY: "sample@test", + })) + ); +} + #[tokio::test] async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() { let (_, turn_context) = make_session_and_context().await; @@ -1111,6 +1135,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Create Event".to_string()), tool_description: Some("Create a calendar event.".to_string()), mcp_app_resource_uri: None, @@ -1573,6 +1598,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: None, tool_description: None, mcp_app_resource_uri: None, @@ -2290,6 +2316,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Read Only Tool".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2363,6 +2390,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Read Only Tool".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2419,6 +2447,7 @@ async fn permission_request_hook_allows_mcp_tool_call() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Create entities".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2554,6 +2583,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Create entities".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2640,6 +2670,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Reads calendar data.".to_string()), mcp_app_resource_uri: None, @@ -2693,6 +2724,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Read Only Tool".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2772,6 +2804,7 @@ async fn approve_mode_skips_arc_interrupt_for_model() { connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, @@ -2839,6 +2872,7 @@ async fn custom_approve_mode_skips_arc_interrupt_for_model() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, @@ -2906,6 +2940,7 @@ async fn approve_mode_skips_arc_interrupt_without_annotations() { connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, @@ -2978,6 +3013,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() { connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, @@ -3046,6 +3082,7 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() { connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, From afa0101ae222792c1c7e072107e1eb7b72c19b7b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 15:43:01 -0700 Subject: [PATCH 44/53] [codex] Move pending input into input queue (#22728) ## Why Pending model input was split across `Session`, `TurnState`, and the agent mailbox. That made it easy for new paths to manage queued user input or mailbox delivery outside the intended ownership boundary. This PR consolidates the model-facing input lifecycle behind the session input queue so turn-local pending input, next-turn queued items, and mailbox delivery coordination are owned in one place. ## What Changed - Added `session/input_queue.rs` to own pending input queues and mailbox delivery coordination. - Removed the standalone `agent/mailbox.rs` channel wrapper and store mailbox items directly in the input queue. - Moved pending-input mutations off `TurnState`; `TurnState` now exposes the queue-owned storage directly for now. - Routed abort cleanup, mailbox delivery phase changes, next-turn queued items, and active-turn pending input through `InputQueue`. - Boxed stack-heavy agent resume/fork startup futures that the refactor pushed over the default test stack. - Updated session, task, goal, stream-event, and multi-agent call sites and tests to use the new queue ownership. ## Verification - `cargo test -p codex-core --lib agent::control::tests` - `cargo test -p codex-core --lib agent::control::tests::resume_closed_child_reopens_open_descendants -- --exact` - `cargo test -p codex-core --lib agent::control::tests::spawn_agent_fork_last_n_turns_keeps_only_recent_turns -- --exact` - `cargo test -p codex-core --lib agent::control::tests::resume_thread_subagent_restores_stored_nickname_and_role -- --exact` - `cargo test -p codex-core` was also run; it completed with 1814 passed, 4 ignored, and one timeout in `agent::control::tests::resume_thread_subagent_restores_stored_nickname_and_role`, which passed when rerun in isolation. --- codex-rs/core/src/agent/control.rs | 13 +- codex-rs/core/src/agent/control_tests.rs | 8 +- codex-rs/core/src/agent/mailbox.rs | 161 ------- codex-rs/core/src/agent/mod.rs | 3 - codex-rs/core/src/codex_thread.rs | 1 + codex-rs/core/src/goals.rs | 24 +- codex-rs/core/src/session/handlers.rs | 8 +- codex-rs/core/src/session/input_queue.rs | 392 ++++++++++++++++++ codex-rs/core/src/session/mod.rs | 189 +-------- codex-rs/core/src/session/session.rs | 13 +- codex-rs/core/src/session/tests.rs | 129 +++--- codex-rs/core/src/session/turn.rs | 13 +- codex-rs/core/src/state/turn.rs | 42 +- codex-rs/core/src/stream_events_utils.rs | 9 +- codex-rs/core/src/tasks/mod.rs | 41 +- codex-rs/core/src/tasks/regular.rs | 2 +- .../src/tools/handlers/multi_agents_tests.rs | 85 ++-- .../tools/handlers/multi_agents_v2/wait.rs | 14 +- 18 files changed, 623 insertions(+), 524 deletions(-) delete mode 100644 codex-rs/core/src/agent/mailbox.rs create mode 100644 codex-rs/core/src/session/input_queue.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 71bc026a13..a246f0084f 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -499,13 +499,12 @@ impl AgentControl { agent_nickname: None, agent_role: None, }); - match self - .resume_single_agent_from_rollout( - config.clone(), - child_thread_id, - child_session_source, - ) - .await + match Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + child_thread_id, + child_session_source, + )) + .await { Ok(_) => true, Err(err) => { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b95aad4489..e5883f60da 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -486,7 +486,13 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig timeout(Duration::from_secs(5), async { loop { - if thread.codex.session.has_pending_input().await { + if thread + .codex + .session + .input_queue + .has_pending_input(&thread.codex.session.active_turn) + .await + { break; } sleep(Duration::from_millis(10)).await; diff --git a/codex-rs/core/src/agent/mailbox.rs b/codex-rs/core/src/agent/mailbox.rs deleted file mode 100644 index c328236475..0000000000 --- a/codex-rs/core/src/agent/mailbox.rs +++ /dev/null @@ -1,161 +0,0 @@ -use codex_protocol::protocol::InterAgentCommunication; -use std::collections::VecDeque; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; -use tokio::sync::mpsc; -use tokio::sync::watch; - -#[cfg(test)] -use codex_protocol::AgentPath; - -pub(crate) struct Mailbox { - tx: mpsc::UnboundedSender, - next_seq: AtomicU64, - seq_tx: watch::Sender, -} - -pub(crate) struct MailboxReceiver { - rx: mpsc::UnboundedReceiver, - pending_mails: VecDeque, -} - -impl Mailbox { - pub(crate) fn new() -> (Self, MailboxReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - let (seq_tx, _) = watch::channel(0); - ( - Self { - tx, - next_seq: AtomicU64::new(0), - seq_tx, - }, - MailboxReceiver { - rx, - pending_mails: VecDeque::new(), - }, - ) - } - - pub(crate) fn subscribe(&self) -> watch::Receiver { - self.seq_tx.subscribe() - } - - pub(crate) fn send(&self, communication: InterAgentCommunication) -> u64 { - let seq = self.next_seq.fetch_add(1, Ordering::Relaxed) + 1; - let _ = self.tx.send(communication); - self.seq_tx.send_replace(seq); - seq - } -} - -impl MailboxReceiver { - fn sync_pending_mails(&mut self) { - while let Ok(mail) = self.rx.try_recv() { - self.pending_mails.push_back(mail); - } - } - - pub(crate) fn has_pending(&mut self) -> bool { - self.sync_pending_mails(); - !self.pending_mails.is_empty() - } - - pub(crate) fn has_pending_trigger_turn(&mut self) -> bool { - self.sync_pending_mails(); - self.pending_mails.iter().any(|mail| mail.trigger_turn) - } - - pub(crate) fn drain(&mut self) -> Vec { - self.sync_pending_mails(); - self.pending_mails.drain(..).collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn make_mail( - author: AgentPath, - recipient: AgentPath, - content: &str, - trigger_turn: bool, - ) -> InterAgentCommunication { - InterAgentCommunication::new( - author, - recipient, - Vec::new(), - content.to_string(), - trigger_turn, - ) - } - - #[tokio::test] - async fn mailbox_assigns_monotonic_sequence_numbers() { - let (mailbox, _receiver) = Mailbox::new(); - let mut seq_rx = mailbox.subscribe(); - - let seq_a = mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "one", - /*trigger_turn*/ false, - )); - let seq_b = mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "two", - /*trigger_turn*/ false, - )); - - seq_rx.changed().await.expect("first seq update"); - assert_eq!(*seq_rx.borrow(), seq_b); - assert_eq!(seq_a, 1); - assert_eq!(seq_b, 2); - } - - #[tokio::test] - async fn mailbox_drains_in_delivery_order() { - let (mailbox, mut receiver) = Mailbox::new(); - let mail_one = make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "one", - /*trigger_turn*/ false, - ); - let mail_two = make_mail( - AgentPath::try_from("/root/worker").expect("agent path"), - AgentPath::root(), - "two", - /*trigger_turn*/ false, - ); - - mailbox.send(mail_one.clone()); - mailbox.send(mail_two.clone()); - - assert_eq!(receiver.drain(), vec![mail_one, mail_two]); - assert!(!receiver.has_pending()); - } - - #[tokio::test] - async fn mailbox_tracks_pending_trigger_turn_mail() { - let (mailbox, mut receiver) = Mailbox::new(); - - mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "queued", - /*trigger_turn*/ false, - )); - assert!(!receiver.has_pending_trigger_turn()); - - mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "wake", - /*trigger_turn*/ true, - )); - assert!(receiver.has_pending_trigger_turn()); - } -} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index a60fc3004a..350962dc08 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,14 +1,11 @@ pub(crate) mod agent_resolver; pub(crate) mod control; -pub(crate) mod mailbox; mod registry; pub(crate) mod role; pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; -pub(crate) use mailbox::Mailbox; -pub(crate) use mailbox::MailboxReceiver; pub(crate) use registry::exceeds_thread_spawn_depth_limit; pub(crate) use registry::next_thread_spawn_depth; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 7e9e6e38cd..8f5023300c 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -383,6 +383,7 @@ impl CodexThread { { self.codex .session + .input_queue .queue_response_items_for_next_turn(items) .await; self.codex.session.maybe_start_turn_for_pending_work().await; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 18b7c77bda..13f8cc95c8 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -1331,12 +1331,9 @@ impl Session { .await; return; } - { - let mut turn_state = turn_state.lock().await; - for item in candidate.items { - turn_state.push_pending_input(item); - } - } + self.input_queue + .extend_pending_input_for_turn_state(turn_state.as_ref(), candidate.items) + .await; let turn_context = self .new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string()) @@ -1374,11 +1371,15 @@ impl Session { tracing::debug!("skipping active goal continuation because a turn is already active"); return None; } - if self.has_queued_response_items_for_next_turn().await { + if self + .input_queue + .has_queued_response_items_for_next_turn() + .await + { tracing::debug!("skipping active goal continuation because queued input exists"); return None; } - if self.has_trigger_turn_mailbox_items().await { + if self.input_queue.has_trigger_turn_mailbox_items().await { tracing::debug!( "skipping active goal continuation because trigger-turn mailbox input is pending" ); @@ -1415,8 +1416,11 @@ impl Session { return None; } if self.active_turn.lock().await.is_some() - || self.has_queued_response_items_for_next_turn().await - || self.has_trigger_turn_mailbox_items().await + || self + .input_queue + .has_queued_response_items_for_next_turn() + .await + || self.input_queue.has_trigger_turn_mailbox_items().await { tracing::debug!("skipping active goal continuation because pending work appeared"); return None; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index c2ffe2f4e4..eb27ae78be 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -319,7 +319,9 @@ pub async fn inter_agent_communication( communication: InterAgentCommunication, ) { let trigger_turn = communication.trigger_turn; - sess.enqueue_mailbox_communication(communication); + sess.input_queue + .enqueue_mailbox_communication(communication) + .await; if trigger_turn { sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id) .await; @@ -961,7 +963,9 @@ Approved action: }]; if let Err(items) = sess.inject_response_items(items).await { - sess.queue_response_items_for_next_turn(items).await; + sess.input_queue + .queue_response_items_for_next_turn(items) + .await; } } diff --git a/codex-rs/core/src/session/input_queue.rs b/codex-rs/core/src/session/input_queue.rs new file mode 100644 index 0000000000..2e8a6ded9d --- /dev/null +++ b/codex-rs/core/src/session/input_queue.rs @@ -0,0 +1,392 @@ +use crate::state::ActiveTurn; +use crate::state::MailboxDeliveryPhase; +use crate::state::TurnState; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::protocol::InterAgentCommunication; +use std::collections::VecDeque; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::watch; + +/// Turn-local pending input storage owned by the input queue flow. +#[derive(Default)] +pub(crate) struct TurnInputQueue { + items: Vec, +} + +/// Session-scoped pending input storage and active-turn mailbox delivery coordination. +pub(crate) struct InputQueue { + mailbox_tx: watch::Sender<()>, + mailbox_pending_mails: Mutex>, + + idle_pending_input: Mutex>, +} + +impl InputQueue { + pub(crate) fn new() -> Self { + let (mailbox_tx, _) = watch::channel(()); + Self { + mailbox_tx, + mailbox_pending_mails: Mutex::new(VecDeque::new()), + idle_pending_input: Mutex::new(Vec::new()), + } + } + + pub(crate) async fn subscribe_mailbox(&self) -> watch::Receiver<()> { + let mut mailbox_rx = self.mailbox_tx.subscribe(); + if self.has_pending_mailbox_items().await { + mailbox_rx.mark_changed(); + } + mailbox_rx + } + + pub(crate) async fn enqueue_mailbox_communication( + &self, + communication: InterAgentCommunication, + ) { + self.mailbox_pending_mails + .lock() + .await + .push_back(communication); + self.mailbox_tx.send_replace(()); + } + + pub(crate) async fn has_pending_mailbox_items(&self) -> bool { + !self.mailbox_pending_mails.lock().await.is_empty() + } + + pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool { + self.mailbox_pending_mails + .lock() + .await + .iter() + .any(|mail| mail.trigger_turn) + } + + pub(crate) async fn drain_mailbox_input_items(&self) -> Vec { + self.mailbox_pending_mails + .lock() + .await + .drain(..) + .map(|mail| mail.to_response_input_item()) + .collect() + } + + pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { + if items.is_empty() { + return; + } + + self.idle_pending_input.lock().await.extend(items); + } + + pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec { + std::mem::take(&mut *self.idle_pending_input.lock().await) + } + + pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { + !self.idle_pending_input.lock().await.is_empty() + } + + pub(crate) async fn turn_state_for_sub_id( + &self, + active_turn: &Mutex>, + sub_id: &str, + ) -> Option>> { + let active = active_turn.lock().await; + active.as_ref().and_then(|active_turn| { + active_turn + .tasks + .contains_key(sub_id) + .then(|| Arc::clone(&active_turn.turn_state)) + }) + } + + /// Clear any pending waiters and input buffered for the current turn. + pub(crate) async fn clear_pending(&self, active_turn: &ActiveTurn) { + let mut turn_state = active_turn.turn_state.lock().await; + turn_state.clear_pending_waiters(); + turn_state.pending_input.items.clear(); + } + + pub(crate) async fn defer_mailbox_delivery_to_next_turn( + &self, + active_turn: &Mutex>, + sub_id: &str, + ) { + let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await; + let Some(turn_state) = turn_state else { + return; + }; + let mut turn_state = turn_state.lock().await; + if !turn_state.pending_input.items.is_empty() { + return; + } + turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn); + } + + pub(crate) async fn accept_mailbox_delivery_for_current_turn( + &self, + active_turn: &Mutex>, + sub_id: &str, + ) { + let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await; + let Some(turn_state) = turn_state else { + return; + }; + self.accept_mailbox_delivery_for_turn_state(turn_state.as_ref()) + .await; + } + + pub(super) async fn accept_mailbox_delivery_for_turn_state( + &self, + turn_state: &Mutex, + ) { + turn_state + .lock() + .await + .accept_mailbox_delivery_for_current_turn(); + } + + pub(super) async fn push_pending_input_and_accept_mailbox_delivery_for_turn_state( + &self, + turn_state: &Mutex, + input: ResponseInputItem, + ) { + let mut turn_state = turn_state.lock().await; + turn_state.pending_input.items.push(input); + turn_state.accept_mailbox_delivery_for_current_turn(); + } + + pub(crate) async fn extend_pending_input_for_turn_state( + &self, + turn_state: &Mutex, + input: Vec, + ) { + turn_state.lock().await.pending_input.items.extend(input); + } + + pub(crate) async fn take_pending_input_for_turn_state( + &self, + turn_state: &Mutex, + ) -> Vec { + turn_state.lock().await.pending_input.items.split_off(0) + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub(crate) async fn inject_response_items( + &self, + active_turn: &Mutex>, + input: Vec, + ) -> Result<(), Vec> { + let mut active = active_turn.lock().await; + match active.as_mut() { + Some(active_turn) => { + active_turn + .turn_state + .lock() + .await + .pending_input + .items + .extend(input); + Ok(()) + } + None => Err(input), + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub(crate) async fn prepend_pending_input( + &self, + active_turn: &Mutex>, + mut input: Vec, + ) -> Result<(), ()> { + let mut active = active_turn.lock().await; + match active.as_mut() { + Some(active_turn) => { + let mut turn_state = active_turn.turn_state.lock().await; + if !input.is_empty() { + let pending_input = &mut turn_state.pending_input; + input.append(&mut pending_input.items); + pending_input.items = input; + } + Ok(()) + } + None => Err(()), + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub(crate) async fn get_pending_input( + &self, + active_turn: &Mutex>, + ) -> Vec { + let (pending_input, accepts_mailbox_delivery) = { + let mut active = active_turn.lock().await; + match active.as_mut() { + Some(active_turn) => { + let mut turn_state = active_turn.turn_state.lock().await; + ( + turn_state.pending_input.items.split_off(0), + turn_state.accepts_mailbox_delivery_for_current_turn(), + ) + } + None => (Vec::new(), true), + } + }; + if !accepts_mailbox_delivery { + return pending_input; + } + let mailbox_items = self.drain_mailbox_input_items().await; + if pending_input.is_empty() { + mailbox_items + } else if mailbox_items.is_empty() { + pending_input + } else { + let mut pending_input = pending_input; + pending_input.extend(mailbox_items); + pending_input + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state reads must remain atomic" + )] + pub(crate) async fn has_pending_input(&self, active_turn: &Mutex>) -> bool { + let (has_turn_pending_input, accepts_mailbox_delivery) = { + let active = active_turn.lock().await; + match active.as_ref() { + Some(active_turn) => { + let turn_state = active_turn.turn_state.lock().await; + ( + !turn_state.pending_input.items.is_empty(), + turn_state.accepts_mailbox_delivery_for_current_turn(), + ) + } + None => (false, true), + } + }; + if has_turn_pending_input { + return true; + } + if !accepts_mailbox_delivery { + return false; + } + self.has_pending_mailbox_items().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::AgentPath; + use pretty_assertions::assert_eq; + + fn make_mail( + author: AgentPath, + recipient: AgentPath, + content: &str, + trigger_turn: bool, + ) -> InterAgentCommunication { + InterAgentCommunication::new( + author, + recipient, + Vec::new(), + content.to_string(), + trigger_turn, + ) + } + + #[tokio::test] + async fn input_queue_notifies_mailbox_subscribers() { + let input_queue = InputQueue::new(); + let mut mailbox_rx = input_queue.subscribe_mailbox().await; + + input_queue + .enqueue_mailbox_communication(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + )) + .await; + input_queue + .enqueue_mailbox_communication(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "two", + /*trigger_turn*/ false, + )) + .await; + + mailbox_rx.changed().await.expect("mailbox update"); + } + + #[tokio::test] + async fn input_queue_drains_mailbox_in_delivery_order() { + let input_queue = InputQueue::new(); + let mail_one = make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + ); + let mail_two = make_mail( + AgentPath::try_from("/root/worker").expect("agent path"), + AgentPath::root(), + "two", + /*trigger_turn*/ false, + ); + + input_queue + .enqueue_mailbox_communication(mail_one.clone()) + .await; + input_queue + .enqueue_mailbox_communication(mail_two.clone()) + .await; + + assert_eq!( + input_queue.drain_mailbox_input_items().await, + vec![ + mail_one.to_response_input_item(), + mail_two.to_response_input_item() + ] + ); + assert!(!input_queue.has_pending_mailbox_items().await); + } + + #[tokio::test] + async fn input_queue_tracks_pending_trigger_turn_mail() { + let input_queue = InputQueue::new(); + + input_queue + .enqueue_mailbox_communication(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "queued", + /*trigger_turn*/ false, + )) + .await; + assert!(!input_queue.has_trigger_turn_mailbox_items().await); + + input_queue + .enqueue_mailbox_communication(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "wake", + /*trigger_turn*/ true, + )) + .await; + assert!(input_queue.has_trigger_turn_mailbox_items().await); + } +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 02d9e75d4d..4db6c97e4b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -10,8 +10,6 @@ use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; -use crate::agent::Mailbox; -use crate::agent::MailboxReceiver; use crate::agent::agent_status_from_event; use crate::agent::status::is_final; use crate::attestation::AttestationProvider; @@ -193,6 +191,7 @@ use codex_protocol::exec_output::StreamOutput; mod config_lock; mod handlers; +mod input_queue; mod mcp; mod multi_agents; mod review; @@ -206,6 +205,7 @@ use self::config_lock::validate_config_lock_if_configured; #[cfg(test)] use self::handlers::submission_dispatch_span; use self::handlers::submission_loop; +pub(crate) use self::input_queue::TurnInputQueue; use self::review::spawn_review_thread; use self::session::AppServerClientMetadata; use self::session::Session; @@ -289,8 +289,6 @@ use crate::rollout::map_session_init_error; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; use crate::shell_snapshot::ShellSnapshot; -use crate::state::ActiveTurn; -use crate::state::MailboxDeliveryPhase; use crate::state::PendingRequestPermissions; use crate::state::SessionServices; use crate::state::SessionState; @@ -3163,195 +3161,36 @@ impl Session { .set_responsesapi_client_metadata(responsesapi_client_metadata); } - let mut turn_state = active_turn.turn_state.lock().await; - turn_state.push_pending_input(input.into()); - turn_state.accept_mailbox_delivery_for_current_turn(); + self.input_queue + .push_pending_input_and_accept_mailbox_delivery_for_turn_state( + active_turn.turn_state.as_ref(), + input.into(), + ) + .await; Ok(active_turn_id.clone()) } /// Returns the input if there was no task running to inject into. - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state updates must remain atomic" - )] pub async fn inject_response_items( &self, input: Vec, ) -> Result<(), Vec> { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - for item in input { - ts.push_pending_input(item); - } - Ok(()) - } - None => Err(input), - } - } - - pub(crate) async fn defer_mailbox_delivery_to_next_turn(&self, sub_id: &str) { - let turn_state = self.turn_state_for_sub_id(sub_id).await; - let Some(turn_state) = turn_state else { - return; - }; - let mut turn_state = turn_state.lock().await; - if turn_state.has_pending_input() { - return; - } - turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn); - } - - pub(crate) async fn accept_mailbox_delivery_for_current_turn(&self, sub_id: &str) { - let turn_state = self.turn_state_for_sub_id(sub_id).await; - let Some(turn_state) = turn_state else { - return; - }; - turn_state - .lock() + self.input_queue + .inject_response_items(&self.active_turn, input) .await - .set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn); } pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) { - let turn_state = self.turn_state_for_sub_id(sub_id).await; + let turn_state = self + .input_queue + .turn_state_for_sub_id(&self.active_turn, sub_id) + .await; let Some(turn_state) = turn_state else { return; }; turn_state.lock().await.has_memory_citation = true; } - async fn turn_state_for_sub_id( - &self, - sub_id: &str, - ) -> Option>> { - let active = self.active_turn.lock().await; - active.as_ref().and_then(|active_turn| { - active_turn - .tasks - .contains_key(sub_id) - .then(|| Arc::clone(&active_turn.turn_state)) - }) - } - - pub(crate) fn subscribe_mailbox_seq(&self) -> watch::Receiver { - self.mailbox.subscribe() - } - - pub(crate) fn enqueue_mailbox_communication(&self, communication: InterAgentCommunication) { - self.mailbox.send(communication); - } - - pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool { - self.mailbox_rx.lock().await.has_pending_trigger_turn() - } - - pub(crate) async fn has_pending_mailbox_items(&self) -> bool { - self.mailbox_rx.lock().await.has_pending() - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state updates must remain atomic" - )] - pub async fn prepend_pending_input(&self, input: Vec) -> Result<(), ()> { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - ts.prepend_pending_input(input); - Ok(()) - } - None => Err(()), - } - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state updates must remain atomic" - )] - pub async fn get_pending_input(&self) -> Vec { - let (pending_input, accepts_mailbox_delivery) = { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - ( - ts.take_pending_input(), - ts.accepts_mailbox_delivery_for_current_turn(), - ) - } - None => (Vec::new(), true), - } - }; - if !accepts_mailbox_delivery { - return pending_input; - } - let mailbox_items = { - let mut mailbox_rx = self.mailbox_rx.lock().await; - mailbox_rx - .drain() - .into_iter() - .map(|mail| mail.to_response_input_item()) - .collect::>() - }; - if pending_input.is_empty() { - mailbox_items - } else if mailbox_items.is_empty() { - pending_input - } else { - let mut pending_input = pending_input; - pending_input.extend(mailbox_items); - pending_input - } - } - - /// Queue response items to be injected into the next active turn created for this session. - pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { - if items.is_empty() { - return; - } - - let mut idle_pending_input = self.idle_pending_input.lock().await; - idle_pending_input.extend(items); - } - - pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec { - std::mem::take(&mut *self.idle_pending_input.lock().await) - } - - pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { - !self.idle_pending_input.lock().await.is_empty() - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state reads must remain atomic" - )] - pub async fn has_pending_input(&self) -> bool { - let (has_turn_pending_input, accepts_mailbox_delivery) = { - let active = self.active_turn.lock().await; - match active.as_ref() { - Some(at) => { - let ts = at.turn_state.lock().await; - ( - ts.has_pending_input(), - ts.accepts_mailbox_delivery_for_current_turn(), - ) - } - None => (false, true), - } - }; - if has_turn_pending_input { - return true; - } - if !accepts_mailbox_delivery { - return false; - } - self.has_pending_mailbox_items().await - } - pub async fn interrupt_task(self: &Arc) { info!("interrupt received: abort current task, if any"); let had_active_turn = self.active_turn.lock().await.is_some(); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 925ee2df59..082308f20d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,5 +1,7 @@ +use super::input_queue::InputQueue; use super::*; use crate::goals::GoalRuntimeState; +use crate::state::ActiveTurn; use codex_protocol::SessionId; use codex_protocol::config_types::ServiceTier; use codex_protocol::permissions::FileSystemPath; @@ -27,9 +29,7 @@ pub(crate) struct Session { pub(super) pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, - pub(super) mailbox: Mailbox, - pub(super) mailbox_rx: Mutex, - pub(super) idle_pending_input: Mutex>, // TODO (jif) merge with mailbox! + pub(crate) input_queue: InputQueue, pub(crate) goal_runtime: GoalRuntimeState, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, @@ -950,7 +950,6 @@ impl Session { let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) = watch::channel(false); - let (mailbox, mailbox_rx) = Mailbox::new(); let sess = Arc::new(Session { conversation_id: thread_id, installation_id, @@ -963,9 +962,7 @@ impl Session { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), - mailbox, - mailbox_rx: Mutex::new(mailbox_rx), - idle_pending_input: Mutex::new(Vec::new()), + input_queue: InputQueue::new(), goal_runtime: GoalRuntimeState::new(), guardian_review_session: GuardianReviewSessionManager::default(), services, @@ -1130,7 +1127,7 @@ impl Session { }; // record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted. - sess.record_initial_history(initial_history).await; + Box::pin(sess.record_initial_history(initial_history)).await; { let mut state = sess.state.lock().await; state.set_pending_session_start_source(Some(session_start_source)); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index e3f038eff7..4486d287c5 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4394,7 +4394,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*goal_tools_supported*/ true, ); - let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Session { conversation_id: thread_id, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), @@ -4407,9 +4406,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), - mailbox, - mailbox_rx: Mutex::new(mailbox_rx), - idle_pending_input: Mutex::new(Vec::new()), + input_queue: super::input_queue::InputQueue::new(), goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, @@ -6252,7 +6249,6 @@ where /*goal_tools_supported*/ true, )); - let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Arc::new(Session { conversation_id: thread_id, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), @@ -6265,9 +6261,7 @@ where pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), - mailbox, - mailbox_rx: Mutex::new(mailbox_rx), - idle_pending_input: Mutex::new(Vec::new()), + input_queue: super::input_queue::InputQueue::new(), goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, @@ -8098,7 +8092,7 @@ async fn steer_input_returns_active_turn_id() { .expect("steering with matching expected turn id should succeed"); assert_eq!(turn_id, tc.sub_id); - assert!(sess.has_pending_input().await); + assert!(sess.input_queue.has_pending_input(&sess.active_turn).await); } #[tokio::test] @@ -8144,7 +8138,7 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { .await .expect("inject initial pending input into active turn"); - let drained = sess.get_pending_input().await; + let drained = sess.input_queue.get_pending_input(&sess.active_turn).await; assert_eq!(drained, vec![blocked, later.clone()]); sess.inject_response_items(vec![newer.clone()]) @@ -8153,11 +8147,15 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { let mut drained_iter = drained.into_iter(); let _blocked = drained_iter.next().expect("blocked prompt should exist"); - sess.prepend_pending_input(drained_iter.collect()) + sess.input_queue + .prepend_pending_input(&sess.active_turn, drained_iter.collect()) .await .expect("requeue later pending input at the front of the queue"); - assert_eq!(sess.get_pending_input().await, vec![later, newer]); + assert_eq!( + sess.input_queue.get_pending_input(&sess.active_turn).await, + vec![later, newer] + ); } #[tokio::test] @@ -8171,7 +8169,8 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() { phase: None, }; - sess.queue_response_items_for_next_turn(vec![queued_item.clone()]) + sess.input_queue + .queue_response_items_for_next_turn(vec![queued_item.clone()]) .await; sess.spawn_task( @@ -8184,7 +8183,10 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() { ) .await; - assert_eq!(sess.get_pending_input().await, vec![queued_item]); + assert_eq!( + sess.input_queue.get_pending_input(&sess.active_turn).await, + vec![queued_item] + ); } #[tokio::test] @@ -8198,13 +8200,18 @@ async fn idle_interrupt_does_not_wake_queued_next_turn_items() { phase: None, }; - sess.queue_response_items_for_next_turn(vec![queued_item]) + sess.input_queue + .queue_response_items_for_next_turn(vec![queued_item]) .await; sess.abort_all_tasks(TurnAbortReason::Interrupted).await; assert!(sess.active_turn.lock().await.is_none()); - assert!(sess.has_queued_response_items_for_next_turn().await); + assert!( + sess.input_queue + .has_queued_response_items_for_next_turn() + .await + ); } #[tokio::test] @@ -8222,16 +8229,17 @@ async fn abort_empty_active_turn_preserves_pending_input() { let active_turn = active.get_or_insert_with(ActiveTurn::default); Arc::clone(&active_turn.turn_state) }; - turn_state - .lock() - .await - .push_pending_input(pending_item.clone()); + sess.input_queue + .extend_pending_input_for_turn_state(turn_state.as_ref(), vec![pending_item.clone()]) + .await; sess.abort_all_tasks(TurnAbortReason::Replaced).await; assert!(sess.active_turn.lock().await.is_none()); assert_eq!( - turn_state.lock().await.take_pending_input(), + sess.input_queue + .take_pending_input_for_turn_state(turn_state.as_ref()) + .await, vec![pending_item] ); } @@ -8593,7 +8601,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh }) .await?; - let pending_input = sess.get_pending_input().await; + let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; let [ResponseInputItem::Message { role, content, .. }] = pending_input.as_slice() else { panic!("expected one budget-limit steering message, got {pending_input:#?}"); }; @@ -8814,7 +8822,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { }) .await?; - let pending_input = sess.get_pending_input().await; + let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; assert!( pending_input.iter().any(|item| { matches!( @@ -9021,19 +9029,26 @@ async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(communication.clone()); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; assert!( - !sess.has_pending_input().await, + !sess.input_queue.has_pending_input(&sess.active_turn).await, "queue-only mailbox mail should stay buffered once the current turn emitted its answer" ); - assert_eq!(sess.get_pending_input().await, Vec::new()); + assert_eq!( + sess.input_queue.get_pending_input(&sess.active_turn).await, + Vec::new() + ); sess.abort_all_tasks(TurnAbortReason::Replaced).await; assert_eq!( - sess.get_pending_input().await, + sess.input_queue.get_pending_input(&sess.active_turn).await, vec![communication.to_response_input_item()], ); } @@ -9051,23 +9066,27 @@ async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(InterAgentCommunication::new( - AgentPath::try_from("/root/worker").expect("worker path should parse"), - AgentPath::root(), - Vec::new(), - "late trigger update".to_string(), - /*trigger_turn*/ true, - )); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "late trigger update".to_string(), + /*trigger_turn*/ true, + )) + .await; assert!( - !sess.has_pending_input().await, + !sess.input_queue.has_pending_input(&sess.active_turn).await, "trigger-turn mailbox mail should not extend the current turn after its answer boundary" ); sess.abort_all_tasks(TurnAbortReason::Replaced).await; - assert!(sess.has_trigger_turn_mailbox_items().await); + assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); } #[tokio::test] @@ -9090,8 +9109,12 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(communication.clone()); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; sess.steer_input( vec![UserInput::Text { text: "follow up".to_string(), @@ -9104,7 +9127,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { .expect("steered input should be accepted"); assert_eq!( - sess.get_pending_input().await, + sess.input_queue.get_pending_input(&sess.active_turn).await, vec![ ResponseInputItem::from(vec![UserInput::Text { text: "follow up".to_string(), @@ -9135,8 +9158,12 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(communication.clone()); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; sess.steer_input( vec![UserInput::Text { text: "follow up".to_string(), @@ -9148,10 +9175,12 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { .await .expect("steered input should be accepted"); - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; assert_eq!( - sess.get_pending_input().await, + sess.input_queue.get_pending_input(&sess.active_turn).await, vec![ ResponseInputItem::from(vec![UserInput::Text { text: "follow up".to_string(), @@ -9182,8 +9211,12 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(communication.clone()); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; let item = ResponseItem::FunctionCall { id: None, @@ -9207,7 +9240,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { assert!(output.needs_follow_up); assert!(output.tool_future.is_some()); assert_eq!( - sess.get_pending_input().await, + sess.input_queue.get_pending_input(&sess.active_turn).await, vec![communication.to_response_input_item()], ); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index ab316440e5..0c5e00927a 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -148,7 +148,7 @@ pub(crate) async fn run_turn( prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { - if input.is_empty() && !sess.has_pending_input().await { + if input.is_empty() && !sess.input_queue.has_pending_input(&sess.active_turn).await { return None; } @@ -413,7 +413,7 @@ pub(crate) async fn run_turn( // submitted through the UI while the model was running. Though the UI // may support this, the model might not. let pending_input = if can_drain_pending_input { - sess.get_pending_input().await + sess.input_queue.get_pending_input(&sess.active_turn).await } else { Vec::new() }; @@ -434,7 +434,10 @@ pub(crate) async fn run_turn( } => { let remaining_pending_input = pending_input_iter.collect::>(); if !remaining_pending_input.is_empty() { - let _ = sess.prepend_pending_input(remaining_pending_input).await; + let _ = sess + .input_queue + .prepend_pending_input(&sess.active_turn, remaining_pending_input) + .await; requeued_pending_input = true; } blocked_pending_input_contexts = additional_contexts; @@ -494,7 +497,7 @@ pub(crate) async fn run_turn( last_agent_message: sampling_request_last_agent_message, } = sampling_request_output; can_drain_pending_input = true; - let has_pending_input = sess.has_pending_input().await; + let has_pending_input = sess.input_queue.has_pending_input(&sess.active_turn).await; let needs_follow_up = model_needs_follow_up || has_pending_input; let total_usage_tokens = sess.get_total_token_usage().await; let token_limit_reached = total_usage_tokens >= auto_compact_limit; @@ -2065,7 +2068,7 @@ async fn try_run_sampling_request( } needs_follow_up |= output_result.needs_follow_up; // todo: remove before stabilizing multi-agent v2 - if preempt_for_mailbox_mail && sess.mailbox_rx.lock().await.has_pending() { + if preempt_for_mailbox_mail && sess.input_queue.has_pending_mailbox_items().await { break Ok(SamplingRequestResult { needs_follow_up: true, last_agent_message, diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 70aae74e87..86438ad56a 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -11,7 +11,6 @@ use tokio_util::task::AbortOnDropHandle; use codex_extension_api::ExtensionData; use codex_protocol::dynamic_tools::DynamicToolResponse; -use codex_protocol::models::ResponseInputItem; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; @@ -20,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use rmcp::model::RequestId; use tokio::sync::oneshot; +use crate::session::TurnInputQueue; use crate::session::turn_context::TurnContext; use crate::tasks::AnySessionTask; use codex_protocol::models::AdditionalPermissionProfile; @@ -115,7 +115,7 @@ pub(crate) struct TurnState { pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, pending_dynamic_tools: HashMap>, - pending_input: Vec, + pub(crate) pending_input: TurnInputQueue, mailbox_delivery_phase: MailboxDeliveryPhase, granted_permissions: Option, strict_auto_review_enabled: bool, @@ -146,13 +146,12 @@ impl TurnState { self.pending_approvals.remove(key) } - pub(crate) fn clear_pending(&mut self) { + pub(crate) fn clear_pending_waiters(&mut self) { self.pending_approvals.clear(); self.pending_request_permissions.clear(); self.pending_user_input.clear(); self.pending_elicitations.clear(); self.pending_dynamic_tools.clear(); - self.pending_input.clear(); } pub(crate) fn insert_pending_request_permissions( @@ -220,33 +219,6 @@ impl TurnState { self.pending_dynamic_tools.remove(key) } - pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) { - self.pending_input.push(input); - } - - pub(crate) fn prepend_pending_input(&mut self, mut input: Vec) { - if input.is_empty() { - return; - } - - input.append(&mut self.pending_input); - self.pending_input = input; - } - - pub(crate) fn take_pending_input(&mut self) -> Vec { - if self.pending_input.is_empty() { - Vec::with_capacity(0) - } else { - let mut ret = Vec::new(); - std::mem::swap(&mut ret, &mut self.pending_input); - ret - } - } - - pub(crate) fn has_pending_input(&self) -> bool { - !self.pending_input.is_empty() - } - pub(crate) fn accept_mailbox_delivery_for_current_turn(&mut self) { self.set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn); } @@ -276,11 +248,3 @@ impl TurnState { self.strict_auto_review_enabled } } - -impl ActiveTurn { - /// Clear any pending approvals and input buffered for the current turn. - pub(crate) async fn clear_pending(&self) { - let mut ts = self.turn_state.lock().await; - ts.clear_pending(); - } -} diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index bc44fe0c3b..0e32ccb9fb 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -158,7 +158,8 @@ pub(crate) async fn record_completed_response_item_with_finalized_facts( |facts| facts.defers_mailbox_delivery_to_next_turn, ); if defers_mailbox_delivery { - sess.defer_mailbox_delivery_to_next_turn(&turn_context.sub_id) + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &turn_context.sub_id) .await; } mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await; @@ -351,7 +352,11 @@ pub(crate) async fn handle_output_item_done( // The model emitted a tool call; log it, persist the item immediately, and queue the tool execution. Ok(Some(call)) => { ctx.sess - .accept_mailbox_delivery_for_current_turn(&ctx.turn_context.sub_id) + .input_queue + .accept_mailbox_delivery_for_current_turn( + &ctx.sess.active_turn, + &ctx.turn_context.sub_id, + ) .await; let payload_preview = call.payload.log_payload().into_owned(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 18f650e925..ba7f707927 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -347,24 +347,23 @@ impl Session { { warn!("failed to apply goal runtime turn-start event: {err}"); } - let queued_response_items = self.take_queued_response_items_for_next_turn().await; - let mailbox_items = self.get_pending_input().await; + let queued_response_items = self + .input_queue + .take_queued_response_items_for_next_turn() + .await; + let mailbox_items = self.input_queue.get_pending_input(&self.active_turn).await; let turn_state = { let mut active = self.active_turn.lock().await; let turn = active.get_or_insert_with(ActiveTurn::default); debug_assert!(turn.tasks.is_empty()); Arc::clone(&turn.turn_state) }; - { - let mut turn_state = turn_state.lock().await; - turn_state.token_usage_at_turn_start = token_usage_at_turn_start; - for item in queued_response_items { - turn_state.push_pending_input(item); - } - for item in mailbox_items { - turn_state.push_pending_input(item); - } - } + turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start; + let mut pending_items = queued_response_items; + pending_items.extend(mailbox_items); + self.input_queue + .extend_pending_input_for_turn_state(turn_state.as_ref(), pending_items) + .await; self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()) .await; @@ -468,8 +467,11 @@ impl Session { self: &Arc, sub_id: String, ) { - if !self.has_queued_response_items_for_next_turn().await - && !self.has_trigger_turn_mailbox_items().await + if !self + .input_queue + .has_queued_response_items_for_next_turn() + .await + && !self.input_queue.has_trigger_turn_mailbox_items().await { return; } @@ -521,7 +523,7 @@ impl Session { if let Some(active_turn) = active_turn_to_clear { // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. - active_turn.clear_pending().await; + self.input_queue.clear_pending(&active_turn).await; } if reason == TurnAbortReason::Interrupted && aborted_turn { self.maybe_start_turn_for_pending_work().await; @@ -567,7 +569,7 @@ impl Session { } // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. - active_turn.clear_pending().await; + self.input_queue.clear_pending(&active_turn).await; if reason == TurnAbortReason::Interrupted { self.maybe_start_turn_for_pending_work().await; @@ -609,8 +611,11 @@ impl Session { } }; if let Some(turn_state) = turn_state.as_ref() { - let mut ts = turn_state.lock().await; - pending_input = ts.take_pending_input(); + pending_input = self + .input_queue + .take_pending_input_for_turn_state(turn_state.as_ref()) + .await; + let ts = turn_state.lock().await; turn_had_memory_citation = ts.has_memory_citation; turn_tool_calls = ts.tool_calls; token_usage_at_turn_start = Some(ts.token_usage_at_turn_start.clone()); diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 756a691f11..50414df278 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -80,7 +80,7 @@ impl SessionTask for RegularTask { ) .instrument(run_turn_span.clone()) .await; - if !sess.has_pending_input().await { + if !sess.input_queue.has_pending_input(&sess.active_turn).await { return last_agent_message; } next_input = Vec::new(); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 0044bf8522..a83167ffc5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2791,13 +2791,16 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { }); tokio::task::yield_now().await; - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path, - AgentPath::root(), - Vec::new(), - "hello from worker".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "hello from worker".to_string(), + /*trigger_turn*/ false, + )) + .await; let output = wait_task .await @@ -3276,13 +3279,16 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { }); tokio::task::yield_now().await; - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path, - AgentPath::root(), - Vec::new(), - "completed".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "completed".to_string(), + /*trigger_turn*/ false, + )) + .await; let wait_output = wait_task .await @@ -3346,13 +3352,16 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { .agent_path .expect("worker path"); - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path, - AgentPath::root(), - Vec::new(), - "already queued".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "already queued".to_string(), + /*trigger_turn*/ false, + )) + .await; let output = timeout( Duration::from_millis(500), @@ -3442,13 +3451,16 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { }); tokio::task::yield_now().await; - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_b_path, - AgentPath::root(), - Vec::new(), - "from worker b".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_b_path, + AgentPath::root(), + Vec::new(), + "from worker b".to_string(), + /*trigger_turn*/ false, + )) + .await; let output = wait_task .await @@ -3527,13 +3539,16 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { }); tokio::task::yield_now().await; - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path, - AgentPath::root(), - Vec::new(), - "sensitive child output".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "sensitive child output".to_string(), + /*trigger_turn*/ false, + )) + .await; let output = wait_task .await diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index 8913411b53..285abd9330 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -60,7 +60,7 @@ impl ToolExecutor for Handler { None => default_timeout_ms, }; - let mut mailbox_seq_rx = session.subscribe_mailbox_seq(); + let mut mailbox_rx = session.input_queue.subscribe_mailbox().await; session .send_event( @@ -76,12 +76,8 @@ impl ToolExecutor for Handler { ) .await; - let timed_out = if session.has_pending_mailbox_items().await { - false - } else { - let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); - !wait_for_mailbox_change(&mut mailbox_seq_rx, deadline).await - }; + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + let timed_out = !wait_for_mailbox_change(&mut mailbox_rx, deadline).await; let result = WaitAgentResult::from_timed_out(timed_out); session @@ -153,10 +149,10 @@ impl ToolOutput for WaitAgentResult { } async fn wait_for_mailbox_change( - mailbox_seq_rx: &mut tokio::sync::watch::Receiver, + mailbox_rx: &mut tokio::sync::watch::Receiver<()>, deadline: Instant, ) -> bool { - match timeout_at(deadline, mailbox_seq_rx.changed()).await { + match timeout_at(deadline, mailbox_rx.changed()).await { Ok(Ok(())) => true, Ok(Err(_)) | Err(_) => false, } From a66712c95d63ab4fbd6f2faf67df4ba99b4485e3 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 20:24:09 -0300 Subject: [PATCH 45/53] fix(tui): warn on unsupported iTerm2 pet versions (#23371) ## Why Older iTerm2 builds can be detected as supporting the image transport that terminal pets use, but in practice they fail to render the pet flow correctly. Instead of silently attempting image rendering, Codex should tell the user that their iTerm2 version is too old and that upgrading is the fix. ## What Changed - gate iTerm2 pet auto-detection on version `3.6.0` or newer - show a dedicated upgrade message for older or unknown iTerm2 versions instead of the generic unsupported-terminal warning - keep the existing generic unsupported-terminal path for non-iTerm terminals - add regression coverage for iTerm2 version parsing and the old-iTerm warning path ## How to Test 1. Start Codex in iTerm2 3.6 or newer. 2. Run `/pets`. 3. Confirm the pets picker opens instead of showing a warning. 4. Start Codex in an older iTerm2 build, or exercise the equivalent test path. 5. Run `/pets`. 6. Confirm Codex warns that pets require iTerm2 3.6 or newer and tells the user to upgrade. 7. Also verify that a non-iTerm unsupported terminal still shows the generic unsupported-terminal message. Targeted tests: - `cargo test -p codex-terminal-detection` - `cargo test -p codex-tui pets::` - `cargo test -p codex-tui slash_pets_on_unsupported_terminal` - `cargo test -p codex-tui slash_pets_on_old_iterm2` --- .../src/chatwidget/tests/slash_commands.rs | 50 ++++++++ codex-rs/tui/src/pets/image_protocol.rs | 112 +++++++++++++++++- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e4865e64f3..c8c0f60584 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -15,6 +15,18 @@ fn force_tmux_pet_image_unsupported(chat: &mut ChatWidget) { )); } +fn force_terminal_pet_image_unsupported(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported( + crate::pets::PetImageUnsupportedReason::Terminal, + )); +} + +fn force_old_iterm2_pet_image_unsupported(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported( + crate::pets::PetImageUnsupportedReason::Iterm2TooOld, + )); +} + fn fast_tier_command() -> ServiceTierCommand { ServiceTierCommand { id: ServiceTier::Fast.request_value().to_string(), @@ -1934,6 +1946,44 @@ async fn slash_pets_with_arg_on_unsupported_terminal_warns_without_selection() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +#[serial] +async fn slash_pets_on_unsupported_terminal_shows_terminal_warning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_terminal_pet_image_unsupported(&mut chat); + + chat.dispatch_command(SlashCommand::Pets); + + assert!(!chat.bottom_pane.has_active_view()); + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!(rendered.contains("Pets aren’t available in this terminal.")); + assert!(rendered.contains("Kitty graphics or Sixel support")); +} + +#[tokio::test] +#[serial] +async fn slash_pets_on_old_iterm2_shows_upgrade_warning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_old_iterm2_pet_image_unsupported(&mut chat); + + chat.dispatch_command(SlashCommand::Pets); + + assert!(!chat.bottom_pane.has_active_view()); + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!(rendered.contains("Pets require iTerm2 3.6 or newer.")); + assert!(rendered.contains("Upgrade iTerm2 to use terminal pets.")); +} + #[tokio::test] async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs index cf241aee9f..06b6fc77bc 100644 --- a/codex-rs/tui/src/pets/image_protocol.rs +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -21,6 +21,7 @@ const ESC: &str = "\x1b"; const ST: &str = "\x1b\\"; const KITTY_CHUNK_SIZE: usize = 4096; const SIXEL_CACHE_VERSION: &str = "v2"; +const ITERM2_KITTY_MIN_VERSION: (u64, u64, u64) = (3, 6, 0); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ImageProtocol { @@ -55,6 +56,7 @@ impl PetImageSupport { pub(crate) enum PetImageUnsupportedReason { Tmux, Zellij, + Iterm2TooOld, Terminal, } @@ -67,6 +69,9 @@ impl PetImageUnsupportedReason { Self::Zellij => { "Pets are disabled in Zellij. Terminal images don’t stay reliably pane-local in Zellij. Run Codex outside Zellij to use pets." } + Self::Iterm2TooOld => { + "Pets require iTerm2 3.6 or newer. Upgrade iTerm2 to use terminal pets." + } Self::Terminal => { "Pets aren’t available in this terminal. Terminal pets need image support, and this terminal environment doesn’t expose a supported image protocol. Try a terminal with Kitty graphics or Sixel support, or run Codex outside tmux." } @@ -142,6 +147,10 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { return PetImageSupport::Supported(ImageProtocol::KittyLocalFile); } + if is_iterm2_terminal(info) { + return PetImageSupport::Unsupported(PetImageUnsupportedReason::Iterm2TooOld); + } + if supports_kitty_graphics(info) { return PetImageSupport::Supported(ImageProtocol::Kitty); } @@ -154,6 +163,14 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { } fn supports_iterm2_kitty_graphics(info: &TerminalInfo) -> bool { + is_iterm2_terminal(info) + && version_is_at_least( + info.version.as_deref(), + /*minimum*/ ITERM2_KITTY_MIN_VERSION, + ) +} + +fn is_iterm2_terminal(info: &TerminalInfo) -> bool { matches!(info.name, TerminalName::Iterm2) || terminal_field_contains(info.term_program.as_deref(), "iterm") } @@ -181,6 +198,24 @@ fn terminal_field_contains(value: Option<&str>, needle: &str) -> bool { value.is_some_and(|value| value.to_ascii_lowercase().contains(needle)) } +fn version_is_at_least(version: Option<&str>, minimum: (u64, u64, u64)) -> bool { + parse_dotted_version(version).is_some_and(|version| version >= minimum) +} + +fn parse_dotted_version(version: Option<&str>) -> Option<(u64, u64, u64)> { + let version = version?; + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + + if parts.next().is_some() { + return None; + } + + Some((major, minor, patch)) +} + pub fn kitty_delete_image(image_id: u32) -> String { wrap_for_tmux_if_needed(&format!("{ESC}_Ga=d,d=I,i={image_id},q=2;{ST}")) } @@ -400,16 +435,18 @@ mod tests { #[test] fn pet_image_support_detects_iterm2_kitty_file_graphics() { for info in [ - terminal_info_for_test( + terminal_info_with_version_for_test( TerminalName::Iterm2, /*multiplexer*/ None, Some("iTerm.app"), + Some("3.6.10"), /*term*/ None, ), - terminal_info_for_test( + terminal_info_with_version_for_test( TerminalName::Unknown, /*multiplexer*/ None, Some("iTerm.app"), + Some("3.6.10"), Some("xterm-256color"), ), ] { @@ -420,6 +457,49 @@ mod tests { } } + #[test] + fn pet_image_support_rejects_old_iterm2_versions() { + for info in [ + terminal_info_with_version_for_test( + TerminalName::Iterm2, + /*multiplexer*/ None, + Some("iTerm.app"), + Some("3.5.14"), + /*term*/ None, + ), + terminal_info_with_version_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + Some("iTerm.app"), + /*version*/ None, + Some("xterm-256color"), + ), + terminal_info_with_version_for_test( + TerminalName::Iterm2, + /*multiplexer*/ None, + Some("iTerm.app"), + Some("3.5"), + /*term*/ None, + ), + ] { + assert_eq!( + pet_image_support_for_terminal(&info), + PetImageSupport::Unsupported(PetImageUnsupportedReason::Iterm2TooOld) + ); + } + } + + #[test] + fn pet_image_support_old_iterm2_message_mentions_upgrade() { + let message = PetImageSupport::Unsupported(PetImageUnsupportedReason::Iterm2TooOld) + .unsupported_message(); + + assert_eq!( + message, + Some("Pets require iTerm2 3.6 or newer. Upgrade iTerm2 to use terminal pets.") + ); + } + #[test] fn pet_image_support_detects_kitty_graphics_terminals() { for info in [ @@ -538,16 +618,42 @@ mod tests { multiplexer: Option, term_program: Option<&str>, term: Option<&str>, + ) -> TerminalInfo { + terminal_info_with_version_for_test( + name, + multiplexer, + term_program, + /*version*/ None, + term, + ) + } + + fn terminal_info_with_version_for_test( + name: TerminalName, + multiplexer: Option, + term_program: Option<&str>, + version: Option<&str>, + term: Option<&str>, ) -> TerminalInfo { TerminalInfo { name, term_program: term_program.map(str::to_string), - version: /*version*/ None, + version: version.map(str::to_string), term: term.map(str::to_string), multiplexer, } } + #[test] + fn parse_dotted_version_requires_simple_numeric_components() { + assert_eq!(parse_dotted_version(Some("3.6.10")), Some((3, 6, 10))); + assert_eq!(parse_dotted_version(Some("3.6")), Some((3, 6, 0))); + assert_eq!(parse_dotted_version(Some("3")), Some((3, 0, 0))); + assert_eq!(parse_dotted_version(Some("3.6.10.1")), None); + assert_eq!(parse_dotted_version(Some("3.6beta")), None); + assert_eq!(parse_dotted_version(/*version*/ None), None); + } + #[test] fn sixel_frame_encodes_without_external_crate() { let dir = tempfile::tempdir().unwrap(); From 5696167fe86fd723694521acdacd7223de2ea8cf Mon Sep 17 00:00:00 2001 From: marksteinbrick-oai Date: Mon, 18 May 2026 17:13:49 -0700 Subject: [PATCH 46/53] [codex-analytics] preserve user thread source for exec threads (#23376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why - Follows #20949. - The above moved `thread_source` attribution from the reducer to explicit caller provided metadata - The `codex exec` path still omitted this metadata, leaving exec-created threads without `thread_source` ## What Changed - Ensures exec threads are marked as user created (`thread_source = "user"`) - Preserves thread-source metadata in exec’s startup session event ## Verification - Updated unit tests to validate exec `thread_source` propagation. - `cargo +1.93.0 test -p codex-exec --manifest-path codex-rs/Cargo.toml` - `cargo +1.93.1 build -p codex-cli --manifest-path codex-rs/Cargo.toml` - Validated locally with a freshly built `codex exec` run: - Startup logs showed `thread_source: Some(User)`. - Rollout metadata recorded `"thread_source":"user"`. --- codex-rs/exec/src/lib.rs | 7 +++++- codex-rs/exec/src/lib_tests.rs | 42 +++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 3e2cf21051..761625856e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -40,6 +40,7 @@ use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -974,6 +975,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { permissions, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), + thread_source: Some(ThreadSource::User), ..ThreadStartParams::default() } } @@ -1082,6 +1084,7 @@ fn session_configured_from_thread_start_response( session_configured_from_thread_response( &response.thread.session_id, &response.thread.id, + response.thread.thread_source.map(Into::into), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), @@ -1103,6 +1106,7 @@ fn session_configured_from_thread_resume_response( session_configured_from_thread_response( &response.thread.session_id, &response.thread.id, + response.thread.thread_source.map(Into::into), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), @@ -1133,6 +1137,7 @@ fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { fn session_configured_from_thread_response( session_id: &str, thread_id: &str, + thread_source: Option, thread_name: Option, rollout_path: Option, model: String, @@ -1154,7 +1159,7 @@ fn session_configured_from_thread_response( session_id, thread_id, forked_from_id: None, - thread_source: None, + thread_source, thread_name, model, model_provider_id, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 92dc0678e5..2f04b2d260 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -458,6 +458,25 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() ); } +#[tokio::test] +async fn thread_start_params_include_user_thread_source() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); + + let params = thread_start_params_from_config(&config); + + assert_eq!( + params.thread_source, + Some(codex_app_server_protocol::ThreadSource::User) + ); +} + #[test] fn active_profile_selection_uses_profile_id_only() { let selection = permissions_selection_from_active_profile(ActivePermissionProfile::new( @@ -548,6 +567,27 @@ async fn session_configured_from_thread_response_uses_permission_profile_from_co ); } +#[tokio::test] +async fn session_configured_from_thread_response_preserves_thread_source() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); + let response = sample_thread_start_response(); + + let event = session_configured_from_thread_start_response(&response, &config) + .expect("build bootstrap session configured event"); + + assert_eq!( + event.thread_source, + Some(codex_protocol::protocol::ThreadSource::User) + ); +} + fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { thread: codex_app_server_protocol::Thread { @@ -564,7 +604,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Cli, - thread_source: None, + thread_source: Some(codex_app_server_protocol::ThreadSource::User), agent_nickname: None, agent_role: None, git_info: None, From 3fd79b7986b8019acb35a8e3a28ae32b67ca31ac Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 18 May 2026 17:28:50 -0700 Subject: [PATCH 47/53] app-server: use profile ids in v2 permission params (#23360) ## Why The v2 app-server permission profile fields are experimental, but the previous migration kept a legacy object payload for profile selection. That made clients aware of server-owned `activePermissionProfile` metadata such as `extends`, and it kept a `legacy_additional_writable_roots` path even though `runtimeWorkspaceRoots` now owns runtime workspace-root selection. This PR makes the client contract match the intended model: clients select a permission profile by id, and the server resolves and reports active profile provenance in response payloads. Follow-up to #22611. ## What Changed - Changed `thread/start`, `thread/resume`, `thread/fork`, and `turn/start` permission profile selection to plain profile id strings. - Changed `command/exec.permissionProfile` to a plain profile id string for the same client/server ownership split. - Removed `PermissionProfileSelectionParams` and the legacy `{ type: "profile", modifications: [...] }` compatibility deserializer. - Updated app-server, TUI, and `codex exec` call sites to send only ids, while keeping `activePermissionProfile` as server response metadata. - Updated app-server docs and schema fixtures for the revised `command/exec.permissionProfile` shape. ## Verification - `cargo test -p codex-app-server-protocol` - `RUST_MIN_STACK=8388608 cargo test -p codex-app-server` - `cargo test -p codex-exec` - `RUST_MIN_STACK=8388608 cargo test -p codex-tui` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23360). * #23368 * __->__ #23360 --- .../schema/json/ClientRequest.json | 20 ---- .../schema/json/v2/CommandExecParams.json | 20 ---- codex-rs/app-server-protocol/src/export.rs | 16 +-- .../src/protocol/common.rs | 3 +- .../src/protocol/v2/command_exec.rs | 5 +- .../src/protocol/v2/permissions.rs | 98 ------------------- .../src/protocol/v2/tests.rs | 77 +++++++-------- .../src/protocol/v2/thread.rs | 13 +-- .../src/protocol/v2/turn.rs | 5 +- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/request_processors.rs | 1 - .../command_exec_processor.rs | 14 +-- .../request_processors/thread_processor.rs | 9 +- .../src/request_processors/thread_summary.rs | 23 ----- .../thread_summary_tests.rs | 40 -------- .../src/request_processors/turn_processor.rs | 7 +- .../app-server/tests/suite/v2/command_exec.rs | 12 +-- .../app-server/tests/suite/v2/turn_start.rs | 8 +- codex-rs/exec/src/lib.rs | 11 +-- codex-rs/exec/src/lib_tests.rs | 7 +- codex-rs/tui/src/app_server_session.rs | 25 ++--- 21 files changed, 79 insertions(+), 337 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index b976c9e4ef..2621a0bd1e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,26 +5,6 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, - "ActivePermissionProfile": { - "properties": { - "extends": { - "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index b6e939386b..d00a0b60e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -5,26 +5,6 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, - "ActivePermissionProfile": { - "properties": { - "extends": { - "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - }, "CommandExecTerminalSize": { "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index d44b67825d..fcaa8a1eb5 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2747,7 +2747,6 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k let _guard = TempDirGuard(output_dir.clone()); let path = output_dir.join("CommandExecParams.ts"); let content = r#"import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; -import type { ActivePermissionProfile } from "./ActivePermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; export type CommandExecParams = {/** @@ -2770,12 +2769,12 @@ size?: CommandExecTerminalSize | null, /** */ sandboxPolicy?: SandboxPolicy | null, /** - * Optional active permissions profile for this command. + * Optional active permissions profile id for this command. * * Defaults to the user's configured permissions when omitted. Cannot be * combined with `sandboxPolicy`. */ -permissionProfile?: ActivePermissionProfile | null}; +permissionProfile?: string | null}; "#; fs::write(&path, content)?; @@ -2788,16 +2787,7 @@ permissionProfile?: ActivePermissionProfile | null}; filter_experimental_type_fields_ts(&output_dir, &[&CUSTOM_FIELD])?; let filtered = fs::read_to_string(&path)?; - assert_eq!( - filtered.contains("permissionProfile?: ActivePermissionProfile"), - false - ); - assert_eq!( - filtered.contains( - r#"import type { ActivePermissionProfile } from "./ActivePermissionProfile";"# - ), - false - ); + assert_eq!(filtered.contains("permissionProfile?: string"), false); assert_eq!(filtered.contains("sandboxPolicy?: SandboxPolicy"), true); assert_eq!( filtered.contains(r#"import type { SandboxPolicy } from "./SandboxPolicy";"#), diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 4e6cef308e..7a7fe5642c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1555,6 +1555,7 @@ mod tests { use anyhow::Result; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeOutputModality; @@ -2994,7 +2995,7 @@ mod tests { env: None, size: None, sandbox_policy: None, - permission_profile: Some(v2::ActivePermissionProfile::read_only()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs index d476dcb90e..8f913b5bcf 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -1,4 +1,3 @@ -use super::ActivePermissionProfile; use super::SandboxPolicy; use codex_experimental_api_macros::ExperimentalApi; use schemars::JsonSchema; @@ -100,13 +99,13 @@ pub struct CommandExecParams { /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Optional active permissions profile for this command. + /// Optional active permissions profile id for this command. /// /// Defaults to the user's configured permissions when omitted. Cannot be /// combined with `sandboxPolicy`. #[experimental("command/exec.permissionProfile")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permission_profile: Option, } /// Final buffered result for `command/exec`. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 3299695497..f00bcfaefb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -18,9 +18,7 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; -use serde::Deserializer; use serde::Serialize; -use serde::Serializer; use std::num::NonZeroUsize; use std::path::PathBuf; use ts_rs::TS; @@ -333,102 +331,6 @@ impl From for CoreActivePermissionProfile { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PermissionProfileSelectionParams { - id: String, - legacy_additional_writable_roots: Vec, -} - -impl PermissionProfileSelectionParams { - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - legacy_additional_writable_roots: Vec::new(), - } - } - - pub fn id(&self) -> &str { - &self.id - } - - pub fn into_id(self) -> String { - self.id - } - - pub fn legacy_additional_writable_roots(&self) -> &[AbsolutePathBuf] { - &self.legacy_additional_writable_roots - } -} - -impl From for PermissionProfileSelectionParams { - fn from(id: String) -> Self { - Self::new(id) - } -} - -impl Serialize for PermissionProfileSelectionParams { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.id) - } -} - -impl<'de> Deserialize<'de> for PermissionProfileSelectionParams { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum Wire { - Id(String), - LegacyProfile { - #[serde(rename = "type")] - _type: LegacyPermissionProfileSelectionType, - id: String, - #[serde(default)] - modifications: Option>, - }, - } - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - enum LegacyPermissionProfileSelectionType { - Profile, - } - - #[derive(Deserialize)] - #[serde(tag = "type", rename_all = "camelCase")] - enum LegacyPermissionProfileModificationParams { - #[serde(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, - } - - match Wire::deserialize(deserializer)? { - Wire::Id(id) => Ok(Self::new(id)), - Wire::LegacyProfile { - id, modifications, .. - } => { - let legacy_additional_writable_roots = modifications - .unwrap_or_default() - .into_iter() - .map(|modification| match modification { - LegacyPermissionProfileModificationParams::AdditionalWritableRoot { - path, - } => path, - }) - .collect(); - Ok(Self { - id, - legacy_additional_writable_roots, - }) - } - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 40ec247338..b445fc4d01 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -14,6 +14,7 @@ use codex_protocol::mcp::CallToolResult; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ImageDetail; use codex_protocol::models::MessagePhase; @@ -614,57 +615,49 @@ fn permissions_request_approval_response_accepts_strict_auto_review() { } #[test] -fn permission_profile_selection_accepts_legacy_object_shape() { - let additional_root = absolute_path("additional-root"); - let params = json!({ - "permissions": { - "type": "profile", - "id": ":workspace", - "modifications": [ - { - "type": "additionalWritableRoot", - "path": additional_root, - } - ], - }, - }); - - let start: ThreadStartParams = - serde_json::from_value(params.clone()).expect("thread/start params deserialize"); - assert_legacy_permission_profile_selection(start.permissions, &additional_root); - - let resume: ThreadResumeParams = serde_json::from_value(json!({ - "threadId": "thread-1", - "permissions": params["permissions"].clone(), +fn permission_profile_selection_uses_id_string() { + let start: ThreadStartParams = serde_json::from_value(json!({ + "permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE, })) - .expect("thread/resume params deserialize"); - assert_legacy_permission_profile_selection(resume.permissions, &additional_root); - - let fork: ThreadForkParams = serde_json::from_value(json!({ - "threadId": "thread-1", - "permissions": params["permissions"].clone(), - })) - .expect("thread/fork params deserialize"); - assert_legacy_permission_profile_selection(fork.permissions, &additional_root); + .expect("thread/start params deserialize"); + assert_eq!( + start.permissions, + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); let turn: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread-1", "input": [], - "permissions": params["permissions"].clone(), + "permissions": "dev", })) .expect("turn/start params deserialize"); - assert_legacy_permission_profile_selection(turn.permissions, &additional_root); -} + assert_eq!(turn.permissions, Some("dev".to_string())); -fn assert_legacy_permission_profile_selection( - selection: Option, - additional_root: &AbsolutePathBuf, -) { - let selection = selection.expect("permissions should be present"); - assert_eq!(selection.id(), ":workspace"); + let command: CommandExecParams = serde_json::from_value(json!({ + "command": ["echo", "hello"], + "permissionProfile": "dev", + })) + .expect("command/exec params deserialize"); + assert_eq!(command.permission_profile, Some("dev".to_string())); + + let resume: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + })) + .expect("thread/resume params deserialize"); assert_eq!( - selection.legacy_additional_writable_roots(), - std::slice::from_ref(additional_root) + resume.permissions, + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); + + let fork: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + })) + .expect("thread/fork params deserialize"); + assert_eq!( + fork.permissions, + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) ); } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 53f942cf80..41994e59b6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1,7 +1,6 @@ use super::ActivePermissionProfile; use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfileSelectionParams; use super::SandboxMode; use super::SandboxPolicy; use super::Thread; @@ -122,10 +121,8 @@ pub struct ThreadStartParams { pub sandbox: Option, /// Named profile id for this thread. Cannot be combined with `sandbox`. #[experimental("thread/start.permissions")] - #[schemars(with = "Option")] - #[ts(type = "string | null")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -284,10 +281,8 @@ pub struct ThreadResumeParams { /// Named profile id for the resumed thread. Cannot be combined with /// `sandbox`. #[experimental("thread/resume.permissions")] - #[schemars(with = "Option")] - #[ts(type = "string | null")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -395,10 +390,8 @@ pub struct ThreadForkParams { /// Named profile id for the forked thread. Cannot be combined with /// `sandbox`. #[experimental("thread/fork.permissions")] - #[schemars(with = "Option")] - #[ts(type = "string | null")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 8781dd2bd5..50660af1d7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -1,6 +1,5 @@ use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfileSelectionParams; use super::SandboxPolicy; use super::Turn; use codex_experimental_api_macros::ExperimentalApi; @@ -85,10 +84,8 @@ pub struct TurnStartParams { /// Select a named permissions profile id for this turn and subsequent /// turns. Cannot be combined with `sandboxPolicy`. #[experimental("turn/start.permissions")] - #[schemars(with = "Option")] - #[ts(type = "string | null")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4f9cb7c344..e2978861de 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -927,7 +927,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "permissionProfile": { "id": ":workspace", "extends": null }, // optional; defaults to user config + "permissionProfile": ":workspace", // optional profile id; defaults to user config "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index e941caca09..ba28840f50 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,7 +103,6 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 2b07588c8a..1219eb17f9 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -117,13 +117,6 @@ impl CommandExecRequestProcessor { "`permissionProfile` cannot be combined with `sandboxPolicy`", )); } - let permission_profile = if let Some(active_permission_profile) = permission_profile { - Some(PermissionProfileSelectionParams::new( - active_permission_profile.id, - )) - } else { - None - }; if size.is_some() && !tty { return Err(invalid_params("command/exec size requires tty: true")); @@ -199,14 +192,11 @@ impl CommandExecRequestProcessor { network_proxy_permission_profile, managed_network_requirements_enabled, ) = if let Some(permission_profile) = permission_profile { - let mut overrides = ConfigOverrides { + let overrides = ConfigOverrides { cwd: Some(cwd.to_path_buf()), + default_permissions: Some(permission_profile), ..Default::default() }; - apply_permission_profile_selection_to_config_overrides( - &mut overrides, - Some(permission_profile), - ); let config = self .config_manager .load_for_cwd( diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 21160950ab..dd2dd6a57e 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1239,17 +1239,18 @@ impl ThreadRequestProcessor { approval_policy: Option, approvals_reviewer: Option, sandbox: Option, - permissions: Option, + permissions: Option, base_instructions: Option, developer_instructions: Option, personality: Option, ) -> ConfigOverrides { - let mut overrides = ConfigOverrides { + ConfigOverrides { model, model_provider, service_tier, cwd: cwd.map(PathBuf::from), workspace_roots: runtime_workspace_roots, + default_permissions: permissions, approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), approvals_reviewer: approvals_reviewer @@ -1261,9 +1262,7 @@ impl ThreadRequestProcessor { developer_instructions, personality, ..Default::default() - }; - apply_permission_profile_selection_to_config_overrides(&mut overrides, permissions); - overrides + } } fn parse_environment_selections( diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 63ceeb55e2..fea49b1637 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -175,29 +175,6 @@ pub(super) fn thread_response_active_permission_profile( active_permission_profile.map(Into::into) } -pub(super) fn apply_permission_profile_selection_to_config_overrides( - overrides: &mut ConfigOverrides, - permissions: Option, -) { - let Some(selection) = permissions else { - return; - }; - overrides.default_permissions = Some(selection.id().to_string()); - if selection.legacy_additional_writable_roots().is_empty() { - return; - } - - let legacy_roots = selection - .legacy_additional_writable_roots() - .iter() - .map(AbsolutePathBuf::to_path_buf); - if let Some(workspace_roots) = overrides.workspace_roots.as_mut() { - workspace_roots.extend(legacy_roots); - } else { - overrides.additional_writable_roots.extend(legacy_roots); - } -} - pub(super) fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, cwd: &Path, diff --git a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs index 699db34ed4..f8902e132d 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -66,43 +66,3 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { assert_eq!(summary, expected); Ok(()) } - -#[test] -fn legacy_permission_profile_modifications_extend_runtime_roots() -> Result<()> { - let root = if cfg!(windows) { - AbsolutePathBuf::try_from("C:\\workspace-extra")? - } else { - AbsolutePathBuf::try_from("/workspace-extra")? - }; - let selection = serde_json::from_value::(json!({ - "type": "profile", - "id": ":workspace", - "modifications": [ - { - "type": "additionalWritableRoot", - "path": root, - } - ], - }))?; - - let mut overrides = ConfigOverrides::default(); - apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection.clone())); - assert_eq!( - overrides.default_permissions, - Some(":workspace".to_string()) - ); - assert_eq!( - overrides.additional_writable_roots, - vec![root.to_path_buf()] - ); - - let mut overrides = ConfigOverrides { - workspace_roots: Some(Vec::new()), - ..ConfigOverrides::default() - }; - apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection)); - assert_eq!(overrides.additional_writable_roots, Vec::::new()); - assert_eq!(overrides.workspace_roots, Some(vec![root.to_path_buf()])); - - Ok(()) -} diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 54fc0383e4..55a4e3a838 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -425,7 +425,7 @@ impl TurnRequestProcessor { "turn/start permission selection missing thread snapshot", )); }; - let mut overrides = ConfigOverrides { + let overrides = ConfigOverrides { cwd: cwd.clone(), workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( || { @@ -436,14 +436,11 @@ impl TurnRequestProcessor { .collect() }, )), + default_permissions: Some(permissions), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() }; - apply_permission_profile_selection_to_config_overrides( - &mut overrides, - Some(permissions), - ); let config = self .config_manager .load_for_cwd( diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index e4c573e72f..b4038299cf 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -5,7 +5,6 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::STANDARD; -use codex_app_server_protocol::ActivePermissionProfile; use codex_app_server_protocol::CommandExecOutputDeltaNotification; use codex_app_server_protocol::CommandExecOutputStream; use codex_app_server_protocol::CommandExecParams; @@ -18,6 +17,7 @@ use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::Path; @@ -219,7 +219,7 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { env: None, size: None, sandbox_policy: None, - permission_profile: Some(ActivePermissionProfile::read_only()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) .await?; @@ -270,7 +270,7 @@ async fn command_exec_permission_profile_starts_selected_network_proxy() -> Resu env: None, size: None, sandbox_policy: None, - permission_profile: Some(ActivePermissionProfile::new("networked")), + permission_profile: Some("networked".to_string()), }) .await?; @@ -318,7 +318,7 @@ async fn command_exec_permission_profile_does_not_reuse_default_network_proxy() env: None, size: None, sandbox_policy: None, - permission_profile: Some(ActivePermissionProfile::read_only()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) .await?; @@ -376,7 +376,7 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu env: None, size: None, sandbox_policy: None, - permission_profile: Some(ActivePermissionProfile::new("command-cwd")), + permission_profile: Some("command-cwd".to_string()), }) .await?; @@ -423,7 +423,7 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result env: None, size: None, sandbox_policy: Some(SandboxPolicy::DangerFullAccess), - permission_profile: Some(ActivePermissionProfile::read_only()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 844d9ab1bb..ae74b3432f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -889,11 +889,7 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() text: "Hello".to_string(), text_elements: Vec::new(), }], - permissions: Some( - BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS - .to_string() - .into(), - ), + permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }) .await?; @@ -2147,7 +2143,7 @@ stream_max_retries = 0 text_elements: Vec::new(), }], runtime_workspace_roots: Some(vec![old_root]), - permissions: Some("dev".to_string().into()), + permissions: Some("dev".to_string()), ..Default::default() }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 761625856e..9e54a72abd 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -24,7 +24,6 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; @@ -1009,17 +1008,15 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa } } -fn permissions_selection_from_config(config: &Config) -> Option { +fn permissions_selection_from_config(config: &Config) -> Option { config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(permission_profile_id_from_active_profile) } -fn permissions_selection_from_active_profile( - active: ActivePermissionProfile, -) -> PermissionProfileSelectionParams { - PermissionProfileSelectionParams::new(active.id) +fn permission_profile_id_from_active_profile(active: ActivePermissionProfile) -> String { + active.id } fn sandbox_mode_from_permission_profile( diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 2f04b2d260..79d07a0b76 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -479,14 +479,11 @@ async fn thread_start_params_include_user_thread_source() { #[test] fn active_profile_selection_uses_profile_id_only() { - let selection = permissions_selection_from_active_profile(ActivePermissionProfile::new( + let selection = permission_profile_id_from_active_profile(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_WORKSPACE, )); - assert_eq!( - selection, - PermissionProfileSelectionParams::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) - ); + assert_eq!(selection, BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()); } #[tokio::test] diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2620f80d53..22e4776559 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery; @@ -1182,10 +1181,8 @@ fn sandbox_mode_from_permission_profile( } } -fn permissions_selection_from_active_profile( - active: ActivePermissionProfile, -) -> PermissionProfileSelectionParams { - PermissionProfileSelectionParams::new(active.id) +fn permission_profile_id_from_active_profile(active: ActivePermissionProfile) -> String { + active.id } fn turn_permissions_overrides( @@ -1193,13 +1190,13 @@ fn turn_permissions_overrides( cwd: &std::path::Path, ) -> ( Option, - Option, + Option, ) { match permissions_override { TurnPermissionsOverride::Preserve => (None, None), TurnPermissionsOverride::ActiveProfile(active_permission_profile) => ( None, - Some(permissions_selection_from_active_profile( + Some(permission_profile_id_from_active_profile( active_permission_profile, )), ), @@ -1220,7 +1217,7 @@ fn turn_permissions_overrides( fn permissions_selection_from_config( config: &Config, thread_params_mode: ThreadParamsMode, -) -> Option { +) -> Option { if matches!(thread_params_mode, ThreadParamsMode::Remote) { return None; } @@ -1228,7 +1225,7 @@ fn permissions_selection_from_config( config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(permission_profile_id_from_active_profile) } fn thread_start_params_from_config( @@ -1671,7 +1668,7 @@ mod tests { config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(permission_profile_id_from_active_profile) ); assert_eq!(params.model_provider, Some(config.model_provider_id)); assert_eq!(params.thread_source, Some(ThreadSource::User)); @@ -1698,7 +1695,7 @@ mod tests { let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); let expected_permissions = - permissions_selection_from_active_profile(active_permission_profile.clone()); + permission_profile_id_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( TurnPermissionsOverride::ActiveProfile(active_permission_profile), @@ -1723,9 +1720,7 @@ mod tests { assert_eq!(sandbox_policy, None); assert_eq!( permissions, - Some(PermissionProfileSelectionParams::new( - BUILT_IN_PERMISSION_PROFILE_WORKSPACE - )) + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) ); } @@ -1763,7 +1758,7 @@ mod tests { let cwd = test_path_buf("/workspace/project").abs(); let active_permission_profile = ActivePermissionProfile::new("strict"); let expected_permissions = - permissions_selection_from_active_profile(active_permission_profile.clone()); + permission_profile_id_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( TurnPermissionsOverride::ActiveProfile(active_permission_profile), From 17d552fb4d53d602eab87076acff1239a0420708 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 18:13:38 -0700 Subject: [PATCH 48/53] [codex] Remove external websocket session resets (#23384) ## Why Compaction now installs replacement history inside the session, but the turn and compaction callers were still reaching into `ModelClientSession` to reset websocket transport state after that install. That made a transport-level reset part of the compaction API even though websocket incremental request selection already checks whether the next request is a strict extension of the previous one and falls back to a full `response.create` when it is not. ## What changed - Removed the compaction-side calls to `reset_websocket_session` from `compact.rs` and `session/turn.rs`. - Simplified pre-sampling and mid-turn compaction helpers so they return `CodexResult<()>` instead of carrying a reset flag. - Made `ModelClientSession::reset_websocket_session` private to `client.rs`, leaving only the websocket timeout recovery path inside the client as a caller. ## Validation - `cargo test -p codex-core --test all responses_websocket_creates_on_non_prefix` - `cargo test -p codex-core --test all steered_user_input_waits_for_model_continuation_after_mid_turn_compact` - `cargo test -p codex-core --test all pre_sampling_compact_runs_on_switch_to_smaller_context_model` --- codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/compact.rs | 1 - codex-rs/core/src/session/turn.rs | 77 +++++++++++-------------------- 3 files changed, 29 insertions(+), 51 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f604a63458..cd1fcb6696 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -931,7 +931,7 @@ impl Drop for ModelClientSession { } impl ModelClientSession { - pub(crate) fn reset_websocket_session(&mut self) { + fn reset_websocket_session(&mut self) { self.websocket_session.connection = None; self.websocket_session.last_request = None; self.websocket_session.last_response_rx = None; diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9d1c82eb65..b5802268d3 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -282,7 +282,6 @@ async fn run_compact_task_inner_impl( }; sess.replace_compacted_history(new_history, reference_context_item, compacted_item) .await; - client_session.reset_websocket_session(); sess.recompute_token_usage(&turn_context).await; sess.emit_turn_item_completed(&turn_context, compaction_item) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 0c5e00927a..95b0f5e312 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -160,25 +160,18 @@ pub(crate) async fn run_turn( // new user message are recorded. Estimate pending incoming items (context // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. - let pre_sampling_compact = - match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { - Ok(pre_sampling_compact) => pre_sampling_compact, - Err(err) => { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } - error!("Failed to run pre-sampling compact"); - return None; - } - }; - if pre_sampling_compact.reset_client_session { - client_session.reset_websocket_session(); + if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } + error!("Failed to run pre-sampling compact"); + return None; } let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); @@ -519,7 +512,7 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - let reset_client_session = match run_auto_compact( + match run_auto_compact( &sess, &turn_context, &mut client_session, @@ -529,7 +522,7 @@ pub(crate) async fn run_turn( ) .await { - Ok(reset_client_session) => reset_client_session, + Ok(()) => {} Err(err) => { if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded && let Err(err) = sess @@ -545,9 +538,6 @@ pub(crate) async fn run_turn( return None; } }; - if reset_client_session { - client_session.reset_websocket_session(); - } can_drain_pending_input = !model_needs_follow_up; continue; } @@ -767,24 +757,19 @@ async fn track_turn_resolved_config_analytics( }); } -struct PreSamplingCompactResult { - reset_client_session: bool, -} - async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, -) -> CodexResult { +) -> CodexResult<()> { let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; - let mut pre_sampling_compacted = maybe_run_previous_model_inline_compact( + maybe_run_previous_model_inline_compact( sess, turn_context, client_session, total_usage_tokens_before_compaction, ) .await?; - let mut reset_client_session = pre_sampling_compacted; let total_usage_tokens = sess.get_total_token_usage().await; let auto_compact_limit = turn_context .model_info @@ -792,7 +777,7 @@ async fn run_pre_sampling_compact( .unwrap_or(i64::MAX); // Compact if the total usage tokens are greater than the auto compact limit if total_usage_tokens >= auto_compact_limit { - reset_client_session |= run_auto_compact( + run_auto_compact( sess, turn_context, client_session, @@ -801,27 +786,22 @@ async fn run_pre_sampling_compact( CompactionPhase::PreTurn, ) .await?; - pre_sampling_compacted = true; } - Ok(PreSamplingCompactResult { - reset_client_session: pre_sampling_compacted && reset_client_session, - }) + Ok(()) } /// Runs pre-sampling compaction against the previous model when switching to a smaller /// context-window model. /// -/// Returns `Ok(true)` when compaction ran successfully, `Ok(false)` when compaction was skipped -/// because the model/context-window preconditions were not met, and `Err(_)` only when compaction -/// was attempted and failed. +/// Returns `Err(_)` only when compaction was attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, total_usage_tokens: i64, -) -> CodexResult { +) -> CodexResult<()> { let Some(previous_turn_settings) = sess.previous_turn_settings().await else { - return Ok(false); + return Ok(()); }; let previous_model_turn_context = Arc::new( turn_context @@ -830,10 +810,10 @@ async fn maybe_run_previous_model_inline_compact( ); let Some(old_context_window) = previous_model_turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let new_auto_compact_limit = turn_context .model_info @@ -843,7 +823,7 @@ async fn maybe_run_previous_model_inline_compact( && previous_model_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - let _ = run_auto_compact( + run_auto_compact( sess, &previous_model_turn_context, client_session, @@ -852,9 +832,8 @@ async fn maybe_run_previous_model_inline_compact( CompactionPhase::PreTurn, ) .await?; - return Ok(true); } - Ok(false) + Ok(()) } async fn run_auto_compact( @@ -864,7 +843,7 @@ async fn run_auto_compact( initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, -) -> CodexResult { +) -> CodexResult<()> { if should_use_remote_compact_task(turn_context.provider.info()) { if turn_context.features.enabled(Feature::RemoteCompactionV2) { run_inline_remote_auto_compact_task_v2( @@ -876,7 +855,7 @@ async fn run_auto_compact( phase, ) .await?; - return Ok(false); + return Ok(()); } run_inline_remote_auto_compact_task( Arc::clone(sess), @@ -896,7 +875,7 @@ async fn run_auto_compact( ) .await?; } - Ok(true) + Ok(()) } pub(super) fn collect_explicit_app_ids_from_skill_items( From 6b54ced108afdc8eba2218672ba0b9790f246782 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Mon, 18 May 2026 18:24:19 -0700 Subject: [PATCH 49/53] cleanup: Remove skill env var dependency prompting (#22721) Deletes the skill env var dependency prompt feature and its runtime path. env_var entries in skill dependency metadata are now silently ignored during skill loading. --- .../core-skills/src/env_var_dependencies.rs | 30 ----- codex-rs/core-skills/src/lib.rs | 3 - codex-rs/core-skills/src/loader_tests.rs | 13 -- codex-rs/core/src/lib.rs | 2 - codex-rs/core/src/session/mod.rs | 10 -- codex-rs/core/src/session/turn.rs | 11 -- codex-rs/core/src/skills.rs | 126 ------------------ codex-rs/core/src/state/session.rs | 13 -- codex-rs/core/src/tools/handlers/shell.rs | 14 +- codex-rs/features/src/lib.rs | 7 +- 10 files changed, 6 insertions(+), 223 deletions(-) delete mode 100644 codex-rs/core-skills/src/env_var_dependencies.rs diff --git a/codex-rs/core-skills/src/env_var_dependencies.rs b/codex-rs/core-skills/src/env_var_dependencies.rs deleted file mode 100644 index 4d8e57164c..0000000000 --- a/codex-rs/core-skills/src/env_var_dependencies.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::SkillMetadata; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SkillDependencyInfo { - pub skill_name: String, - pub name: String, - pub description: Option, -} - -pub fn collect_env_var_dependencies( - mentioned_skills: &[SkillMetadata], -) -> Vec { - let mut dependencies = Vec::new(); - for skill in mentioned_skills { - let Some(skill_dependencies) = &skill.dependencies else { - continue; - }; - for tool in &skill_dependencies.tools { - if tool.r#type != "env_var" || tool.value.is_empty() { - continue; - } - dependencies.push(SkillDependencyInfo { - skill_name: skill.name.clone(), - name: tool.value.clone(), - description: tool.description.clone(), - }); - } - } - dependencies -} diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index eec3a5f054..1577662bc5 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -1,5 +1,4 @@ pub mod config_rules; -mod env_var_dependencies; pub mod injection; pub(crate) mod invocation_utils; pub mod loader; @@ -10,8 +9,6 @@ pub mod remote; pub mod render; pub mod system; -pub use env_var_dependencies::SkillDependencyInfo; -pub use env_var_dependencies::collect_env_var_dependencies; pub(crate) use invocation_utils::build_implicit_skill_path_indexes; pub use invocation_utils::detect_implicit_skill_invocation_for_command; pub use manager::SkillsLoadInput; diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 65a0b5a4f1..a1d03dead2 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -399,11 +399,6 @@ async fn loads_skill_dependencies_metadata_from_yaml() { { "dependencies": { "tools": [ - { - "type": "env_var", - "value": "GITHUB_TOKEN", - "description": "GitHub API token with repo scopes" - }, { "type": "mcp", "value": "github", @@ -446,14 +441,6 @@ async fn loads_skill_dependencies_metadata_from_yaml() { interface: None, dependencies: Some(SkillDependencies { tools: vec![ - SkillToolDependency { - r#type: "env_var".to_string(), - value: "GITHUB_TOKEN".to_string(), - description: Some("GitHub API token with repo scopes".to_string()), - transport: None, - command: None, - url: None, - }, SkillToolDependency { r#type: "mcp".to_string(), value: "github".to_string(), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 325d65745f..c79598ee14 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -90,13 +90,11 @@ pub(crate) use skills::SkillsManager; pub(crate) use skills::build_available_skills; pub(crate) use skills::build_skill_injections; pub(crate) use skills::build_skill_name_counts; -pub(crate) use skills::collect_env_var_dependencies; pub(crate) use skills::collect_explicit_skill_mentions; pub(crate) use skills::default_skill_metadata_budget; pub(crate) use skills::injection; pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; -pub(crate) use skills::resolve_skill_dependencies_for_turn; pub(crate) use skills::skills_load_input_from_config; mod stream_events_utils; pub mod test_support; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4db6c97e4b..9bac9e3c3a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3019,16 +3019,6 @@ impl Session { state.record_mcp_dependency_prompted(names); } - pub async fn dependency_env(&self) -> HashMap { - let state = self.state.lock().await; - state.dependency_env() - } - - pub async fn set_dependency_env(&self, values: HashMap) { - let mut state = self.state.lock().await; - state.set_dependency_env(values); - } - pub(crate) async fn set_server_reasoning_included(&self, included: bool) { let mut state = self.state.lock().await; state.set_server_reasoning_included(included); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 95b0f5e312..8edf2e4785 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -9,7 +9,6 @@ use crate::build_skill_injections; use crate::client::ModelClientSession; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; -use crate::collect_env_var_dependencies; use crate::collect_explicit_skill_mentions; use crate::compact::InitialContextInjection; use crate::compact::collect_user_messages; @@ -40,7 +39,6 @@ use crate::mentions::collect_explicit_plugin_mentions; use crate::mentions::collect_tool_mentions_from_messages; use crate::parse_turn_item; use crate::plugins::build_plugin_injections; -use crate::resolve_skill_dependencies_for_turn; use crate::session::PreviousTurnSettings; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -234,15 +232,6 @@ pub(crate) async fn run_turn( &connector_slug_counts, ) }); - let config = turn_context.config.clone(); - if config - .features - .enabled(Feature::SkillEnvVarDependencyPrompt) - { - let env_var_dependencies = collect_env_var_dependencies(&mentioned_skills); - resolve_skill_dependencies_for_turn(&sess, &turn_context, &env_var_dependencies).await; - } - maybe_prompt_and_install_mcp_dependencies( sess.as_ref(), turn_context.as_ref(), diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index 2cceb2be36..d540cd6d43 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -1,8 +1,3 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::env; -use std::sync::Arc; - use crate::config::Config; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -10,14 +5,9 @@ use codex_analytics::InvocationType; use codex_analytics::SkillInvocation; use codex_analytics::build_track_events_context; use codex_protocol::protocol::SkillScope; -use codex_protocol::request_user_input::RequestUserInputArgs; -use codex_protocol::request_user_input::RequestUserInputQuestion; -use codex_protocol::request_user_input::RequestUserInputResponse; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::PluginSkillRoot; -use tracing::warn; -pub use codex_core_skills::SkillDependencyInfo; pub use codex_core_skills::SkillError; pub use codex_core_skills::SkillLoadOutcome; pub use codex_core_skills::SkillMetadata; @@ -27,7 +17,6 @@ pub use codex_core_skills::SkillsLoadInput; pub use codex_core_skills::SkillsManager; pub use codex_core_skills::build_available_skills; pub use codex_core_skills::build_skill_name_counts; -pub use codex_core_skills::collect_env_var_dependencies; pub use codex_core_skills::config_rules; pub use codex_core_skills::default_skill_metadata_budget; pub use codex_core_skills::detect_implicit_skill_invocation_for_command; @@ -56,121 +45,6 @@ pub(crate) fn skills_load_input_from_config( ) } -pub(crate) async fn resolve_skill_dependencies_for_turn( - sess: &Arc, - turn_context: &Arc, - dependencies: &[SkillDependencyInfo], -) { - if dependencies.is_empty() { - return; - } - - let existing_env = sess.dependency_env().await; - let mut loaded_values = HashMap::new(); - let mut missing = Vec::new(); - let mut seen_names = HashSet::new(); - - for dependency in dependencies { - let name = dependency.name.clone(); - if !seen_names.insert(name.clone()) || existing_env.contains_key(&name) { - continue; - } - match env::var(&name) { - Ok(value) => { - loaded_values.insert(name.clone(), value); - } - Err(env::VarError::NotPresent) => { - missing.push(dependency.clone()); - } - Err(err) => { - warn!("failed to read env var {name}: {err}"); - missing.push(dependency.clone()); - } - } - } - - if !loaded_values.is_empty() { - sess.set_dependency_env(loaded_values).await; - } - - if !missing.is_empty() { - request_skill_dependencies(sess, turn_context, &missing).await; - } -} - -async fn request_skill_dependencies( - sess: &Arc, - turn_context: &Arc, - dependencies: &[SkillDependencyInfo], -) { - let questions = dependencies - .iter() - .map(|dependency| { - let requirement = dependency.description.as_ref().map_or_else( - || { - format!( - "The skill \"{}\" requires \"{}\" to be set.", - dependency.skill_name, dependency.name - ) - }, - |description| { - format!( - "The skill \"{}\" requires \"{}\" to be set ({}).", - dependency.skill_name, dependency.name, description - ) - }, - ); - RequestUserInputQuestion { - id: dependency.name.clone(), - header: "Skill requires environment variable".to_string(), - question: format!( - "{requirement} This is an experimental internal feature. The value is stored in memory for this session only." - ), - is_other: false, - is_secret: true, - options: None, - } - }) - .collect::>(); - if questions.is_empty() { - return; - } - - let response = sess - .request_user_input( - turn_context, - format!("skill-deps-{}", turn_context.sub_id), - RequestUserInputArgs { questions }, - ) - .await - .unwrap_or_else(|| RequestUserInputResponse { - answers: HashMap::new(), - }); - if response.answers.is_empty() { - return; - } - - let mut values = HashMap::new(); - for (name, answer) in response.answers { - let mut user_note = None; - for entry in &answer.answers { - if let Some(note) = entry.strip_prefix("user_note: ") - && !note.trim().is_empty() - { - user_note = Some(note.trim().to_string()); - } - } - if let Some(value) = user_note { - values.insert(name, value); - } - } - if values.is_empty() { - return; - } - - sess.set_dependency_env(values).await; -} - pub(crate) async fn maybe_emit_implicit_skill_invocation( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 3bd4b8a26e..7121a77524 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -3,7 +3,6 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::ResponseItem; use codex_sandboxing::policy_transforms::merge_permission_profiles; -use std::collections::HashMap; use std::collections::HashSet; use crate::context_manager::ContextManager; @@ -22,7 +21,6 @@ pub(crate) struct SessionState { pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, pub(crate) server_reasoning_included: bool, - pub(crate) dependency_env: HashMap, pub(crate) mcp_dependency_prompted: HashSet, /// Settings used by the latest regular user turn, used for turn-to-turn /// model/realtime handling on subsequent regular turns (including full-context @@ -45,7 +43,6 @@ impl SessionState { history, latest_rate_limits: None, server_reasoning_included: false, - dependency_env: HashMap::new(), mcp_dependency_prompted: HashSet::new(), previous_turn_settings: None, startup_prewarm: None, @@ -165,16 +162,6 @@ impl SessionState { self.mcp_dependency_prompted.clone() } - pub(crate) fn set_dependency_env(&mut self, values: HashMap) { - for (key, value) in values { - self.dependency_env.insert(key, value); - } - } - - pub(crate) fn dependency_env(&self) -> HashMap { - self.dependency_env.clone() - } - pub(crate) fn set_session_startup_prewarm( &mut self, startup_prewarm: SessionStartupPrewarmHandle, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 2c69566b0c..09eb29b1e4 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -70,7 +70,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result { continue; } + "skill_env_var_dependency_prompt" => { + continue; + } "use_legacy_landlock" => { self.record_legacy_usage_force( "features.use_legacy_landlock", @@ -1037,7 +1040,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::SkillEnvVarDependencyPrompt, key: "skill_env_var_dependency_prompt", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { From daa11820b076b32acd13ee453f92fdf54d457b1c Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 18 May 2026 18:24:39 -0700 Subject: [PATCH 50/53] Remove ToolSearch feature toggle (#23389) ## Summary - mark `ToolSearch` as removed and ignore stale config writes for its legacy key - make search tool exposure depend only on model capability, not a feature toggle - remove app-server enablement support and prune now-obsolete test coverage/setup ## Verification - `cargo test -p codex-features` - `cargo test -p codex-tools` - `cargo test -p codex-core search_tool_requires_model_capability` - `cargo test -p codex-app-server experimental_feature_enablement_set_` ## Notes - This keeps the legacy config key as a no-op for compatibility while removing the ability to toggle the behavior off cleanly. - No developer-facing docs update outside the touched app-server README was needed. --- codex-rs/app-server/README.md | 2 +- .../request_processors/config_processor.rs | 1 - .../suite/v2/experimental_feature_list.rs | 11 +--- .../core/src/tools/spec_plan_model_tests.rs | 3 - codex-rs/core/src/tools/spec_plan_tests.rs | 34 ++--------- codex-rs/core/tests/suite/code_mode.rs | 4 -- codex-rs/core/tests/suite/search_tool.rs | 57 ------------------- codex-rs/features/src/lib.rs | 9 ++- codex-rs/features/src/tests.rs | 8 +-- codex-rs/tools/src/tool_config.rs | 3 +- 10 files changed, 17 insertions(+), 115 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e2978861de..7a811ab694 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -189,7 +189,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index b7f973b076..5054ea248d 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -53,7 +53,6 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "mentions_v2", "plugins", "remote_control", - "tool_search", "tool_suggest", "tool_call_mcp_elicitation", ]; diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 279fa60da1..d6101995fa 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -340,7 +340,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), - ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -353,7 +352,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re enablement: BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), - ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -383,13 +381,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("plugins")), Some(&json!(true)) ); - assert_eq!( - config - .additional - .get("features") - .and_then(|features| features.get("tool_search")), - Some(&json!(true)) - ); assert_eq!( config .additional @@ -483,7 +474,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - ); assert!( error.message.contains( - "apps, memories, mentions_v2, plugins, remote_control, tool_search, tool_suggest, tool_call_mcp_elicitation" + "apps, memories, mentions_v2, plugins, remote_control, tool_suggest, tool_call_mcp_elicitation" ), "{}", error.message diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs index 3390d26e0d..1896a73456 100644 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -793,7 +793,6 @@ async fn request_plugin_install_requires_apps_and_plugins_features() { for disabled_feature in [Feature::Apps, Feature::Plugins] { let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); features.enable(Feature::Apps); features.enable(Feature::Plugins); @@ -832,7 +831,6 @@ async fn search_tool_is_hidden_without_deferred_tools() { let model_info = search_capable_model_info().await; let mut features = Features::with_defaults(); features.enable(Feature::Apps); - features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -863,7 +861,6 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio let model_info = search_capable_model_info().await; let mut features = Features::with_defaults(); features.enable(Feature::Apps); - features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 3442fbb5ff..1b64093ad5 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1653,7 +1653,6 @@ fn search_tool_description_lists_each_mcp_source_once() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); - features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1726,7 +1725,7 @@ fn search_tool_description_lists_each_mcp_source_once() { } #[test] -fn search_tool_requires_model_capability_and_enabled_feature() { +fn search_tool_requires_model_capability() { let model_info = search_capable_model_info(); let deferred_mcp_tools = Some(vec![deferred_mcp_tool( "_create_event", @@ -1759,26 +1758,6 @@ fn search_tool_requires_model_capability_and_enabled_feature() { ); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - let mut features_without_tool_search = Features::with_defaults(); - features_without_tool_search.disable(Feature::ToolSearch); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features_without_tool_search, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - deferred_mcp_tools.clone(), - &[], - ); - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, @@ -1801,8 +1780,7 @@ fn search_tool_requires_model_capability_and_enabled_feature() { #[test] fn no_search_tool_when_namespaces_disabled() { let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); + let features = Features::with_defaults(); let available_models = Vec::new(); let mut tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1836,8 +1814,7 @@ fn no_search_tool_when_namespaces_disabled() { #[test] fn search_tool_registers_for_deferred_dynamic_tools() { let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); + let features = Features::with_defaults(); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1896,8 +1873,7 @@ fn search_tool_registers_for_deferred_dynamic_tools() { #[test] fn search_tool_is_hidden_for_deferred_dynamic_tools_when_namespace_tools_are_disabled() { let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); + let features = Features::with_defaults(); let available_models = Vec::new(); let mut tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1946,7 +1922,6 @@ fn search_tool_is_hidden_for_deferred_dynamic_tools_when_namespace_tools_are_dis fn request_plugin_install_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); features.enable(Feature::Apps); features.enable(Feature::Plugins); features.disable(Feature::ToolSuggest); @@ -2036,7 +2011,6 @@ fn request_plugin_install_description_lists_discoverable_tools() { let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::Plugins); - features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index f117631c4a..beae9e4afb 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -400,10 +400,6 @@ if (!tool) { .features .enable(Feature::Apps) .expect("test config should allow feature update"); - config - .features - .enable(Feature::ToolSearch) - .expect("test config should allow feature update"); config .features .enable(Feature::CodeMode) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 69dc74dce3..ae897f2774 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -4,7 +4,6 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; -use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; @@ -113,14 +112,6 @@ fn tool_search_output_has_namespace_child( namespace_child_tool(&output, namespace, tool_name).is_some() } -fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str) { - configure_search_capable_apps(config, apps_base_url); - config - .features - .disable(Feature::ToolSearch) - .expect("test config should allow feature update"); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> { skip_if_no_network!(Ok(())); @@ -225,54 +216,6 @@ async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tool_search_disabled_exposes_apps_tools_directly() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount_searchable(&server).await?; - let mock = mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-1"), - ]), - ) - .await; - - let mut builder = test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| { - configure_apps_without_tool_search(config, apps_server.chatgpt_base_url.as_str()) - }); - let test = builder.build(&server).await?; - - test.submit_turn_with_approval_and_permission_profile( - "list tools", - AskForApproval::Never, - PermissionProfile::Disabled, - ) - .await?; - - let body = mock.single_request().body_json(); - let tools = tool_names(&body); - assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); - assert!( - namespace_child_tool( - &body, - SEARCH_CALENDAR_NAMESPACE, - SEARCH_CALENDAR_CREATE_TOOL - ) - .is_some() - ); - assert!( - namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_some() - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index fca2473c67..c20fa8683a 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -132,7 +132,7 @@ pub enum Feature { EnableMcpApps, /// Use the new path for the host-owned apps MCP server. AppsMcpPathOverride, - /// Enable the tool_search tool for apps. + /// Removed compatibility flag retained as a no-op now that tool_search is always enabled. ToolSearch, /// Always defer MCP tools behind tool_search instead of exposing small sets directly. ToolSearchAlwaysDeferMcpTools, @@ -428,6 +428,9 @@ impl Features { "apply_patch_freeform" => { continue; } + "tool_search" => { + continue; + } "image_detail_original" => { continue; } @@ -946,8 +949,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolSearch, key: "tool_search", - stage: Stage::Stable, - default_enabled: true, + stage: Stage::Removed, + default_enabled: false, }, FeatureSpec { id: Feature::ToolSearchAlwaysDeferMcpTools, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index f4aa3982b9..e2a456f1bb 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -188,9 +188,10 @@ fn network_proxy_is_experimental_and_disabled_by_default() { } #[test] -fn tool_search_is_stable_and_enabled_by_default() { - assert_eq!(Feature::ToolSearch.stage(), Stage::Stable); - assert_eq!(Feature::ToolSearch.default_enabled(), true); +fn tool_search_is_removed_and_disabled_by_default() { + assert_eq!(Feature::ToolSearch.stage(), Stage::Removed); + assert_eq!(Feature::ToolSearch.default_enabled(), false); + assert_eq!(feature_for_key("tool_search"), Some(Feature::ToolSearch)); } #[test] @@ -603,7 +604,6 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( features.enable(Feature::CodeMode); features.enable(Feature::MultiAgentV2); features.enable(Feature::NetworkProxy); - features.disable(Feature::ToolSearch); let mut features_toml = FeaturesToml { multi_agent_v2: Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml { diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 5c1681d164..b6f4ae8dbd 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -182,8 +182,7 @@ impl ToolsConfig { let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); let include_collab_tools = include_multi_agent_v2 || features.enabled(Feature::Collab); let include_agent_jobs = features.enabled(Feature::SpawnCsv); - let include_search_tool = - model_info.supports_search_tool && features.enabled(Feature::ToolSearch); + let include_search_tool = model_info.supports_search_tool; let include_tool_suggest = features.enabled(Feature::ToolSuggest) && features.enabled(Feature::Apps) && features.enabled(Feature::Plugins); From 84d941d07ffd2197eed0311ddd58f21e6b4350f2 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 18:48:35 -0700 Subject: [PATCH 51/53] [1 of 7] Add thread settings to UserInput (#23080) **Stack position:** [1 of 7] ## Summary The first three PRs in this stack are a cleanup pass before the actual thread settings API work. Today, core has several overlapping "user input" ops: `UserInput`, `UserInputWithTurnContext`, and `UserTurn`. They differ mostly in how much next-turn state they carry, which makes the later queued thread settings update harder to reason about and review. This PR starts that cleanup by adding the shared `ThreadSettingsOverrides` payload and allowing `Op::UserInput` to carry it. Existing variants remain in place here, so this layer is mostly a behavior-preserving API shape change plus mechanical constructor updates. ## End State After PR3 By the end of PR3, `Op::UserInput` is the only "user input" core op. It can carry optional thread settings overrides for callers that need to update stored defaults with a turn, while callers without updates use empty settings. `Op::UserInputWithTurnContext` and `Op::UserTurn` are deleted. ## End State After PR5 By the end of PR5, core will have only two ops for this area: - `Op::UserInput` for user-input-bearing submissions. - `Op::ThreadSettings` for settings-only updates. ## Stack 1. [1 of 7] [Add thread settings to UserInput](https://github.com/openai/codex/pull/23080) (this PR) 2. [2 of 7] [Remove UserInputWithTurnContext](https://github.com/openai/codex/pull/23081) 3. [3 of 7] [Remove UserTurn](https://github.com/openai/codex/pull/23075) 4. [4 of 7] [Placeholder for OverrideTurnContext cleanup](https://github.com/openai/codex/pull/23087) 5. [5 of 7] [Replace OverrideTurnContext with ThreadSettings](https://github.com/openai/codex/pull/22508) 6. [6 of 7] [Add app-server thread settings API](https://github.com/openai/codex/pull/22509) 7. [7 of 7] [Sync TUI thread settings](https://github.com/openai/codex/pull/22510) --- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/request_processors.rs | 2 +- .../src/request_processors/turn_processor.rs | 9 +- .../app-server/tests/suite/v2/turn_start.rs | 6 +- codex-rs/core/src/agent/control_tests.rs | 3 + codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_thread.rs | 12 +- codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/session/handlers.rs | 103 +++++++++++++----- codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/tests.rs | 6 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/suite/abort_tasks.rs | 5 + codex-rs/core/tests/suite/client.rs | 32 ++++++ .../core/tests/suite/client_websockets.rs | 2 + .../tests/suite/collaboration_instructions.rs | 14 +++ codex-rs/core/tests/suite/compact.rs | 31 ++++++ codex-rs/core/tests/suite/compact_remote.rs | 53 +++++++++ .../core/tests/suite/compact_remote_parity.rs | 1 + .../core/tests/suite/compact_resume_fork.rs | 3 +- codex-rs/core/tests/suite/fork_thread.rs | 2 + codex-rs/core/tests/suite/hooks.rs | 2 + codex-rs/core/tests/suite/items.rs | 9 ++ .../core/tests/suite/model_visible_layout.rs | 3 + codex-rs/core/tests/suite/otel.rs | 22 ++++ codex-rs/core/tests/suite/pending_input.rs | 3 + .../core/tests/suite/permissions_messages.rs | 15 +++ codex-rs/core/tests/suite/plugins.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 12 +- codex-rs/core/tests/suite/quota_exceeded.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 3 + .../core/tests/suite/request_compression.rs | 2 + codex-rs/core/tests/suite/resume.rs | 7 ++ codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/search_tool.rs | 4 + .../suite/stream_error_allows_next_turn.rs | 2 + .../core/tests/suite/stream_no_completed.rs | 1 + .../core/tests/suite/user_notification.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/memories/write/src/runtime.rs | 1 + codex-rs/protocol/src/protocol.rs | 94 ++++++++++++++-- codex-rs/thread-manager-sample/src/main.rs | 1 + 43 files changed, 429 insertions(+), 52 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7a811ab694..9073282b4e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -832,7 +832,7 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi ### Example: Steer an active turn Use `turn/steer` to append additional user input to the currently active regular turn. This does -not emit `turn/started` and does not accept turn context overrides. +not emit `turn/started` and does not accept thread settings overrides. ```json { "method": "turn/steer", "id": 32, "params": { diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index ba28840f50..305e864ba0 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -259,7 +259,7 @@ use codex_config::ConfigLayerStack; use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; -use codex_core::CodexThreadTurnContextOverrides; +use codex_core::CodexThreadSettingsOverrides; use codex_core::ExternalGoalPreviousStatus; use codex_core::ExternalGoalSet; use codex_core::ForkSnapshot; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 55a4e3a838..3de4ebbcc3 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -457,7 +457,7 @@ impl TurnRequestProcessor { warning.contains("Configured value for `permission_profile` is disallowed") }) { return Err(invalid_request(format!( - "invalid turn context override: {warning}" + "invalid thread settings override: {warning}" ))); } ( @@ -479,7 +479,7 @@ impl TurnRequestProcessor { // still queued together with the input below to preserve submission order. if has_any_overrides { thread - .validate_turn_context_overrides(CodexThreadTurnContextOverrides { + .validate_thread_settings_overrides(CodexThreadSettingsOverrides { cwd: cwd.clone(), workspace_roots: runtime_workspace_roots.clone(), approval_policy, @@ -497,7 +497,9 @@ impl TurnRequestProcessor { personality, }) .await - .map_err(|err| invalid_request(format!("invalid turn context override: {err}")))?; + .map_err(|err| { + invalid_request(format!("invalid thread settings override: {err}")) + })?; } // Start the turn by submitting the user input. Return its submission id as turn_id. @@ -529,6 +531,7 @@ impl TurnRequestProcessor { environments: environment_selections, final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, + thread_settings: Default::default(), } }; let turn_id = self diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index ae74b3432f..62f0725ca1 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -900,7 +900,11 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() .await??; assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); - assert!(err.error.message.contains("invalid turn context override")); + assert!( + err.error + .message + .contains("invalid thread settings override") + ); assert!( err.error.message.contains("allowed set [ReadOnly]"), "unexpected error message: {}", diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index e5883f60da..b9ce39887a 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -442,6 +442,7 @@ async fn send_input_submits_user_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, ); let captured = harness @@ -595,6 +596,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, ); let captured = harness @@ -743,6 +745,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, ); let captured = harness diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 493771e28c..4ed6b2368c 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -192,6 +192,7 @@ pub(crate) async fn run_codex_thread_one_shot( items: input, final_output_json_schema, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 8f5023300c..aff76b8c02 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -80,9 +80,9 @@ impl ThreadConfigSnapshot { } } -/// Turn context overrides that app-server validates before starting a turn. +/// Thread settings overrides that app-server validates before starting a turn. #[derive(Clone, Default)] -pub struct CodexThreadTurnContextOverrides { +pub struct CodexThreadSettingsOverrides { pub cwd: Option, pub workspace_roots: Option>, pub profile_workspace_roots: Option>, @@ -257,12 +257,12 @@ impl CodexThread { .await } - /// Validate persistent turn context overrides without committing them. - pub async fn validate_turn_context_overrides( + /// Validate persistent thread settings overrides without committing them. + pub async fn validate_thread_settings_overrides( &self, - overrides: CodexThreadTurnContextOverrides, + overrides: CodexThreadSettingsOverrides, ) -> ConstraintResult<()> { - let CodexThreadTurnContextOverrides { + let CodexThreadSettingsOverrides { cwd, workspace_roots, profile_workspace_roots, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index c79598ee14..aca6049698 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -20,7 +20,7 @@ mod compact_remote; mod compact_remote_v2; mod config_lock; pub use codex_thread::CodexThread; -pub use codex_thread::CodexThreadTurnContextOverrides; +pub use codex_thread::CodexThreadSettingsOverrides; pub use codex_thread::ThreadConfigSnapshot; pub use session::turn_context::TurnContext; mod agent; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index eb27ae78be..e684ae6f3b 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -42,6 +42,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::ThreadRolledBackEvent; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -184,20 +185,9 @@ pub(super) async fn user_input_or_turn_inner( personality, environments, } => { - let collaboration_mode = if let Some(collab_mode) = collaboration_mode { - Some(collab_mode) - } else { - let state = sess.state.lock().await; - Some( - state - .session_configuration - .collaboration_mode - .with_updates(model, effort, /*developer_instructions*/ None), - ) - }; - ( - items, - SessionSettingsUpdate { + let mut updates = thread_settings_update( + sess, + ThreadSettingsOverrides { cwd, workspace_roots, profile_workspace_roots, @@ -207,32 +197,35 @@ pub(super) async fn user_input_or_turn_inner( permission_profile, active_permission_profile, windows_sandbox_level, - collaboration_mode, - reasoning_summary: summary, + model, + effort, + summary, service_tier, - final_output_json_schema: Some(final_output_json_schema), - environments, + collaboration_mode, personality, - app_server_client_name: None, - app_server_client_version: None, }, - responsesapi_client_metadata, ) + .await; + updates.final_output_json_schema = Some(final_output_json_schema); + updates.environments = environments; + (items, updates, responsesapi_client_metadata) } Op::UserInput { items, environments, final_output_json_schema, responsesapi_client_metadata, - } => ( - items, - SessionSettingsUpdate { - final_output_json_schema: Some(final_output_json_schema), - environments, - ..Default::default() - }, - responsesapi_client_metadata, - ), + thread_settings, + } => { + let mut updates = if thread_settings == ThreadSettingsOverrides::default() { + SessionSettingsUpdate::default() + } else { + thread_settings_update(sess, thread_settings).await + }; + updates.final_output_json_schema = Some(final_output_json_schema); + updates.environments = environments; + (items, updates, responsesapi_client_metadata) + } _ => unreachable!(), }; @@ -289,6 +282,56 @@ pub(super) async fn user_input_or_turn_inner( } } +async fn thread_settings_update( + sess: &Session, + thread_settings: ThreadSettingsOverrides, +) -> SessionSettingsUpdate { + let ThreadSettingsOverrides { + cwd, + workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = thread_settings; + let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode { + collaboration_mode + } else { + let state = sess.state.lock().await; + // Model and reasoning effort live in CollaborationMode settings today, so + // partial thread-settings updates refresh those fields on the active mode. + state + .session_configuration + .collaboration_mode + .with_updates(model, effort, /*developer_instructions*/ None) + }; + SessionSettingsUpdate { + cwd, + workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + collaboration_mode: Some(collaboration_mode), + reasoning_summary: summary, + service_tier, + personality, + ..Default::default() + } +} + async fn mirror_user_text_to_realtime(sess: &Arc, items: &[UserInput]) { let text = UserMessageItem::new(items).message(); if text.is_empty() { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9bac9e3c3a..7c3b4803dd 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1094,6 +1094,7 @@ impl Session { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, /*mirror_user_text_to_realtime*/ None, ) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4486d287c5..46e93b16f4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2221,6 +2221,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2284,6 +2285,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -5220,6 +5222,7 @@ fn op_kind_distinguishes_turn_ops() { items: vec![], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), } .kind(), "user_input" @@ -8369,6 +8372,7 @@ async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -8473,6 +8477,7 @@ async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -8957,6 +8962,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index a83167ffc5..2846cacb70 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2470,6 +2470,7 @@ async fn send_input_accepts_structured_items() { ], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }; let captured = manager .captured_ops() diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c81a1c2f68..c41502a9dc 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -53,6 +53,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -109,6 +110,7 @@ async fn interrupt_tool_records_history_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -129,6 +131,7 @@ async fn interrupt_tool_records_history_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -211,6 +214,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -231,6 +235,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 42bacb9bdc..818e0eba7b 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -392,6 +392,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -757,6 +758,7 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -967,6 +969,7 @@ async fn includes_base_instructions_override_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1023,6 +1026,7 @@ async fn chatgpt_auth_sends_correct_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1146,6 +1150,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1184,6 +1189,7 @@ async fn includes_user_instructions_message_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1271,6 +1277,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1333,6 +1340,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1391,6 +1399,7 @@ async fn omits_apps_guidance_when_configured_off() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1432,6 +1441,7 @@ async fn omits_environment_context_when_configured_off() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1488,6 +1498,7 @@ async fn skills_append_to_developer_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1570,6 +1581,7 @@ async fn skills_use_aliases_in_developer_message_under_budget_pressure() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1630,6 +1642,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1671,6 +1684,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1713,6 +1727,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1826,6 +1841,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1945,6 +1961,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2003,6 +2020,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2041,6 +2059,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2088,6 +2107,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2134,6 +2154,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2185,6 +2206,7 @@ async fn includes_developer_instructions_message_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2480,6 +2502,7 @@ async fn token_count_includes_rate_limits_snapshot() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2618,6 +2641,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -2694,6 +2718,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -2708,6 +2733,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -2792,6 +2818,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -2902,6 +2929,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2990,6 +3018,7 @@ async fn env_var_overrides_loaded_auth() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3046,6 +3075,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3061,6 +3091,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3076,6 +3107,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index a14ee6518f..feee9d4227 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1208,6 +1208,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -1296,6 +1297,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submission should succeed while emitting invalid request events"); diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 5ce115cdaf..2b0c20ab26 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -86,6 +86,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -148,6 +149,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -305,6 +307,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -434,6 +437,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -464,6 +468,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -523,6 +528,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -553,6 +559,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -614,6 +621,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -647,6 +655,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -709,6 +718,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -742,6 +752,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -808,6 +819,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -823,6 +835,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -882,6 +895,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index be3423a85e..d1384ff800 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -386,6 +386,7 @@ async fn summarize_context_three_requests_and_instructions() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -410,6 +411,7 @@ async fn summarize_context_three_requests_and_instructions() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -586,6 +588,7 @@ async fn manual_pre_compact_block_decision_does_not_block_compaction() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit first user turn"); @@ -658,6 +661,7 @@ async fn compact_hooks_respect_matchers_and_post_runs_after_compaction() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit first user turn"); @@ -727,6 +731,7 @@ async fn manual_compact_uses_custom_prompt() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit first user turn"); @@ -873,6 +878,7 @@ async fn manual_compact_emits_context_compaction_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1038,6 +1044,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user input"); @@ -1509,6 +1516,7 @@ async fn auto_compact_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1524,6 +1532,7 @@ async fn auto_compact_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1539,6 +1548,7 @@ async fn auto_compact_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1709,6 +1719,7 @@ async fn auto_compact_emits_context_compaction_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1789,6 +1800,7 @@ async fn auto_compact_starts_after_turn_started() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1803,6 +1815,7 @@ async fn auto_compact_starts_after_turn_started() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1817,6 +1830,7 @@ async fn auto_compact_starts_after_turn_started() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2250,6 +2264,7 @@ async fn auto_compact_persists_rollout_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2264,6 +2279,7 @@ async fn auto_compact_persists_rollout_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2278,6 +2294,7 @@ async fn auto_compact_persists_rollout_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2366,6 +2383,7 @@ async fn manual_compact_retries_after_context_window_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2469,6 +2487,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user input"); @@ -2563,6 +2582,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2580,6 +2600,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2597,6 +2618,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2760,6 +2782,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2864,6 +2887,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3063,6 +3087,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3181,6 +3206,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3242,6 +3268,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user input"); @@ -3281,6 +3308,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user input"); @@ -3469,6 +3497,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit first user"); @@ -3483,6 +3512,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit second user"); @@ -3555,6 +3585,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit follow-up user input"); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 15197042fa..9db94016e6 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -326,6 +326,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -342,6 +343,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -516,6 +518,7 @@ async fn assert_remote_manual_compact_request_parity( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -535,6 +538,7 @@ async fn assert_remote_manual_compact_request_parity( ], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -548,6 +552,7 @@ async fn assert_remote_manual_compact_request_parity( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -567,6 +572,7 @@ async fn assert_remote_manual_compact_request_parity( ], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -580,6 +586,7 @@ async fn assert_remote_manual_compact_request_parity( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -747,6 +754,7 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -763,6 +771,7 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -856,6 +865,7 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -872,6 +882,7 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -962,6 +973,7 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -1033,6 +1045,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1113,6 +1126,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1126,6 +1140,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1243,6 +1258,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1256,6 +1272,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1275,6 +1292,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1374,6 +1392,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1387,6 +1406,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1480,6 +1500,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&baseline_codex, |event| { @@ -1496,6 +1517,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&baseline_codex, |event| { @@ -1586,6 +1608,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&override_codex, |event| { @@ -1602,6 +1625,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&override_codex, |event| { @@ -1672,6 +1696,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1752,6 +1777,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1835,6 +1861,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1977,6 +2004,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1994,6 +2022,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2018,6 +2047,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2113,6 +2143,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2129,6 +2160,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2200,6 +2232,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2213,6 +2246,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2279,6 +2313,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2339,6 +2374,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2354,6 +2390,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2428,6 +2465,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2444,6 +2482,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2526,6 +2565,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2541,6 +2581,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2631,6 +2672,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2660,6 +2702,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2756,6 +2799,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2842,6 +2886,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2871,6 +2916,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2989,6 +3035,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3002,6 +3049,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; let error_message = wait_for_event_match(&codex, |event| match event { @@ -3086,6 +3134,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3163,6 +3212,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3248,6 +3298,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3264,6 +3315,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3345,6 +3397,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 32c13b0b27..ccbd427b1f 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -606,6 +606,7 @@ async fn submit_user_input(codex: &codex_core::CodexThread, items: Vec Resu } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -/// Scenario: rolling back a turn that introduced persistent pre-turn context +/// Scenario: rolling back a turn that introduced persistent thread settings /// diffs should trim those context updates so the next request includes them /// only once. async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { @@ -790,6 +790,7 @@ async fn user_turn(conversation: &Arc, text: &str) { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user turn"); diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 37456dce63..68a167f790 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -57,6 +57,7 @@ async fn fork_thread_twice_drops_to_first_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -180,6 +181,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index ef3918e189..4f376573b3 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1440,6 +1440,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1458,6 +1459,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; } diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 65087a1fe7..0015a418ca 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -121,6 +121,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { items: vec![expected_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -179,6 +180,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -239,6 +241,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -300,6 +303,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -379,6 +383,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -465,6 +470,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -520,6 +526,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1103,6 +1110,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1157,6 +1165,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 7ae148788e..d9c636b35f 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -344,6 +344,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -451,6 +452,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -499,6 +501,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 03692ac547..9d2df4e60e 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -122,6 +122,7 @@ async fn responses_api_emits_api_request_event() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -167,6 +168,7 @@ async fn process_sse_emits_tracing_for_output_item() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -212,6 +214,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -258,6 +261,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -324,6 +328,7 @@ async fn process_sse_failed_event_records_response_error_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -388,6 +393,7 @@ async fn process_sse_failed_event_logs_parse_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -439,6 +445,7 @@ async fn process_sse_failed_event_logs_missing_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -499,6 +506,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -553,6 +561,7 @@ async fn process_sse_emits_completed_telemetry() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -631,6 +640,7 @@ async fn turn_and_completed_response_spans_record_token_usage() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -718,6 +728,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -811,6 +822,7 @@ async fn record_responses_sets_span_fields_for_response_events() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -899,6 +911,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -974,6 +987,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1050,6 +1064,7 @@ async fn handle_response_item_records_tool_result_for_shell_command_call() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1158,6 +1173,7 @@ async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1212,6 +1228,7 @@ async fn handle_shell_command_user_approved_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1281,6 +1298,7 @@ async fn handle_shell_command_user_approved_for_session_records_tool_decision() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1350,6 +1368,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1419,6 +1438,7 @@ async fn handle_shell_command_user_denies_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1488,6 +1508,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1558,6 +1579,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 62851c515d..d511c8f0b6 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -103,6 +103,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap_or_else(|err| panic!("submit user input: {err}")); @@ -285,6 +286,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -303,6 +305,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index c4f1bef968..33d494ae45 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -58,6 +58,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -97,6 +98,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -127,6 +129,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -172,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -185,6 +189,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -230,6 +235,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -260,6 +266,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -318,6 +325,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -350,6 +358,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -365,6 +374,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -424,6 +434,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -456,6 +467,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -477,6 +489,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -512,6 +525,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -572,6 +586,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 7b10db0259..45fdf3df07 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -227,6 +227,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -305,6 +306,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -386,6 +388,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index f887131eef..2023778a11 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -153,6 +153,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -166,6 +167,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -249,6 +251,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -262,6 +265,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -326,6 +330,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -339,6 +344,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -421,6 +427,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -462,6 +469,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -485,7 +493,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }); let expected_permissions_msg = body1["input"][0].clone(); let body1_input = body1["input"].as_array().expect("input array"); - // After overriding the turn context, emit one updated permissions message. + // After overriding thread settings, emit one updated permissions message. let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); assert_ne!( expected_permissions_msg_2, expected_permissions_msg, @@ -547,6 +555,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -700,6 +709,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 4c0677e69a..413855f641 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -48,6 +48,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 31d30d824e..232d2380b0 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2168,6 +2168,7 @@ async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -2302,6 +2303,7 @@ async fn conversation_user_text_turn_is_capped_when_mirrored_to_realtime() -> Re }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -3497,6 +3499,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index fddb18f15b..2d66389d53 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -47,6 +47,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -96,6 +97,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 3211d336e5..db64289354 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -93,6 +93,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -178,6 +179,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -267,6 +269,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -309,6 +312,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |event| { @@ -326,6 +330,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |event| { @@ -398,6 +403,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -444,6 +450,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 3611df6a6a..e128a189b5 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -684,6 +684,7 @@ async fn review_history_surfaces_in_parent_session() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index ae897f2774..51f100d3c5 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -437,6 +437,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -747,6 +748,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1054,6 +1056,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1373,6 +1376,7 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index af86141261..5709cdd12b 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -101,6 +101,7 @@ async fn continue_after_stream_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -122,6 +123,7 @@ async fn continue_after_stream_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 30574718f2..40763960f4 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -83,6 +83,7 @@ async fn retries_on_early_close() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 5fe08789f4..343afd8d53 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -64,6 +64,7 @@ mv "${tmp_path}" "${payload_path}""#, }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index d0e207d963..76fcacf7a9 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -112,6 +112,7 @@ async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 9e3ac1bae9..1898bebeeb 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -116,6 +116,7 @@ pub async fn run_codex_tool_session( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, trace: None, }; @@ -165,6 +166,7 @@ pub async fn run_codex_tool_session_reply( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await { diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 18b1bd6db1..b1ffb2d215 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -261,6 +261,7 @@ impl MemoryStartupContext { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ec780d60c3..cd9078a85c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -396,6 +396,80 @@ pub struct ConversationTextParams { pub text: String, } +/// Persistent thread-settings overrides that can be applied before user input. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct ThreadSettingsOverrides { + /// Updated `cwd` for sandbox/tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + + /// Updated runtime workspace roots used to materialize symbolic + /// `:workspace_roots` filesystem permissions. + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_roots: Option>, + + /// Updated profile-defined workspace roots for status summaries and + /// per-turn config reconstruction. + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_workspace_roots: Option>, + + /// Updated command approval policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + + /// Updated approval reviewer for future approval prompts. + #[serde(skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, + + /// Updated sandbox policy for tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_policy: Option, + + /// Updated permissions profile for tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, + + /// Named or built-in profile that produced `permission_profile`, if the + /// update selected a profile rather than supplying raw permissions. + #[serde(skip_serializing_if = "Option::is_none")] + pub active_permission_profile: Option, + + /// Updated Windows sandbox mode for tool execution. + #[serde(skip_serializing_if = "Option::is_none")] + pub windows_sandbox_level: Option, + + /// Updated model slug. When set, the model info is derived automatically. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Updated reasoning effort (honored only for reasoning-capable models). + /// + /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear the + /// effort, or `None` to leave the existing value unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option>, + + /// Updated reasoning summary preference (honored only for reasoning-capable models). + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + + /// Updated service tier preference for future turns. + /// + /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the + /// preference, or `None` to leave the existing value unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option>, + + /// EXPERIMENTAL - set a pre-set collaboration mode. + /// Takes precedence over model, effort, and developer instructions if set. + #[serde(skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, + + /// Updated personality preference. + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, +} + /// Submission operation #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] @@ -425,10 +499,7 @@ pub enum Op { /// Request the list of voices supported by realtime conversation streams. RealtimeConversationListVoices, - /// Legacy user input. - /// - /// Prefer [`Op::UserTurn`] so the caller provides full turn context - /// (cwd/approval/sandbox/model/etc.) for each turn. + /// User input, optionally with thread-settings overrides applied first. UserInput { /// User input items, see `InputItem` items: Vec, @@ -441,9 +512,13 @@ pub enum Op { /// Optional turn-scoped Responses API `client_metadata`. #[serde(default, skip_serializing_if = "Option::is_none")] responsesapi_client_metadata: Option>, + + /// Persistent thread-settings overrides to apply before the input. + #[serde(default, flatten)] + thread_settings: ThreadSettingsOverrides, }, - /// Similar to [`Op::UserInput`], but first applies persistent turn-context + /// Similar to [`Op::UserInput`], but first applies persistent thread-settings /// overrides in the same queued operation. This preserves submission order /// and prevents the input from starting if the overrides are rejected. UserInputWithTurnContext { @@ -606,11 +681,11 @@ pub enum Op { communication: InterAgentCommunication, }, - /// Override parts of the persistent turn context for subsequent turns. + /// Override parts of the persistent thread settings for subsequent turns. /// /// All fields are optional; when omitted, the existing value is preserved. /// This does not enqueue any input – it only updates defaults used for - /// turns that rely on persistent session-level context (for example, + /// turns that rely on persistent session-level settings (for example, /// [`Op::UserInput`]). OverrideTurnContext { /// Updated `cwd` for sandbox/tool calls. @@ -790,6 +865,7 @@ impl From> for Op { items: value, final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: ThreadSettingsOverrides::default(), } } } @@ -5028,6 +5104,7 @@ mod tests { items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }; let json_op = serde_json::to_value(op)?; @@ -5047,6 +5124,7 @@ mod tests { items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), } ); @@ -5068,6 +5146,7 @@ mod tests { items: Vec::new(), final_output_json_schema: Some(schema.clone()), responsesapi_client_metadata: None, + thread_settings: Default::default(), }; let json_op = serde_json::to_value(op)?; @@ -5093,6 +5172,7 @@ mod tests { "fiber_run_id".to_string(), "fiber-123".to_string(), )])), + thread_settings: Default::default(), }; let json_op = serde_json::to_value(&op)?; diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 8a168bad13..8593b8a477 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -293,6 +293,7 @@ async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyh environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .context("submit user input")?; From e3fe02900030913a2ec255f526232d5c39eea0e8 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 16 May 2026 14:20:00 -0700 Subject: [PATCH 52/53] Remove UserInputWithTurnContext op --- .../src/request_processors/turn_processor.rs | 26 +++--- codex-rs/core/src/session/handlers.rs | 50 +---------- codex-rs/core/src/session/tests.rs | 25 ------ codex-rs/protocol/src/protocol.rs | 90 ------------------- 4 files changed, 13 insertions(+), 178 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 3de4ebbcc3..90c77c645e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -502,13 +502,8 @@ impl TurnRequestProcessor { })?; } - // Start the turn by submitting the user input. Return its submission id as turn_id. - let turn_op = if has_any_overrides { - Op::UserInputWithTurnContext { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, + let thread_settings = if has_any_overrides { + codex_protocol::protocol::ThreadSettingsOverrides { cwd, workspace_roots: runtime_workspace_roots, profile_workspace_roots, @@ -526,13 +521,16 @@ impl TurnRequestProcessor { personality, } } else { - Op::UserInput { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - thread_settings: Default::default(), - } + Default::default() + }; + + // Start the turn by submitting the user input. Return its submission id as turn_id. + let turn_op = Op::UserInput { + items: mapped_items, + environments: environment_selections, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + thread_settings, }; let turn_id = self .submit_core_op(&request_id, thread.as_ref(), turn_op) diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index e684ae6f3b..f8a4fa2920 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -164,52 +164,6 @@ pub(super) async fn user_input_or_turn_inner( None, ) } - Op::UserInputWithTurnContext { - cwd, - workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - final_output_json_schema, - items, - responsesapi_client_metadata, - collaboration_mode, - personality, - environments, - } => { - let mut updates = thread_settings_update( - sess, - ThreadSettingsOverrides { - cwd, - workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - }, - ) - .await; - updates.final_output_json_schema = Some(final_output_json_schema); - updates.environments = environments; - (items, updates, responsesapi_client_metadata) - } Op::UserInput { items, environments, @@ -872,9 +826,7 @@ pub(super) async fn submission_loop( .await; false } - Op::UserInput { .. } - | Op::UserInputWithTurnContext { .. } - | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserTurn { .. } => { user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 46e93b16f4..a6d82e211d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5227,31 +5227,6 @@ fn op_kind_distinguishes_turn_ops() { .kind(), "user_input" ); - assert_eq!( - Op::UserInputWithTurnContext { - environments: None, - items: vec![], - final_output_json_schema: None, - responsesapi_client_metadata: None, - cwd: None, - workspace_roots: None, - profile_workspace_roots: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - active_permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - } - .kind(), - "user_input_with_turn_context" - ); } #[tokio::test] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cd9078a85c..77d862086c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -518,95 +518,6 @@ pub enum Op { thread_settings: ThreadSettingsOverrides, }, - /// Similar to [`Op::UserInput`], but first applies persistent thread-settings - /// overrides in the same queued operation. This preserves submission order - /// and prevents the input from starting if the overrides are rejected. - UserInputWithTurnContext { - /// User input items, see `InputItem` - items: Vec, - /// Optional turn-scoped environment selections. - #[serde(default, skip_serializing_if = "Option::is_none")] - environments: Option>, - /// Optional JSON Schema used to constrain the final assistant message for this turn. - #[serde(skip_serializing_if = "Option::is_none")] - final_output_json_schema: Option, - /// Optional turn-scoped Responses API `client_metadata`. - #[serde(default, skip_serializing_if = "Option::is_none")] - responsesapi_client_metadata: Option>, - - /// Updated `cwd` for sandbox/tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - cwd: Option, - - /// Updated runtime workspace roots used to materialize symbolic - /// `:workspace_roots` filesystem permissions. - #[serde(skip_serializing_if = "Option::is_none")] - workspace_roots: Option>, - - /// Updated profile-defined workspace roots for status summaries and - /// per-turn config reconstruction. - #[serde(skip_serializing_if = "Option::is_none")] - profile_workspace_roots: Option>, - - /// Updated command approval policy. - #[serde(skip_serializing_if = "Option::is_none")] - approval_policy: Option, - - /// Updated approval reviewer for future approval prompts. - #[serde(skip_serializing_if = "Option::is_none")] - approvals_reviewer: Option, - - /// Updated sandbox policy for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - sandbox_policy: Option, - - /// Updated permissions profile for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - permission_profile: Option, - - /// Named or built-in profile that produced `permission_profile`, if - /// the update selected a profile rather than supplying raw - /// permissions. - #[serde(skip_serializing_if = "Option::is_none")] - active_permission_profile: Option, - - /// Updated Windows sandbox mode for tool execution. - #[serde(skip_serializing_if = "Option::is_none")] - windows_sandbox_level: Option, - - /// Updated model slug. When set, the model info is derived - /// automatically. - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - - /// Updated reasoning effort (honored only for reasoning-capable models). - /// - /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear - /// the effort, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] - effort: Option>, - - /// Updated reasoning summary preference (honored only for reasoning-capable models). - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - - /// Updated service tier preference for future turns. - /// - /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the - /// preference, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, - - /// EXPERIMENTAL - set a pre-set collaboration mode. - /// Takes precedence over model, effort, and developer instructions if set. - #[serde(skip_serializing_if = "Option::is_none")] - collaboration_mode: Option, - - /// Updated personality preference. - #[serde(skip_serializing_if = "Option::is_none")] - personality: Option, - }, - /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -932,7 +843,6 @@ impl Op { Self::RealtimeConversationClose => "realtime_conversation_close", Self::RealtimeConversationListVoices => "realtime_conversation_list_voices", Self::UserInput { .. } => "user_input", - Self::UserInputWithTurnContext { .. } => "user_input_with_turn_context", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", From 6927359c0cb39ca79108af5ede6e548a8ae319f4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 16 May 2026 17:42:10 -0700 Subject: [PATCH 53/53] Simplify turn start context overrides --- .../src/request_processors/turn_processor.rs | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 90c77c645e..d383f64d4e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -502,26 +502,22 @@ impl TurnRequestProcessor { })?; } - let thread_settings = if has_any_overrides { - codex_protocol::protocol::ThreadSettingsOverrides { - cwd, - workspace_roots: runtime_workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level: None, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } - } else { - Default::default() + let thread_settings = codex_protocol::protocol::ThreadSettingsOverrides { + cwd, + workspace_roots: runtime_workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level: None, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, }; // Start the turn by submitting the user input. Return its submission id as turn_id.