Generate Python SDK types from repository app-server schemas (#44032)

## Why

Keep Python protocol models aligned with the checked-in app-server schemas and preserve reviewed generated artifacts when staging SDK releases.

## What changed

- Generate SDK types from the schema directory configured in `pyproject.toml`, with a `--schema-dir` override, instead of invoking the pinned runtime binary.
- Refresh Python artifacts through `just write-app-server-schema` for standard repository exports. Skip SDK updates for scratch and experimental exports.
- Regenerate protocol models and notification dispatch, deriving the known payload union from the registry so `Notification.payload` covers every registered event.
- Explicitly allowlist convenience API parameters so new protocol fields do not silently expand method signatures. Preserve existing approval path wrappers.
- Stage SDK releases using checked-in generated files without regenerating them.

## Testing

Add coverage for schema selection, refresh gating and failure handling, release artifact preservation, notification payload typing, and approval path compatibility. Update the generation drift test to use repository schemas.

GitOrigin-RevId: fab350b07cf170258b91fbafa8da384aeb2e3bd7
This commit is contained in:
Ahmed Ibrahim
2026-09-09 03:14:45 +00:00
committed by copyberry
parent fe52d795c9
commit 45134c0463
11 changed files with 2437 additions and 602 deletions

View File

@@ -29,7 +29,8 @@ def main() -> None:
args = parser.parse_args()
workspace_root = Path(__file__).resolve().parents[2]
schema_root = args.schema_root or workspace_root / "app-server-protocol" / "schema"
repository_schema_root = workspace_root / "app-server-protocol" / "schema"
schema_root = args.schema_root or repository_schema_root
env = os.environ.copy()
env["CODEX_APP_SERVER_SCHEMA_ROOT"] = str(schema_root)
@@ -54,6 +55,31 @@ def main() -> None:
check=True,
)
# Scratch exports and experimental-only bundles do not update checked-in SDK code.
if (
not args.experimental
and schema_root.resolve() == repository_schema_root.resolve()
):
sdk_root = workspace_root.parent / "sdk" / "python"
subprocess.run(
[
"uv",
"run",
"--project",
str(sdk_root),
"--frozen",
"--only-group",
"test",
"python",
str(sdk_root / "scripts" / "update_sdk_artifacts.py"),
"generate-types",
"--schema-dir",
str(schema_root / "json"),
],
cwd=workspace_root,
check=True,
)
if __name__ == "__main__":
main()

View File

@@ -173,9 +173,9 @@ build-for-release:
write-config-schema:
cargo run -p codex-config-schema --bin codex-write-config-schema
# Regenerate vendored app-server protocol schema artifacts.
# Regenerate app-server protocol schemas and the Python SDK derived from them.
write-app-server-schema *args:
cargo run -p codex-app-server-protocol --bin write_schema_fixtures -- {args}
{{ python }} app-server-protocol/scripts/write_schema_fixtures.py {args}
[no-cd]
write-hooks-schema:

View File

@@ -268,6 +268,23 @@ from openai_codex.types import (
)
```
### Notifications and generated models
Known notifications have typed `Notification.payload` values, including
authentication recovery, thread queue/project changes, thread reversion, and
realtime item updates. The `Notification.payload` type covers every registered
event. Unknown methods and payloads that fail validation still produce
`UnknownNotification`, with the raw data in
`.params`. When an event gains a typed payload, read its named fields instead
of `.params`.
Returned models include the current CLI's thread metadata, richer turn errors,
and `functionCallOutput` history items. Code that imports generated
`HookMetadata` directly must access the handler through `.root`, inspect its
`handler_type`, and then read the fields for that handler. For example, only a
`"command"` handler has a `command` field. This reflects the app-server's
separate command, MCP tool, prompt, and agent hook variants.
## Retry + errors
```python

View File

