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:
parent
9513f18bfe
commit
3cc9122ee2
37 changed files with 2682 additions and 1076 deletions
1748
codex-rs/Cargo.lock
generated
1748
codex-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "EXPERIMENTAL - list collaboration mode presets.",
|
||||
"title": "CollaborationModeListParams",
|
||||
"type": "object"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -79,15 +79,6 @@
|
|||
"null"
|
||||
]
|
||||
},
|
||||
"dynamicTools": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/DynamicToolSpec"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"ephemeral": {
|
||||
"type": [
|
||||
"boolean",
|
||||
|
|
|
|||
|
|
@ -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, };
|
||||
|
|
|
|||
|
|
@ -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, };
|
||||
|
|
@ -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, };
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
70
codex-rs/app-server-protocol/src/experimental_api.rs
Normal file
70
codex-rs/app-server-protocol/src/experimental_api.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ®istered_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, ®istered_fields);
|
||||
filter_experimental_fields_in_definitions(bundle, ®istered_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(¤t)? {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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/")]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
160
codex-rs/app-server/tests/suite/v2/experimental_api.rs
Normal file
160
codex-rs/app-server/tests/suite/v2/experimental_api.rs
Normal 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
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
7
codex-rs/codex-experimental-api-macros/BUILD.bazel
Normal file
7
codex-rs/codex-experimental-api-macros/BUILD.bazel
Normal 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,
|
||||
)
|
||||
16
codex-rs/codex-experimental-api-macros/Cargo.toml
Normal file
16
codex-rs/codex-experimental-api-macros/Cargo.toml
Normal 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
|
||||
293
codex-rs/codex-experimental-api-macros/src/lib.rs
Normal file
293
codex-rs/codex-experimental-api-macros/src/lib.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
7
defs.bzl
7
defs.bzl
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue