feat: experimental flags (#10231)

## Problem being solved
- We need a single, reliable way to mark app-server API surface as
experimental so that:
  1. the runtime can reject experimental usage unless the client opts in
2. generated TS/JSON schemas can exclude experimental methods/fields for
stable clients.

Right now that’s easy to drift or miss when done ad-hoc.

## How to declare experimental methods and fields
- **Experimental method**: add `#[experimental("method/name")]` to the
`ClientRequest` variant in `client_request_definitions!`.
- **Experimental field**: on the params struct, derive `ExperimentalApi`
and annotate the field with `#[experimental("method/name.field")]` + set
`inspect_params: true` for the method variant so
`ClientRequest::experimental_reason()` inspects params for experimental
fields.

## How the macro solves it
- The new derive macro lives in
`codex-rs/codex-experimental-api-macros/src/lib.rs` and is used via
`#[derive(ExperimentalApi)]` plus `#[experimental("reason")]`
attributes.
- **Structs**:
- Generates `ExperimentalApi::experimental_reason(&self)` that checks
only annotated fields.
  - The “presence” check is type-aware:
    - `Option<T>`: `is_some_and(...)` recursively checks inner.
    - `Vec`/`HashMap`/`BTreeMap`: must be non-empty.
    - `bool`: must be `true`.
    - Other types: considered present (returns `true`).
- Registers each experimental field in an `inventory` with `(type_name,
serialized field name, reason)` and exposes `EXPERIMENTAL_FIELDS` for
that type. Field names are converted from `snake_case` to `camelCase`
for schema/TS filtering.
- **Enums**:
- Generates an exhaustive `match` returning `Some(reason)` for annotated
variants and `None` otherwise (no wildcard arm).
- **Wiring**:
- Runtime gating uses `ExperimentalApi::experimental_reason()` in
`codex-rs/app-server/src/message_processor.rs` to reject requests unless
`InitializeParams.capabilities.experimental_api == true`.
- Schema/TS export filters use the inventory list and
`EXPERIMENTAL_CLIENT_METHODS` from `client_request_definitions!` to
strip experimental methods/fields when `experimental_api` is false.
This commit is contained in:
jif-oai 2026-02-02 12:06:50 +01:00 committed by GitHub
parent 9513f18bfe
commit 3cc9122ee2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2682 additions and 1076 deletions

1748
codex-rs/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,7 @@ members = [
"codex-client",
"codex-api",
"state",
"codex-experimental-api-macros",
]
resolver = "2"
@ -81,6 +82,7 @@ codex-common = { path = "common" }
codex-core = { path = "core" }
codex-exec = { path = "exec" }
codex-execpolicy = { path = "execpolicy" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
codex-feedback = { path = "feedback" }
codex-file-search = { path = "file-search" }
codex-git = { path = "utils/git" }
@ -155,6 +157,7 @@ image = { version = "^0.25.9", default-features = false }
include_dir = "0.7.4"
indexmap = "2.12.0"
insta = "1.46.0"
inventory = "0.3.19"
itertools = "0.14.0"
keyring = { version = "3.6", default-features = false }
landlock = "0.4.4"

View file

@ -15,6 +15,7 @@ workspace = true
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-protocol = { workspace = true }
codex-experimental-api-macros = { workspace = true }
codex-utils-absolute-path = { workspace = true }
mcp-types = { workspace = true }
schemars = { workspace = true }
@ -23,6 +24,7 @@ serde_json = { workspace = true }
strum_macros = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }
inventory = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] }
[dev-dependencies]

View file

@ -176,10 +176,6 @@
],
"type": "object"
},
"CollaborationModeListParams": {
"description": "EXPERIMENTAL - list collaboration mode presets.",
"type": "object"
},
"CommandExecParams": {
"properties": {
"command": {
@ -678,8 +674,29 @@
],
"type": "object"
},
"InitializeCapabilities": {
"description": "Client-declared capabilities negotiated during initialize.",
"properties": {
"experimentalApi": {
"default": false,
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
}
},
"type": "object"
},
"InitializeParams": {
"properties": {
"capabilities": {
"anyOf": [
{
"$ref": "#/definitions/InitializeCapabilities"
},
{
"type": "null"
}
]
},
"clientInfo": {
"$ref": "#/definitions/ClientInfo"
}
@ -2551,15 +2568,6 @@
"null"
]
},
"dynamicTools": {
"items": {
"$ref": "#/definitions/DynamicToolSpec"
},
"type": [
"array",
"null"
]
},
"ephemeral": {
"type": [
"boolean",
@ -3434,31 +3442,6 @@
"title": "Model/listRequest",
"type": "object"
},
{
"description": "EXPERIMENTAL - list collaboration mode presets.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"collaborationMode/list"
],
"title": "CollaborationMode/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/CollaborationModeListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "CollaborationMode/listRequest",
"type": "object"
},
{
"properties": {
"id": {

View file

@ -942,31 +942,6 @@
"title": "Model/listRequest",
"type": "object"
},
{
"description": "EXPERIMENTAL - list collaboration mode presets.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"collaborationMode/list"
],
"title": "CollaborationMode/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/CollaborationModeListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "CollaborationMode/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@ -5463,9 +5438,30 @@
],
"type": "object"
},
"InitializeCapabilities": {
"description": "Client-declared capabilities negotiated during initialize.",
"properties": {
"experimentalApi": {
"default": false,
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
}
},
"type": "object"
},
"InitializeParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"capabilities": {
"anyOf": [
{
"$ref": "#/definitions/InitializeCapabilities"
},
{
"type": "null"
}
]
},
"clientInfo": {
"$ref": "#/definitions/ClientInfo"
}
@ -10408,29 +10404,6 @@
],
"type": "object"
},
"CollaborationModeListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - list collaboration mode presets.",
"title": "CollaborationModeListParams",
"type": "object"
},
"CollaborationModeListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - collaboration mode presets response.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/CollaborationModeMask"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "CollaborationModeListResponse",
"type": "object"
},
"CollaborationModeMask": {
"description": "A mask for collaboration mode settings, allowing partial updates. All fields except `name` are optional, enabling selective updates.",
"properties": {
@ -15319,15 +15292,6 @@
"null"
]
},
"dynamicTools": {
"items": {
"$ref": "#/definitions/v2/DynamicToolSpec"
},
"type": [
"array",
"null"
]
},
"ephemeral": {
"type": [
"boolean",

View file

@ -21,9 +21,30 @@
"version"
],
"type": "object"
},
"InitializeCapabilities": {
"description": "Client-declared capabilities negotiated during initialize.",
"properties": {
"experimentalApi": {
"default": false,
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
}
},
"type": "object"
}
},
"properties": {
"capabilities": {
"anyOf": [
{
"$ref": "#/definitions/InitializeCapabilities"
},
{
"type": "null"
}
]
},
"clientInfo": {
"$ref": "#/definitions/ClientInfo"
}

View file

@ -1,6 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - list collaboration mode presets.",
"title": "CollaborationModeListParams",
"type": "object"
}

View file

@ -1,93 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CollaborationModeMask": {
"description": "A mask for collaboration mode settings, allowing partial updates. All fields except `name` are optional, enabling selective updates.",
"properties": {
"developer_instructions": {
"type": [
"string",
"null"
]
},
"mode": {
"anyOf": [
{
"$ref": "#/definitions/ModeKind"
},
{
"type": "null"
}
]
},
"model": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"reasoning_effort": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
}
},
"required": [
"name"
],
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
"plan",
"code",
"pair_programming",
"execute",
"custom"
],
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
}
},
"description": "EXPERIMENTAL - collaboration mode presets response.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/CollaborationModeMask"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "CollaborationModeListResponse",
"type": "object"
}

View file

@ -79,15 +79,6 @@
"null"
]
},
"dynamicTools": {
"items": {
"$ref": "#/definitions/DynamicToolSpec"
},
"type": [
"array",
"null"
]
},
"ephemeral": {
"type": [
"boolean",

View file

@ -23,7 +23,6 @@ import type { SendUserTurnParams } from "./SendUserTurnParams";
import type { SetDefaultModelParams } from "./SetDefaultModelParams";
import type { AppsListParams } from "./v2/AppsListParams";
import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams";
import type { CollaborationModeListParams } from "./v2/CollaborationModeListParams";
import type { CommandExecParams } from "./v2/CommandExecParams";
import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams";
import type { ConfigReadParams } from "./v2/ConfigReadParams";
@ -53,4 +52,4 @@ import type { TurnStartParams } from "./v2/TurnStartParams";
/**
* Request from the client to the server.
*/
export type ClientRequest = { "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "collaborationMode/list", id: RequestId, params: CollaborationModeListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, };
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, };

View file

@ -0,0 +1,12 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Client-declared capabilities negotiated during initialize.
*/
export type InitializeCapabilities = {
/**
* Opt into receiving experimental API methods and fields.
*/
experimentalApi: boolean, };

View file

@ -2,5 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ClientInfo } from "./ClientInfo";
import type { InitializeCapabilities } from "./InitializeCapabilities";
export type InitializeParams = { clientInfo: ClientInfo, };
export type InitializeParams = { clientInfo: ClientInfo, capabilities: InitializeCapabilities | null, };

View file

@ -93,6 +93,7 @@ export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse";
export type { GitSha } from "./GitSha";
export type { HistoryEntry } from "./HistoryEntry";
export type { ImageContent } from "./ImageContent";
export type { InitializeCapabilities } from "./InitializeCapabilities";
export type { InitializeParams } from "./InitializeParams";
export type { InitializeResponse } from "./InitializeResponse";
export type { InputItem } from "./InputItem";

View file

@ -1,8 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* EXPERIMENTAL - list collaboration mode presets.
*/
export type CollaborationModeListParams = Record<string, never>;

View file

@ -1,9 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CollaborationModeMask } from "../CollaborationModeMask";
/**
* EXPERIMENTAL - collaboration mode presets response.
*/
export type CollaborationModeListResponse = { data: Array<CollaborationModeMask>, };

View file

@ -4,14 +4,12 @@
import type { Personality } from "../Personality";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { DynamicToolSpec } from "./DynamicToolSpec";
import type { SandboxMode } from "./SandboxMode";
export type ThreadStartParams = { model: string | null, modelProvider: string | null, cwd: string | null, approvalPolicy: AskForApproval | null, sandbox: SandboxMode | null, config: { [key in string]?: JsonValue } | null, baseInstructions: string | null, developerInstructions: string | null, personality: Personality | null, ephemeral: boolean | null, dynamicTools: Array<DynamicToolSpec> | null,
/**
export type ThreadStartParams = {model: string | null, modelProvider: string | null, cwd: string | null, approvalPolicy: AskForApproval | null, sandbox: SandboxMode | null, config: { [key in string]?: JsonValue } | null, baseInstructions: string | null, developerInstructions: string | null, personality: Personality | null, ephemeral: boolean | null, /**
* If true, opt into emitting raw response items on the event stream.
*
* This is for internal use only (e.g. Codex Cloud).
* (TODO): Figure out a better way to categorize internal / experimental events & protocols.
*/
experimentalRawEvents: boolean, };
experimentalRawEvents: boolean};

View file

@ -22,8 +22,6 @@ export type { CollabAgentState } from "./CollabAgentState";
export type { CollabAgentStatus } from "./CollabAgentStatus";
export type { CollabAgentTool } from "./CollabAgentTool";
export type { CollabAgentToolCallStatus } from "./CollabAgentToolCallStatus";
export type { CollaborationModeListParams } from "./CollaborationModeListParams";
export type { CollaborationModeListResponse } from "./CollaborationModeListResponse";
export type { CommandAction } from "./CommandAction";
export type { CommandExecParams } from "./CommandExecParams";
export type { CommandExecResponse } from "./CommandExecResponse";

View file

@ -0,0 +1,70 @@
/// Marker trait for protocol types that can signal experimental usage.
pub trait ExperimentalApi {
/// Returns a short reason identifier when an experimental method or field is
/// used, or `None` when the value is entirely stable.
fn experimental_reason(&self) -> Option<&'static str>;
}
/// Describes an experimental field on a specific type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExperimentalField {
pub type_name: &'static str,
pub field_name: &'static str,
/// Stable identifier returned when this field is used.
/// Convention: `<method>` for method-level gates or `<method>.<field>` for
/// field-level gates.
pub reason: &'static str,
}
inventory::collect!(ExperimentalField);
/// Returns all experimental fields registered across the protocol types.
pub fn experimental_fields() -> Vec<&'static ExperimentalField> {
inventory::iter::<ExperimentalField>.into_iter().collect()
}
/// Constructs a consistent error message for experimental gating.
pub fn experimental_required_message(reason: &str) -> String {
format!("{reason} requires experimentalApi capability")
}
#[cfg(test)]
mod tests {
use super::ExperimentalApi as ExperimentalApiTrait;
use codex_experimental_api_macros::ExperimentalApi;
use pretty_assertions::assert_eq;
#[allow(dead_code)]
#[derive(ExperimentalApi)]
enum EnumVariantShapes {
#[experimental("enum/unit")]
Unit,
#[experimental("enum/tuple")]
Tuple(u8),
#[experimental("enum/named")]
Named {
value: u8,
},
StableTuple(u8),
}
#[test]
fn derive_supports_all_enum_variant_shapes() {
assert_eq!(
ExperimentalApiTrait::experimental_reason(&EnumVariantShapes::Unit),
Some("enum/unit")
);
assert_eq!(
ExperimentalApiTrait::experimental_reason(&EnumVariantShapes::Tuple(1)),
Some("enum/tuple")
);
assert_eq!(
ExperimentalApiTrait::experimental_reason(&EnumVariantShapes::Named { value: 1 }),
Some("enum/named")
);
assert_eq!(
ExperimentalApiTrait::experimental_reason(&EnumVariantShapes::StableTuple(1)),
None
);
}
}

View file