@@ -36,6 +36,9 @@ dev = [
{ include-group = "format" },
]
[tool.codex.codegen]
schema-dir = "../../codex-rs/app-server-protocol/schema/json"
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env python3
import argparse
import importlib
import importlib.metadata
import importlib.util
import json
import platform
import re
@@ -41,13 +40,8 @@ def python_runtime_root() -> Path:
return repo_root() / "sdk" / "python-runtime"
def sdk_pyproject_path() -> Path:
"""Return the SDK pyproject file that owns package pins and versions."""
return sdk_root() / "pyproject.toml"
def schema_bundle_path(schema_dir: Path) -> Path:
"""Return the aggregate v2 schema bundle emitted by the runtime binary."""
"""Return the aggregate v2 app-server schema bundle."""
return schema_dir / "codex_app_server_protocol.v2.schemas.json"
@@ -75,67 +69,6 @@ def run_python_module(module: str, args: list[str], cwd: Path) -> None:
run([sys.executable, "-m", module, *args], cwd)
def current_sdk_version() -> str:
match = re.search(
r'^version = "([^"]+)"$',
sdk_pyproject_path().read_text(),
flags=re.MULTILINE,
)
if match is None:
raise RuntimeError("Could not determine Python SDK version from pyproject.toml")
return match.group(1)
def pinned_runtime_version() -> str:
"""Read the exact runtime package pin used for schema generation."""
pyproject_text = sdk_pyproject_path().read_text()
match = re.search(r"(?ms)^dependencies = \[(.*?)\]$", pyproject_text)
if match is None:
raise RuntimeError("Could not find dependencies array in sdk/python/pyproject.toml")
pins = re.findall(
rf'"{re.escape(RUNTIME_DISTRIBUTION_NAME)}==([^"]+)"',
match.group(1),
)
if len(pins) != 1:
raise RuntimeError(
f"Expected exactly one {RUNTIME_DISTRIBUTION_NAME} dependency pin "
"in sdk/python/pyproject.toml"
)
return normalize_codex_version(pins[0])
def pinned_runtime_codex_path() -> Path:
"""Return the bundled Codex binary from the installed pinned runtime wheel."""
expected_version = pinned_runtime_version()
try:
installed_version = importlib.metadata.version(RUNTIME_DISTRIBUTION_NAME)
except importlib.metadata.PackageNotFoundError as exc:
raise RuntimeError(
f"Install {RUNTIME_DISTRIBUTION_NAME}=={expected_version} before "
"generating Python SDK types."
) from exc
normalized_installed_version = normalize_codex_version(installed_version)
if normalized_installed_version != expected_version:
raise RuntimeError(
f"Expected {RUNTIME_DISTRIBUTION_NAME}=={expected_version}, "
f"but found {installed_version}."
)
try:
from codex_cli_bin import bundled_codex_path
except ImportError as exc:
raise RuntimeError(
f"Installed {RUNTIME_DISTRIBUTION_NAME} package does not expose bundled_codex_path."
) from exc
codex_path = bundled_codex_path()
if not codex_path.exists():
raise RuntimeError(f"Pinned Codex runtime binary not found at {codex_path}.")
return codex_path
def _copy_package_tree(src: Path, dst: Path) -> None:
if dst.exists():
if dst.is_dir():
@@ -527,29 +460,25 @@ def _make_chatgpt_account_email_nullable(schema: dict[str, Any]) -> None:
raise RuntimeError("Schema bundle is missing the ChatGPT account variant")
def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path:
"""Generate app-server schemas by invoking the installed pinned runtime binary."""
codex_path = pinned_runtime_codex_path()
if schema_dir.exists():
shutil.rmtree(schema_dir)
schema_dir.mkdir(parents=True)
run(
[
str(codex_path),
"app-server",
"generate-json-schema",
"--out",
str(schema_dir),
],
cwd=sdk_root(),
)
return schema_dir
def _preserve_guardian_approval_path_wrappers(schema: dict[str, Any]) -> None:
"""Preserve the path wrappers accepted by the existing Python API."""
definitions = schema.get("definitions", {})
if not isinstance(definitions, dict):
return
for variant in definitions.get("GuardianApprovalReviewAction", {}).get("oneOf", []):
properties = variant.get("properties", {})
kind = properties.get("type", {}).get("enum")
if kind in (["command"], ["applyPatch"]):
properties["cwd"] = {"$ref": "#/definitions/AbsolutePathBuf"}
if kind == ["applyPatch"]:
properties["files"]["items"] = {"$ref": "#/definitions/AbsolutePathBuf"}
def _normalized_schema_bundle_text(schema_dir: Path) -> str:
"""Normalize the schema bundle before feeding it to the Python type generator."""
schema = json.loads(schema_bundle_path(schema_dir).read_text())
_make_chatgpt_account_email_nullable(schema)
_preserve_guardian_approval_path_wrappers(schema)
definitions = schema.get("definitions", {})
if isinstance(definitions, dict):
for definition in definitions.values():
@@ -562,7 +491,7 @@ def _normalized_schema_bundle_text(schema_dir: Path) -> str:
def generate_v2_all(schema_dir: Path) -> None:
"""Regenerate the Pydantic v2 protocol model module from runtime schemas."""
"""Regenerate the Pydantic v2 protocol model module from app-server schemas."""
out_path = sdk_root() / "src" / "openai_codex" / "generated" / "v2_all.py"
out_dir = out_path.parent
old_package_dir = out_dir / "v2_all"
@@ -803,10 +732,12 @@ def _type_tuple_source(class_names: list[str]) -> str:
def generate_notification_registry(schema_dir: Path) -> None:
"""Regenerate notification dispatch metadata from the runtime notification schema."""
"""Regenerate notification dispatch metadata from the app-server notification schema."""
out = sdk_root() / "src" / "openai_codex" / "generated" / "notification_registry.py"
specs = _notification_specs(schema_dir)
class_names = sorted({class_name for _, class_name in specs})
if not class_names:
raise RuntimeError("Schema did not contain any supported notification payloads")
direct_turn_id_types, nested_turn_types = _notification_turn_id_specs(
schema_dir,
specs,
@@ -818,6 +749,8 @@ def generate_notification_registry(schema_dir: Path) -> None:
"",
"from __future__ import annotations",
"",
"from typing import TypeAlias",
"",
"from pydantic import BaseModel",
"",
]
@@ -827,7 +760,11 @@ def generate_notification_registry(schema_dir: Path) -> None:
lines.extend(
[
"",
"NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {",
"KnownNotificationPayload: TypeAlias = (",
" " + "\n | ".join(class_names),
")",
"",
"NOTIFICATION_MODELS: dict[str, type[KnownNotificationPayload]] = {",
]
)
for method, class_name in specs:
@@ -871,6 +808,78 @@ FIELD_ANNOTATION_OVERRIDES: dict[str, str] = {
# Keep public API typed without falling back to `Any`.
"config": "JsonObject",
"output_schema": "JsonObject",
"sandbox": "Sandbox",
"sandbox_policy": "Sandbox",
}
PUBLIC_FIELD_NAMES = {
"sandbox_policy": "sandbox",
}
# Adding a protocol field must not silently add a public SDK parameter. These
# reviewed wire fields define the convenience API; protocol models stay complete.
PUBLIC_METHOD_FIELDS = {
"ThreadStartParams": (
"base_instructions",
"config",
"cwd",
"developer_instructions",
"ephemeral",
"model",
"model_provider",
"personality",
"sandbox",
"service_name",
"service_tier",
"session_start_source",
"thread_source",
),
"ThreadListParams": (
"archived",
"cursor",
"cwd",
"limit",
"model_providers",
"search_term",
"section_id",
"sort_direction",
"sort_key",
"source_kinds",
"use_state_db_only",
),
"ThreadResumeParams": (
"base_instructions",
"config",
"cwd",
"developer_instructions",
"model",
"model_provider",
"personality",
"sandbox",
"service_tier",
),
"ThreadForkParams": (
"base_instructions",
"config",
"cwd",
"developer_instructions",
"ephemeral",
"model",
"model_provider",
"sandbox",
"service_tier",
"thread_source",
),
"TurnStartParams": (
"cwd",
"effort",
"model",
"output_schema",
"personality",
"sandbox_policy",
"service_tier",
"summary",
),
}
@@ -884,10 +893,9 @@ class PublicFieldSpec:
@dataclass(frozen=True)
class CliOps:
generate_types: Callable[[], None]
generate_types: Callable[[Path], None]
stage_python_sdk_package: Callable[[Path, str], Path]
stage_python_runtime_package: Callable[[Path, str, Path, str | None], Path]
current_sdk_version: Callable[[], str]
def _annotation_to_source(annotation: Any) -> str:
@@ -926,20 +934,15 @@ def _camel_to_snake(name: str) -> str:
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", head).lower()
def _load_public_fields(
module_name: str, class_name: str, *, exclude: set[str] | None = None
) -> list[PublicFieldSpec]:
"""Load generated model fields used to render the ergonomic public methods."""
exclude = exclude or set()
if module_name == "openai_codex.generated.v2_all":
module = _load_generated_v2_all_module()
else:
module = importlib.import_module(module_name)
def _load_public_fields(class_name: str) -> list[PublicFieldSpec]:
"""Load only the protocol fields deliberately exposed by the public SDK."""
module = _load_generated_v2_all_module()
model = getattr(module, class_name)
fields: list[PublicFieldSpec] = []
for name, field in model.model_fields.items():
if name in exclude:
continue
for name in PUBLIC_METHOD_FIELDS[class_name]:
if name not in model.model_fields:
raise RuntimeError(f"Public SDK field {class_name}.{name} is missing from the schema")
field = model.model_fields[name]
required = field.is_required()
annotation = _annotation_to_source(field.annotation)
override = FIELD_ANNOTATION_OVERRIDES.get(name)
@@ -948,12 +951,12 @@ def _load_public_fields(
fields.append(
PublicFieldSpec(
wire_name=name,
py_name=name,
py_name=PUBLIC_FIELD_NAMES.get(name, name),
annotation=annotation,
required=required,
)
)
return fields
return sorted(fields, key=lambda field: field.py_name)
def _load_generated_v2_all_module() -> types.ModuleType:
@@ -1013,32 +1016,6 @@ def _model_arg_lines(fields: list[PublicFieldSpec], *, indent: str = "
return lines
def _replace_public_sandbox_field(
fields: list[PublicFieldSpec], *, wire_name: str
) -> list[PublicFieldSpec]:
"""Expose stable wire sandbox settings through one public enum parameter."""
public_fields: list[PublicFieldSpec] = []
replaced = False
for field in fields:
if field.wire_name != wire_name:
public_fields.append(field)
continue
if replaced:
raise RuntimeError(f"Found more than one generated sandbox field named {wire_name}")
public_fields.append(
PublicFieldSpec(
wire_name=wire_name,
py_name="sandbox",
annotation="Sandbox | None",
required=False,
)
)
replaced = True
if not replaced:
raise RuntimeError(f"Could not find generated sandbox field named {wire_name}")
return public_fields
def _replace_generated_block(source: str, block_name: str, body: str) -> str:
start_tag = f" # BEGIN GENERATED: {block_name}"
end_tag = f" # END GENERATED: {block_name}"
@@ -1282,37 +1259,11 @@ def generate_public_api_flat_methods() -> None:
if src_dir_str not in sys.path:
sys.path.insert(0, src_dir_str)
approval_fields = {"approval_policy", "approvals_reviewer"}
thread_start_fields = _load_public_fields(
"openai_codex.generated.v2_all",
"ThreadStartParams",
exclude=approval_fields,
)
thread_start_fields = _replace_public_sandbox_field(thread_start_fields, wire_name="sandbox")
thread_list_fields = _load_public_fields(
"openai_codex.generated.v2_all",
"ThreadListParams",
)
thread_resume_fields = _load_public_fields(
"openai_codex.generated.v2_all",
"ThreadResumeParams",
exclude={"thread_id", *approval_fields},
)
thread_resume_fields = _replace_public_sandbox_field(thread_resume_fields, wire_name="sandbox")
thread_fork_fields = _load_public_fields(
"openai_codex.generated.v2_all",
"ThreadForkParams",
exclude={"thread_id", "last_turn_id", *approval_fields},
)
thread_fork_fields = _replace_public_sandbox_field(thread_fork_fields, wire_name="sandbox")
turn_start_fields = _load_public_fields(
"openai_codex.generated.v2_all",
"TurnStartParams",
# Keep the wire model current without exposing this app-server field
# through the ergonomic Python API yet.
exclude={"thread_id", "input", "client_user_message_id", *approval_fields},
)
turn_start_fields = _replace_public_sandbox_field(turn_start_fields, wire_name="sandbox_policy")
thread_start_fields = _load_public_fields("ThreadStartParams")
thread_list_fields = _load_public_fields("ThreadListParams")
thread_resume_fields = _load_public_fields("ThreadResumeParams")
thread_fork_fields = _load_public_fields("ThreadForkParams")
turn_start_fields = _load_public_fields("TurnStartParams")
source = public_api_path.read_text()
source = _replace_generated_block(
@@ -1357,22 +1308,22 @@ def generate_types_from_schema_dir(schema_dir: Path) -> None:
generate_public_api_flat_methods()
def generate_types() -> None:
"""Generate schemas from the pinned runtime and then refresh SDK artifacts."""
with tempfile.TemporaryDirectory(prefix="codex-python-schema-") as td:
schema_dir = generate_schema_from_pinned_runtime(Path(td) / "schema")
generate_types_from_schema_dir(schema_dir)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Single SDK maintenance entrypoint")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("generate-types", help="Regenerate Python protocol-derived types")
generate_types_parser = subparsers.add_parser(
"generate-types", help="Regenerate Python types from the repository's app-server schemas"
)
generate_types_parser.add_argument(
"--schema-dir",
type=Path,
help="App-server JSON schema directory (defaults to tool.codex.codegen.schema-dir)",
)
stage_sdk_parser = subparsers.add_parser(
"stage-sdk",
help="Stage a releasable SDK package while preserving its reviewed runtime pin",
help="Stage a releasable SDK package from its reviewed generated files and runtime pin",
)
stage_sdk_parser.add_argument(
"staging_dir",
@@ -1427,18 +1378,25 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
def default_cli_ops() -> CliOps:
return CliOps(
generate_types=generate_types,
generate_types=generate_types_from_schema_dir,
stage_python_sdk_package=stage_python_sdk_package,
stage_python_runtime_package=stage_python_runtime_package,
current_sdk_version=current_sdk_version,
)
def run_command(args: argparse.Namespace, ops: CliOps) -> None:
if args.command == "generate-types":
ops.generate_types()
schema_dir = args.schema_dir
if schema_dir is None:
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
pyproject = tomllib.loads((sdk_root() / "pyproject.toml").read_text())
schema_dir = sdk_root() / pyproject["tool"]["codex"]["codegen"]["schema-dir"]
ops.generate_types(schema_dir.resolve())
elif args.command == "stage-sdk":
ops.generate_types()
ops.stage_python_sdk_package(
args.staging_dir,
normalize_codex_version(args.sdk_version),

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
from typing import TypeAlias
from pydantic import BaseModel
from .v2_all import AccountLoginCompletedNotification
@@ -10,6 +12,7 @@ from .v2_all import AccountRateLimitsUpdatedNotification
from .v2_all import AccountUpdatedNotification
from .v2_all import AgentMessageDeltaNotification
from .v2_all import AppListUpdatedNotification
from .v2_all import AuthRecoveryNotification
from .v2_all import CommandExecOutputDeltaNotification
from .v2_all import CommandExecutionOutputDeltaNotification
from .v2_all import ConfigWarningNotification
@@ -31,6 +34,7 @@ from .v2_all import ItemCompletedNotification
from .v2_all import ItemGuardianApprovalReviewCompletedNotification
from .v2_all import ItemGuardianApprovalReviewStartedNotification
from .v2_all import ItemStartedNotification
from .v2_all import McpServerEventStreamNotification
from .v2_all import McpServerOauthLoginCompletedNotification
from .v2_all import McpServerStatusUpdatedNotification
from .v2_all import McpToolCallProgressNotification
@@ -40,12 +44,14 @@ from .v2_all import ModelVerificationNotification
from .v2_all import PlanDeltaNotification
from .v2_all import ProcessExitedNotification
from .v2_all import ProcessOutputDeltaNotification
from .v2_all import ProjectChangedNotification
from .v2_all import ReasoningSummaryPartAddedNotification
from .v2_all import ReasoningSummaryTextDeltaNotification
from .v2_all import ReasoningTextDeltaNotification
from .v2_all import RemoteControlStatusChangedNotification
from .v2_all import ServerRequestResolvedNotification
from .v2_all import SkillsChangedNotification
from .v2_all import StrictReviewRequiredNotification
from .v2_all import TerminalInteractionNotification
from .v2_all import ThreadArchivedNotification
from .v2_all import ThreadClosedNotification
@@ -53,14 +59,20 @@ from .v2_all import ThreadDeletedNotification
from .v2_all import ThreadGoalClearedNotification
from .v2_all import ThreadGoalUpdatedNotification
from .v2_all import ThreadNameUpdatedNotification
from .v2_all import ThreadProjectUpdatedNotification
from .v2_all import ThreadQueueChangedNotification
from .v2_all import ThreadRealtimeClosedNotification
from .v2_all import ThreadRealtimeErrorNotification
from .v2_all import ThreadRealtimeItemAddedNotification
from .v2_all import ThreadRealtimeItemCompletedNotification
from .v2_all import ThreadRealtimeItemStartedNotification
from .v2_all import ThreadRealtimeItemTranscriptDeltaNotification
from .v2_all import ThreadRealtimeOutputAudioDeltaNotification
from .v2_all import ThreadRealtimeSdpNotification
from .v2_all import ThreadRealtimeStartedNotification
from .v2_all import ThreadRealtimeTranscriptDeltaNotification
from .v2_all import ThreadRealtimeTranscriptDoneNotification
from .v2_all import ThreadRevertedNotification
from .v2_all import ThreadSettingsUpdatedNotification
from .v2_all import ThreadStartedNotification
from .v2_all import ThreadStatusChangedNotification
@@ -75,11 +87,94 @@ from .v2_all import WarningNotification
from .v2_all import WindowsSandboxSetupCompletedNotification
from .v2_all import WindowsWorldWritableWarningNotification
NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
KnownNotificationPayload: TypeAlias = (
AccountLoginCompletedNotification
| AccountRateLimitsUpdatedNotification
| AccountUpdatedNotification
| AgentMessageDeltaNotification
| AppListUpdatedNotification
| AuthRecoveryNotification
| CommandExecOutputDeltaNotification
| CommandExecutionOutputDeltaNotification
| ConfigWarningNotification
| ContextCompactedNotification
| DeprecationNoticeNotification
| EnvironmentConnectionNotification
| ErrorNotification
| ExternalAgentConfigImportCompletedNotification
| ExternalAgentConfigImportProgressNotification
| FileChangeOutputDeltaNotification
| FileChangePatchUpdatedNotification
| FsChangedNotification
| FuzzyFileSearchSessionCompletedNotification
| FuzzyFileSearchSessionUpdatedNotification
| GuardianWarningNotification
| HookCompletedNotification
| HookStartedNotification
| ItemCompletedNotification
| ItemGuardianApprovalReviewCompletedNotification
| ItemGuardianApprovalReviewStartedNotification
| ItemStartedNotification
| McpServerEventStreamNotification
| McpServerOauthLoginCompletedNotification
| McpServerStatusUpdatedNotification
| McpToolCallProgressNotification
| ModelReroutedNotification
| ModelSafetyBufferingUpdatedNotification
| ModelVerificationNotification
| PlanDeltaNotification
| ProcessExitedNotification
| ProcessOutputDeltaNotification
| ProjectChangedNotification
| ReasoningSummaryPartAddedNotification
| ReasoningSummaryTextDeltaNotification
| ReasoningTextDeltaNotification
| RemoteControlStatusChangedNotification
| ServerRequestResolvedNotification
| SkillsChangedNotification
| StrictReviewRequiredNotification
| TerminalInteractionNotification
| ThreadArchivedNotification
| ThreadClosedNotification
| ThreadDeletedNotification
| ThreadGoalClearedNotification
| ThreadGoalUpdatedNotification
| ThreadNameUpdatedNotification
| ThreadProjectUpdatedNotification
| ThreadQueueChangedNotification
| ThreadRealtimeClosedNotification
| ThreadRealtimeErrorNotification
| ThreadRealtimeItemAddedNotification
| ThreadRealtimeItemCompletedNotification
| ThreadRealtimeItemStartedNotification
| ThreadRealtimeItemTranscriptDeltaNotification
| ThreadRealtimeOutputAudioDeltaNotification
| ThreadRealtimeSdpNotification
| ThreadRealtimeStartedNotification
| ThreadRealtimeTranscriptDeltaNotification
| ThreadRealtimeTranscriptDoneNotification
| ThreadRevertedNotification
| ThreadSettingsUpdatedNotification
| ThreadStartedNotification
| ThreadStatusChangedNotification
| ThreadTokenUsageUpdatedNotification
| ThreadUnarchivedNotification
| TurnCompletedNotification
| TurnDiffUpdatedNotification
| TurnModerationMetadataNotification
| TurnPlanUpdatedNotification
| TurnStartedNotification
| WarningNotification
| WindowsSandboxSetupCompletedNotification
| WindowsWorldWritableWarningNotification
)
NOTIFICATION_MODELS: dict[str, type[KnownNotificationPayload]] = {
"account/login/completed": AccountLoginCompletedNotification,
"account/rateLimits/updated": AccountRateLimitsUpdatedNotification,
"account/updated": AccountUpdatedNotification,
"app/list/updated": AppListUpdatedNotification,
"autoApprovalReview/strictReviewRequired": StrictReviewRequiredNotification,
"command/exec/outputDelta": CommandExecOutputDeltaNotification,
"configWarning": ConfigWarningNotification,
"deprecationNotice": DeprecationNoticeNotification,
@@ -106,13 +201,17 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"item/reasoning/summaryTextDelta": ReasoningSummaryTextDeltaNotification,
"item/reasoning/textDelta": ReasoningTextDeltaNotification,
"item/started": ItemStartedNotification,
"mcpServer/event/stream/notification": McpServerEventStreamNotification,
"mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification,
"mcpServer/startupStatus/updated": McpServerStatusUpdatedNotification,
"model/rerouted": ModelReroutedNotification,
"model/safetyBuffering/updated": ModelSafetyBufferingUpdatedNotification,
"model/verification": ModelVerificationNotification,
"modelProvider/authRecoveryCompleted": AuthRecoveryNotification,
"modelProvider/authRecoveryStarted": AuthRecoveryNotification,
"process/exited": ProcessExitedNotification,
"process/outputDelta": ProcessOutputDeltaNotification,
"project/changed": ProjectChangedNotification,
"remoteControl/status/changed": RemoteControlStatusChangedNotification,
"serverRequest/resolved": ServerRequestResolvedNotification,
"skills/changed": SkillsChangedNotification,
@@ -125,14 +224,20 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"thread/goal/cleared": ThreadGoalClearedNotification,
"thread/goal/updated": ThreadGoalUpdatedNotification,
"thread/name/updated": ThreadNameUpdatedNotification,
"thread/project/updated": ThreadProjectUpdatedNotification,
"thread/queue/changed": ThreadQueueChangedNotification,
"thread/realtime/closed": ThreadRealtimeClosedNotification,
"thread/realtime/error": ThreadRealtimeErrorNotification,
"thread/realtime/item/completed": ThreadRealtimeItemCompletedNotification,
"thread/realtime/item/started": ThreadRealtimeItemStartedNotification,
"thread/realtime/item/transcript/delta": ThreadRealtimeItemTranscriptDeltaNotification,
"thread/realtime/itemAdded": ThreadRealtimeItemAddedNotification,
"thread/realtime/outputAudio/delta": ThreadRealtimeOutputAudioDeltaNotification,
"thread/realtime/sdp": ThreadRealtimeSdpNotification,
"thread/realtime/started": ThreadRealtimeStartedNotification,
"thread/realtime/transcript/delta": ThreadRealtimeTranscriptDeltaNotification,
"thread/realtime/transcript/done": ThreadRealtimeTranscriptDoneNotification,
"thread/reverted": ThreadRevertedNotification,
"thread/settings/updated": ThreadSettingsUpdatedNotification,
"thread/started": ThreadStartedNotification,
"thread/status/changed": ThreadStatusChangedNotification,
@@ -150,6 +255,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
DIRECT_TURN_ID_NOTIFICATION_TYPES: tuple[type[BaseModel], ...] = (
AgentMessageDeltaNotification,
AuthRecoveryNotification,
CommandExecutionOutputDeltaNotification,
ContextCompactedNotification,
ErrorNotification,
@@ -169,6 +275,7 @@ DIRECT_TURN_ID_NOTIFICATION_TYPES: tuple[type[BaseModel], ...] = (
ReasoningSummaryPartAddedNotification,
ReasoningSummaryTextDeltaNotification,
ReasoningTextDeltaNotification,
StrictReviewRequiredNotification,
TerminalInteractionNotification,
ThreadGoalUpdatedNotification,
ThreadTokenUsageUpdatedNotification,

File diff suppressed because it is too large Load Diff

View File

@@ -5,38 +5,41 @@ from typing import TypeAlias
from pydantic import BaseModel
from .generated.notification_registry import KnownNotificationPayload as _KnownNotificationPayload
# Preserve the notification names previously importable from this module.
from .generated.v2_all import (
AccountLoginCompletedNotification,
AccountRateLimitsUpdatedNotification,
AccountUpdatedNotification,
AgentMessageDeltaNotification,
AppListUpdatedNotification,
CommandExecutionOutputDeltaNotification,
ConfigWarningNotification,
ContextCompactedNotification,
DeprecationNoticeNotification,
ErrorNotification,
FileChangeOutputDeltaNotification,
ItemCompletedNotification,
ItemStartedNotification,
McpServerOauthLoginCompletedNotification,
McpToolCallProgressNotification,
PlanDeltaNotification,
RawResponseItemCompletedNotification,
ReasoningSummaryPartAddedNotification,
ReasoningSummaryTextDeltaNotification,
ReasoningTextDeltaNotification,
TerminalInteractionNotification,
ThreadGoalClearedNotification,
ThreadGoalUpdatedNotification,
ThreadNameUpdatedNotification,
ThreadStartedNotification,
ThreadTokenUsageUpdatedNotification,
TurnCompletedNotification,
TurnDiffUpdatedNotification,
TurnPlanUpdatedNotification,
TurnStartedNotification,
WindowsWorldWritableWarningNotification,
AccountLoginCompletedNotification as AccountLoginCompletedNotification,
AccountRateLimitsUpdatedNotification as AccountRateLimitsUpdatedNotification,
AccountUpdatedNotification as AccountUpdatedNotification,
AgentMessageDeltaNotification as AgentMessageDeltaNotification,
AppListUpdatedNotification as AppListUpdatedNotification,
CommandExecutionOutputDeltaNotification as CommandExecutionOutputDeltaNotification,
ConfigWarningNotification as ConfigWarningNotification,
ContextCompactedNotification as ContextCompactedNotification,
DeprecationNoticeNotification as DeprecationNoticeNotification,
ErrorNotification as ErrorNotification,
FileChangeOutputDeltaNotification as FileChangeOutputDeltaNotification,
ItemCompletedNotification as ItemCompletedNotification,
ItemStartedNotification as ItemStartedNotification,
McpServerOauthLoginCompletedNotification as McpServerOauthLoginCompletedNotification,
McpToolCallProgressNotification as McpToolCallProgressNotification,
PlanDeltaNotification as PlanDeltaNotification,
RawResponseItemCompletedNotification as RawResponseItemCompletedNotification,
ReasoningSummaryPartAddedNotification as ReasoningSummaryPartAddedNotification,
ReasoningSummaryTextDeltaNotification as ReasoningSummaryTextDeltaNotification,
ReasoningTextDeltaNotification as ReasoningTextDeltaNotification,
TerminalInteractionNotification as TerminalInteractionNotification,
ThreadGoalClearedNotification as ThreadGoalClearedNotification,
ThreadGoalUpdatedNotification as ThreadGoalUpdatedNotification,
ThreadNameUpdatedNotification as ThreadNameUpdatedNotification,
ThreadStartedNotification as ThreadStartedNotification,
ThreadTokenUsageUpdatedNotification as ThreadTokenUsageUpdatedNotification,
TurnCompletedNotification as TurnCompletedNotification,
TurnDiffUpdatedNotification as TurnDiffUpdatedNotification,
TurnPlanUpdatedNotification as TurnPlanUpdatedNotification,
TurnStartedNotification as TurnStartedNotification,
WindowsWorldWritableWarningNotification as WindowsWorldWritableWarningNotification,
)
JsonScalar: TypeAlias = str | int | float | bool | None
@@ -49,39 +52,9 @@ class UnknownNotification:
params: JsonObject
# Preserve the existing raw-item type, which app-server omits from its notification schema.
NotificationPayload: TypeAlias = (
AccountLoginCompletedNotification
| AccountRateLimitsUpdatedNotification
| AccountUpdatedNotification
| AgentMessageDeltaNotification
| AppListUpdatedNotification
| CommandExecutionOutputDeltaNotification
| ConfigWarningNotification
| ContextCompactedNotification
| DeprecationNoticeNotification
| ErrorNotification
| FileChangeOutputDeltaNotification
| ItemCompletedNotification
| ItemStartedNotification
| McpServerOauthLoginCompletedNotification
| McpToolCallProgressNotification
| PlanDeltaNotification
| RawResponseItemCompletedNotification
| ReasoningSummaryPartAddedNotification
| ReasoningSummaryTextDeltaNotification
| ReasoningTextDeltaNotification
| TerminalInteractionNotification
| ThreadNameUpdatedNotification
| ThreadGoalClearedNotification
| ThreadGoalUpdatedNotification
| ThreadStartedNotification
| ThreadTokenUsageUpdatedNotification
| TurnCompletedNotification
| TurnDiffUpdatedNotification
| TurnPlanUpdatedNotification
| TurnStartedNotification
| WindowsWorldWritableWarningNotification
| UnknownNotification
_KnownNotificationPayload | RawResponseItemCompletedNotification | UnknownNotification
)

View File

@@ -10,9 +10,13 @@ import urllib.error
from pathlib import Path
import pytest
import tomllib
from pydantic import ValidationError
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
ROOT = Path(__file__).resolve().parents[1]
@@ -378,38 +382,76 @@ def test_generate_types_wires_all_generation_steps() -> None:
]
def _load_runtime_schema_bundle(tmp_path: Path) -> dict:
"""Ask the pinned runtime package for a real schema bundle used by tests."""
@pytest.mark.parametrize("schema_override", [None, "override-schema"])
def test_generation_resolves_configured_schema_and_explicit_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, schema_override: str | None
) -> None:
script = _load_update_script_module()
schema_dir = script.generate_schema_from_pinned_runtime(tmp_path / "schema")
sdk_dir = tmp_path / "sdk" / "python"
sdk_dir.mkdir(parents=True)
monkeypatch.setattr(script, "sdk_root", lambda: sdk_dir)
monkeypatch.chdir(tmp_path)
selected_schemas: list[Path] = []
monkeypatch.setattr(script, "generate_types_from_schema_dir", selected_schemas.append)
args = ["generate-types"]
if schema_override is None:
(sdk_dir / "pyproject.toml").write_text(
'[tool.codex.codegen]\nschema-dir = "../../configured-schema"\n'
)
expected_schema = tmp_path / "configured-schema"
else:
args.extend(["--schema-dir", schema_override])
expected_schema = tmp_path / schema_override
script.main(args)
assert selected_schemas == [expected_schema]
def _load_repository_schema_bundle() -> dict:
"""Read the repository app-server schema bundle used by generation."""
script = _load_update_script_module()
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
schema_dir = ROOT / pyproject["tool"]["codex"]["codegen"]["schema-dir"]
return json.loads(script.schema_bundle_path(schema_dir).read_text())
def test_schema_normalization_only_flattens_string_literal_oneofs(
tmp_path: Path,
) -> None:
"""Schema normalization should only flatten the enum-shaped oneOf variants."""
def test_schema_normalization_flattens_string_literal_oneofs() -> None:
script = _load_update_script_module()
schema = _load_runtime_schema_bundle(tmp_path)
definitions = schema["definitions"]
flattened = [
name
for name, definition in definitions.items()
if isinstance(definition, dict) and script._flatten_string_enum_one_of(definition.copy())
]
definition = {
"title": "Mode",
"description": "Allowed modes.",
"oneOf": [
{"type": "string", "enum": ["first"]},
{"type": "string", "enum": ["second"]},
],
}
assert sorted(flattened) == [
"AuthMode",
"AutoCompactTokenLimitScope",
"CommandExecOutputStream",
"ConsumeAccountRateLimitResetCreditOutcome",
"ExperimentalFeatureStage",
"InputModality",
"MessagePhase",
"PluginAvailability",
"ProcessOutputStream",
"TurnItemsView",
]
assert script._flatten_string_enum_one_of(definition)
assert definition == {
"title": "Mode",
"description": "Allowed modes.",
"type": "string",
"enum": ["first", "second"],
}
@pytest.mark.parametrize(
"branch",
[
{"type": "object", "properties": {"value": {"type": "string"}}},
{"type": "string", "enum": ["first", "second"]},
{"type": "string", "enum": [1]},
{"type": "string", "enum": ["second"], "minLength": 2},
],
)
def test_schema_normalization_preserves_nonliteral_unions(branch: dict) -> None:
script = _load_update_script_module()
definition = {"oneOf": [{"type": "string", "enum": ["first"]}, branch]}
original = json.loads(json.dumps(definition))
assert not script._flatten_string_enum_one_of(definition)
assert definition == original
def test_schema_normalization_makes_chatgpt_account_email_nullable() -> None:
@@ -438,12 +480,10 @@ def test_schema_normalization_makes_chatgpt_account_email_nullable() -> None:
assert "email" in chatgpt_account["required"]
def test_python_codegen_schema_annotation_adds_stable_variant_titles(
tmp_path: Path,
) -> None:
def test_python_codegen_schema_annotation_adds_stable_variant_titles() -> None:
"""Schema annotations should give generated protocol classes stable names."""
script = _load_update_script_module()
schema = _load_runtime_schema_bundle(tmp_path)
schema = _load_repository_schema_bundle()
script._annotate_schema(schema)
definitions = schema["definitions"]
@@ -525,25 +565,6 @@ def test_runtime_distribution_name_is_consistent() -> None:
)
def test_source_sdk_template_pins_published_runtime() -> None:
"""The source template should carry a development version and reviewed runtime pin."""
script = _load_update_script_module()
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
assert {
"sdk_template_version": pyproject["project"]["version"],
"runtime_pin": script.pinned_runtime_version(),
"dependencies": pyproject["project"]["dependencies"],
} == {
"sdk_template_version": "0.0.0-dev",
"runtime_pin": "0.147.0",
"dependencies": [
"pydantic>=2.12",
"openai-codex-cli-bin==0.147.0",
],
}
def test_source_sdk_package_declares_stable_documentation() -> None:
"""Public package metadata should link stable docs."""
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
@@ -858,11 +879,24 @@ def test_runtime_package_layout_is_included_by_wheel_config(
]
def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> None:
def test_stage_sdk_release_packages_reviewed_artifacts(tmp_path: Path) -> None:
script = _load_update_script_module()
staged = script.stage_python_sdk_package(
tmp_path / "sdk-stage",
"0.147.0",
staged = tmp_path / "sdk-stage"
source_project = tomllib.loads((ROOT / "pyproject.toml").read_text())
generated_paths = [
"src/openai_codex/generated/v2_all.py",
"src/openai_codex/generated/notification_registry.py",
"src/openai_codex/api.py",
]
reviewed_artifacts = {path: (ROOT / path).read_bytes() for path in generated_paths}
script.main(
[
"stage-sdk",
str(staged),
"--sdk-version",
"0.153.0",
]
)
pyproject = tomllib.loads((staged / "pyproject.toml").read_text())
@@ -872,12 +906,10 @@ def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> Non
"dependencies": pyproject["project"]["dependencies"],
} == {
"name": "openai-codex",
"version": "0.147.0",
"dependencies": [
"pydantic>=2.12",
"openai-codex-cli-bin==0.147.0",
],
"version": "0.153.0",
"dependencies": source_project["project"]["dependencies"],
}
assert {path: (staged / path).read_bytes() for path in generated_paths} == reviewed_artifacts
assert (
'__version__ = "0.147.0"'
not in (staged / "src" / "openai_codex" / "__init__.py").read_text()
@@ -933,48 +965,6 @@ def test_sdk_release_matches_stable_runtime(tmp_path: Path) -> None:
}
def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
script = _load_update_script_module()
calls: list[str] = []
args = script.parse_args(
[
"stage-sdk",
str(tmp_path / "sdk-stage"),
"--sdk-version",
"0.147.0",
]
)
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_sdk_package(_staging_dir: Path, sdk_version: str) -> Path:
calls.append(f"stage_sdk:{sdk_version}")
return tmp_path / "sdk-stage"
def fake_stage_runtime_package(
_staging_dir: Path,
_runtime_version: str,
_package_dir: Path,
_platform_tag: str | None,
) -> Path:
raise AssertionError("runtime staging should not run for stage-sdk")
def fake_current_sdk_version() -> str:
return "0.116.0a1"
ops = script.CliOps(
generate_types=fake_generate_types,
stage_python_sdk_package=fake_stage_sdk_package,
stage_python_runtime_package=fake_stage_runtime_package,
current_sdk_version=fake_current_sdk_version,
)
script.run_command(args, ops)
assert calls == ["generate_types", "stage_sdk:0.147.0"]
def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> None:
script = _load_update_script_module()
package_archive = _write_fake_codex_package_archive(tmp_path, script)
@@ -991,7 +981,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) ->
]
)
def fake_generate_types() -> None:
def fake_generate_types(_schema_dir: Path) -> None:
calls.append("generate_types")
def fake_stage_sdk_package(_staging_dir: Path, _codex_version: str) -> Path:
@@ -1006,14 +996,10 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) ->
calls.append(f"stage_runtime:{codex_version}:{platform_tag}:{package_archive.name}")
return tmp_path / "runtime-stage"
def fake_current_sdk_version() -> str:
return "0.116.0a1"
ops = script.CliOps(
generate_types=fake_generate_types,
stage_python_sdk_package=fake_stage_sdk_package,
stage_python_runtime_package=fake_stage_runtime_package,
current_sdk_version=fake_current_sdk_version,
)
script.run_command(args, ops)

View File

@@ -1,22 +1,28 @@
from __future__ import annotations
from pathlib import Path
from typing import get_type_hints
import pytest
from openai_codex.client import CodexClient, _params_dict
from openai_codex.generated.notification_registry import notification_turn_id
from openai_codex.generated.v2_all import (
AbsolutePathBuf,
AccountRateLimitsUpdatedNotification,
AccountUpdatedNotification,
AgentMessageDeltaNotification,
ApplyPatchGuardianApprovalReviewAction,
ApprovalsReviewer,
AuthRecoveryNotification,
CommandGuardianApprovalReviewAction,
GetAccountResponse,
PlanType,
ReasoningEffort,
ReasoningEffortOption,
ThreadForkParams,
ThreadListParams,
ThreadQueueChangedNotification,
ThreadResumeResponse,
ThreadStartParams,
ThreadTokenUsageUpdatedNotification,
@@ -30,6 +36,31 @@ from openai_codex.types import ThreadSource
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.parametrize(
("model", "fields"),
[
(
CommandGuardianApprovalReviewAction,
{"type": "command", "command": "pwd", "source": "shell"},
),
(
ApplyPatchGuardianApprovalReviewAction,
{"type": "applyPatch", "files": [AbsolutePathBuf("/workspace/file")]},
),
],
)
def test_approval_review_paths_preserve_existing_wrappers(model, fields) -> None:
action = model(cwd=AbsolutePathBuf("/workspace"), **fields)
expected = {
**fields,
"cwd": "/workspace",
}
if "files" in expected:
expected["files"] = ["/workspace/file"]
assert action.model_dump(mode="json") == expected
assert isinstance(action.cwd, AbsolutePathBuf)
def test_generated_params_models_are_snake_case_and_dump_by_alias() -> None:
params = ThreadListParams(search_term="needle", limit=5)
@@ -209,6 +240,44 @@ def test_unknown_notifications_fall_back_to_unknown_payloads() -> None:
assert event.payload.params["msg"] == {"type": "turn_aborted"}
@pytest.mark.parametrize(
("method", "params", "expected"),
[
(
"modelProvider/authRecoveryCompleted",
{
"provider": "openai",
"message": "Authentication recovered",
"threadId": "thread-1",
"turnId": "turn-1",
},
AuthRecoveryNotification(
provider="openai",
message="Authentication recovered",
thread_id="thread-1",
turn_id="turn-1",
),
),
(
"thread/queue/changed",
{"threadId": "thread-1"},
ThreadQueueChangedNotification(thread_id="thread-1"),
),
("warning", {"message": "heads up"}, WarningNotification(message="heads up")),
(
"future/notification",
{"newField": "value"},
UnknownNotification(params={"newField": "value"}),
),
],
)
def test_decoded_notifications_match_the_declared_payload_type(method, params, expected) -> None:
event = CodexClient()._coerce_notification(method, params)
assert event == Notification(method=method, payload=expected)
assert isinstance(event.payload, get_type_hints(Notification)["payload"])
def test_invalid_notification_payload_falls_back_to_unknown() -> None:
client = CodexClient()
event = client._coerce_notification("thread/tokenUsage/updated", {"threadId": "missing"})

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import importlib.metadata
import os
import runpy
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
GENERATED_TARGETS = [
Path("src/openai_codex/generated/notification_registry.py"),
@@ -35,14 +37,10 @@ def _snapshot_targets(root: Path) -> dict[str, dict[str, bytes] | bytes | None]:
def test_generated_files_are_up_to_date():
"""Regenerating from the pinned runtime package should leave artifacts unchanged."""
"""Regenerating from repository schemas should leave reviewed artifacts unchanged."""
before = _snapshot_targets(ROOT)
# Regenerate contract artifacts via the pinned runtime package, not a local
# app-server binary from the checkout or CI environment.
assert importlib.metadata.version("openai-codex-cli-bin") == "0.147.0"
env = os.environ.copy()
env.pop("CODEX_EXEC_PATH", None)
python_bin = str(Path(sys.executable).parent)
env["PATH"] = f"{python_bin}{os.pathsep}{env.get('PATH', '')}"
@@ -55,3 +53,44 @@ def test_generated_files_are_up_to_date():
after = _snapshot_targets(ROOT)
assert before == after, "Generated files drifted after regeneration"
@pytest.mark.parametrize("mode", ["repository", "scratch", "experimental"])
def test_schema_refresh_only_updates_python_for_repository_schemas(monkeypatch, tmp_path, mode):
script = ROOT.parents[1] / "codex-rs/app-server-protocol/scripts/write_schema_fixtures.py"
arguments = {
"repository": [],
"scratch": ["--schema-root", str(tmp_path / "schema")],
"experimental": ["--experimental"],
}[mode]
calls = []
monkeypatch.setattr(sys, "argv", [str(script), *arguments])
monkeypatch.setattr(subprocess, "run", lambda args, **kwargs: calls.append((args, kwargs)))
runpy.run_path(str(script), run_name="__main__")
assert [args[0] for args, _kwargs in calls] == (
["cargo", "uv"] if mode == "repository" else ["cargo"]
)
assert all(kwargs["check"] for _args, kwargs in calls)
if mode == "repository":
assert calls[1][0][-3:] == [
"generate-types",
"--schema-dir",
str(ROOT.parents[1] / "codex-rs/app-server-protocol/schema/json"),
]
def test_schema_generation_failure_does_not_update_python(monkeypatch):
script = ROOT.parents[1] / "codex-rs/app-server-protocol/scripts/write_schema_fixtures.py"
calls = []
def fail(args, **_kwargs):
calls.append(args[0])
raise subprocess.CalledProcessError(1, args)
monkeypatch.setattr(sys, "argv", [str(script)])
monkeypatch.setattr(subprocess, "run", fail)
with pytest.raises(subprocess.CalledProcessError):
runpy.run_path(str(script), run_name="__main__")
assert calls == ["cargo"]