diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 2eb7ef2a47..8a1b6030a8 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -220,6 +220,48 @@ jobs: "$dest/${binary}-${{ matrix.target }}.exe" done + - name: Build Python runtime wheel + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-pc-windows-msvc) + platform_tag="win_arm64" + ;; + x86_64-pc-windows-msvc) + platform_tag="win_amd64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + # Keep the helpers next to codex.exe in the runtime wheel so Windows + # sandbox/elevation lookup matches the standalone release zip. + python "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ + stage-runtime \ + "$stage_dir" \ + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex.exe" \ + --codex-version "${GITHUB_REF_NAME}" \ + --platform-tag "$platform_tag" \ + --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" \ + --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe" + "${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + - name: Install DotSlash uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index d05340a020..6dd751b7f6 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -399,6 +399,65 @@ jobs: cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi + - name: Build Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + platform_tag="macosx_11_0_arm64" + ;; + x86_64-apple-darwin) + platform_tag="macosx_10_9_x86_64" + ;; + aarch64-unknown-linux-musl) + platform_tag="musllinux_1_1_aarch64" + ;; + x86_64-unknown-linux-musl) + platform_tag="musllinux_1_1_x86_64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + # Do not install into the runner's system Python; macOS runners mark + # the Homebrew Python as externally managed under PEP 668. + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + stage_runtime_args=( + "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" + stage-runtime + "$stage_dir" + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" + --codex-version "${GITHUB_REF_NAME}" + --platform-tag "$platform_tag" + ) + if [[ "${{ matrix.target }}" == *linux* ]]; then + # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior + # matches the standalone release bundle on hosts without system bwrap. + stage_runtime_args+=( + --resource-binary + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" + ) + fi + python3 "${stage_runtime_args[@]}" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + - name: Compress artifacts shell: bash run: | @@ -478,6 +537,7 @@ jobs: tag: ${{ github.ref_name }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} + should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository @@ -554,6 +614,22 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi + - name: Determine Python runtime publish settings + id: python_runtime_publish_settings + env: + VERSION: ${{ steps.release_name.outputs.name }} + run: | + set -euo pipefail + version="${VERSION}" + + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + else + echo "should_publish=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup pnpm uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: @@ -787,6 +863,48 @@ jobs: exit "${publish_status}" done + # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. + # PyPI project configuration must trust this workflow and job. Keep this + # non-blocking while the Python runtime publishing path is new; failures still + # need release follow-up, but should not invalidate the Rust release itself. + publish-python-runtime: + # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. + if: ${{ needs.release.outputs.should_publish_python_runtime == 'true' }} + name: publish-python-runtime + needs: release + runs-on: ubuntu-latest + continue-on-error: true + environment: pypi + permissions: + id-token: write # Required for PyPI trusted publishing. + contents: read + + steps: + - name: Download Python runtime wheels from release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.release.outputs.tag }} + RELEASE_VERSION: ${{ needs.release.outputs.version }} + run: | + set -euo pipefail + python_version="$RELEASE_VERSION" + python_version="${python_version/-alpha./a}" + python_version="${python_version/-beta./b}" + python_version="${python_version/-rc./rc}" + + mkdir -p dist/python-runtime + gh release download "$RELEASE_TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ + --dir dist/python-runtime + ls -lh dist/python-runtime + + - name: Publish Python runtime wheels to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/python-runtime + skip-existing: true + winget: name: winget needs: release diff --git a/sdk/python/README.md b/sdk/python/README.md index 149420ad95..031471b811 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -91,7 +91,7 @@ This supports the CI release flow: - run `generate-types` before packaging - stage `openai-codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency - stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version -- build and publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist +- build and publish `openai-codex-cli-bin` as platform wheels only through PyPI trusted publishing; do not publish an sdist ## Compatibility and versioning diff --git a/sdk/python/_runtime_setup.py b/sdk/python/_runtime_setup.py index 8d33da6c88..0dc2a579ff 100644 --- a/sdk/python/_runtime_setup.py +++ b/sdk/python/_runtime_setup.py @@ -27,16 +27,22 @@ class RuntimeSetupError(RuntimeError): def pinned_runtime_version() -> str: - source_version = _source_tree_project_version() - if source_version is not None: - return _normalized_package_version(source_version) + """Return the exact runtime version pinned by the SDK package dependency.""" + source_pin = _source_tree_runtime_dependency_version() + if source_pin is not None: + return _normalized_package_version(source_pin) try: - return _normalized_package_version(importlib.metadata.version(SDK_PACKAGE_NAME)) + installed_pin = _installed_sdk_runtime_dependency_version() except importlib.metadata.PackageNotFoundError as exc: raise RuntimeSetupError( - f"Unable to resolve {SDK_PACKAGE_NAME} version for runtime pinning." + f"Unable to resolve {SDK_PACKAGE_NAME} metadata for runtime pinning." ) from exc + if installed_pin is None: + raise RuntimeSetupError( + f"Unable to resolve {PACKAGE_NAME} dependency pin from {SDK_PACKAGE_NAME}." + ) + return _normalized_package_version(installed_pin) def ensure_runtime_package_installed( @@ -399,20 +405,33 @@ def _release_tag(version: str) -> str: return f"rust-v{_codex_release_version(version)}" -def _source_tree_project_version() -> str | None: +def _source_tree_runtime_dependency_version() -> str | None: + """Read the runtime dependency pin when the SDK is running from a checkout.""" pyproject_path = Path(__file__).resolve().parent / "pyproject.toml" if not pyproject_path.exists(): return None - match = re.search( - r'(?m)^version = "([^"]+)"$', - pyproject_path.read_text(encoding="utf-8"), - ) + match = re.search(_runtime_dependency_pin_pattern(), pyproject_path.read_text()) if match is None: return None return match.group(1) +def _installed_sdk_runtime_dependency_version() -> str | None: + """Read the runtime dependency pin from installed package metadata.""" + requirements = importlib.metadata.requires(SDK_PACKAGE_NAME) or [] + for requirement in requirements: + match = re.search(_runtime_dependency_pin_pattern(), requirement) + if match is not None: + return match.group(1) + return None + + +def _runtime_dependency_pin_pattern() -> str: + """Match the exact runtime dependency pin in TOML and wheel metadata.""" + return rf'{re.escape(PACKAGE_NAME)}\s*==\s*"?([^",;\s]+)"?' + + __all__ = [ "PACKAGE_NAME", "SDK_PACKAGE_NAME", diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 59428ba322..3a93c28a0c 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -28,7 +28,7 @@ will download the matching GitHub release artifact, stage a temporary local `openai-codex-cli-bin` package, install it into your active interpreter, and clean up the temporary files afterward. -The pinned runtime version comes from the SDK package version. +The pinned runtime version comes from the SDK package dependency. ## Run examples diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index f54838bfa8..6b92983c91 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openai-codex-app-server-sdk" -version = "0.116.0a1" +version = "0.131.0a4" description = "Python SDK for Codex app-server v2" readme = "README.md" requires-python = ">=3.10" @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["pydantic>=2.12"] +dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.131.0a4"] [project.urls] Homepage = "https://github.com/openai/codex" @@ -63,8 +63,10 @@ testpaths = ["tests"] [tool.uv] exclude-newer = "7 days" +exclude-newer-package = { openai-codex-cli-bin = "2026-05-10T00:00:00Z" } index-strategy = "first-index" [tool.uv.pip] exclude-newer = "7 days" +exclude-newer-package = { openai-codex-cli-bin = "2026-05-10T00:00:00Z" } index-strategy = "first-index" diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 4ff6f0c24f..c44a1444c0 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -61,6 +61,7 @@ def staged_runtime_bin_path(root: Path) -> Path: def staged_runtime_resource_path(root: Path, resource: Path) -> Path: + """Stage runtime helper binaries beside the main bundled Codex binary.""" # Runtime wheels include the whole bin/ directory, so helper executables # should be staged beside the main Codex binary instead of changing the # package template for each platform. @@ -588,6 +589,7 @@ def _notification_specs() -> list[tuple[str, str]]: def _notification_turn_id_specs( specs: list[tuple[str, str]], ) -> tuple[list[str], list[str]]: + """Classify generated notification payloads by where the turn id lives.""" server_notifications = json.loads( (schema_root_dir() / "ServerNotification.json").read_text() ) @@ -615,6 +617,7 @@ def _notification_turn_id_specs( def _type_tuple_source(class_names: list[str]) -> str: + """Render a generated tuple literal for notification payload classes.""" if not class_names: return "()" if len(class_names) == 1: @@ -623,6 +626,7 @@ def _type_tuple_source(class_names: list[str]) -> str: def generate_notification_registry() -> None: + """Regenerate notification models and routing metadata from generated schemas.""" out = ( sdk_root() / "src" @@ -666,6 +670,7 @@ def generate_notification_registry() -> None: "", "", "def notification_turn_id(payload: BaseModel) -> str | None:", + ' """Return the turn id carried by generated notification payload metadata."""', " if isinstance(payload, DIRECT_TURN_ID_NOTIFICATION_TYPES):", " return payload.turn_id if isinstance(payload.turn_id, str) else None", " if isinstance(payload, NESTED_TURN_NOTIFICATION_TYPES):", diff --git a/sdk/python/src/codex_app_server/_message_router.py b/sdk/python/src/codex_app_server/_message_router.py index 6de575166c..fb466f958c 100644 --- a/sdk/python/src/codex_app_server/_message_router.py +++ b/sdk/python/src/codex_app_server/_message_router.py @@ -22,6 +22,7 @@ class MessageRouter: """ def __init__(self) -> None: + """Create empty response, turn, and global notification queues.""" self._lock = threading.Lock() self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {} self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {} @@ -144,6 +145,7 @@ class MessageRouter: self._global_notifications.put(exc) def _notification_turn_id(self, notification: Notification) -> str | None: + """Extract routing ids from known generated payloads or raw unknown payloads.""" payload = notification.payload if isinstance(payload, UnknownNotification): raw_turn_id = payload.params.get("turnId") diff --git a/sdk/python/src/codex_app_server/api.py b/sdk/python/src/codex_app_server/api.py index 6f61a55868..886e4dd826 100644 --- a/sdk/python/src/codex_app_server/api.py +++ b/sdk/python/src/codex_app_server/api.py @@ -678,6 +678,7 @@ class TurnHandle: return self._client.turn_interrupt(self.thread_id, self.id) def stream(self) -> Iterator[Notification]: + """Yield only notifications routed to this turn handle.""" self._client.register_turn_notifications(self.id) try: while True: @@ -730,6 +731,7 @@ class AsyncTurnHandle: return await self._codex._client.turn_interrupt(self.thread_id, self.id) async def stream(self) -> AsyncIterator[Notification]: + """Yield only notifications routed to this async turn handle.""" await self._codex._ensure_initialized() self._codex._client.register_turn_notifications(self.id) try: diff --git a/sdk/python/src/codex_app_server/async_client.py b/sdk/python/src/codex_app_server/async_client.py index 6768c7d9fc..581d14abe9 100644 --- a/sdk/python/src/codex_app_server/async_client.py +++ b/sdk/python/src/codex_app_server/async_client.py @@ -40,13 +40,16 @@ class AsyncAppServerClient: """Async wrapper around AppServerClient using thread offloading.""" def __init__(self, config: AppServerConfig | None = None) -> None: + """Create the wrapped sync client that owns the transport process.""" self._sync = AppServerClient(config=config) async def __aenter__(self) -> "AsyncAppServerClient": + """Start the app-server process when entering an async context.""" await self.start() return self async def __aexit__(self, _exc_type, _exc, _tb) -> None: + """Close the app-server process when leaving an async context.""" await self.close() async def _call_sync( @@ -56,30 +59,37 @@ class AsyncAppServerClient: *args: ParamsT.args, **kwargs: ParamsT.kwargs, ) -> ReturnT: + """Run a blocking sync-client operation without blocking the event loop.""" return await asyncio.to_thread(fn, *args, **kwargs) @staticmethod def _next_from_iterator( iterator: Iterator[AgentMessageDeltaNotification], ) -> tuple[bool, AgentMessageDeltaNotification | None]: + """Convert StopIteration into a value that can cross asyncio.to_thread.""" try: return True, next(iterator) except StopIteration: return False, None async def start(self) -> None: + """Start the wrapped sync client in a worker thread.""" await self._call_sync(self._sync.start) async def close(self) -> None: + """Close the wrapped sync client in a worker thread.""" await self._call_sync(self._sync.close) async def initialize(self) -> InitializeResponse: + """Initialize the app-server session.""" return await self._call_sync(self._sync.initialize) def register_turn_notifications(self, turn_id: str) -> None: + """Register a turn notification queue on the wrapped sync client.""" self._sync.register_turn_notifications(turn_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) async def request( @@ -89,6 +99,7 @@ class AsyncAppServerClient: *, response_model: type[ModelT], ) -> ModelT: + """Send a typed JSON-RPC request through the wrapped sync client.""" return await self._call_sync( self._sync.request, method, @@ -99,6 +110,7 @@ class AsyncAppServerClient: async def thread_start( self, params: V2ThreadStartParams | JsonObject | None = None ) -> ThreadStartResponse: + """Start a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_start, params) async def thread_resume( @@ -106,16 +118,19 @@ class AsyncAppServerClient: thread_id: str, params: V2ThreadResumeParams | JsonObject | None = None, ) -> ThreadResumeResponse: + """Resume a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_resume, thread_id, params) async def thread_list( self, params: V2ThreadListParams | JsonObject | None = None ) -> ThreadListResponse: + """List threads using the wrapped sync client.""" return await self._call_sync(self._sync.thread_list, params) async def thread_read( self, thread_id: str, include_turns: bool = False ) -> ThreadReadResponse: + """Read a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_read, thread_id, include_turns) async def thread_fork( @@ -123,18 +138,23 @@ class AsyncAppServerClient: thread_id: str, params: V2ThreadForkParams | JsonObject | None = None, ) -> ThreadForkResponse: + """Fork a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_fork, thread_id, params) async def thread_archive(self, thread_id: str) -> ThreadArchiveResponse: + """Archive a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_archive, thread_id) async def thread_unarchive(self, thread_id: str) -> ThreadUnarchiveResponse: + """Unarchive a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_unarchive, thread_id) async def thread_set_name(self, thread_id: str, name: str) -> ThreadSetNameResponse: + """Rename a thread using the wrapped sync client.""" return await self._call_sync(self._sync.thread_set_name, thread_id, name) async def thread_compact(self, thread_id: str) -> ThreadCompactStartResponse: + """Start thread compaction using the wrapped sync client.""" return await self._call_sync(self._sync.thread_compact, thread_id) async def turn_start( @@ -143,6 +163,7 @@ class AsyncAppServerClient: input_items: list[JsonObject] | JsonObject | str, params: V2TurnStartParams | JsonObject | None = None, ) -> TurnStartResponse: + """Start a turn using the wrapped sync client.""" return await self._call_sync( self._sync.turn_start, thread_id, input_items, params ) @@ -150,6 +171,7 @@ class AsyncAppServerClient: async def turn_interrupt( self, thread_id: str, turn_id: str ) -> TurnInterruptResponse: + """Interrupt a turn using the wrapped sync client.""" return await self._call_sync(self._sync.turn_interrupt, thread_id, turn_id) async def turn_steer( @@ -158,6 +180,7 @@ class AsyncAppServerClient: expected_turn_id: str, input_items: list[JsonObject] | JsonObject | str, ) -> TurnSteerResponse: + """Send steering input to a turn using the wrapped sync client.""" return await self._call_sync( self._sync.turn_steer, thread_id, @@ -166,6 +189,7 @@ class AsyncAppServerClient: ) async def model_list(self, include_hidden: bool = False) -> ModelListResponse: + """List models using the wrapped sync client.""" return await self._call_sync(self._sync.model_list, include_hidden) async def request_with_retry_on_overload( @@ -178,6 +202,7 @@ class AsyncAppServerClient: initial_delay_s: float = 0.25, max_delay_s: float = 2.0, ) -> ModelT: + """Send a typed request with the sync client's overload retry policy.""" return await self._call_sync( self._sync.request_with_retry_on_overload, method, @@ -189,12 +214,15 @@ class AsyncAppServerClient: ) async def next_notification(self) -> Notification: + """Wait for the next global notification without blocking the event loop.""" return await self._call_sync(self._sync.next_notification) 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_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) async def stream_text( @@ -203,6 +231,7 @@ class AsyncAppServerClient: text: str, params: V2TurnStartParams | JsonObject | None = None, ) -> AsyncIterator[AgentMessageDeltaNotification]: + """Stream text deltas from one turn without monopolizing the event loop.""" iterator = self._sync.stream_text(thread_id, text, params) while True: has_value, chunk = await asyncio.to_thread( diff --git a/sdk/python/src/codex_app_server/client.py b/sdk/python/src/codex_app_server/client.py index ce3df4e416..26d23e3fa7 100644 --- a/sdk/python/src/codex_app_server/client.py +++ b/sdk/python/src/codex_app_server/client.py @@ -243,6 +243,7 @@ class AppServerClient: return response_model.model_validate(result) def _request_raw(self, method: str, params: JsonObject | None = None) -> JsonValue: + """Send a JSON-RPC request and wait for the reader thread to route its response.""" request_id = str(uuid.uuid4()) waiter = self._router.create_response_waiter(request_id) @@ -260,18 +261,23 @@ class AppServerClient: return item 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 {}}) 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_turn_notifications(self, turn_id: str) -> None: + """Start routing notifications for one turn into its dedicated queue.""" self._router.register_turn(turn_id) def unregister_turn_notifications(self, turn_id: str) -> None: + """Stop routing notifications for one turn into its dedicated queue.""" self._router.unregister_turn(turn_id) def next_turn_notification(self, turn_id: str) -> Notification: + """Return the next routed notification for the requested turn id.""" return self._router.next_turn_notification(turn_id) def thread_start( @@ -349,6 +355,7 @@ class AppServerClient: input_items: list[JsonObject] | JsonObject | str, params: V2TurnStartParams | JsonObject | None = None, ) -> TurnStartResponse: + """Start a turn and register its notification queue as early as possible.""" payload = { **_params_dict(params), "threadId": thread_id, @@ -406,6 +413,7 @@ class AppServerClient: ) def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification: + """Block on the routed turn stream until the matching completion arrives.""" self.register_turn_notifications(turn_id) try: while True: @@ -425,6 +433,7 @@ class AppServerClient: text: str, params: V2TurnStartParams | JsonObject | None = None, ) -> Iterator[AgentMessageDeltaNotification]: + """Start a text turn and yield only its agent-message delta payloads.""" started = self.turn_start(thread_id, text, params=params) turn_id = started.turn.id self.register_turn_notifications(turn_id) @@ -477,6 +486,7 @@ class AppServerClient: def _default_approval_handler( self, method: str, params: JsonObject | None ) -> JsonObject: + """Accept approval requests when the caller did not provide a handler.""" if method == "item/commandExecution/requestApproval": return {"decision": "accept"} if method == "item/fileChange/requestApproval": @@ -498,6 +508,7 @@ class AppServerClient: self._stderr_thread.start() def _start_reader_thread(self) -> None: + """Start the sole stdout reader that fans messages into router queues.""" if self._proc is None or self._proc.stdout is None: return @@ -505,6 +516,7 @@ class AppServerClient: self._reader_thread.start() def _reader_loop(self) -> None: + """Continuously classify transport messages into requests, responses, and events.""" try: while True: msg = self._read_message() diff --git a/sdk/python/src/codex_app_server/generated/notification_registry.py b/sdk/python/src/codex_app_server/generated/notification_registry.py index b44ca2a436..3319f4edb5 100644 --- a/sdk/python/src/codex_app_server/generated/notification_registry.py +++ b/sdk/python/src/codex_app_server/generated/notification_registry.py @@ -165,6 +165,7 @@ NESTED_TURN_NOTIFICATION_TYPES: tuple[type[BaseModel], ...] = ( def notification_turn_id(payload: BaseModel) -> str | None: + """Return the turn id carried by generated notification payload metadata.""" if isinstance(payload, DIRECT_TURN_ID_NOTIFICATION_TYPES): return payload.turn_id if isinstance(payload.turn_id, str) else None if isinstance(payload, NESTED_TURN_NOTIFICATION_TYPES): diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index e9b4e6a8bb..98710fdce2 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -163,8 +163,9 @@ def test_runtime_package_template_has_no_checked_in_binaries() -> None: def test_examples_readme_points_to_runtime_version_source_of_truth() -> None: + """Document that examples should point at the dependency pin, not release lore.""" readme = (ROOT / "examples" / "README.md").read_text() - assert "The pinned runtime version comes from the SDK package version." in readme + assert "The pinned runtime version comes from the SDK package dependency." in readme def test_runtime_distribution_name_is_consistent() -> None: @@ -211,12 +212,33 @@ def test_release_metadata_retries_without_invalid_auth( assert authorizations == ["Bearer invalid-token", None] +def test_source_sdk_package_pins_published_runtime() -> None: + """The source package metadata should pin the runtime wheel that ships schemas.""" + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + + assert { + "sdk_version": pyproject["project"]["version"], + "dependencies": pyproject["project"]["dependencies"], + } == { + "sdk_version": "0.131.0a4", + "dependencies": [ + "pydantic>=2.12", + "openai-codex-cli-bin==0.131.0a4", + ], + } + + def test_runtime_setup_uses_pep440_package_version_and_codex_release_tags() -> None: + """The SDK uses PEP 440 package pins and converts only when fetching releases.""" runtime_setup = _load_runtime_setup_module() pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin" assert runtime_setup.pinned_runtime_version() == pyproject["project"]["version"] + assert ( + f"{runtime_setup.PACKAGE_NAME}=={pyproject['project']['version']}" + in pyproject["project"]["dependencies"] + ) assert ( runtime_setup._normalized_package_version("rust-v0.116.0-alpha.1") == "0.116.0a1" @@ -352,6 +374,7 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non def test_stage_runtime_release_copies_resource_binaries(tmp_path: Path) -> None: + """Runtime staging should copy every helper binary into the wheel bin dir.""" script = _load_update_script_module() fake_binary = tmp_path / script.runtime_binary_name() helper = tmp_path / "helper" @@ -382,6 +405,7 @@ def test_stage_runtime_release_copies_resource_binaries(tmp_path: Path) -> None: def test_runtime_resource_binaries_are_included_by_wheel_config( tmp_path: Path, ) -> None: + """The runtime wheel config should include helper binaries beside Codex.""" script = _load_update_script_module() fake_binary = tmp_path / script.runtime_binary_name() helper = tmp_path / "helper" diff --git a/sdk/python/tests/test_async_client_behavior.py b/sdk/python/tests/test_async_client_behavior.py index 0c4e8096fb..51359f041f 100644 --- a/sdk/python/tests/test_async_client_behavior.py +++ b/sdk/python/tests/test_async_client_behavior.py @@ -13,12 +13,15 @@ from codex_app_server.models import Notification, UnknownNotification def test_async_client_allows_concurrent_transport_calls() -> None: + """Async wrappers should offload sync calls so concurrent awaits can overlap.""" async def scenario() -> int: + """Run two blocking sync calls and report peak overlap.""" client = AsyncAppServerClient() active = 0 max_active = 0 def fake_model_list(include_hidden: bool = False) -> bool: + """Simulate a blocking sync transport call.""" nonlocal active, max_active active += 1 max_active = max(max_active, active) @@ -34,16 +37,20 @@ def test_async_client_allows_concurrent_transport_calls() -> None: def test_async_stream_text_is_incremental_without_blocking_parallel_calls() -> None: + """Async text streaming should yield incrementally without blocking other calls.""" async def scenario() -> tuple[str, list[str], bool]: + """Start a stream, then prove another async client call can finish.""" client = AsyncAppServerClient() def fake_stream_text(thread_id: str, text: str, params=None): # type: ignore[no-untyped-def] + """Yield one item before sleeping so the async wrapper can interleave.""" yield "first" time.sleep(0.03) yield "second" yield "third" def fake_model_list(include_hidden: bool = False) -> str: + """Return immediately to prove the event loop was not monopolized.""" return "done" client._sync.stream_text = fake_stream_text # type: ignore[method-assign] @@ -70,7 +77,9 @@ def test_async_stream_text_is_incremental_without_blocking_parallel_calls() -> N def test_async_client_turn_notification_methods_delegate_to_sync_client() -> None: + """Async turn routing methods should preserve sync-client registration semantics.""" async def scenario() -> tuple[list[tuple[str, str]], Notification, str]: + """Record the sync-client calls made by async turn notification wrappers.""" client = AsyncAppServerClient() event = Notification( method="unknown/direct", @@ -85,16 +94,20 @@ def test_async_client_turn_notification_methods_delegate_to_sync_client() -> Non calls: list[tuple[str, str]] = [] def fake_register(turn_id: str) -> None: + """Record turn registration through the wrapped sync client.""" calls.append(("register", turn_id)) def fake_unregister(turn_id: str) -> None: + """Record turn unregistration through the wrapped sync client.""" calls.append(("unregister", turn_id)) def fake_next(turn_id: str) -> Notification: + """Return one routed notification through the wrapped sync client.""" calls.append(("next", turn_id)) return event def fake_wait(turn_id: str) -> TurnCompletedNotification: + """Return one completion through the wrapped sync client.""" calls.append(("wait", turn_id)) return completed @@ -132,7 +145,9 @@ def test_async_client_turn_notification_methods_delegate_to_sync_client() -> Non def test_async_stream_text_uses_sync_turn_routing() -> None: + """Async text streaming should consume the same per-turn routing path as sync.""" async def scenario() -> tuple[list[tuple[str, str]], list[str]]: + """Record routing calls while streaming two deltas and one completion.""" client = AsyncAppServerClient() notifications = [ Notification( @@ -170,17 +185,21 @@ def test_async_stream_text_uses_sync_turn_routing() -> None: calls: list[tuple[str, str]] = [] def fake_turn_start(thread_id: str, text: str, *, params=None): # type: ignore[no-untyped-def] + """Return a started turn id while recording the request thread.""" calls.append(("turn_start", thread_id)) return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) def fake_register(turn_id: str) -> None: + """Record stream registration for the started turn.""" calls.append(("register", turn_id)) def fake_next(turn_id: str) -> Notification: + """Return the next queued turn notification.""" calls.append(("next", turn_id)) return notifications.pop(0) def fake_unregister(turn_id: str) -> None: + """Record stream cleanup for the started turn.""" calls.append(("unregister", turn_id)) client._sync.turn_start = fake_turn_start # type: ignore[method-assign] diff --git a/sdk/python/tests/test_client_rpc_methods.py b/sdk/python/tests/test_client_rpc_methods.py index 07b88215a8..a049960b07 100644 --- a/sdk/python/tests/test_client_rpc_methods.py +++ b/sdk/python/tests/test_client_rpc_methods.py @@ -135,6 +135,7 @@ def test_invalid_notification_payload_falls_back_to_unknown() -> None: def test_generated_notification_turn_id_handles_known_payload_shapes() -> None: + """Generated routing metadata should cover direct, nested, and unscoped payloads.""" direct = AgentMessageDeltaNotification.model_validate( { "delta": "hello", @@ -159,6 +160,7 @@ def test_generated_notification_turn_id_handles_known_payload_shapes() -> None: def test_turn_notification_router_demuxes_registered_turns() -> None: + """The router should deliver out-of-order turn events to the matching queues.""" client = AppServerClient() client.register_turn_notifications("turn-1") client.register_turn_notifications("turn-2") @@ -201,6 +203,7 @@ def test_turn_notification_router_demuxes_registered_turns() -> None: def test_client_reader_routes_interleaved_turn_notifications_by_turn_id() -> None: + """Reader-loop routing should preserve order within each interleaved turn stream.""" client = AppServerClient() client.register_turn_notifications("turn-1") client.register_turn_notifications("turn-2") @@ -245,6 +248,7 @@ def test_client_reader_routes_interleaved_turn_notifications_by_turn_id() -> Non ] def fake_read_message() -> dict[str, object]: + """Feed the reader loop a realistic interleaved stdout sequence.""" if messages: return messages.pop(0) raise EOFError @@ -278,6 +282,7 @@ def test_client_reader_routes_interleaved_turn_notifications_by_turn_id() -> Non def test_turn_notification_router_buffers_events_before_registration() -> None: + """Early turn events should be replayed once their TurnHandle registers.""" client = AppServerClient() client._router.route_notification( client._coerce_notification( @@ -302,6 +307,7 @@ def test_turn_notification_router_buffers_events_before_registration() -> None: def test_turn_notification_router_clears_unregistered_turn_when_completed() -> None: + """A completed unregistered turn should not leave a pending queue behind.""" client = AppServerClient() client._router.route_notification( client._coerce_notification( @@ -328,6 +334,7 @@ def test_turn_notification_router_clears_unregistered_turn_when_completed() -> N def test_turn_notification_router_routes_unknown_turn_notifications() -> None: + """Unknown notifications should still route when their raw params carry a turn id.""" client = AppServerClient() client.register_turn_notifications("turn-1") client.register_turn_notifications("turn-2") diff --git a/sdk/python/tests/test_public_api_runtime_behavior.py b/sdk/python/tests/test_public_api_runtime_behavior.py index 4f0fc45a7c..3cf18c4e47 100644 --- a/sdk/python/tests/test_public_api_runtime_behavior.py +++ b/sdk/python/tests/test_public_api_runtime_behavior.py @@ -227,6 +227,7 @@ def test_async_codex_initializes_only_once_under_concurrency() -> None: def test_turn_streams_can_consume_multiple_turns_on_one_client() -> None: + """Two sync TurnHandle streams should advance independently on one client.""" client = AppServerClient() notifications: dict[str, deque[Notification]] = { "turn-1": deque( @@ -257,10 +258,13 @@ def test_turn_streams_can_consume_multiple_turns_on_one_client() -> None: def test_async_turn_streams_can_consume_multiple_turns_on_one_client() -> None: + """Two async TurnHandle streams should advance independently on one client.""" async def scenario() -> None: + """Interleave two async streams backed by separate per-turn queues.""" codex = AsyncCodex() async def fake_ensure_initialized() -> None: + """Avoid starting a real app-server process for this stream test.""" return None notifications: dict[str, deque[Notification]] = { @@ -279,6 +283,7 @@ def test_async_turn_streams_can_consume_multiple_turns_on_one_client() -> None: } async def fake_next_notification(turn_id: str) -> Notification: + """Return the next notification from the requested per-turn queue.""" return notifications[turn_id].popleft() codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign] @@ -468,6 +473,7 @@ def test_thread_run_raises_on_failed_turn() -> None: def test_stream_text_registers_and_consumes_turn_notifications() -> None: + """stream_text should register, consume, and unregister one turn queue.""" client = AppServerClient() notifications: deque[Notification] = deque( [ @@ -482,13 +488,16 @@ def test_stream_text_registers_and_consumes_turn_notifications() -> None: ) def fake_register(turn_id: str) -> None: + """Record registration for the turn created by stream_text.""" calls.append(("register", turn_id)) def fake_next(turn_id: str) -> Notification: + """Return the next queued notification for stream_text.""" calls.append(("next", turn_id)) return notifications.popleft() def fake_unregister(turn_id: str) -> None: + """Record cleanup for the turn created by stream_text.""" calls.append(("unregister", turn_id)) client.register_turn_notifications = fake_register # type: ignore[method-assign] @@ -510,10 +519,13 @@ def test_stream_text_registers_and_consumes_turn_notifications() -> None: def test_async_thread_run_accepts_string_input_and_returns_run_result() -> None: + """Async Thread.run should normalize string input and collect routed results.""" async def scenario() -> None: + """Feed item, usage, and completion events through the async turn stream.""" codex = AsyncCodex() async def fake_ensure_initialized() -> None: + """Avoid starting a real app-server process for this run test.""" return None item_notification = _item_completed_notification(text="Hello async.") @@ -528,12 +540,14 @@ def test_async_thread_run_accepts_string_input_and_returns_run_result() -> None: seen: dict[str, object] = {} async def fake_turn_start(thread_id: str, wire_input: object, *, params=None): # noqa: ANN001,ANN202 + """Capture normalized input and return a synthetic turn id.""" seen["thread_id"] = thread_id seen["wire_input"] = wire_input seen["params"] = params return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) async def fake_next_notification(_turn_id: str) -> Notification: + """Return the next queued notification for the synthetic turn.""" return notifications.popleft() codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign] @@ -556,10 +570,13 @@ def test_async_thread_run_accepts_string_input_and_returns_run_result() -> None: def test_async_thread_run_uses_last_completed_assistant_message_as_final_response() -> ( None ): + """Async run should use the last final assistant message as the response text.""" async def scenario() -> None: + """Feed two completed agent messages through the async per-turn stream.""" codex = AsyncCodex() async def fake_ensure_initialized() -> None: + """Avoid starting a real app-server process for this run test.""" return None first_item_notification = _item_completed_notification( @@ -577,9 +594,11 @@ def test_async_thread_run_uses_last_completed_assistant_message_as_final_respons ) async def fake_turn_start(thread_id: str, wire_input: object, *, params=None): # noqa: ANN001,ANN202,ARG001 + """Return a synthetic turn id after AsyncThread.run builds input.""" return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) async def fake_next_notification(_turn_id: str) -> Notification: + """Return the next queued notification for that synthetic turn.""" return notifications.popleft() codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign] @@ -598,10 +617,13 @@ def test_async_thread_run_uses_last_completed_assistant_message_as_final_respons def test_async_thread_run_returns_none_when_only_commentary_messages_complete() -> None: + """Async Thread.run should ignore commentary-only messages for final text.""" async def scenario() -> None: + """Feed a commentary item and completion through the async turn stream.""" codex = AsyncCodex() async def fake_ensure_initialized() -> None: + """Avoid starting a real app-server process for this run test.""" return None commentary_notification = _item_completed_notification( @@ -616,9 +638,11 @@ def test_async_thread_run_returns_none_when_only_commentary_messages_complete() ) async def fake_turn_start(thread_id: str, wire_input: object, *, params=None): # noqa: ANN001,ANN202,ARG001 + """Return a synthetic turn id for commentary-only output.""" return SimpleNamespace(turn=SimpleNamespace(id="turn-1")) async def fake_next_notification(_turn_id: str) -> Notification: + """Return the next queued commentary/completion notification.""" return notifications.popleft() codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign] diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index d0e2cf7372..b8e3a8f662 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -3,9 +3,12 @@ revision = 3 requires-python = ">=3.10" [options] -exclude-newer = "2026-04-20T18:19:27.620299Z" +exclude-newer = "2026-05-02T06:28:46.47929Z" exclude-newer-span = "P7D" +[options.exclude-newer-package] +openai-codex-cli-bin = "2026-05-10T00:00:00Z" + [[package]] name = "annotated-types" version = "0.7.0" @@ -279,9 +282,10 @@ wheels = [ [[package]] name = "openai-codex-app-server-sdk" -version = "0.116.0a1" +version = "0.131.0a4" source = { editable = "." } dependencies = [ + { name = "openai-codex-cli-bin" }, { name = "pydantic" }, ] @@ -295,12 +299,26 @@ dev = [ [package.metadata] requires-dist = [ { name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" }, + { name = "openai-codex-cli-bin", specifier = "==0.131.0a4" }, { name = "pydantic", specifier = ">=2.12" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, ] provides-extras = ["dev"] +[[package]] +name = "openai-codex-cli-bin" +version = "0.131.0a4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/9f/f9fc4bb1b2b7a20d4d65143ebb4c4dcd2301a718183b539ecb5b1c0ac3ec/openai_codex_cli_bin-0.131.0a4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:db0f3cb7dda310641ac04fbaf3f128693a3817ab83ae59b67a3c9c74bd53f8b8", size = 88367585, upload-time = "2026-05-09T06:14:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/dc/39/eb95ed0e8156669e895a192dec760be07dabe891c3c6340f7c6487b9a976/openai_codex_cli_bin-0.131.0a4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6cae5af6edca7f6d3f0bcbbd93cfc8a6dc3e33fb5955af21ae492b6d5d0dcb72", size = 79245567, upload-time = "2026-05-09T06:14:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/0c/92/ade176fa78d746d5ff7a6e371d64740c0d95ab299b0dd58a5404b89b3915/openai_codex_cli_bin-0.131.0a4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5728f9887baf62d7e72f4f242093b3ff81e26c81d80d346fe1eef7eda6838aa8", size = 77758628, upload-time = "2026-05-09T06:14:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/bfe6c65f8e3e5499f71b24c3b6e8d07e4d426543d25e429b9b141b544e5f/openai_codex_cli_bin-0.131.0a4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d7a47fd3667fbcc216593839c202deffa056e9b3d46c6933e72594d461f4fea0", size = 84535509, upload-time = "2026-05-09T06:14:22.851Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/53dc094a691ab6f2ca079e8e865b122843809ac4fad51cac4d59021e599d/openai_codex_cli_bin-0.131.0a4-py3-none-win_amd64.whl", hash = "sha256:c61bcf029672494c4c7fdc8567dbaa659a48bb75641d91c2ade27c1e46803434", size = 88185543, upload-time = "2026-05-09T06:14:27.282Z" }, + { url = "https://files.pythonhosted.org/packages/82/99/e0852ffcf9b4d2794fef83e0c3a267b3c773a776f136e9f7ce19f0c8df42/openai_codex_cli_bin-0.131.0a4-py3-none-win_arm64.whl", hash = "sha256:bbde750186861f102e346ac066f4e9608f515f7b71b16a6e8b7ef1ddc02a97a5", size = 81196380, upload-time = "2026-05-09T06:14:32.103Z" }, +] + [[package]] name = "packaging" version = "26.1"