From 729b0165154f36de3086b111ed127ede751bdb94 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 5 Feb 2026 15:31:04 -0800 Subject: [PATCH] Add stage field for experimental flags. (#10793) - [x] Add stage field for experimental flags. --- .../codex_app_server_protocol.schemas.json | 76 ++++++++++++++++--- .../v2/ExperimentalFeatureListResponse.json | 76 ++++++++++++++++--- .../typescript/v2/ExperimentalFeature.ts | 16 +++- .../typescript/v2/ExperimentalFeatureStage.ts | 5 ++ .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 29 ++++++- codex-rs/app-server/README.md | 5 +- .../app-server/src/codex_message_processor.rs | 48 ++++++++---- .../suite/v2/experimental_feature_list.rs | 42 ++++++---- 9 files changed, 236 insertions(+), 62 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureStage.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 559da3ffc..b11f3ecf9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11231,37 +11231,52 @@ "ExperimentalFeature": { "properties": { "announcement": { - "description": "Announcement copy shown to users when the feature is introduced.", - "type": "string" + "description": "Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] }, "defaultEnabled": { "description": "Whether this feature is enabled by default.", "type": "boolean" }, "description": { - "description": "Short summary describing what the feature does.", - "type": "string" + "description": "Short summary describing what the feature does. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] }, "displayName": { - "description": "User-facing display name shown in the experimental features UI.", - "type": "string" + "description": "User-facing display name shown in the experimental features UI. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] }, "enabled": { "description": "Whether this feature is currently enabled in the loaded config.", "type": "boolean" }, - "flagName": { + "name": { "description": "Stable key used in config.toml and CLI flag toggles.", "type": "string" + }, + "stage": { + "allOf": [ + { + "$ref": "#/definitions/v2/ExperimentalFeatureStage" + } + ], + "description": "Lifecycle stage of this feature flag." } }, "required": [ - "announcement", "defaultEnabled", - "description", - "displayName", "enabled", - "flagName" + "name", + "stage" ], "type": "object" }, @@ -11311,6 +11326,45 @@ "title": "ExperimentalFeatureListResponse", "type": "object" }, + "ExperimentalFeatureStage": { + "oneOf": [ + { + "description": "Feature is available for user testing and feedback.", + "enum": [ + "beta" + ], + "type": "string" + }, + { + "description": "Feature is still being built and not ready for broad use.", + "enum": [ + "underDevelopment" + ], + "type": "string" + }, + { + "description": "Feature is production-ready.", + "enum": [ + "stable" + ], + "type": "string" + }, + { + "description": "Feature is deprecated and should be avoided.", + "enum": [ + "deprecated" + ], + "type": "string" + }, + { + "description": "Feature flag is retained only for backwards compatibility.", + "enum": [ + "removed" + ], + "type": "string" + } + ] + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json index b9e36760b..25398fc0e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json @@ -4,39 +4,93 @@ "ExperimentalFeature": { "properties": { "announcement": { - "description": "Announcement copy shown to users when the feature is introduced.", - "type": "string" + "description": "Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] }, "defaultEnabled": { "description": "Whether this feature is enabled by default.", "type": "boolean" }, "description": { - "description": "Short summary describing what the feature does.", - "type": "string" + "description": "Short summary describing what the feature does. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] }, "displayName": { - "description": "User-facing display name shown in the experimental features UI.", - "type": "string" + "description": "User-facing display name shown in the experimental features UI. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] }, "enabled": { "description": "Whether this feature is currently enabled in the loaded config.", "type": "boolean" }, - "flagName": { + "name": { "description": "Stable key used in config.toml and CLI flag toggles.", "type": "string" + }, + "stage": { + "allOf": [ + { + "$ref": "#/definitions/ExperimentalFeatureStage" + } + ], + "description": "Lifecycle stage of this feature flag." } }, "required": [ - "announcement", "defaultEnabled", - "description", - "displayName", "enabled", - "flagName" + "name", + "stage" ], "type": "object" + }, + "ExperimentalFeatureStage": { + "oneOf": [ + { + "description": "Feature is available for user testing and feedback.", + "enum": [ + "beta" + ], + "type": "string" + }, + { + "description": "Feature is still being built and not ready for broad use.", + "enum": [ + "underDevelopment" + ], + "type": "string" + }, + { + "description": "Feature is production-ready.", + "enum": [ + "stable" + ], + "type": "string" + }, + { + "description": "Feature is deprecated and should be avoided.", + "enum": [ + "deprecated" + ], + "type": "string" + }, + { + "description": "Feature flag is retained only for backwards compatibility.", + "enum": [ + "removed" + ], + "type": "string" + } + ] } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeature.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeature.ts index 627514878..e17ef8313 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeature.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeature.ts @@ -1,24 +1,32 @@ // 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 { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; export type ExperimentalFeature = { /** * Stable key used in config.toml and CLI flag toggles. */ -flagName: string, +name: string, +/** + * Lifecycle stage of this feature flag. + */ +stage: ExperimentalFeatureStage, /** * User-facing display name shown in the experimental features UI. + * Null when this feature is not in beta. */ -displayName: string, +displayName: string | null, /** * Short summary describing what the feature does. + * Null when this feature is not in beta. */ -description: string, +description: string | null, /** * Announcement copy shown to users when the feature is introduced. + * Null when this feature is not in beta. */ -announcement: string, +announcement: string | null, /** * Whether this feature is currently enabled in the loaded config. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureStage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureStage.ts new file mode 100644 index 000000000..dbd206e05 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureStage.ts @@ -0,0 +1,5 @@ +// 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. + +export type ExperimentalFeatureStage = "beta" | "underDevelopment" | "stable" | "deprecated" | "removed"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index dfbdc58ac..faa88dfe3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -55,6 +55,7 @@ export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExperimentalFeature } from "./ExperimentalFeature"; export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams"; export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse"; +export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; export type { FeedbackUploadParams } from "./FeedbackUploadParams"; export type { FeedbackUploadResponse } from "./FeedbackUploadResponse"; export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 73d664016..4d4c4b793 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1055,18 +1055,39 @@ pub struct ExperimentalFeatureListParams { pub limit: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ExperimentalFeatureStage { + /// Feature is available for user testing and feedback. + Beta, + /// Feature is still being built and not ready for broad use. + UnderDevelopment, + /// Feature is production-ready. + Stable, + /// Feature is deprecated and should be avoided. + Deprecated, + /// Feature flag is retained only for backwards compatibility. + Removed, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ExperimentalFeature { /// Stable key used in config.toml and CLI flag toggles. - pub flag_name: String, + pub name: String, + /// Lifecycle stage of this feature flag. + pub stage: ExperimentalFeatureStage, /// User-facing display name shown in the experimental features UI. - pub display_name: String, + /// Null when this feature is not in beta. + pub display_name: Option, /// Short summary describing what the feature does. - pub description: String, + /// Null when this feature is not in beta. + pub description: Option, /// Announcement copy shown to users when the feature is introduced. - pub announcement: String, + /// Null when this feature is not in beta. + pub announcement: Option, /// Whether this feature is currently enabled in the loaded config. pub enabled: bool, /// Whether this feature is enabled by default. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3236cb0ee..07594e255 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -99,7 +99,7 @@ Example (from OpenAI's official VSCode extension): - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `model/list` — list available models (with reasoning effort options and optional `upgrade` model ids). -- `experimentalFeature/list` — list experimental feature flags with metadata (flag name, display name, description, announcement, enabled/default-enabled) and cursor pagination. +- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `skills/remote/read` — list public remote skills (**under development; do not call from production clients yet**). @@ -896,6 +896,7 @@ Examples of descriptor strings: - `thread/start.mockExperimentalField` (field-level gate) ### For maintainers: Adding experimental fields and methods + 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. @@ -916,7 +917,7 @@ At runtime, clients must send `initialize` with `capabilities.experimentalApi = # Include experimental API fields/methods in fixtures. just write-app-server-schema --experimental ``` - + 5. Verify the protocol crate: ```bash diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9ffd38fa8..a522499bc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ExecOneOffCommandResponse; use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; +use codex_app_server_protocol::ExperimentalFeatureStage as ApiExperimentalFeatureStage; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::ForkConversationParams; @@ -3262,23 +3263,40 @@ impl CodexMessageProcessor { let data = FEATURES .iter() - .filter_map(|spec| { - let Stage::Experimental { - name, - menu_description, - announcement, - } = spec.stage - else { - return None; + .map(|spec| { + let (stage, display_name, description, announcement) = match spec.stage { + Stage::Experimental { + name, + menu_description, + announcement, + } => ( + ApiExperimentalFeatureStage::Beta, + Some(name.to_string()), + Some(menu_description.to_string()), + Some(announcement.to_string()), + ), + Stage::UnderDevelopment => ( + ApiExperimentalFeatureStage::UnderDevelopment, + None, + None, + None, + ), + Stage::Stable => (ApiExperimentalFeatureStage::Stable, None, None, None), + Stage::Deprecated => { + (ApiExperimentalFeatureStage::Deprecated, None, None, None) + } + Stage::Removed => (ApiExperimentalFeatureStage::Removed, None, None, None), }; - Some(ApiExperimentalFeature { - flag_name: spec.key.to_string(), - display_name: name.to_string(), - description: menu_description.to_string(), - announcement: announcement.to_string(), + + ApiExperimentalFeature { + name: spec.key.to_string(), + stage, + display_name, + description, + announcement, enabled: config.features.enabled(spec.id), default_enabled: spec.default_enabled, - }) + } }) .collect::>(); @@ -3317,7 +3335,7 @@ impl CodexMessageProcessor { if start > total { self.send_invalid_request_error( request_id, - format!("cursor {start} exceeds total experimental features {total}"), + format!("cursor {start} exceeds total feature flags {total}"), ) .await; return; diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 2ca236d89..fdcbaca5b 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -6,6 +6,7 @@ use app_test_support::to_response; use codex_app_server_protocol::ExperimentalFeature; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; +use codex_app_server_protocol::ExperimentalFeatureStage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::features::FEATURES; @@ -17,7 +18,7 @@ use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] -async fn experimental_feature_list_returns_experimental_feature_metadata() -> Result<()> { +async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -36,24 +37,35 @@ async fn experimental_feature_list_returns_experimental_feature_metadata() -> Re let actual = to_response::(response)?; let expected_data = FEATURES .iter() - .filter_map(|spec| { - let Stage::Experimental { - name, - menu_description, - announcement, - } = spec.stage - else { - return None; + .map(|spec| { + let (stage, display_name, description, announcement) = match spec.stage { + Stage::Experimental { + name, + menu_description, + announcement, + } => ( + ExperimentalFeatureStage::Beta, + Some(name.to_string()), + Some(menu_description.to_string()), + Some(announcement.to_string()), + ), + Stage::UnderDevelopment => { + (ExperimentalFeatureStage::UnderDevelopment, None, None, None) + } + Stage::Stable => (ExperimentalFeatureStage::Stable, None, None, None), + Stage::Deprecated => (ExperimentalFeatureStage::Deprecated, None, None, None), + Stage::Removed => (ExperimentalFeatureStage::Removed, None, None, None), }; - Some(ExperimentalFeature { - flag_name: spec.key.to_string(), - display_name: name.to_string(), - description: menu_description.to_string(), - announcement: announcement.to_string(), + ExperimentalFeature { + name: spec.key.to_string(), + stage, + display_name, + description, + announcement, enabled: spec.default_enabled, default_enabled: spec.default_enabled, - }) + } }) .collect::>(); let expected = ExperimentalFeatureListResponse {