Expose Python SDK history selection and per-turn options (#44084)

## Why

Python callers need control over response history loading and a way to override the service tier for one turn. These options also need runtime compatibility checks to prevent older CLIs from silently ignoring them.

## What changed

- Add `include_turns` to sync and async thread resume/fork methods. Omission preserves server defaults; `False` skips response history loading without changing model context.
- Add `turn_service_tier` and `source` to sync and async `run()` and `turn()`, and generate both methods together to keep their options aligned.
- Require CLI `0.151.0` or newer when sending the new options, with lazy schema checks for unversioned local builds.
- Pin the bundled runtime dependency to `0.153.4` and reject unsupported runtime versions during SDK packaging.

## Testing

Add coverage for option forwarding, history flag omission and inversion, runtime version checks, cached schema probing, and packaging compatibility. Extend app-server and installed SDK smoke tests to exercise the new options.

GitOrigin-RevId: 4bcc9cff687b0651e67852e7c080df7fadac6d76
This commit is contained in:
Ahmed Ibrahim
2026-09-09 06:40:56 +00:00
committed by copyberry
parent 7b9e7d99bd
commit 8afccec87a
14 changed files with 667 additions and 108 deletions
+158 -1
View File
@@ -1,11 +1,14 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import get_type_hints
import pytest
from openai_codex._runtime_requirements import CheckoutCapabilities
from openai_codex.client import CodexClient, _params_dict
from openai_codex.errors import CodexError
from openai_codex.generated.notification_registry import notification_turn_id
from openai_codex.generated.v2_all import (
AbsolutePathBuf,
@@ -30,7 +33,7 @@ from openai_codex.generated.v2_all import (
TurnStartParams,
WarningNotification,
)
from openai_codex.models import Notification, UnknownNotification
from openai_codex.models import InitializeResponse, JsonObject, Notification, UnknownNotification
from openai_codex.types import ThreadSource
ROOT = Path(__file__).resolve().parents[1]
@@ -61,6 +64,160 @@ def test_approval_review_paths_preserve_existing_wrappers(model, fields) -> None
assert isinstance(action.cwd, AbsolutePathBuf)
def _initialized_client(
monkeypatch: pytest.MonkeyPatch, metadata: JsonObject
) -> tuple[CodexClient, list[tuple[str, JsonObject | None]]]:
client = CodexClient()
requests: list[tuple[str, JsonObject | None]] = []
def request_raw(method: str, params: JsonObject | None) -> JsonObject:
requests.append((method, params))
return metadata if method == "initialize" else {}
monkeypatch.setattr(client, "_request_raw", request_raw)
monkeypatch.setattr(client, "notify", lambda *_args: None)
client.initialize()
requests.clear()
return client, requests
@pytest.mark.parametrize(
("method", "params"),
[
("turn/start", {"input": [], "turnTrigger": "automation"}),
("turn/start", {"input": [], "serviceTierForTurn": "default"}),
("thread/resume", {"threadId": "thread-1", "excludeTurns": False}),
("thread/fork", {"threadId": "thread-1", "excludeTurns": True}),
],
)
@pytest.mark.parametrize("version", ["0.147.0", "0.149.0", "0.151.0-alpha.6", "unknown", ""])
def test_new_options_reject_unsupported_runtime_before_sending(
monkeypatch: pytest.MonkeyPatch, method: str, params: JsonObject, version: str
) -> None:
client, requests = _initialized_client(monkeypatch, {"userAgent": f"codex-cli/{version}"})
with pytest.raises(CodexError, match=r"Codex CLI 0\.151\.0 or newer"):
client.request(method, params, response_model=InitializeResponse)
assert requests == []
@pytest.mark.parametrize(
"metadata",
[
{"userAgent": "codex-cli/0.151.0 (Linux)"},
{"userAgent": "codex-cli 0.153.0"},
{"userAgent": "codex-cli/0.154.0-alpha.1"},
{"userAgent": "codex-cli/0.154.0-alpha.1.2"},
{"userAgent": "codex-cli/0.151.0.post1"},
{"userAgent": "unknown", "serverInfo": {"name": "codex", "version": "0.153.0"}},
],
)
def test_new_options_accept_supported_runtime_metadata(
monkeypatch: pytest.MonkeyPatch, metadata: JsonObject
) -> None:
client, requests = _initialized_client(monkeypatch, metadata)
params = {"input": [], "turnTrigger": "automation"}
client.request("turn/start", params, response_model=InitializeResponse)
assert requests == [("turn/start", params)]
@pytest.mark.parametrize("supports_options", [True, False])
def test_unversioned_checkout_probes_and_caches_its_own_schema(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, supports_options: bool
) -> None:
client, requests = _initialized_client(monkeypatch, {"userAgent": "codex-cli/0.0.0"})
command = ("checkout-codex", "--config", "key=value", "app-server")
client._checkout_capabilities = CheckoutCapabilities(
command, str(tmp_path), {"CUSTOM": "value"}
)
probes = []
def generate_schema(args, **kwargs):
probes.append((args[:-1], kwargs))
output = Path(args[-1]) / "v2"
output.mkdir()
for name, fields in (
("TurnStartParams", ["turnTrigger", "serviceTierForTurn"]),
("ThreadResumeParams", ["excludeTurns"]),
("ThreadForkParams", ["excludeTurns"]),
):
(output / f"{name}.json").write_text(
json.dumps(
{"properties": {field: {} for field in fields} if supports_options else {}}
)
)
monkeypatch.setattr("openai_codex._runtime_requirements.subprocess.run", generate_schema)
for method, params in (
("turn/start", {"input": [], "turnTrigger": "automation"}),
("thread/resume", {"threadId": "thread-1", "excludeTurns": False}),
):
if supports_options:
client.request(method, params, response_model=InitializeResponse)
else:
with pytest.raises(CodexError, match="checkout does not support"):
client.request(method, params, response_model=InitializeResponse)
assert len(requests) == (2 if supports_options else 0)
assert probes == [
(
[*command, "generate-json-schema", "--experimental", "--out"],
{
"cwd": str(tmp_path),
"env": {"CUSTOM": "value"},
"capture_output": True,
"check": True,
"timeout": 30,
},
)
]
client.close()
assert client._checkout_capabilities is None
def test_unversioned_custom_launch_requires_verifiable_capabilities(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, requests = _initialized_client(monkeypatch, {"userAgent": "codex-cli/0.0.0"})
with pytest.raises(CodexError, match="Cannot verify an unversioned CLI"):
client.request(
"turn/start", {"turnTrigger": "automation"}, response_model=InitializeResponse
)
assert requests == []
@pytest.mark.parametrize("metadata", [{}, {"userAgent": "codex-cli/0.147.0"}])
def test_ordinary_requests_keep_working_on_old_or_unknown_runtime(
monkeypatch: pytest.MonkeyPatch, metadata: JsonObject
) -> None:
client, requests = _initialized_client(monkeypatch, metadata)
params = {"input": [{"type": "text", "text": "Hello"}], "serviceTier": "default"}
client.request("turn/start", params, response_model=InitializeResponse)
client.request("thread/resume", {"threadId": "thread-1"}, response_model=InitializeResponse)
client.request("thread/fork", {"threadId": "thread-1"}, response_model=InitializeResponse)
assert requests == [
("turn/start", params),
("thread/resume", {"threadId": "thread-1"}),
("thread/fork", {"threadId": "thread-1"}),
]
def test_new_options_require_fresh_initialize_metadata_after_close(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, requests = _initialized_client(monkeypatch, {"userAgent": "codex-cli/0.153.0"})
client.close()
with pytest.raises(CodexError, match="reported version is 'unknown'"):
client.request("thread/resume", {"excludeTurns": True}, response_model=InitializeResponse)
assert requests == []
def test_generated_params_models_are_snake_case_and_dump_by_alias() -> None:
params = ThreadListParams(search_term="needle", limit=5)