From b1e8aaba49d38440e2779a4d71a6d1c4e03aee46 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 4 Feb 2026 22:39:55 -0800 Subject: [PATCH 1/2] update --- .../schema/json/ClientRequest.json | 45 ++++++++ .../codex_app_server_protocol.schemas.json | 107 ++++++++++++++++++ .../v2/ExperimentalFeatureListParams.json | 23 ++++ .../v2/ExperimentalFeatureListResponse.json | 62 ++++++++++ .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/ExperimentalFeature.ts | 29 +++++ .../v2/ExperimentalFeatureListParams.ts | 13 +++ .../v2/ExperimentalFeatureListResponse.ts | 11 ++ .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 24 ++++ .../app-server-protocol/src/protocol/v2.rs | 40 +++++++ codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 100 ++++++++++++++++ .../app-server/tests/common/mcp_process.rs | 10 ++ .../suite/v2/experimental_feature_list.rs | 66 +++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + 16 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeature.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListResponse.ts create mode 100644 codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 54041b40f23..bffc9269142 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -422,6 +422,27 @@ ], "type": "object" }, + "ExperimentalFeatureListParams": { + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "FeedbackUploadParams": { "properties": { "classification": { @@ -3562,6 +3583,30 @@ "title": "Model/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/list" + ], + "title": "ExperimentalFeature/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/listRequest", + "type": "object" + }, { "properties": { "id": { 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 9b22ff2207e..559da3ffc46 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 @@ -934,6 +934,30 @@ "title": "Model/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/list" + ], + "title": "ExperimentalFeature/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExperimentalFeatureListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -11204,6 +11228,89 @@ "title": "ErrorNotification", "type": "object" }, + "ExperimentalFeature": { + "properties": { + "announcement": { + "description": "Announcement copy shown to users when the feature is introduced.", + "type": "string" + }, + "defaultEnabled": { + "description": "Whether this feature is enabled by default.", + "type": "boolean" + }, + "description": { + "description": "Short summary describing what the feature does.", + "type": "string" + }, + "displayName": { + "description": "User-facing display name shown in the experimental features UI.", + "type": "string" + }, + "enabled": { + "description": "Whether this feature is currently enabled in the loaded config.", + "type": "boolean" + }, + "flagName": { + "description": "Stable key used in config.toml and CLI flag toggles.", + "type": "string" + } + }, + "required": [ + "announcement", + "defaultEnabled", + "description", + "displayName", + "enabled", + "flagName" + ], + "type": "object" + }, + "ExperimentalFeatureListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ExperimentalFeatureListParams", + "type": "object" + }, + "ExperimentalFeatureListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/ExperimentalFeature" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ExperimentalFeatureListResponse", + "type": "object" + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json new file mode 100644 index 00000000000..ab562edbf2a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ExperimentalFeatureListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json new file mode 100644 index 00000000000..b9e36760bcb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ExperimentalFeature": { + "properties": { + "announcement": { + "description": "Announcement copy shown to users when the feature is introduced.", + "type": "string" + }, + "defaultEnabled": { + "description": "Whether this feature is enabled by default.", + "type": "boolean" + }, + "description": { + "description": "Short summary describing what the feature does.", + "type": "string" + }, + "displayName": { + "description": "User-facing display name shown in the experimental features UI.", + "type": "string" + }, + "enabled": { + "description": "Whether this feature is currently enabled in the loaded config.", + "type": "boolean" + }, + "flagName": { + "description": "Stable key used in config.toml and CLI flag toggles.", + "type": "string" + } + }, + "required": [ + "announcement", + "defaultEnabled", + "description", + "displayName", + "enabled", + "flagName" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/ExperimentalFeature" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ExperimentalFeatureListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 176f86be56b..04d52f03bcf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -27,6 +27,7 @@ import type { CommandExecParams } from "./v2/CommandExecParams"; import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; +import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams"; import type { FeedbackUploadParams } from "./v2/FeedbackUploadParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; @@ -55,4 +56,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/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "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": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "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, }; +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/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "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": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeature.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeature.ts new file mode 100644 index 00000000000..62751487806 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeature.ts @@ -0,0 +1,29 @@ +// 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 ExperimentalFeature = { +/** + * Stable key used in config.toml and CLI flag toggles. + */ +flagName: string, +/** + * User-facing display name shown in the experimental features UI. + */ +displayName: string, +/** + * Short summary describing what the feature does. + */ +description: string, +/** + * Announcement copy shown to users when the feature is introduced. + */ +announcement: string, +/** + * Whether this feature is currently enabled in the loaded config. + */ +enabled: boolean, +/** + * Whether this feature is enabled by default. + */ +defaultEnabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts new file mode 100644 index 00000000000..1d4dc84e0d4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts @@ -0,0 +1,13 @@ +// 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 ExperimentalFeatureListParams = { +/** + * Opaque pagination cursor returned by a previous call. + */ +cursor?: string | null, +/** + * Optional page size; defaults to a reasonable server-side value. + */ +limit?: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListResponse.ts new file mode 100644 index 00000000000..46b39ba0194 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListResponse.ts @@ -0,0 +1,11 @@ +// 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 { ExperimentalFeature } from "./ExperimentalFeature"; + +export type ExperimentalFeatureListResponse = { data: Array, +/** + * Opaque cursor to pass to the next call to continue after the last item. + * If None, there are no more items to return. + */ +nextCursor: string | null, }; 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 ec5d54cba14..dfbdc58acb4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -52,6 +52,9 @@ export type { DynamicToolCallResponse } from "./DynamicToolCallResponse"; export type { DynamicToolSpec } from "./DynamicToolSpec"; export type { ErrorNotification } from "./ErrorNotification"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; +export type { ExperimentalFeature } from "./ExperimentalFeature"; +export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams"; +export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse"; 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/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c84d28ae89d..912f4d58ac5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -265,6 +265,10 @@ client_request_definitions! { params: v2::ModelListParams, response: v2::ModelListResponse, }, + ExperimentalFeatureList => "experimentalFeature/list" { + params: v2::ExperimentalFeatureListParams, + response: v2::ExperimentalFeatureListResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { @@ -1087,6 +1091,26 @@ mod tests { Ok(()) } + #[test] + fn serialize_list_experimental_features() -> Result<()> { + let request = ClientRequest::ExperimentalFeatureList { + request_id: RequestId::Integer(8), + params: v2::ExperimentalFeatureListParams::default(), + }; + assert_eq!( + json!({ + "method": "experimentalFeature/list", + "id": 8, + "params": { + "cursor": null, + "limit": null + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn mock_experimental_method_is_marked_experimental() { let request = ClientRequest::MockExperimentalMethod { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index baafa84677a..73d664016f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1043,6 +1043,46 @@ pub struct CollaborationModeListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[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, + /// User-facing display name shown in the experimental features UI. + pub display_name: String, + /// Short summary describing what the feature does. + pub description: String, + /// Announcement copy shown to users when the feature is introduced. + pub announcement: String, + /// Whether this feature is currently enabled in the loaded config. + pub enabled: bool, + /// Whether this feature is enabled by default. + pub default_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 0fbb6649a28..7c31e6689ff 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -92,6 +92,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. - `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**). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ef7e175b103..bd8533077eb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -32,6 +32,9 @@ use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; 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::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::ForkConversationParams; @@ -165,7 +168,9 @@ use codex_core::default_client::get_codex_user_agent; use codex_core::error::CodexErr; use codex_core::exec::ExecParams; use codex_core::exec_env::create_env; +use codex_core::features::FEATURES; use codex_core::features::Feature; +use codex_core::features::Stage; use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; use codex_core::git_info::git_diff_to_remote; @@ -517,6 +522,9 @@ impl CodexMessageProcessor { Self::list_models(outgoing, thread_manager, config, request_id, params).await; }); } + ClientRequest::ExperimentalFeatureList { request_id, params } => { + self.experimental_feature_list(request_id, params).await; + } ClientRequest::CollaborationModeList { request_id, params } => { let outgoing = self.outgoing.clone(); let thread_manager = self.thread_manager.clone(); @@ -3050,6 +3058,98 @@ impl CodexMessageProcessor { outgoing.send_response(request_id, response).await; } + async fn experimental_feature_list( + &self, + request_id: RequestId, + params: ExperimentalFeatureListParams, + ) { + let ExperimentalFeatureListParams { cursor, limit } = params; + let config = match self.load_latest_config().await { + Ok(config) => config, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let data = FEATURES + .iter() + .filter_map(|spec| { + let Stage::Experimental { + name, + menu_description, + announcement, + } = spec.stage + else { + return None; + }; + Some(ApiExperimentalFeature { + flag_name: spec.key.to_string(), + display_name: name.to_string(), + description: menu_description.to_string(), + announcement: announcement.to_string(), + enabled: config.features.enabled(spec.id), + default_enabled: spec.default_enabled, + }) + }) + .collect::>(); + + let total = data.len(); + if total == 0 { + self.outgoing + .send_response( + request_id, + ExperimentalFeatureListResponse { + data: Vec::new(), + next_cursor: None, + }, + ) + .await; + return; + } + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => { + self.send_invalid_request_error( + request_id, + format!("invalid cursor: {cursor}"), + ) + .await; + return; + } + }, + None => 0, + }; + + if start > total { + self.send_invalid_request_error( + request_id, + format!("cursor {start} exceeds total experimental features {total}"), + ) + .await; + return; + } + + let end = start.saturating_add(effective_limit).min(total); + let data = data[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + self.outgoing + .send_response( + request_id, + ExperimentalFeatureListResponse { data, next_cursor }, + ) + .await; + } + async fn mock_experimental_method( &self, request_id: RequestId, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index ba6dc058e6c..d992db6d45c 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::ForkConversationParams; use codex_app_server_protocol::GetAccountParams; @@ -473,6 +474,15 @@ impl McpProcess { self.send_request("model/list", params).await } + /// Send an `experimentalFeature/list` JSON-RPC request. + pub async fn send_experimental_feature_list_request( + &mut self, + params: ExperimentalFeatureListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("experimentalFeature/list", params).await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); 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 new file mode 100644 index 00000000000..2ca236d89e2 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -0,0 +1,66 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +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::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_core::features::FEATURES; +use codex_core::features::Stage; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn experimental_feature_list_returns_experimental_feature_metadata() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + 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; + }; + + Some(ExperimentalFeature { + flag_name: spec.key.to_string(), + display_name: name.to_string(), + description: menu_description.to_string(), + announcement: announcement.to_string(), + enabled: spec.default_enabled, + default_enabled: spec.default_enabled, + }) + }) + .collect::>(); + let expected = ExperimentalFeatureListResponse { + data: expected_data, + next_cursor: None, + }; + + assert_eq!(actual, expected); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 3a84e48b9e9..2130c6e8968 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -6,6 +6,7 @@ mod compaction; mod config_rpc; mod dynamic_tools; mod experimental_api; +mod experimental_feature_list; mod initialize; mod model_list; mod output_schema; From e33517b22bb525001d915dd8fd7dca568677a48f Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 5 Feb 2026 11:21:35 -0800 Subject: [PATCH 2/2] update --- codex-rs/app-server/src/codex_message_processor.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index bd8533077eb..a075e62be64 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3108,6 +3108,7 @@ impl CodexMessageProcessor { return; } + // Clamp to 1 so limit=0 cannot return a non-advancing page. let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; let effective_limit = effective_limit.min(total); let start = match cursor {