@ -2,6 +2,7 @@ use crate::ClientNotification;
use crate::ClientRequest;
use crate::ServerNotification;
use crate::ServerRequest;
use crate::experimental_api::experimental_fields;
use crate::export_client_notification_schemas;
use crate::export_client_param_schemas;
use crate::export_client_response_schemas;
@ -10,6 +11,9 @@ use crate::export_server_notification_schemas;
use crate::export_server_param_schemas;
use crate::export_server_response_schemas;
use crate::export_server_responses;
use crate::protocol::common::EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES;
use crate::protocol::common::EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES;
use crate::protocol::common::EXPERIMENTAL_CLIENT_METHODS;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
@ -67,6 +71,7 @@ pub struct GenerateTsOptions {
pub generate_indices: bool,
pub ensure_headers: bool,
pub run_prettier: bool,
pub experimental_api: bool,
}
impl Default for GenerateTsOptions {
@ -75,6 +80,7 @@ impl Default for GenerateTsOptions {
generate_indices: true,
ensure_headers: true,
run_prettier: true,
experimental_api: false,
}
}
}
@ -100,6 +106,10 @@ pub fn generate_ts_with_options(
export_server_responses(out_dir)?;
ServerNotification::export_all_to(out_dir)?;
if !options.experimental_api {
filter_experimental_ts(out_dir)?;
}
if options.generate_indices {
generate_index_ts(out_dir)?;
generate_index_ts(&v2_out_dir)?;
@ -140,8 +150,12 @@ pub fn generate_ts_with_options(
}
pub fn generate_json(out_dir: &Path) -> Result<()> {
generate_json_with_experimental(out_dir, false)
}
pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) -> Result<()> {
ensure_dir(out_dir)?;
let envelope_emitters: &[JsonSchemaEmitter] = &[
let envelope_emitters: Vec<JsonSchemaEmitter> = vec![
|d| write_json_schema_with_return::<crate::RequestId>(d, "RequestId"),
|d| write_json_schema_with_return::<crate::JSONRPCMessage>(d, "JSONRPCMessage"),
|d| write_json_schema_with_return::<crate::JSONRPCRequest>(d, "JSONRPCRequest"),
@ -157,7 +171,7 @@ pub fn generate_json(out_dir: &Path) -> Result<()> {
];
let mut schemas: Vec<GeneratedSchema> = Vec::new();
for emit in envelope_emitters {
for emit in &envelope_emitters {
schemas.push(emit(out_dir)?);
}
@ -168,15 +182,654 @@ pub fn generate_json(out_dir: &Path) -> Result<()> {
schemas.extend(export_client_notification_schemas(out_dir)?);
schemas.extend(export_server_notification_schemas(out_dir)?);
let bundle = build_schema_bundle(schemas)?;
let mut bundle = build_schema_bundle(schemas)?;
if !experimental_api {
filter_experimental_schema(&mut bundle)?;
}
write_pretty_json(
out_dir.join("codex_app_server_protocol.schemas.json"),
&bundle,
)?;
if !experimental_api {
filter_experimental_json_files(out_dir)?;
}
Ok(())
}
fn filter_experimental_ts(out_dir: &Path) -> Result<()> {
let registered_fields = experimental_fields();
let experimental_method_types = experimental_method_types();
// Most generated TS files are filtered by schema processing, but
// `ClientRequest.ts` and any type with `#[experimental(...)]` fields need
// direct post-processing because they encode method/field information in
// file-local unions/interfaces.
filter_client_request_ts(out_dir, EXPERIMENTAL_CLIENT_METHODS)?;
filter_experimental_type_fields_ts(out_dir, &registered_fields)?;
remove_generated_type_files(out_dir, &experimental_method_types, "ts")?;
Ok(())
}
/// Removes union arms from `ClientRequest.ts` for methods marked experimental.
fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Result<()> {
let path = out_dir.join("ClientRequest.ts");
if !path.exists() {
return Ok(());
}
let mut content =
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
let Some((prefix, body, suffix)) = split_type_alias(&content) else {
return Ok(());
};
let experimental_methods: HashSet<&str> = experimental_methods
.iter()
.copied()
.filter(|method| !method.is_empty())
.collect();
let arms = split_top_level(&body, '|');
let filtered_arms: Vec<String> = arms
.into_iter()
.filter(|arm| {
extract_method_from_arm(arm)
.is_none_or(|method| !experimental_methods.contains(method.as_str()))
})
.collect();
let new_body = filtered_arms.join(" | ");
content = format!("{prefix}{new_body}{suffix}");
content = prune_unused_type_imports(content, &new_body);
fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
/// Removes experimental properties from generated TypeScript type files.
fn filter_experimental_type_fields_ts(
out_dir: &Path,
experimental_fields: &[&'static crate::experimental_api::ExperimentalField],
) -> Result<()> {
let mut fields_by_type_name: HashMap<String, HashSet<String>> = HashMap::new();
for field in experimental_fields {
fields_by_type_name
.entry(field.type_name.to_string())
.or_default()
.insert(field.field_name.to_string());
}
if fields_by_type_name.is_empty() {
return Ok(());
}
for path in ts_files_in_recursive(out_dir)? {
let Some(type_name) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let Some(experimental_field_names) = fields_by_type_name.get(type_name) else {
continue;
};
filter_experimental_fields_in_ts_file(&path, experimental_field_names)?;
}
Ok(())
}
fn filter_experimental_fields_in_ts_file(
path: &Path,
experimental_field_names: &HashSet<String>,
) -> Result<()> {
let mut content =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let Some((open_brace, close_brace)) = type_body_brace_span(&content) else {
return Ok(());
};
let inner = &content[open_brace + 1..close_brace];
let fields = split_top_level_multi(inner, &[',', ';']);
let filtered_fields: Vec<String> = fields
.into_iter()
.filter(|field| {
let field = strip_leading_block_comments(field);
parse_property_name(field)
.is_none_or(|name| !experimental_field_names.contains(name.as_str()))
})
.collect();
let new_inner = filtered_fields.join(", ");
let prefix = &content[..open_brace + 1];
let suffix = &content[close_brace..];
content = format!("{prefix}{new_inner}{suffix}");
content = prune_unused_type_imports(content, &new_inner);
fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
fn filter_experimental_schema(bundle: &mut Value) -> Result<()> {
let registered_fields = experimental_fields();
filter_experimental_fields_in_root(bundle, &registered_fields);
filter_experimental_fields_in_definitions(bundle, &registered_fields);
prune_experimental_methods(bundle, EXPERIMENTAL_CLIENT_METHODS);
remove_experimental_method_type_definitions(bundle);
Ok(())
}
fn filter_experimental_fields_in_root(
schema: &mut Value,
experimental_fields: &[&'static crate::experimental_api::ExperimentalField],
) {
let Some(title) = schema.get("title").and_then(Value::as_str) else {
return;
};
let title = title.to_string();
for field in experimental_fields {
if title != field.type_name {
continue;
}
remove_property_from_schema(schema, field.field_name);
}
}
fn filter_experimental_fields_in_definitions(
bundle: &mut Value,
experimental_fields: &[&'static crate::experimental_api::ExperimentalField],
) {
let Some(definitions) = bundle.get_mut("definitions").and_then(Value::as_object_mut) else {
return;
};
filter_experimental_fields_in_definitions_map(definitions, experimental_fields);
}
fn filter_experimental_fields_in_definitions_map(
definitions: &mut Map<String, Value>,
experimental_fields: &[&'static crate::experimental_api::ExperimentalField],
) {
for (def_name, def_schema) in definitions.iter_mut() {
if is_namespace_map(def_schema) {
if let Some(namespace_defs) = def_schema.as_object_mut() {
filter_experimental_fields_in_definitions_map(namespace_defs, experimental_fields);
}
continue;
}
for field in experimental_fields {
if !definition_matches_type(def_name, field.type_name) {
continue;
}
remove_property_from_schema(def_schema, field.field_name);
}
}
}
fn is_namespace_map(value: &Value) -> bool {
let Value::Object(map) = value else {
return false;
};
if map.keys().any(|key| key.starts_with('$')) {
return false;
}
let looks_like_schema = map.contains_key("type")
|| map.contains_key("properties")
|| map.contains_key("anyOf")
|| map.contains_key("oneOf")
|| map.contains_key("allOf");
!looks_like_schema && map.values().all(Value::is_object)
}
fn definition_matches_type(def_name: &str, type_name: &str) -> bool {
def_name == type_name || def_name.ends_with(&format!("::{type_name}"))
}
fn remove_property_from_schema(schema: &mut Value, field_name: &str) {
if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
properties.remove(field_name);
}
if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) {
required.retain(|entry| entry.as_str() != Some(field_name));
}
if let Some(inner_schema) = schema.get_mut("schema") {
remove_property_from_schema(inner_schema, field_name);
}
}
fn prune_experimental_methods(bundle: &mut Value, experimental_methods: &[&str]) {
let experimental_methods: HashSet<&str> = experimental_methods
.iter()
.copied()
.filter(|method| !method.is_empty())
.collect();
prune_experimental_methods_inner(bundle, &experimental_methods);
}
fn prune_experimental_methods_inner(value: &mut Value, experimental_methods: &HashSet<&str>) {
match value {
Value::Array(items) => {
items.retain(|item| !is_experimental_method_variant(item, experimental_methods));
for item in items {
prune_experimental_methods_inner(item, experimental_methods);
}
}
Value::Object(map) => {
for entry in map.values_mut() {
prune_experimental_methods_inner(entry, experimental_methods);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
fn is_experimental_method_variant(value: &Value, experimental_methods: &HashSet<&str>) -> bool {
let Value::Object(map) = value else {
return false;
};
let Some(properties) = map.get("properties").and_then(Value::as_object) else {
return false;
};
let Some(method_schema) = properties.get("method").and_then(Value::as_object) else {
return false;
};
if let Some(method) = method_schema.get("const").and_then(Value::as_str) {
return experimental_methods.contains(method);
}
if let Some(values) = method_schema.get("enum").and_then(Value::as_array)
&& values.len() == 1
&& let Some(method) = values[0].as_str()
{
return experimental_methods.contains(method);
}
false
}
fn filter_experimental_json_files(out_dir: &Path) -> Result<()> {
for path in json_files_in_recursive(out_dir)? {
let mut value = read_json_value(&path)?;
filter_experimental_schema(&mut value)?;
write_pretty_json(path, &value)?;
}
let experimental_method_types = experimental_method_types();
remove_generated_type_files(out_dir, &experimental_method_types, "json")?;
Ok(())
}
fn experimental_method_types() -> HashSet<String> {
let mut type_names = HashSet::new();
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES, &mut type_names);
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES, &mut type_names);
type_names
}
fn collect_experimental_type_names(entries: &[&str], out: &mut HashSet<String>) {
for entry in entries {
let trimmed = entry.trim();
if trimmed.is_empty() {
continue;
}
let name = trimmed.rsplit("::").next().unwrap_or(trimmed);
if !name.is_empty() {
out.insert(name.to_string());
}
}
}
fn remove_generated_type_files(
out_dir: &Path,
type_names: &HashSet<String>,
extension: &str,
) -> Result<()> {
for type_name in type_names {
for subdir in ["", "v1", "v2"] {
let path = if subdir.is_empty() {
out_dir.join(format!("{type_name}.{extension}"))
} else {
out_dir
.join(subdir)
.join(format!("{type_name}.{extension}"))
};
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
}
}
}
Ok(())
}
fn remove_experimental_method_type_definitions(bundle: &mut Value) {
let type_names = experimental_method_types();
let Some(definitions) = bundle.get_mut("definitions").and_then(Value::as_object_mut) else {
return;
};
remove_experimental_method_type_definitions_map(definitions, &type_names);
}
fn remove_experimental_method_type_definitions_map(
definitions: &mut Map<String, Value>,
experimental_type_names: &HashSet<String>,
) {
let keys_to_remove: Vec<String> = definitions
.keys()
.filter(|def_name| {
experimental_type_names
.iter()
.any(|type_name| definition_matches_type(def_name, type_name))
})
.cloned()
.collect();
for key in keys_to_remove {
definitions.remove(&key);
}
for value in definitions.values_mut() {
if !is_namespace_map(value) {
continue;
}
if let Some(namespace_defs) = value.as_object_mut() {
remove_experimental_method_type_definitions_map(
namespace_defs,
experimental_type_names,
);
}
}
}
fn prune_unused_type_imports(content: String, type_alias_body: &str) -> String {
let trailing_newline = content.ends_with('\n');
let mut lines = Vec::new();
for line in content.lines() {
if let Some(type_name) = parse_imported_type_name(line)
&& !type_alias_body.contains(type_name)
{
continue;
}
lines.push(line);
}
let mut rewritten = lines.join("\n");
if trailing_newline {
rewritten.push('\n');
}
rewritten
}
fn parse_imported_type_name(line: &str) -> Option<&str> {
let line = line.trim();
let rest = line.strip_prefix("import type {")?;
let (type_name, _) = rest.split_once("} from ")?;
let type_name = type_name.trim();
if type_name.is_empty() || type_name.contains(',') || type_name.contains(" as ") {
return None;
}
Some(type_name)
}
fn json_files_in_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
for entry in fs::read_dir(&current)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if matches!(path.extension().and_then(|ext| ext.to_str()), Some("json")) {
out.push(path);
}
}
}
Ok(out)
}
fn read_json_value(path: &Path) -> Result<Value> {
let content =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
}
fn split_type_alias(content: &str) -> Option<(String, String, String)> {
let eq_index = content.find('=')?;
let semi_index = content.rfind(';')?;
if semi_index <= eq_index {
return None;
}
let prefix = content[..eq_index + 1].to_string();
let body = content[eq_index + 1..semi_index].to_string();
let suffix = content[semi_index..].to_string();
Some((prefix, body, suffix))
}
fn type_body_brace_span(content: &str) -> Option<(usize, usize)> {
if let Some(eq_index) = content.find('=') {
let after_eq = &content[eq_index + 1..];
let (open_rel, close_rel) = find_top_level_brace_span(after_eq)?;
return Some((eq_index + 1 + open_rel, eq_index + 1 + close_rel));
}
const INTERFACE_MARKER: &str = "export interface";
let interface_index = content.find(INTERFACE_MARKER)?;
let after_interface = &content[interface_index + INTERFACE_MARKER.len()..];
let (open_rel, close_rel) = find_top_level_brace_span(after_interface)?;
Some((
interface_index + INTERFACE_MARKER.len() + open_rel,
interface_index + INTERFACE_MARKER.len() + close_rel,
))
}
fn find_top_level_brace_span(input: &str) -> Option<(usize, usize)> {
let mut state = ScanState::default();
let mut open_index = None;
for (index, ch) in input.char_indices() {
if !state.in_string() && ch == '{' && state.depth.is_top_level() {
open_index = Some(index);
}
state.observe(ch);
if !state.in_string()
&& ch == '}'
&& state.depth.is_top_level()
&& let Some(open) = open_index
{
return Some((open, index));
}
}
None
}
fn split_top_level(input: &str, delimiter: char) -> Vec<String> {
split_top_level_multi(input, &[delimiter])
}
fn split_top_level_multi(input: &str, delimiters: &[char]) -> Vec<String> {
let mut state = ScanState::default();
let mut start = 0usize;
let mut parts = Vec::new();
for (index, ch) in input.char_indices() {
if !state.in_string() && state.depth.is_top_level() && delimiters.contains(&ch) {
let part = input[start..index].trim();
if !part.is_empty() {
parts.push(part.to_string());
}
start = index + ch.len_utf8();
}
state.observe(ch);
}
let tail = input[start..].trim();
if !tail.is_empty() {
parts.push(tail.to_string());
}
parts
}
fn extract_method_from_arm(arm: &str) -> Option<String> {
let (open, close) = find_top_level_brace_span(arm)?;
let inner = &arm[open + 1..close];
for field in split_top_level(inner, ',') {
let Some((name, value)) = parse_property(field.as_str()) else {
continue;
};
if name != "method" {
continue;
}
let value = value.trim_start();
let (literal, _) = parse_string_literal(value)?;
return Some(literal);
}
None
}
fn parse_property(input: &str) -> Option<(String, &str)> {
let name = parse_property_name(input)?;
let colon_index = input.find(':')?;
Some((name, input[colon_index + 1..].trim_start()))
}
fn strip_leading_block_comments(input: &str) -> &str {
let mut rest = input.trim_start();
loop {
let Some(after_prefix) = rest.strip_prefix("/*") else {
return rest;
};
let Some(end_rel) = after_prefix.find("*/") else {
return rest;
};
rest = after_prefix[end_rel + 2..].trim_start();
}
}
fn parse_property_name(input: &str) -> Option<String> {
let trimmed = input.trim_start();
if trimmed.is_empty() {
return None;
}
if let Some((literal, consumed)) = parse_string_literal(trimmed) {
let rest = trimmed[consumed..].trim_start();
if rest.starts_with(':') {
return Some(literal);
}
return None;
}
let mut end = 0usize;
for (index, ch) in trimmed.char_indices() {
if !is_ident_char(ch) {
break;
}
end = index + ch.len_utf8();
}
if end == 0 {
return None;
}
let name = &trimmed[..end];
let rest = trimmed[end..].trim_start();
let rest = if let Some(stripped) = rest.strip_prefix('?') {
stripped.trim_start()
} else {
rest
};
if rest.starts_with(':') {
return Some(name.to_string());
}
None
}
fn parse_string_literal(input: &str) -> Option<(String, usize)> {
let mut chars = input.char_indices();
let (start_index, quote) = chars.next()?;
if quote != '"' && quote != '\'' {
return None;
}
let mut escape = false;
for (index, ch) in chars {
if escape {
escape = false;
continue;
}
if ch == '\\' {
escape = true;
continue;
}
if ch == quote {
let literal = input[start_index + 1..index].to_string();
let consumed = index + ch.len_utf8();
return Some((literal, consumed));
}
}
None
}
fn is_ident_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_'
}
#[derive(Default)]
struct ScanState {
depth: Depth,
string_delim: Option<char>,
escape: bool,
}
impl ScanState {
fn observe(&mut self, ch: char) {
if let Some(delim) = self.string_delim {
if self.escape {
self.escape = false;
return;
}
if ch == '\\' {
self.escape = true;
return;
}
if ch == delim {
self.string_delim = None;
}
return;
}
match ch {
'"' | '\'' => {
self.string_delim = Some(ch);
}
'{' => self.depth.brace += 1,
'}' => self.depth.brace = (self.depth.brace - 1).max(0),
'[' => self.depth.bracket += 1,
']' => self.depth.bracket = (self.depth.bracket - 1).max(0),
'(' => self.depth.paren += 1,
')' => self.depth.paren = (self.depth.paren - 1).max(0),
'<' => self.depth.angle += 1,
'>' => {
if self.depth.angle > 0 {
self.depth.angle -= 1;
}
}
_ => {}
}
}
fn in_string(&self) -> bool {
self.string_delim.is_some()
}
}
#[derive(Default)]
struct Depth {
brace: i32,
bracket: i32,
paren: i32,
angle: i32,
}
impl Depth {
fn is_top_level(&self) -> bool {
self.brace == 0 && self.bracket == 0 && self.paren == 0 && self.angle == 0
}
}
fn build_schema_bundle(schemas: Vec<GeneratedSchema>) -> Result<Value> {
const SPECIAL_DEFINITIONS: &[&str] = &[
"ClientNotification",
@ -740,7 +1393,9 @@ fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::v2;
use anyhow::Result;
use pretty_assertions::assert_eq;
use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
@ -767,9 +1422,34 @@ mod tests {
generate_indices: false,
ensure_headers: false,
run_prettier: false,
experimental_api: false,
};
generate_ts_with_options(&output_dir, None, options)?;
let client_request_ts = fs::read_to_string(output_dir.join("ClientRequest.ts"))?;
assert_eq!(client_request_ts.contains("mock/experimentalMethod"), false);
assert_eq!(
client_request_ts.contains("MockExperimentalMethodParams"),
false
);
let thread_start_ts =
fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?;
assert_eq!(thread_start_ts.contains("mockExperimentalField"), false);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodParams.ts")
.exists(),
false
);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodResponse.ts")
.exists(),
false
);
let mut undefined_offenders = Vec::new();
let mut optional_nullable_offenders = BTreeSet::new();
let mut stack = vec![output_dir];
@ -943,4 +1623,174 @@ mod tests {
Ok(())
}
#[test]
fn generate_ts_with_experimental_api_retains_experimental_entries() -> Result<()> {
let output_dir =
std::env::temp_dir().join(format!("codex_ts_types_experimental_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let _guard = TempDirGuard(output_dir.clone());
let options = GenerateTsOptions {
generate_indices: false,
ensure_headers: false,
run_prettier: false,
experimental_api: true,
};
generate_ts_with_options(&output_dir, None, options)?;
let client_request_ts = fs::read_to_string(output_dir.join("ClientRequest.ts"))?;
assert_eq!(client_request_ts.contains("mock/experimentalMethod"), true);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodParams.ts")
.exists(),
true
);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodResponse.ts")
.exists(),
true
);
let thread_start_ts =
fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?;
assert_eq!(thread_start_ts.contains("mockExperimentalField"), true);
Ok(())
}
#[test]
fn stable_schema_filter_removes_mock_thread_start_field() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
let schema = write_json_schema_with_return::<v2::ThreadStartParams>(
&output_dir,
"ThreadStartParams",
)?;
let mut bundle = build_schema_bundle(vec![schema])?;
filter_experimental_schema(&mut bundle)?;
let definitions = bundle["definitions"]
.as_object()
.expect("schema bundle should include definitions");
let (_, def_schema) = definitions
.iter()
.find(|(name, _)| definition_matches_type(name, "ThreadStartParams"))
.expect("ThreadStartParams definition should exist");
let properties = def_schema["properties"]
.as_object()
.expect("ThreadStartParams should have properties");
assert_eq!(properties.contains_key("mockExperimentalField"), false);
let _cleanup = fs::remove_dir_all(&output_dir);
Ok(())
}
#[test]
fn experimental_type_fields_ts_filter_handles_interface_shape() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_ts_filter_{}", Uuid::now_v7()));
fs::create_dir_all(&output_dir)?;
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let _guard = TempDirGuard(output_dir.clone());
let path = output_dir.join("CustomParams.ts");
let content = r#"export interface CustomParams {
stableField: string | null;
unstableField: string | null;
otherStableField: boolean;
}
"#;
fs::write(&path, content)?;
static CUSTOM_FIELD: crate::experimental_api::ExperimentalField =
crate::experimental_api::ExperimentalField {
type_name: "CustomParams",
field_name: "unstableField",
reason: "custom/unstableField",
};
filter_experimental_type_fields_ts(&output_dir, &[&CUSTOM_FIELD])?;
let filtered = fs::read_to_string(&path)?;
assert_eq!(filtered.contains("unstableField"), false);
assert_eq!(filtered.contains("stableField"), true);
assert_eq!(filtered.contains("otherStableField"), true);
Ok(())
}
#[test]
fn stable_schema_filter_removes_mock_experimental_method() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
let schema =
write_json_schema_with_return::<crate::ClientRequest>(&output_dir, "ClientRequest")?;
let mut bundle = build_schema_bundle(vec![schema])?;
filter_experimental_schema(&mut bundle)?;
let bundle_str = serde_json::to_string(&bundle)?;
assert_eq!(bundle_str.contains("mock/experimentalMethod"), false);
let _cleanup = fs::remove_dir_all(&output_dir);
Ok(())
}
#[test]
fn generate_json_filters_experimental_fields_and_methods() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
generate_json_with_experimental(&output_dir, false)?;
let thread_start_json =
fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.json"))?;
assert_eq!(thread_start_json.contains("mockExperimentalField"), false);
let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?;
assert_eq!(
client_request_json.contains("mock/experimentalMethod"),
false
);
let bundle_json =
fs::read_to_string(output_dir.join("codex_app_server_protocol.schemas.json"))?;
assert_eq!(bundle_json.contains("mockExperimentalField"), false);
assert_eq!(bundle_json.contains("MockExperimentalMethodParams"), false);
assert_eq!(
bundle_json.contains("MockExperimentalMethodResponse"),
false
);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodParams.json")
.exists(),
false
);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodResponse.json")
.exists(),
false
);
let _cleanup = fs::remove_dir_all(&output_dir);
Ok(())
}
}

View file

@ -1,10 +1,15 @@
mod experimental_api;
mod export;
mod jsonrpc_lite;
mod protocol;
mod schema_fixtures;
pub use experimental_api::*;
pub use export::GenerateTsOptions;
pub use export::generate_json;
pub use export::generate_json_with_experimental;
pub use export::generate_ts;
pub use export::generate_ts_with_options;
pub use export::generate_types;
pub use jsonrpc_lite::*;
pub use protocol::common::*;

View file

@ -41,6 +41,42 @@ pub enum AuthMode {
ChatgptAuthTokens,
}
macro_rules! experimental_reason_expr {
// If a request variant is explicitly marked experimental, that reason wins.
(#[experimental($reason:expr)] $params:ident $(, $inspect_params:tt)?) => {
Some($reason)
};
// `inspect_params: true` is used when a method is mostly stable but needs
// field-level gating from its params type (for example, ThreadStart).
($params:ident, true) => {
crate::experimental_api::ExperimentalApi::experimental_reason($params)
};
($params:ident $(, $inspect_params:tt)?) => {
None
};
}
macro_rules! experimental_method_entry {
(#[experimental($reason:expr)] => $wire:literal) => {
$wire
};
(#[experimental($reason:expr)]) => {
$reason
};
($($tt:tt)*) => {
""
};
}
macro_rules! experimental_type_entry {
(#[experimental($reason:expr)] $ty:ty) => {
stringify!($ty)
};
($ty:ty) => {
""
};
}
/// Generates an `enum ClientRequest` where each variant is a request that the
/// client can send to the server. Each variant has associated `params` and
/// `response` types. Also generates a `export_client_responses()` function to
@ -48,9 +84,11 @@ pub enum AuthMode {
macro_rules! client_request_definitions {
(
$(
$(#[$variant_meta:meta])*
$(#[experimental($reason:expr)])?
$(#[doc = $variant_doc:literal])*
$variant:ident $(=> $wire:literal)? {
params: $(#[$params_meta:meta])* $params:ty,
$(inspect_params: $inspect_params:tt,)?
response: $response:ty,
}
),* $(,)?
@ -60,7 +98,7 @@ macro_rules! client_request_definitions {
#[serde(tag = "method", rename_all = "camelCase")]
pub enum ClientRequest {
$(
$(#[$variant_meta])*
$(#[doc = $variant_doc])*
$(#[serde(rename = $wire)] #[ts(rename = $wire)])?
$variant {
#[serde(rename = "id")]
@ -71,6 +109,38 @@ macro_rules! client_request_definitions {
)*
}
impl crate::experimental_api::ExperimentalApi for ClientRequest {
fn experimental_reason(&self) -> Option<&'static str> {
match self {
$(
Self::$variant { params: _params, .. } => {
experimental_reason_expr!(
$(#[experimental($reason)])?
_params
$(, $inspect_params)?
)
}
)*
}
}
}
pub(crate) const EXPERIMENTAL_CLIENT_METHODS: &[&str] = &[
$(
experimental_method_entry!($(#[experimental($reason)])? $(=> $wire)?),
)*
];
pub(crate) const EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES: &[&str] = &[
$(
experimental_type_entry!($(#[experimental($reason)])? $params),
)*
];
pub(crate) const EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES: &[&str] = &[
$(
experimental_type_entry!($(#[experimental($reason)])? $response),
)*
];
pub fn export_client_responses(
out_dir: &::std::path::Path,
) -> ::std::result::Result<(), ::ts_rs::ExportError> {
@ -112,8 +182,10 @@ client_request_definitions! {
/// NEW APIs
// Thread lifecycle
// Uses `inspect_params` because only some fields are experimental.
ThreadStart => "thread/start" {
params: v2::ThreadStartParams,
inspect_params: true,
response: v2::ThreadStartResponse,
},
ThreadResume => "thread/resume" {
@ -181,11 +253,18 @@ client_request_definitions! {
params: v2::ModelListParams,
response: v2::ModelListResponse,
},
/// EXPERIMENTAL - list collaboration mode presets.
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
params: v2::CollaborationModeListParams,
response: v2::CollaborationModeListResponse,
},
#[experimental("mock/experimentalMethod")]
/// Test-only method used to validate experimental gating.
MockExperimentalMethod => "mock/experimentalMethod" {
params: v2::MockExperimentalMethodParams,
response: v2::MockExperimentalMethodResponse,
},
McpServerOauthLogin => "mcpServer/oauth/login" {
params: v2::McpServerOauthLoginParams,
@ -995,4 +1074,27 @@ mod tests {
);
Ok(())
}
#[test]
fn mock_experimental_method_is_marked_experimental() {
let request = ClientRequest::MockExperimentalMethod {
request_id: RequestId::Integer(1),
params: v2::MockExperimentalMethodParams::default(),
};
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("mock/experimentalMethod"));
}
#[test]
fn thread_start_mock_field_is_marked_experimental() {
let request = ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: v2::ThreadStartParams {
mock_experimental_field: Some("mock".to_string()),
..Default::default()
},
};
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("thread/start.mockExperimentalField"));
}
}

View file

@ -33,6 +33,8 @@ use crate::protocol::common::GitSha;
#[serde(rename_all = "camelCase")]
pub struct InitializeParams {
pub client_info: ClientInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<InitializeCapabilities>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
@ -43,6 +45,15 @@ pub struct ClientInfo {
pub version: String,
}
/// Client-declared capabilities negotiated during initialize.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct InitializeCapabilities {
/// Opt into receiving experimental API methods and fields.
#[serde(default)]
pub experimental_api: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {

View file

@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use crate::protocol::common::AuthMode;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::account::PlanType;
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
use codex_protocol::config_types::CollaborationMode;
@ -1165,7 +1166,9 @@ pub struct CommandExecResponse {
// === Threads, Turns, and Items ===
// Thread APIs
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[derive(
Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi,
)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadStartParams {
@ -1179,7 +1182,12 @@ pub struct ThreadStartParams {
pub developer_instructions: Option<String>,
pub personality: Option<Personality>,
pub ephemeral: Option<bool>,
#[experimental("thread/start.dynamicTools")]
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
/// Test-only experimental field used to validate experimental gating and
/// schema filtering behavior in a stable way.
#[experimental("thread/start.mockExperimentalField")]
pub mock_experimental_field: Option<String>,
/// If true, opt into emitting raw response items on the event stream.
///
/// This is for internal use only (e.g. Codex Cloud).
@ -1188,6 +1196,22 @@ pub struct ThreadStartParams {
pub experimental_raw_events: bool,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MockExperimentalMethodParams {
/// Test-only payload field.
pub value: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MockExperimentalMethodResponse {
/// Echoes the input `value`.
pub echoed: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View file

@ -28,6 +28,7 @@ use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::InputItem;
@ -419,6 +420,9 @@ impl CodexClient {
title: Some("Codex Toy App Server".to_string()),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
}),
},
};

View file

@ -15,6 +15,7 @@
- [Skills](#skills)
- [Apps](#apps)
- [Auth endpoints](#auth-endpoints)
- [Adding an experimental field](#adding-an-experimental-field)
## Protocol
@ -768,3 +769,29 @@ Field notes:
- `usedPercent` is current usage within the OpenAI quota window.
- `windowDurationMins` is the quota window length.
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
## Adding an experimental field
Use this checklist when introducing a field/method that should only be available when the client opts into experimental APIs.
At runtime, clients must send `initialize` with `capabilities.experimentalApi = true` to use experimental methods or fields.
1. Annotate the field in the protocol type (usually `app-server-protocol/src/protocol/v2.rs`) with:
```rust
#[experimental("thread/start.myField")]
pub my_field: Option<String>,
```
2. Ensure the params type derives `ExperimentalApi` so field-level gating can be detected at runtime.
3. In `app-server-protocol/src/protocol/common.rs`, keep the method stable and use `inspect_params: true` when only some fields are experimental (like `thread/start`). If the entire method is experimental, annotate the method variant with `#[experimental("method/name")]`.
4. Regenerate protocol fixtures:
```bash
just write-app-server-schema
```
5. Verify the protocol crate:
```bash
cargo test -p codex-app-server-protocol
```

View file

@ -69,6 +69,8 @@ use codex_app_server_protocol::McpServerOauthLoginParams;
use codex_app_server_protocol::McpServerOauthLoginResponse;
use codex_app_server_protocol::McpServerRefreshResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::MockExperimentalMethodResponse;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::NewConversationParams;
@ -507,6 +509,9 @@ impl CodexMessageProcessor {
.await;
});
}
ClientRequest::MockExperimentalMethod { request_id, params } => {
self.mock_experimental_method(request_id, params).await;
}
ClientRequest::McpServerOauthLogin { request_id, params } => {
self.mcp_server_oauth_login(request_id, params).await;
}
@ -1606,6 +1611,7 @@ impl CodexMessageProcessor {
base_instructions,
developer_instructions,
dynamic_tools,
mock_experimental_field: _mock_experimental_field,
experimental_raw_events,
personality,
ephemeral,
@ -3001,6 +3007,16 @@ impl CodexMessageProcessor {
outgoing.send_response(request_id, response).await;
}
async fn mock_experimental_method(
&self,
request_id: RequestId,
params: MockExperimentalMethodParams,
) {
let MockExperimentalMethodParams { value } = params;
let response = MockExperimentalMethodResponse { echoed: value };
self.outgoing.send_response(request_id, response).await;
}
async fn mcp_server_refresh(&self, request_id: RequestId, _params: Option<()>) {
let config = match self.load_latest_config().await {
Ok(config) => config,

View file

@ -1,5 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_message_processor::CodexMessageProcessorArgs;
@ -16,6 +18,7 @@ use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
@ -25,6 +28,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::experimental_required_message;
use codex_core::AuthManager;
use codex_core::ThreadManager;
use codex_core::auth::ExternalAuthRefreshContext;
@ -107,6 +111,7 @@ pub(crate) struct MessageProcessor {
config_api: ConfigApi,
config: Arc<Config>,
initialized: bool,
experimental_api_enabled: Arc<AtomicBool>,
config_warnings: Vec<ConfigWarningNotification>,
}
@ -136,6 +141,7 @@ impl MessageProcessor {
config_warnings,
} = args;
let outgoing = Arc::new(outgoing);
let experimental_api_enabled = Arc::new(AtomicBool::new(false));
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
false,
@ -173,6 +179,7 @@ impl MessageProcessor {
config_api,
config,
initialized: false,
experimental_api_enabled,
config_warnings,
}
}
@ -218,6 +225,12 @@ impl MessageProcessor {
self.outgoing.send_error(request_id, error).await;
return;
} else {
let experimental_api_enabled = params
.capabilities
.as_ref()
.is_some_and(|cap| cap.experimental_api);
self.experimental_api_enabled
.store(experimental_api_enabled, Ordering::Relaxed);
let ClientInfo {
name,
title: _title,
@ -281,6 +294,18 @@ impl MessageProcessor {
}
}
if let Some(reason) = codex_request.experimental_reason()
&& !self.experimental_api_enabled.load(Ordering::Relaxed)
{
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: experimental_required_message(reason),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
match codex_request {
ClientRequest::ConfigRead { request_id, params } => {
self.handle_config_read(request_id, params).await;

View file

@ -26,6 +26,7 @@ use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::ForkConversationParams;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::JSONRPCError;
@ -37,6 +38,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ListConversationsParams;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::RemoveConversationListenerParams;
@ -164,7 +166,32 @@ impl McpProcess {
&mut self,
client_info: ClientInfo,
) -> anyhow::Result<JSONRPCMessage> {
let params = Some(serde_json::to_value(InitializeParams { client_info })?);
self.initialize_with_capabilities(
client_info,
Some(InitializeCapabilities {
experimental_api: true,
}),
)
.await
}
pub async fn initialize_with_capabilities(
&mut self,
client_info: ClientInfo,
capabilities: Option<InitializeCapabilities>,
) -> anyhow::Result<JSONRPCMessage> {
self.initialize_with_params(InitializeParams {
client_info,
capabilities,
})
.await
}
async fn initialize_with_params(
&mut self,
params: InitializeParams,
) -> anyhow::Result<JSONRPCMessage> {
let params = Some(serde_json::to_value(params)?);
let request_id = self.send_request("initialize", params).await?;
let message = self.read_jsonrpc_message().await?;
match message {
@ -451,6 +478,15 @@ impl McpProcess {
self.send_request("collaborationMode/list", params).await
}
/// Send a `mock/experimentalMethod` JSON-RPC request.
pub async fn send_mock_experimental_method_request(
&mut self,
params: MockExperimentalMethodParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("mock/experimentalMethod", params).await
}
/// Send a `resumeConversation` JSON-RPC request.
pub async fn send_resume_conversation_request(
&mut self,

View file

@ -0,0 +1,160 @@
use anyhow::Result;
use app_test_support::DEFAULT_CLIENT_NAME;
use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn mock_experimental_method_requires_experimental_api_capability() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
}),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
anyhow::bail!("expected initialize response, got {init:?}");
};
let request_id = mcp
.send_mock_experimental_method_request(MockExperimentalMethodParams::default())
.await?;
let error = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_experimental_capability_error(error, "mock/experimentalMethod");
Ok(())
}
#[tokio::test]
async fn thread_start_mock_field_requires_experimental_api_capability() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
}),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
anyhow::bail!("expected initialize response, got {init:?}");
};
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
mock_experimental_field: Some("mock".to_string()),
..Default::default()
})
.await?;
let error = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_experimental_capability_error(error, "thread/start.mockExperimentalField");
Ok(())
}
#[tokio::test]
async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capability()
-> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
}),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
anyhow::bail!("expected initialize response, got {init:?}");
};
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: ThreadStartResponse = to_response(response)?;
Ok(())
}
fn default_client_info() -> ClientInfo {
ClientInfo {
name: DEFAULT_CLIENT_NAME.to_string(),
title: None,
version: "0.1.0".to_string(),
}
}
fn assert_experimental_capability_error(error: JSONRPCError, reason: &str) {
assert_eq!(error.error.code, -32600);
assert_eq!(
error.error.message,
format!("{reason} requires experimentalApi capability")
);
assert_eq!(error.error.data, None);
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}

View file

@ -5,6 +5,7 @@ mod collaboration_mode_list;
mod compaction;
mod config_rpc;
mod dynamic_tools;
mod experimental_api;
mod initialize;
mod model_list;
mod output_schema;

View file

@ -303,6 +303,10 @@ struct GenerateTsCommand {
/// Optional path to the Prettier executable to format generated files
#[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
prettier: Option<PathBuf>,
/// Include experimental methods and fields in the generated output
#[arg(long = "experimental", default_value_t = false)]
experimental: bool,
}
#[derive(Debug, Args)]
@ -310,6 +314,10 @@ struct GenerateJsonSchemaCommand {
/// Output directory where the schema bundle will be written
#[arg(short = 'o', long = "out", value_name = "DIR")]
out_dir: PathBuf,
/// Include experimental methods and fields in the generated output
#[arg(long = "experimental", default_value_t = false)]
experimental: bool,
}
#[derive(Debug, Parser)]
@ -539,13 +547,21 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
.await?;
}
Some(AppServerSubcommand::GenerateTs(gen_cli)) => {
codex_app_server_protocol::generate_ts(
let options = codex_app_server_protocol::GenerateTsOptions {
experimental_api: gen_cli.experimental,
..Default::default()
};
codex_app_server_protocol::generate_ts_with_options(
&gen_cli.out_dir,
gen_cli.prettier.as_deref(),
options,
)?;
}
Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => {
codex_app_server_protocol::generate_json(&gen_cli.out_dir)?;
codex_app_server_protocol::generate_json_with_experimental(
&gen_cli.out_dir,
gen_cli.experimental,
)?;
}
},
Some(Subcommand::Resume(ResumeCommand {

View file

@ -0,0 +1,7 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "codex-experimental-api-macros",
crate_name = "codex_experimental_api_macros",
proc_macro = True,
)

View file

@ -0,0 +1,16 @@
[package]
name = "codex-experimental-api-macros"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }
[lints]
workspace = true

View file

@ -0,0 +1,293 @@
use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::quote;
use syn::Attribute;
use syn::Data;
use syn::DataEnum;
use syn::DataStruct;
use syn::DeriveInput;
use syn::Field;
use syn::Fields;
use syn::Ident;
use syn::LitStr;
use syn::Type;
use syn::parse_macro_input;
#[proc_macro_derive(ExperimentalApi, attributes(experimental))]
pub fn derive_experimental_api(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match &input.data {
Data::Struct(data) => derive_for_struct(&input, data),
Data::Enum(data) => derive_for_enum(&input, data),
Data::Union(_) => {
syn::Error::new_spanned(&input.ident, "ExperimentalApi does not support unions")
.to_compile_error()
.into()
}
}
}
fn derive_for_struct(input: &DeriveInput, data: &DataStruct) -> TokenStream {
let name = &input.ident;
let type_name_lit = LitStr::new(&name.to_string(), Span::call_site());
let (checks, experimental_fields, registrations) = match &data.fields {
Fields::Named(named) => {
let mut checks = Vec::new();
let mut experimental_fields = Vec::new();
let mut registrations = Vec::new();
for field in &named.named {
let reason = experimental_reason(&field.attrs);
if let Some(reason) = reason {
let expr = experimental_presence_expr(field, false);
checks.push(quote! {
if #expr {
return Some(#reason);
}
});
if let Some(field_name) = field_serialized_name(field) {
let field_name_lit = LitStr::new(&field_name, Span::call_site());
experimental_fields.push(quote! {
crate::experimental_api::ExperimentalField {
type_name: #type_name_lit,
field_name: #field_name_lit,
reason: #reason,
}
});
registrations.push(quote! {
::inventory::submit! {
crate::experimental_api::ExperimentalField {
type_name: #type_name_lit,
field_name: #field_name_lit,
reason: #reason,
}
}
});
}
}
}
(checks, experimental_fields, registrations)
}
Fields::Unnamed(unnamed) => {
let mut checks = Vec::new();
let mut experimental_fields = Vec::new();
let mut registrations = Vec::new();
for (index, field) in unnamed.unnamed.iter().enumerate() {
let reason = experimental_reason(&field.attrs);
if let Some(reason) = reason {
let expr = index_presence_expr(index, &field.ty);
checks.push(quote! {
if #expr {
return Some(#reason);
}
});
let field_name_lit = LitStr::new(&index.to_string(), Span::call_site());
experimental_fields.push(quote! {
crate::experimental_api::ExperimentalField {
type_name: #type_name_lit,
field_name: #field_name_lit,
reason: #reason,
}
});
registrations.push(quote! {
::inventory::submit! {
crate::experimental_api::ExperimentalField {
type_name: #type_name_lit,
field_name: #field_name_lit,
reason: #reason,
}
}
});
}
}
(checks, experimental_fields, registrations)
}
Fields::Unit => (Vec::new(), Vec::new(), Vec::new()),
};
let checks = if checks.is_empty() {
quote! { None }
} else {
quote! {
#(#checks)*
None
}
};
let experimental_fields = if experimental_fields.is_empty() {
quote! { &[] }
} else {
quote! { &[ #(#experimental_fields,)* ] }
};
let expanded = quote! {
#(#registrations)*
impl #name {
pub(crate) const EXPERIMENTAL_FIELDS: &'static [crate::experimental_api::ExperimentalField] =
#experimental_fields;
}
impl crate::experimental_api::ExperimentalApi for #name {
fn experimental_reason(&self) -> Option<&'static str> {
#checks
}
}
};
expanded.into()
}
fn derive_for_enum(input: &DeriveInput, data: &DataEnum) -> TokenStream {
let name = &input.ident;
let mut match_arms = Vec::new();
for variant in &data.variants {
let variant_name = &variant.ident;
let pattern = match &variant.fields {
Fields::Named(_) => quote!(Self::#variant_name { .. }),
Fields::Unnamed(_) => quote!(Self::#variant_name ( .. )),
Fields::Unit => quote!(Self::#variant_name),
};
let reason = experimental_reason(&variant.attrs);
if let Some(reason) = reason {
match_arms.push(quote! {
#pattern => Some(#reason),
});
} else {
match_arms.push(quote! {
#pattern => None,
});
}
}
let expanded = quote! {
impl crate::experimental_api::ExperimentalApi for #name {
fn experimental_reason(&self) -> Option<&'static str> {
match self {
#(#match_arms)*
}
}
}
};
expanded.into()
}
fn experimental_reason(attrs: &[Attribute]) -> Option<LitStr> {
let attr = attrs
.iter()
.find(|attr| attr.path().is_ident("experimental"))?;
attr.parse_args::<LitStr>().ok()
}
fn field_serialized_name(field: &Field) -> Option<String> {
let ident = field.ident.as_ref()?;
let name = ident.to_string();
Some(snake_to_camel(&name))
}
fn snake_to_camel(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut upper = false;
for ch in s.chars() {
if ch == '_' {
upper = true;
continue;
}
if upper {
out.push(ch.to_ascii_uppercase());
upper = false;
} else {
out.push(ch);
}
}
out
}
fn experimental_presence_expr(
field: &Field,
tuple_struct: bool,
) -> Option<proc_macro2::TokenStream> {
if tuple_struct {
return None;
}
let ident = field.ident.as_ref()?;
Some(presence_expr_for_access(quote!(self.#ident), &field.ty))
}
fn index_presence_expr(index: usize, ty: &Type) -> proc_macro2::TokenStream {
let index = syn::Index::from(index);
presence_expr_for_access(quote!(self.#index), ty)
}
fn presence_expr_for_access(
access: proc_macro2::TokenStream,
ty: &Type,
) -> proc_macro2::TokenStream {
if let Some(inner) = option_inner(ty) {
let inner_expr = presence_expr_for_ref(quote!(value), inner);
return quote! {
#access.as_ref().is_some_and(|value| #inner_expr)
};
}
if is_vec_like(ty) || is_map_like(ty) {
return quote! { !#access.is_empty() };
}
if is_bool(ty) {
return quote! { #access };
}
quote! { true }
}
fn presence_expr_for_ref(access: proc_macro2::TokenStream, ty: &Type) -> proc_macro2::TokenStream {
if let Some(inner) = option_inner(ty) {
let inner_expr = presence_expr_for_ref(quote!(value), inner);
return quote! {
#access.as_ref().is_some_and(|value| #inner_expr)
};
}
if is_vec_like(ty) || is_map_like(ty) {
return quote! { !#access.is_empty() };
}
if is_bool(ty) {
return quote! { *#access };
}
quote! { true }
}
fn option_inner(ty: &Type) -> Option<&Type> {
let Type::Path(type_path) = ty else {
return None;
};
let segment = type_path.path.segments.last()?;
if segment.ident != "Option" {
return None;
}
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
return None;
};
args.args.iter().find_map(|arg| match arg {
syn::GenericArgument::Type(inner) => Some(inner),
_ => None,
})
}
fn is_vec_like(ty: &Type) -> bool {
type_last_ident(ty).is_some_and(|ident| ident == "Vec")
}
fn is_map_like(ty: &Type) -> bool {
type_last_ident(ty).is_some_and(|ident| ident == "HashMap" || ident == "BTreeMap")
}
fn is_bool(ty: &Type) -> bool {
type_last_ident(ty).is_some_and(|ident| ident == "bool")
}
fn type_last_ident(ty: &Type) -> Option<Ident> {
let Type::Path(type_path) = ty else {
return None;
};
type_path.path.segments.last().map(|seg| seg.ident.clone())
}

View file

@ -20,6 +20,7 @@ use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
@ -99,6 +100,9 @@ impl AppServerClient {
title: Some("Debug Client".to_string()),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
}),
},
};

View file

@ -35,6 +35,7 @@ pub enum CargoBinError {
/// In `cargo test`, `CARGO_BIN_EXE_*` env vars are absolute.
/// In `bazel test`, `CARGO_BIN_EXE_*` env vars are rlocationpaths, intended to be consumed by `rlocation`.
/// This helper allows callers to transparently support both.
#[allow(deprecated)]
pub fn cargo_bin(name: &str) -> Result<PathBuf, CargoBinError> {
let env_keys = cargo_bin_env_keys(name);
for key in &env_keys {

View file

@ -1,7 +1,7 @@
load("@crates//:data.bzl", "DEP_DATA")
load("@crates//:defs.bzl", "all_crate_deps")
load("@rules_platform//platform_data:defs.bzl", "platform_data")
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test")
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_proc_macro", "rust_test")
load("@rules_rust//cargo/private:cargo_build_script_wrapper.bzl", "cargo_build_script")
PLATFORMS = [
@ -34,6 +34,7 @@ def codex_rust_crate(
crate_features = [],
crate_srcs = None,
crate_edition = None,
proc_macro = False,
build_script_data = [],
compile_data = [],
lib_data_extra = [],
@ -63,6 +64,7 @@ def codex_rust_crate(
crate_srcs: Optional explicit srcs; defaults to `src/**/*.rs`.
crate_edition: Rust edition override, if not default.
You probably don't want this, it's only here for a single caller.
proc_macro: Whether this crate builds a proc-macro library.
build_script_data: Data files exposed to the build script at runtime.
compile_data: Non-Rust compile-time data for the library target.
lib_data_extra: Extra runtime data for the library target.
@ -109,7 +111,8 @@ def codex_rust_crate(
deps = deps + [name + "-build-script"]
if lib_srcs:
rust_library(
lib_rule = rust_proc_macro if proc_macro else rust_library
lib_rule(
name = name,
crate_name = crate_name,
crate_features = crate_features,