mirror of
https://github.com/openai/codex.git
synced 2026-02-02 23:13:37 +00:00
Compare commits
19 Commits
latest-alp
...
dev/cc/new
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6210111383 | ||
|
|
0f15ed4325 | ||
|
|
6ac69ce640 | ||
|
|
c0dbd84533 | ||
|
|
a5b8225480 | ||
|
|
f50c8b2f81 | ||
|
|
059d386f03 | ||
|
|
74327fa59c | ||
|
|
34c0534f6e | ||
|
|
0b460eda32 | ||
|
|
9d976962ec | ||
|
|
3392c5af24 | ||
|
|
d1e71cd202 | ||
|
|
4f1cfaf892 | ||
|
|
e9a774e7ae | ||
|
|
4971e96a98 | ||
|
|
3cc9122ee2 | ||
|
|
9513f18bfe | ||
|
|
1644cbfc6d |
7
.github/workflows/issue-labeler.yml
vendored
7
.github/workflows/issue-labeler.yml
vendored
@@ -38,9 +38,10 @@ jobs:
|
||||
- If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to.
|
||||
1. CLI — the Codex command line interface.
|
||||
2. extension — VS Code (or other IDE) extension-specific issues.
|
||||
3. codex-web — Issues targeting the Codex web UI/Cloud experience.
|
||||
4. github-action — Issues with the Codex GitHub action.
|
||||
5. iOS — Issues with the Codex iOS app.
|
||||
3. app - Issues related to the Codex desktop application.
|
||||
4. codex-web — Issues targeting the Codex web UI/Cloud experience.
|
||||
5. github-action — Issues with the Codex GitHub action.
|
||||
6. iOS — Issues with the Codex iOS app.
|
||||
|
||||
- Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones.
|
||||
1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures).
|
||||
|
||||
1750
codex-rs/Cargo.lock
generated
1750
codex-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -50,11 +50,12 @@ members = [
|
||||
"codex-client",
|
||||
"codex-api",
|
||||
"state",
|
||||
"codex-experimental-api-macros",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.94.0"
|
||||
version = "0.0.0"
|
||||
# Track the edition for all workspace crates in one place. Individual
|
||||
# crates can still override this value, but keeping it here means new
|
||||
# crates created with `cargo new -w ...` automatically inherit the 2024
|
||||
@@ -69,6 +70,7 @@ codex-ansi-escape = { path = "ansi-escape" }
|
||||
codex-api = { path = "codex-api" }
|
||||
codex-app-server = { path = "app-server" }
|
||||
codex-app-server-protocol = { path = "app-server-protocol" }
|
||||
codex-app-server-test-client = { path = "app-server-test-client" }
|
||||
codex-apply-patch = { path = "apply-patch" }
|
||||
codex-arg0 = { path = "arg0" }
|
||||
codex-async-utils = { path = "async-utils" }
|
||||
@@ -81,6 +83,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 +158,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";
|
||||
|
||||
@@ -13,6 +13,10 @@ struct Args {
|
||||
/// Optional path to the Prettier executable to format generated TypeScript files.
|
||||
#[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
|
||||
prettier: Option<PathBuf>,
|
||||
|
||||
/// Include experimental API methods and fields in generated fixtures.
|
||||
#[arg(long = "experimental")]
|
||||
experimental: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -22,11 +26,17 @@ fn main() -> Result<()> {
|
||||
.schema_root
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schema"));
|
||||
|
||||
codex_app_server_protocol::write_schema_fixtures(&schema_root, args.prettier.as_deref())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to regenerate schema fixtures under {}",
|
||||
schema_root.display()
|
||||
)
|
||||
})
|
||||
codex_app_server_protocol::write_schema_fixtures_with_options(
|
||||
&schema_root,
|
||||
args.prettier.as_deref(),
|
||||
codex_app_server_protocol::SchemaFixtureOptions {
|
||||
experimental_api: args.experimental,
|
||||
},
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to regenerate schema fixtures under {}",
|
||||
schema_root.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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,15 +1,22 @@
|
||||
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::*;
|
||||
pub use protocol::thread_history::*;
|
||||
pub use protocol::v1::*;
|
||||
pub use protocol::v2::*;
|
||||
pub use schema_fixtures::SchemaFixtureOptions;
|
||||
pub use schema_fixtures::read_schema_fixture_tree;
|
||||
pub use schema_fixtures::write_schema_fixtures;
|
||||
pub use schema_fixtures::write_schema_fixtures_with_options;
|
||||
|
||||
@@ -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/")]
|
||||
|
||||
@@ -6,6 +6,11 @@ use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SchemaFixtureOptions {
|
||||
pub experimental_api: bool,
|
||||
}
|
||||
|
||||
pub fn read_schema_fixture_tree(schema_root: &Path) -> Result<BTreeMap<PathBuf, Vec<u8>>> {
|
||||
let typescript_root = schema_root.join("typescript");
|
||||
let json_root = schema_root.join("json");
|
||||
@@ -26,14 +31,30 @@ pub fn read_schema_fixture_tree(schema_root: &Path) -> Result<BTreeMap<PathBuf,
|
||||
/// This is intended to be used by tooling (e.g., `just write-app-server-schema`).
|
||||
/// It deletes any previously generated files so stale artifacts are removed.
|
||||
pub fn write_schema_fixtures(schema_root: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
write_schema_fixtures_with_options(schema_root, prettier, SchemaFixtureOptions::default())
|
||||
}
|
||||
|
||||
/// Regenerates schema fixtures with configurable options.
|
||||
pub fn write_schema_fixtures_with_options(
|
||||
schema_root: &Path,
|
||||
prettier: Option<&Path>,
|
||||
options: SchemaFixtureOptions,
|
||||
) -> Result<()> {
|
||||
let typescript_out_dir = schema_root.join("typescript");
|
||||
let json_out_dir = schema_root.join("json");
|
||||
|
||||
ensure_empty_dir(&typescript_out_dir)?;
|
||||
ensure_empty_dir(&json_out_dir)?;
|
||||
|
||||
crate::generate_ts(&typescript_out_dir, prettier)?;
|
||||
crate::generate_json(&json_out_dir)?;
|
||||
crate::generate_ts_with_options(
|
||||
&typescript_out_dir,
|
||||
prettier,
|
||||
crate::GenerateTsOptions {
|
||||
experimental_api: options.experimental_api,
|
||||
..crate::GenerateTsOptions::default()
|
||||
},
|
||||
)?;
|
||||
crate::generate_json_with_experimental(&json_out_dir, options.experimental_api)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
971
codex-rs/app-server-test-client/src/lib.rs
Normal file
971
codex-rs/app-server-test-client/src/lib.rs
Normal file
@@ -0,0 +1,971 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Child;
|
||||
use std::process::ChildStdin;
|
||||
use std::process::ChildStdout;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use clap::ArgAction;
|
||||
use clap::Parser;
|
||||
use clap::Subcommand;
|
||||
use codex_app_server_protocol::AddConversationListenerParams;
|
||||
use codex_app_server_protocol::AddConversationSubscriptionResponse;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
||||
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;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginChatGptCompleteNotification;
|
||||
use codex_app_server_protocol::LoginChatGptResponse;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::NewConversationParams;
|
||||
use codex_app_server_protocol::NewConversationResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SandboxPolicy;
|
||||
use codex_app_server_protocol::SendUserMessageParams;
|
||||
use codex_app_server_protocol::SendUserMessageResponse;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Minimal launcher that initializes the Codex app-server and logs the handshake.
|
||||
#[derive(Parser)]
|
||||
#[command(author = "Codex", version, about = "Bootstrap Codex app-server", long_about = None)]
|
||||
struct Cli {
|
||||
/// Path to the `codex` CLI binary.
|
||||
#[arg(long, env = "CODEX_BIN", default_value = "codex")]
|
||||
codex_bin: PathBuf,
|
||||
|
||||
/// Forwarded to the `codex` CLI as `--config key=value`. Repeatable.
|
||||
///
|
||||
/// Example:
|
||||
/// `--config 'model_providers.mock.base_url="http://localhost:4010/v2"'`
|
||||
#[arg(
|
||||
short = 'c',
|
||||
long = "config",
|
||||
value_name = "key=value",
|
||||
action = ArgAction::Append,
|
||||
global = true
|
||||
)]
|
||||
config_overrides: Vec<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: CliCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum CliCommand {
|
||||
/// Send a user message through the Codex app-server.
|
||||
SendMessage {
|
||||
/// User message to send to Codex.
|
||||
#[arg()]
|
||||
user_message: String,
|
||||
},
|
||||
/// Send a user message through the app-server V2 thread/turn APIs.
|
||||
SendMessageV2 {
|
||||
/// User message to send to Codex.
|
||||
#[arg()]
|
||||
user_message: String,
|
||||
},
|
||||
/// Start a V2 turn that elicits an ExecCommand approval.
|
||||
#[command(name = "trigger-cmd-approval")]
|
||||
TriggerCmdApproval {
|
||||
/// Optional prompt; defaults to a simple python command.
|
||||
#[arg()]
|
||||
user_message: Option<String>,
|
||||
},
|
||||
/// Start a V2 turn that elicits an ApplyPatch approval.
|
||||
#[command(name = "trigger-patch-approval")]
|
||||
TriggerPatchApproval {
|
||||
/// Optional prompt; defaults to creating a file via apply_patch.
|
||||
#[arg()]
|
||||
user_message: Option<String>,
|
||||
},
|
||||
/// Start a V2 turn that should not elicit an ExecCommand approval.
|
||||
#[command(name = "no-trigger-cmd-approval")]
|
||||
NoTriggerCmdApproval,
|
||||
/// Send two sequential V2 turns in the same thread to test follow-up behavior.
|
||||
SendFollowUpV2 {
|
||||
/// Initial user message for the first turn.
|
||||
#[arg()]
|
||||
first_message: String,
|
||||
/// Follow-up user message for the second turn.
|
||||
#[arg()]
|
||||
follow_up_message: String,
|
||||
},
|
||||
/// Trigger the ChatGPT login flow and wait for completion.
|
||||
TestLogin,
|
||||
/// Fetch the current account rate limits from the Codex app-server.
|
||||
GetAccountRateLimits,
|
||||
/// List the available models from the Codex app-server.
|
||||
#[command(name = "model-list")]
|
||||
ModelList,
|
||||
}
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let Cli {
|
||||
codex_bin,
|
||||
config_overrides,
|
||||
command,
|
||||
} = Cli::parse();
|
||||
|
||||
match command {
|
||||
CliCommand::SendMessage { user_message } => {
|
||||
send_message(&codex_bin, &config_overrides, user_message)
|
||||
}
|
||||
CliCommand::SendMessageV2 { user_message } => {
|
||||
send_message_v2(&codex_bin, &config_overrides, user_message)
|
||||
}
|
||||
CliCommand::TriggerCmdApproval { user_message } => {
|
||||
trigger_cmd_approval(&codex_bin, &config_overrides, user_message)
|
||||
}
|
||||
CliCommand::TriggerPatchApproval { user_message } => {
|
||||
trigger_patch_approval(&codex_bin, &config_overrides, user_message)
|
||||
}
|
||||
CliCommand::NoTriggerCmdApproval => no_trigger_cmd_approval(&codex_bin, &config_overrides),
|
||||
CliCommand::SendFollowUpV2 {
|
||||
first_message,
|
||||
follow_up_message,
|
||||
} => send_follow_up_v2(
|
||||
&codex_bin,
|
||||
&config_overrides,
|
||||
first_message,
|
||||
follow_up_message,
|
||||
),
|
||||
CliCommand::TestLogin => test_login(&codex_bin, &config_overrides),
|
||||
CliCommand::GetAccountRateLimits => get_account_rate_limits(&codex_bin, &config_overrides),
|
||||
CliCommand::ModelList => model_list(&codex_bin, &config_overrides),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(codex_bin: &Path, config_overrides: &[String], user_message: String) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let conversation = client.start_thread()?;
|
||||
println!("< newConversation response: {conversation:?}");
|
||||
|
||||
let subscription = client.add_conversation_listener(&conversation.conversation_id)?;
|
||||
println!("< addConversationListener response: {subscription:?}");
|
||||
|
||||
let send_response = client.send_user_message(&conversation.conversation_id, &user_message)?;
|
||||
println!("< sendUserMessage response: {send_response:?}");
|
||||
|
||||
client.stream_conversation(&conversation.conversation_id)?;
|
||||
|
||||
client.remove_thread_listener(subscription.subscription_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_message_v2(
|
||||
codex_bin: &Path,
|
||||
config_overrides: &[String],
|
||||
user_message: String,
|
||||
) -> Result<()> {
|
||||
send_message_v2_with_policies(codex_bin, config_overrides, user_message, None, None)
|
||||
}
|
||||
|
||||
fn trigger_cmd_approval(
|
||||
codex_bin: &Path,
|
||||
config_overrides: &[String],
|
||||
user_message: Option<String>,
|
||||
) -> Result<()> {
|
||||
let default_prompt =
|
||||
"Run `touch /tmp/should-trigger-approval` so I can confirm the file exists.";
|
||||
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
|
||||
send_message_v2_with_policies(
|
||||
codex_bin,
|
||||
config_overrides,
|
||||
message,
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::ReadOnly),
|
||||
)
|
||||
}
|
||||
|
||||
fn trigger_patch_approval(
|
||||
codex_bin: &Path,
|
||||
config_overrides: &[String],
|
||||
user_message: Option<String>,
|
||||
) -> Result<()> {
|
||||
let default_prompt =
|
||||
"Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch.";
|
||||
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
|
||||
send_message_v2_with_policies(
|
||||
codex_bin,
|
||||
config_overrides,
|
||||
message,
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::ReadOnly),
|
||||
)
|
||||
}
|
||||
|
||||
fn no_trigger_cmd_approval(codex_bin: &Path, config_overrides: &[String]) -> Result<()> {
|
||||
let prompt = "Run `touch should_not_trigger_approval.txt`";
|
||||
send_message_v2_with_policies(codex_bin, config_overrides, prompt.to_string(), None, None)
|
||||
}
|
||||
|
||||
fn send_message_v2_with_policies(
|
||||
codex_bin: &Path,
|
||||
config_overrides: &[String],
|
||||
user_message: String,
|
||||
approval_policy: Option<AskForApproval>,
|
||||
sandbox_policy: Option<SandboxPolicy>,
|
||||
) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let thread_response = client.thread_start(ThreadStartParams::default())?;
|
||||
println!("< thread/start response: {thread_response:?}");
|
||||
let mut turn_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: user_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
turn_params.approval_policy = approval_policy;
|
||||
turn_params.sandbox_policy = sandbox_policy;
|
||||
|
||||
let turn_response = client.turn_start(turn_params)?;
|
||||
println!("< turn/start response: {turn_response:?}");
|
||||
|
||||
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_follow_up_v2(
|
||||
codex_bin: &Path,
|
||||
config_overrides: &[String],
|
||||
first_message: String,
|
||||
follow_up_message: String,
|
||||
) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let thread_response = client.thread_start(ThreadStartParams::default())?;
|
||||
println!("< thread/start response: {thread_response:?}");
|
||||
|
||||
let first_turn_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: first_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let first_turn_response = client.turn_start(first_turn_params)?;
|
||||
println!("< turn/start response (initial): {first_turn_response:?}");
|
||||
client.stream_turn(&thread_response.thread.id, &first_turn_response.turn.id)?;
|
||||
|
||||
let follow_up_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: follow_up_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let follow_up_response = client.turn_start(follow_up_params)?;
|
||||
println!("< turn/start response (follow-up): {follow_up_response:?}");
|
||||
client.stream_turn(&thread_response.thread.id, &follow_up_response.turn.id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_login(codex_bin: &Path, config_overrides: &[String]) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let login_response = client.login_chat_gpt()?;
|
||||
println!("< loginChatGpt response: {login_response:?}");
|
||||
println!(
|
||||
"Open the following URL in your browser to continue:\n{}",
|
||||
login_response.auth_url
|
||||
);
|
||||
|
||||
let completion = client.wait_for_login_completion(&login_response.login_id)?;
|
||||
println!("< loginChatGptComplete notification: {completion:?}");
|
||||
|
||||
if completion.success {
|
||||
println!("Login succeeded.");
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"login failed: {}",
|
||||
completion
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("unknown error from loginChatGptComplete")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_account_rate_limits(codex_bin: &Path, config_overrides: &[String]) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let response = client.get_account_rate_limits()?;
|
||||
println!("< account/rateLimits/read response: {response:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn model_list(codex_bin: &Path, config_overrides: &[String]) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let response = client.model_list(ModelListParams::default())?;
|
||||
println!("< model/list response: {response:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct CodexClient {
|
||||
child: Child,
|
||||
stdin: Option<ChildStdin>,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
pending_notifications: VecDeque<JSONRPCNotification>,
|
||||
}
|
||||
|
||||
impl CodexClient {
|
||||
fn spawn(codex_bin: &Path, config_overrides: &[String]) -> Result<Self> {
|
||||
let codex_bin_display = codex_bin.display();
|
||||
let mut cmd = Command::new(codex_bin);
|
||||
for override_kv in config_overrides {
|
||||
cmd.arg("--config").arg(override_kv);
|
||||
}
|
||||
let mut codex_app_server = cmd
|
||||
.arg("app-server")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to start `{codex_bin_display}` app-server"))?;
|
||||
|
||||
let stdin = codex_app_server
|
||||
.stdin
|
||||
.take()
|
||||
.context("codex app-server stdin unavailable")?;
|
||||
let stdout = codex_app_server
|
||||
.stdout
|
||||
.take()
|
||||
.context("codex app-server stdout unavailable")?;
|
||||
|
||||
Ok(Self {
|
||||
child: codex_app_server,
|
||||
stdin: Some(stdin),
|
||||
stdout: BufReader::new(stdout),
|
||||
pending_notifications: VecDeque::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn initialize(&mut self) -> Result<InitializeResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::Initialize {
|
||||
request_id: request_id.clone(),
|
||||
params: InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "codex-toy-app-server".to_string(),
|
||||
title: Some("Codex Toy App Server".to_string()),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "initialize")
|
||||
}
|
||||
|
||||
fn start_thread(&mut self) -> Result<NewConversationResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::NewConversation {
|
||||
request_id: request_id.clone(),
|
||||
params: NewConversationParams::default(),
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "newConversation")
|
||||
}
|
||||
|
||||
fn add_conversation_listener(
|
||||
&mut self,
|
||||
conversation_id: &ThreadId,
|
||||
) -> Result<AddConversationSubscriptionResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::AddConversationListener {
|
||||
request_id: request_id.clone(),
|
||||
params: AddConversationListenerParams {
|
||||
conversation_id: *conversation_id,
|
||||
experimental_raw_events: false,
|
||||
},
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "addConversationListener")
|
||||
}
|
||||
|
||||
fn remove_thread_listener(&mut self, subscription_id: Uuid) -> Result<()> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::RemoveConversationListener {
|
||||
request_id: request_id.clone(),
|
||||
params: codex_app_server_protocol::RemoveConversationListenerParams { subscription_id },
|
||||
};
|
||||
|
||||
self.send_request::<codex_app_server_protocol::RemoveConversationSubscriptionResponse>(
|
||||
request,
|
||||
request_id,
|
||||
"removeConversationListener",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_user_message(
|
||||
&mut self,
|
||||
conversation_id: &ThreadId,
|
||||
message: &str,
|
||||
) -> Result<SendUserMessageResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::SendUserMessage {
|
||||
request_id: request_id.clone(),
|
||||
params: SendUserMessageParams {
|
||||
conversation_id: *conversation_id,
|
||||
items: vec![InputItem::Text {
|
||||
text: message.to_string(),
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "sendUserMessage")
|
||||
}
|
||||
|
||||
fn thread_start(&mut self, params: ThreadStartParams) -> Result<ThreadStartResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::ThreadStart {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "thread/start")
|
||||
}
|
||||
|
||||
fn turn_start(&mut self, params: TurnStartParams) -> Result<TurnStartResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::TurnStart {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "turn/start")
|
||||
}
|
||||
|
||||
fn login_chat_gpt(&mut self) -> Result<LoginChatGptResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::LoginChatGpt {
|
||||
request_id: request_id.clone(),
|
||||
params: None,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "loginChatGpt")
|
||||
}
|
||||
|
||||
fn get_account_rate_limits(&mut self) -> Result<GetAccountRateLimitsResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::GetAccountRateLimits {
|
||||
request_id: request_id.clone(),
|
||||
params: None,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "account/rateLimits/read")
|
||||
}
|
||||
|
||||
fn model_list(&mut self, params: ModelListParams) -> Result<ModelListResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::ModelList {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "model/list")
|
||||
}
|
||||
|
||||
fn stream_conversation(&mut self, conversation_id: &ThreadId) -> Result<()> {
|
||||
loop {
|
||||
let notification = self.next_notification()?;
|
||||
|
||||
if !notification.method.starts_with("codex/event/") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(event) = self.extract_event(notification, conversation_id)? {
|
||||
match &event.msg {
|
||||
EventMsg::AgentMessage(event) => {
|
||||
println!("{}", event.message);
|
||||
}
|
||||
EventMsg::AgentMessageDelta(event) => {
|
||||
print!("{}", event.delta);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
EventMsg::TurnComplete(event) => {
|
||||
println!("\n[task complete: {event:?}]");
|
||||
break;
|
||||
}
|
||||
EventMsg::TurnAborted(event) => {
|
||||
println!("\n[turn aborted: {:?}]", event.reason);
|
||||
break;
|
||||
}
|
||||
EventMsg::Error(event) => {
|
||||
println!("[error] {event:?}");
|
||||
}
|
||||
_ => {
|
||||
println!("[UNKNOWN EVENT] {:?}", event.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_login_completion(
|
||||
&mut self,
|
||||
expected_login_id: &Uuid,
|
||||
) -> Result<LoginChatGptCompleteNotification> {
|
||||
loop {
|
||||
let notification = self.next_notification()?;
|
||||
|
||||
if let Ok(server_notification) = ServerNotification::try_from(notification) {
|
||||
match server_notification {
|
||||
ServerNotification::LoginChatGptComplete(completion) => {
|
||||
if &completion.login_id == expected_login_id {
|
||||
return Ok(completion);
|
||||
}
|
||||
|
||||
println!(
|
||||
"[ignoring loginChatGptComplete for unexpected login_id: {}]",
|
||||
completion.login_id
|
||||
);
|
||||
}
|
||||
ServerNotification::AuthStatusChange(status) => {
|
||||
println!("< authStatusChange notification: {status:?}");
|
||||
}
|
||||
ServerNotification::AccountRateLimitsUpdated(snapshot) => {
|
||||
println!("< accountRateLimitsUpdated notification: {snapshot:?}");
|
||||
}
|
||||
ServerNotification::SessionConfigured(_) => {
|
||||
// SessionConfigured notifications are unrelated to login; skip.
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a server notification (likely a conversation event); keep waiting.
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_turn(&mut self, thread_id: &str, turn_id: &str) -> Result<()> {
|
||||
loop {
|
||||
let notification = self.next_notification()?;
|
||||
|
||||
let Ok(server_notification) = ServerNotification::try_from(notification) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match server_notification {
|
||||
ServerNotification::ThreadStarted(payload) => {
|
||||
if payload.thread.id == thread_id {
|
||||
println!("< thread/started notification: {:?}", payload.thread);
|
||||
}
|
||||
}
|
||||
ServerNotification::TurnStarted(payload) => {
|
||||
if payload.turn.id == turn_id {
|
||||
println!("< turn/started notification: {:?}", payload.turn.status);
|
||||
}
|
||||
}
|
||||
ServerNotification::AgentMessageDelta(delta) => {
|
||||
print!("{}", delta.delta);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
ServerNotification::CommandExecutionOutputDelta(delta) => {
|
||||
print!("{}", delta.delta);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
ServerNotification::TerminalInteraction(delta) => {
|
||||
println!("[stdin sent: {}]", delta.stdin);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
ServerNotification::ItemStarted(payload) => {
|
||||
println!("\n< item started: {:?}", payload.item);
|
||||
}
|
||||
ServerNotification::ItemCompleted(payload) => {
|
||||
println!("< item completed: {:?}", payload.item);
|
||||
}
|
||||
ServerNotification::TurnCompleted(payload) => {
|
||||
if payload.turn.id == turn_id {
|
||||
println!("\n< turn/completed notification: {:?}", payload.turn.status);
|
||||
if payload.turn.status == TurnStatus::Failed
|
||||
&& let Some(error) = payload.turn.error
|
||||
{
|
||||
println!("[turn error] {}", error.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
ServerNotification::McpToolCallProgress(payload) => {
|
||||
println!("< MCP tool progress: {}", payload.message);
|
||||
}
|
||||
_ => {
|
||||
println!("[UNKNOWN SERVER NOTIFICATION] {server_notification:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_event(
|
||||
&self,
|
||||
notification: JSONRPCNotification,
|
||||
conversation_id: &ThreadId,
|
||||
) -> Result<Option<Event>> {
|
||||
let params = notification
|
||||
.params
|
||||
.context("event notification missing params")?;
|
||||
|
||||
let mut map = match params {
|
||||
Value::Object(map) => map,
|
||||
other => bail!("unexpected params shape: {other:?}"),
|
||||
};
|
||||
|
||||
let conversation_value = map
|
||||
.remove("conversationId")
|
||||
.context("event missing conversationId")?;
|
||||
let notification_conversation: ThreadId = serde_json::from_value(conversation_value)
|
||||
.context("conversationId was not a valid UUID")?;
|
||||
|
||||
if ¬ification_conversation != conversation_id {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let event_value = Value::Object(map);
|
||||
let event: Event =
|
||||
serde_json::from_value(event_value).context("failed to decode event payload")?;
|
||||
Ok(Some(event))
|
||||
}
|
||||
|
||||
fn send_request<T>(
|
||||
&mut self,
|
||||
request: ClientRequest,
|
||||
request_id: RequestId,
|
||||
method: &str,
|
||||
) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
self.write_request(&request)?;
|
||||
self.wait_for_response(request_id, method)
|
||||
}
|
||||
|
||||
fn write_request(&mut self, request: &ClientRequest) -> Result<()> {
|
||||
let request_json = serde_json::to_string(request)?;
|
||||
let request_pretty = serde_json::to_string_pretty(request)?;
|
||||
print_multiline_with_prefix("> ", &request_pretty);
|
||||
|
||||
if let Some(stdin) = self.stdin.as_mut() {
|
||||
writeln!(stdin, "{request_json}")?;
|
||||
stdin
|
||||
.flush()
|
||||
.context("failed to flush request to codex app-server")?;
|
||||
} else {
|
||||
bail!("codex app-server stdin closed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_response<T>(&mut self, request_id: RequestId, method: &str) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message()?;
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Response(JSONRPCResponse { id, result }) => {
|
||||
if id == request_id {
|
||||
return serde_json::from_value(result)
|
||||
.with_context(|| format!("{method} response missing payload"));
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Error(err) => {
|
||||
if err.id == request_id {
|
||||
bail!("{method} failed: {err:?}");
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Notification(notification) => {
|
||||
self.pending_notifications.push_back(notification);
|
||||
}
|
||||
JSONRPCMessage::Request(request) => {
|
||||
self.handle_server_request(request)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_notification(&mut self) -> Result<JSONRPCNotification> {
|
||||
if let Some(notification) = self.pending_notifications.pop_front() {
|
||||
return Ok(notification);
|
||||
}
|
||||
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message()?;
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(notification) => return Ok(notification),
|
||||
JSONRPCMessage::Response(_) | JSONRPCMessage::Error(_) => {
|
||||
// No outstanding requests, so ignore stray responses/errors for now.
|
||||
continue;
|
||||
}
|
||||
JSONRPCMessage::Request(request) => {
|
||||
self.handle_server_request(request)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_jsonrpc_message(&mut self) -> Result<JSONRPCMessage> {
|
||||
loop {
|
||||
let mut response_line = String::new();
|
||||
let bytes = self
|
||||
.stdout
|
||||
.read_line(&mut response_line)
|
||||
.context("failed to read from codex app-server")?;
|
||||
|
||||
if bytes == 0 {
|
||||
bail!("codex app-server closed stdout");
|
||||
}
|
||||
|
||||
let trimmed = response_line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: Value =
|
||||
serde_json::from_str(trimmed).context("response was not valid JSON-RPC")?;
|
||||
let pretty = serde_json::to_string_pretty(&parsed)?;
|
||||
print_multiline_with_prefix("< ", &pretty);
|
||||
let message: JSONRPCMessage = serde_json::from_value(parsed)
|
||||
.context("response was not a valid JSON-RPC message")?;
|
||||
return Ok(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn request_id(&self) -> RequestId {
|
||||
RequestId::String(Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
fn handle_server_request(&mut self, request: JSONRPCRequest) -> Result<()> {
|
||||
let server_request = ServerRequest::try_from(request)
|
||||
.context("failed to deserialize ServerRequest from JSONRPCRequest")?;
|
||||
|
||||
match server_request {
|
||||
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
|
||||
self.handle_command_execution_request_approval(request_id, params)?;
|
||||
}
|
||||
ServerRequest::FileChangeRequestApproval { request_id, params } => {
|
||||
self.approve_file_change_request(request_id, params)?;
|
||||
}
|
||||
other => {
|
||||
bail!("received unsupported server request: {other:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_command_execution_request_approval(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
params: CommandExecutionRequestApprovalParams,
|
||||
) -> Result<()> {
|
||||
let CommandExecutionRequestApprovalParams {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
reason,
|
||||
command,
|
||||
cwd,
|
||||
command_actions,
|
||||
proposed_execpolicy_amendment,
|
||||
} = params;
|
||||
|
||||
println!(
|
||||
"\n< commandExecution approval requested for thread {thread_id}, turn {turn_id}, item {item_id}"
|
||||
);
|
||||
if let Some(reason) = reason.as_deref() {
|
||||
println!("< reason: {reason}");
|
||||
}
|
||||
if let Some(command) = command.as_deref() {
|
||||
println!("< command: {command}");
|
||||
}
|
||||
if let Some(cwd) = cwd.as_ref() {
|
||||
println!("< cwd: {}", cwd.display());
|
||||
}
|
||||
if let Some(command_actions) = command_actions.as_ref()
|
||||
&& !command_actions.is_empty()
|
||||
{
|
||||
println!("< command actions: {command_actions:?}");
|
||||
}
|
||||
if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() {
|
||||
println!("< proposed execpolicy amendment: {execpolicy_amendment:?}");
|
||||
}
|
||||
|
||||
let response = CommandExecutionRequestApprovalResponse {
|
||||
decision: CommandExecutionApprovalDecision::Accept,
|
||||
};
|
||||
self.send_server_request_response(request_id, &response)?;
|
||||
println!("< approved commandExecution request for item {item_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn approve_file_change_request(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
params: FileChangeRequestApprovalParams,
|
||||
) -> Result<()> {
|
||||
let FileChangeRequestApprovalParams {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
reason,
|
||||
grant_root,
|
||||
} = params;
|
||||
|
||||
println!(
|
||||
"\n< fileChange approval requested for thread {thread_id}, turn {turn_id}, item {item_id}"
|
||||
);
|
||||
if let Some(reason) = reason.as_deref() {
|
||||
println!("< reason: {reason}");
|
||||
}
|
||||
if let Some(grant_root) = grant_root.as_deref() {
|
||||
println!("< grant root: {}", grant_root.display());
|
||||
}
|
||||
|
||||
let response = FileChangeRequestApprovalResponse {
|
||||
decision: FileChangeApprovalDecision::Accept,
|
||||
};
|
||||
self.send_server_request_response(request_id, &response)?;
|
||||
println!("< approved fileChange request for item {item_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_server_request_response<T>(&mut self, request_id: RequestId, response: &T) -> Result<()>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let message = JSONRPCMessage::Response(JSONRPCResponse {
|
||||
id: request_id,
|
||||
result: serde_json::to_value(response)?,
|
||||
});
|
||||
self.write_jsonrpc_message(message)
|
||||
}
|
||||
|
||||
fn write_jsonrpc_message(&mut self, message: JSONRPCMessage) -> Result<()> {
|
||||
let payload = serde_json::to_string(&message)?;
|
||||
let pretty = serde_json::to_string_pretty(&message)?;
|
||||
print_multiline_with_prefix("> ", &pretty);
|
||||
|
||||
if let Some(stdin) = self.stdin.as_mut() {
|
||||
writeln!(stdin, "{payload}")?;
|
||||
stdin
|
||||
.flush()
|
||||
.context("failed to flush response to codex app-server")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
bail!("codex app-server stdin closed")
|
||||
}
|
||||
}
|
||||
|
||||
fn print_multiline_with_prefix(prefix: &str, payload: &str) {
|
||||
for line in payload.lines() {
|
||||
println!("{prefix}{line}");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CodexClient {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stdin.take();
|
||||
|
||||
if let Ok(Some(status)) = self.child.try_wait() {
|
||||
println!("[codex app-server exited: {status}]");
|
||||
return;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
if let Ok(Some(status)) = self.child.try_wait() {
|
||||
println!("[codex app-server exited: {status}]");
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
@@ -1,964 +1,5 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::io::Write;
|
||||
use std::process::Child;
|
||||
use std::process::ChildStdin;
|
||||
use std::process::ChildStdout;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use clap::ArgAction;
|
||||
use clap::Parser;
|
||||
use clap::Subcommand;
|
||||
use codex_app_server_protocol::AddConversationListenerParams;
|
||||
use codex_app_server_protocol::AddConversationSubscriptionResponse;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
||||
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::InitializeParams;
|
||||
use codex_app_server_protocol::InitializeResponse;
|
||||
use codex_app_server_protocol::InputItem;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginChatGptCompleteNotification;
|
||||
use codex_app_server_protocol::LoginChatGptResponse;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::NewConversationParams;
|
||||
use codex_app_server_protocol::NewConversationResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SandboxPolicy;
|
||||
use codex_app_server_protocol::SendUserMessageParams;
|
||||
use codex_app_server_protocol::SendUserMessageResponse;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Minimal launcher that initializes the Codex app-server and logs the handshake.
|
||||
#[derive(Parser)]
|
||||
#[command(author = "Codex", version, about = "Bootstrap Codex app-server", long_about = None)]
|
||||
struct Cli {
|
||||
/// Path to the `codex` CLI binary.
|
||||
#[arg(long, env = "CODEX_BIN", default_value = "codex")]
|
||||
codex_bin: String,
|
||||
|
||||
/// Forwarded to the `codex` CLI as `--config key=value`. Repeatable.
|
||||
///
|
||||
/// Example:
|
||||
/// `--config 'model_providers.mock.base_url="http://localhost:4010/v2"'`
|
||||
#[arg(
|
||||
short = 'c',
|
||||
long = "config",
|
||||
value_name = "key=value",
|
||||
action = ArgAction::Append,
|
||||
global = true
|
||||
)]
|
||||
config_overrides: Vec<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: CliCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum CliCommand {
|
||||
/// Send a user message through the Codex app-server.
|
||||
SendMessage {
|
||||
/// User message to send to Codex.
|
||||
#[arg()]
|
||||
user_message: String,
|
||||
},
|
||||
/// Send a user message through the app-server V2 thread/turn APIs.
|
||||
SendMessageV2 {
|
||||
/// User message to send to Codex.
|
||||
#[arg()]
|
||||
user_message: String,
|
||||
},
|
||||
/// Start a V2 turn that elicits an ExecCommand approval.
|
||||
#[command(name = "trigger-cmd-approval")]
|
||||
TriggerCmdApproval {
|
||||
/// Optional prompt; defaults to a simple python command.
|
||||
#[arg()]
|
||||
user_message: Option<String>,
|
||||
},
|
||||
/// Start a V2 turn that elicits an ApplyPatch approval.
|
||||
#[command(name = "trigger-patch-approval")]
|
||||
TriggerPatchApproval {
|
||||
/// Optional prompt; defaults to creating a file via apply_patch.
|
||||
#[arg()]
|
||||
user_message: Option<String>,
|
||||
},
|
||||
/// Start a V2 turn that should not elicit an ExecCommand approval.
|
||||
#[command(name = "no-trigger-cmd-approval")]
|
||||
NoTriggerCmdApproval,
|
||||
/// Send two sequential V2 turns in the same thread to test follow-up behavior.
|
||||
SendFollowUpV2 {
|
||||
/// Initial user message for the first turn.
|
||||
#[arg()]
|
||||
first_message: String,
|
||||
/// Follow-up user message for the second turn.
|
||||
#[arg()]
|
||||
follow_up_message: String,
|
||||
},
|
||||
/// Trigger the ChatGPT login flow and wait for completion.
|
||||
TestLogin,
|
||||
/// Fetch the current account rate limits from the Codex app-server.
|
||||
GetAccountRateLimits,
|
||||
/// List the available models from the Codex app-server.
|
||||
#[command(name = "model-list")]
|
||||
ModelList,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let Cli {
|
||||
codex_bin,
|
||||
config_overrides,
|
||||
command,
|
||||
} = Cli::parse();
|
||||
|
||||
match command {
|
||||
CliCommand::SendMessage { user_message } => {
|
||||
send_message(&codex_bin, &config_overrides, user_message)
|
||||
}
|
||||
CliCommand::SendMessageV2 { user_message } => {
|
||||
send_message_v2(&codex_bin, &config_overrides, user_message)
|
||||
}
|
||||
CliCommand::TriggerCmdApproval { user_message } => {
|
||||
trigger_cmd_approval(&codex_bin, &config_overrides, user_message)
|
||||
}
|
||||
CliCommand::TriggerPatchApproval { user_message } => {
|
||||
trigger_patch_approval(&codex_bin, &config_overrides, user_message)
|
||||
}
|
||||
CliCommand::NoTriggerCmdApproval => no_trigger_cmd_approval(&codex_bin, &config_overrides),
|
||||
CliCommand::SendFollowUpV2 {
|
||||
first_message,
|
||||
follow_up_message,
|
||||
} => send_follow_up_v2(
|
||||
&codex_bin,
|
||||
&config_overrides,
|
||||
first_message,
|
||||
follow_up_message,
|
||||
),
|
||||
CliCommand::TestLogin => test_login(&codex_bin, &config_overrides),
|
||||
CliCommand::GetAccountRateLimits => get_account_rate_limits(&codex_bin, &config_overrides),
|
||||
CliCommand::ModelList => model_list(&codex_bin, &config_overrides),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(codex_bin: &str, config_overrides: &[String], user_message: String) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let conversation = client.start_thread()?;
|
||||
println!("< newConversation response: {conversation:?}");
|
||||
|
||||
let subscription = client.add_conversation_listener(&conversation.conversation_id)?;
|
||||
println!("< addConversationListener response: {subscription:?}");
|
||||
|
||||
let send_response = client.send_user_message(&conversation.conversation_id, &user_message)?;
|
||||
println!("< sendUserMessage response: {send_response:?}");
|
||||
|
||||
client.stream_conversation(&conversation.conversation_id)?;
|
||||
|
||||
client.remove_thread_listener(subscription.subscription_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_message_v2(
|
||||
codex_bin: &str,
|
||||
config_overrides: &[String],
|
||||
user_message: String,
|
||||
) -> Result<()> {
|
||||
send_message_v2_with_policies(codex_bin, config_overrides, user_message, None, None)
|
||||
}
|
||||
|
||||
fn trigger_cmd_approval(
|
||||
codex_bin: &str,
|
||||
config_overrides: &[String],
|
||||
user_message: Option<String>,
|
||||
) -> Result<()> {
|
||||
let default_prompt =
|
||||
"Run `touch /tmp/should-trigger-approval` so I can confirm the file exists.";
|
||||
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
|
||||
send_message_v2_with_policies(
|
||||
codex_bin,
|
||||
config_overrides,
|
||||
message,
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::ReadOnly),
|
||||
)
|
||||
}
|
||||
|
||||
fn trigger_patch_approval(
|
||||
codex_bin: &str,
|
||||
config_overrides: &[String],
|
||||
user_message: Option<String>,
|
||||
) -> Result<()> {
|
||||
let default_prompt =
|
||||
"Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch.";
|
||||
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
|
||||
send_message_v2_with_policies(
|
||||
codex_bin,
|
||||
config_overrides,
|
||||
message,
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::ReadOnly),
|
||||
)
|
||||
}
|
||||
|
||||
fn no_trigger_cmd_approval(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
|
||||
let prompt = "Run `touch should_not_trigger_approval.txt`";
|
||||
send_message_v2_with_policies(codex_bin, config_overrides, prompt.to_string(), None, None)
|
||||
}
|
||||
|
||||
fn send_message_v2_with_policies(
|
||||
codex_bin: &str,
|
||||
config_overrides: &[String],
|
||||
user_message: String,
|
||||
approval_policy: Option<AskForApproval>,
|
||||
sandbox_policy: Option<SandboxPolicy>,
|
||||
) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let thread_response = client.thread_start(ThreadStartParams::default())?;
|
||||
println!("< thread/start response: {thread_response:?}");
|
||||
let mut turn_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: user_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
turn_params.approval_policy = approval_policy;
|
||||
turn_params.sandbox_policy = sandbox_policy;
|
||||
|
||||
let turn_response = client.turn_start(turn_params)?;
|
||||
println!("< turn/start response: {turn_response:?}");
|
||||
|
||||
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_follow_up_v2(
|
||||
codex_bin: &str,
|
||||
config_overrides: &[String],
|
||||
first_message: String,
|
||||
follow_up_message: String,
|
||||
) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let thread_response = client.thread_start(ThreadStartParams::default())?;
|
||||
println!("< thread/start response: {thread_response:?}");
|
||||
|
||||
let first_turn_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: first_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let first_turn_response = client.turn_start(first_turn_params)?;
|
||||
println!("< turn/start response (initial): {first_turn_response:?}");
|
||||
client.stream_turn(&thread_response.thread.id, &first_turn_response.turn.id)?;
|
||||
|
||||
let follow_up_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: follow_up_message,
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let follow_up_response = client.turn_start(follow_up_params)?;
|
||||
println!("< turn/start response (follow-up): {follow_up_response:?}");
|
||||
client.stream_turn(&thread_response.thread.id, &follow_up_response.turn.id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_login(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let login_response = client.login_chat_gpt()?;
|
||||
println!("< loginChatGpt response: {login_response:?}");
|
||||
println!(
|
||||
"Open the following URL in your browser to continue:\n{}",
|
||||
login_response.auth_url
|
||||
);
|
||||
|
||||
let completion = client.wait_for_login_completion(&login_response.login_id)?;
|
||||
println!("< loginChatGptComplete notification: {completion:?}");
|
||||
|
||||
if completion.success {
|
||||
println!("Login succeeded.");
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"login failed: {}",
|
||||
completion
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("unknown error from loginChatGptComplete")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_account_rate_limits(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let response = client.get_account_rate_limits()?;
|
||||
println!("< account/rateLimits/read response: {response:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn model_list(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
|
||||
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
|
||||
|
||||
let initialize = client.initialize()?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let response = client.model_list(ModelListParams::default())?;
|
||||
println!("< model/list response: {response:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct CodexClient {
|
||||
child: Child,
|
||||
stdin: Option<ChildStdin>,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
pending_notifications: VecDeque<JSONRPCNotification>,
|
||||
}
|
||||
|
||||
impl CodexClient {
|
||||
fn spawn(codex_bin: &str, config_overrides: &[String]) -> Result<Self> {
|
||||
let mut cmd = Command::new(codex_bin);
|
||||
for override_kv in config_overrides {
|
||||
cmd.arg("--config").arg(override_kv);
|
||||
}
|
||||
let mut codex_app_server = cmd
|
||||
.arg("app-server")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to start `{codex_bin}` app-server"))?;
|
||||
|
||||
let stdin = codex_app_server
|
||||
.stdin
|
||||
.take()
|
||||
.context("codex app-server stdin unavailable")?;
|
||||
let stdout = codex_app_server
|
||||
.stdout
|
||||
.take()
|
||||
.context("codex app-server stdout unavailable")?;
|
||||
|
||||
Ok(Self {
|
||||
child: codex_app_server,
|
||||
stdin: Some(stdin),
|
||||
stdout: BufReader::new(stdout),
|
||||
pending_notifications: VecDeque::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn initialize(&mut self) -> Result<InitializeResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::Initialize {
|
||||
request_id: request_id.clone(),
|
||||
params: InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "codex-toy-app-server".to_string(),
|
||||
title: Some("Codex Toy App Server".to_string()),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "initialize")
|
||||
}
|
||||
|
||||
fn start_thread(&mut self) -> Result<NewConversationResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::NewConversation {
|
||||
request_id: request_id.clone(),
|
||||
params: NewConversationParams::default(),
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "newConversation")
|
||||
}
|
||||
|
||||
fn add_conversation_listener(
|
||||
&mut self,
|
||||
conversation_id: &ThreadId,
|
||||
) -> Result<AddConversationSubscriptionResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::AddConversationListener {
|
||||
request_id: request_id.clone(),
|
||||
params: AddConversationListenerParams {
|
||||
conversation_id: *conversation_id,
|
||||
experimental_raw_events: false,
|
||||
},
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "addConversationListener")
|
||||
}
|
||||
|
||||
fn remove_thread_listener(&mut self, subscription_id: Uuid) -> Result<()> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::RemoveConversationListener {
|
||||
request_id: request_id.clone(),
|
||||
params: codex_app_server_protocol::RemoveConversationListenerParams { subscription_id },
|
||||
};
|
||||
|
||||
self.send_request::<codex_app_server_protocol::RemoveConversationSubscriptionResponse>(
|
||||
request,
|
||||
request_id,
|
||||
"removeConversationListener",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_user_message(
|
||||
&mut self,
|
||||
conversation_id: &ThreadId,
|
||||
message: &str,
|
||||
) -> Result<SendUserMessageResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::SendUserMessage {
|
||||
request_id: request_id.clone(),
|
||||
params: SendUserMessageParams {
|
||||
conversation_id: *conversation_id,
|
||||
items: vec![InputItem::Text {
|
||||
text: message.to_string(),
|
||||
// Test client sends plain text without UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "sendUserMessage")
|
||||
}
|
||||
|
||||
fn thread_start(&mut self, params: ThreadStartParams) -> Result<ThreadStartResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::ThreadStart {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "thread/start")
|
||||
}
|
||||
|
||||
fn turn_start(&mut self, params: TurnStartParams) -> Result<TurnStartResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::TurnStart {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "turn/start")
|
||||
}
|
||||
|
||||
fn login_chat_gpt(&mut self) -> Result<LoginChatGptResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::LoginChatGpt {
|
||||
request_id: request_id.clone(),
|
||||
params: None,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "loginChatGpt")
|
||||
}
|
||||
|
||||
fn get_account_rate_limits(&mut self) -> Result<GetAccountRateLimitsResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::GetAccountRateLimits {
|
||||
request_id: request_id.clone(),
|
||||
params: None,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "account/rateLimits/read")
|
||||
}
|
||||
|
||||
fn model_list(&mut self, params: ModelListParams) -> Result<ModelListResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::ModelList {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "model/list")
|
||||
}
|
||||
|
||||
fn stream_conversation(&mut self, conversation_id: &ThreadId) -> Result<()> {
|
||||
loop {
|
||||
let notification = self.next_notification()?;
|
||||
|
||||
if !notification.method.starts_with("codex/event/") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(event) = self.extract_event(notification, conversation_id)? {
|
||||
match &event.msg {
|
||||
EventMsg::AgentMessage(event) => {
|
||||
println!("{}", event.message);
|
||||
}
|
||||
EventMsg::AgentMessageDelta(event) => {
|
||||
print!("{}", event.delta);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
EventMsg::TurnComplete(event) => {
|
||||
println!("\n[task complete: {event:?}]");
|
||||
break;
|
||||
}
|
||||
EventMsg::TurnAborted(event) => {
|
||||
println!("\n[turn aborted: {:?}]", event.reason);
|
||||
break;
|
||||
}
|
||||
EventMsg::Error(event) => {
|
||||
println!("[error] {event:?}");
|
||||
}
|
||||
_ => {
|
||||
println!("[UNKNOWN EVENT] {:?}", event.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_login_completion(
|
||||
&mut self,
|
||||
expected_login_id: &Uuid,
|
||||
) -> Result<LoginChatGptCompleteNotification> {
|
||||
loop {
|
||||
let notification = self.next_notification()?;
|
||||
|
||||
if let Ok(server_notification) = ServerNotification::try_from(notification) {
|
||||
match server_notification {
|
||||
ServerNotification::LoginChatGptComplete(completion) => {
|
||||
if &completion.login_id == expected_login_id {
|
||||
return Ok(completion);
|
||||
}
|
||||
|
||||
println!(
|
||||
"[ignoring loginChatGptComplete for unexpected login_id: {}]",
|
||||
completion.login_id
|
||||
);
|
||||
}
|
||||
ServerNotification::AuthStatusChange(status) => {
|
||||
println!("< authStatusChange notification: {status:?}");
|
||||
}
|
||||
ServerNotification::AccountRateLimitsUpdated(snapshot) => {
|
||||
println!("< accountRateLimitsUpdated notification: {snapshot:?}");
|
||||
}
|
||||
ServerNotification::SessionConfigured(_) => {
|
||||
// SessionConfigured notifications are unrelated to login; skip.
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a server notification (likely a conversation event); keep waiting.
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_turn(&mut self, thread_id: &str, turn_id: &str) -> Result<()> {
|
||||
loop {
|
||||
let notification = self.next_notification()?;
|
||||
|
||||
let Ok(server_notification) = ServerNotification::try_from(notification) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match server_notification {
|
||||
ServerNotification::ThreadStarted(payload) => {
|
||||
if payload.thread.id == thread_id {
|
||||
println!("< thread/started notification: {:?}", payload.thread);
|
||||
}
|
||||
}
|
||||
ServerNotification::TurnStarted(payload) => {
|
||||
if payload.turn.id == turn_id {
|
||||
println!("< turn/started notification: {:?}", payload.turn.status);
|
||||
}
|
||||
}
|
||||
ServerNotification::AgentMessageDelta(delta) => {
|
||||
print!("{}", delta.delta);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
ServerNotification::CommandExecutionOutputDelta(delta) => {
|
||||
print!("{}", delta.delta);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
ServerNotification::TerminalInteraction(delta) => {
|
||||
println!("[stdin sent: {}]", delta.stdin);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
ServerNotification::ItemStarted(payload) => {
|
||||
println!("\n< item started: {:?}", payload.item);
|
||||
}
|
||||
ServerNotification::ItemCompleted(payload) => {
|
||||
println!("< item completed: {:?}", payload.item);
|
||||
}
|
||||
ServerNotification::TurnCompleted(payload) => {
|
||||
if payload.turn.id == turn_id {
|
||||
println!("\n< turn/completed notification: {:?}", payload.turn.status);
|
||||
if payload.turn.status == TurnStatus::Failed
|
||||
&& let Some(error) = payload.turn.error
|
||||
{
|
||||
println!("[turn error] {}", error.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
ServerNotification::McpToolCallProgress(payload) => {
|
||||
println!("< MCP tool progress: {}", payload.message);
|
||||
}
|
||||
_ => {
|
||||
println!("[UNKNOWN SERVER NOTIFICATION] {server_notification:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_event(
|
||||
&self,
|
||||
notification: JSONRPCNotification,
|
||||
conversation_id: &ThreadId,
|
||||
) -> Result<Option<Event>> {
|
||||
let params = notification
|
||||
.params
|
||||
.context("event notification missing params")?;
|
||||
|
||||
let mut map = match params {
|
||||
Value::Object(map) => map,
|
||||
other => bail!("unexpected params shape: {other:?}"),
|
||||
};
|
||||
|
||||
let conversation_value = map
|
||||
.remove("conversationId")
|
||||
.context("event missing conversationId")?;
|
||||
let notification_conversation: ThreadId = serde_json::from_value(conversation_value)
|
||||
.context("conversationId was not a valid UUID")?;
|
||||
|
||||
if ¬ification_conversation != conversation_id {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let event_value = Value::Object(map);
|
||||
let event: Event =
|
||||
serde_json::from_value(event_value).context("failed to decode event payload")?;
|
||||
Ok(Some(event))
|
||||
}
|
||||
|
||||
fn send_request<T>(
|
||||
&mut self,
|
||||
request: ClientRequest,
|
||||
request_id: RequestId,
|
||||
method: &str,
|
||||
) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
self.write_request(&request)?;
|
||||
self.wait_for_response(request_id, method)
|
||||
}
|
||||
|
||||
fn write_request(&mut self, request: &ClientRequest) -> Result<()> {
|
||||
let request_json = serde_json::to_string(request)?;
|
||||
let request_pretty = serde_json::to_string_pretty(request)?;
|
||||
print_multiline_with_prefix("> ", &request_pretty);
|
||||
|
||||
if let Some(stdin) = self.stdin.as_mut() {
|
||||
writeln!(stdin, "{request_json}")?;
|
||||
stdin
|
||||
.flush()
|
||||
.context("failed to flush request to codex app-server")?;
|
||||
} else {
|
||||
bail!("codex app-server stdin closed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_response<T>(&mut self, request_id: RequestId, method: &str) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message()?;
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Response(JSONRPCResponse { id, result }) => {
|
||||
if id == request_id {
|
||||
return serde_json::from_value(result)
|
||||
.with_context(|| format!("{method} response missing payload"));
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Error(err) => {
|
||||
if err.id == request_id {
|
||||
bail!("{method} failed: {err:?}");
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Notification(notification) => {
|
||||
self.pending_notifications.push_back(notification);
|
||||
}
|
||||
JSONRPCMessage::Request(request) => {
|
||||
self.handle_server_request(request)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_notification(&mut self) -> Result<JSONRPCNotification> {
|
||||
if let Some(notification) = self.pending_notifications.pop_front() {
|
||||
return Ok(notification);
|
||||
}
|
||||
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message()?;
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(notification) => return Ok(notification),
|
||||
JSONRPCMessage::Response(_) | JSONRPCMessage::Error(_) => {
|
||||
// No outstanding requests, so ignore stray responses/errors for now.
|
||||
continue;
|
||||
}
|
||||
JSONRPCMessage::Request(request) => {
|
||||
self.handle_server_request(request)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_jsonrpc_message(&mut self) -> Result<JSONRPCMessage> {
|
||||
loop {
|
||||
let mut response_line = String::new();
|
||||
let bytes = self
|
||||
.stdout
|
||||
.read_line(&mut response_line)
|
||||
.context("failed to read from codex app-server")?;
|
||||
|
||||
if bytes == 0 {
|
||||
bail!("codex app-server closed stdout");
|
||||
}
|
||||
|
||||
let trimmed = response_line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: Value =
|
||||
serde_json::from_str(trimmed).context("response was not valid JSON-RPC")?;
|
||||
let pretty = serde_json::to_string_pretty(&parsed)?;
|
||||
print_multiline_with_prefix("< ", &pretty);
|
||||
let message: JSONRPCMessage = serde_json::from_value(parsed)
|
||||
.context("response was not a valid JSON-RPC message")?;
|
||||
return Ok(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn request_id(&self) -> RequestId {
|
||||
RequestId::String(Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
fn handle_server_request(&mut self, request: JSONRPCRequest) -> Result<()> {
|
||||
let server_request = ServerRequest::try_from(request)
|
||||
.context("failed to deserialize ServerRequest from JSONRPCRequest")?;
|
||||
|
||||
match server_request {
|
||||
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
|
||||
self.handle_command_execution_request_approval(request_id, params)?;
|
||||
}
|
||||
ServerRequest::FileChangeRequestApproval { request_id, params } => {
|
||||
self.approve_file_change_request(request_id, params)?;
|
||||
}
|
||||
other => {
|
||||
bail!("received unsupported server request: {other:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_command_execution_request_approval(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
params: CommandExecutionRequestApprovalParams,
|
||||
) -> Result<()> {
|
||||
let CommandExecutionRequestApprovalParams {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
reason,
|
||||
command,
|
||||
cwd,
|
||||
command_actions,
|
||||
proposed_execpolicy_amendment,
|
||||
} = params;
|
||||
|
||||
println!(
|
||||
"\n< commandExecution approval requested for thread {thread_id}, turn {turn_id}, item {item_id}"
|
||||
);
|
||||
if let Some(reason) = reason.as_deref() {
|
||||
println!("< reason: {reason}");
|
||||
}
|
||||
if let Some(command) = command.as_deref() {
|
||||
println!("< command: {command}");
|
||||
}
|
||||
if let Some(cwd) = cwd.as_ref() {
|
||||
println!("< cwd: {}", cwd.display());
|
||||
}
|
||||
if let Some(command_actions) = command_actions.as_ref()
|
||||
&& !command_actions.is_empty()
|
||||
{
|
||||
println!("< command actions: {command_actions:?}");
|
||||
}
|
||||
if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() {
|
||||
println!("< proposed execpolicy amendment: {execpolicy_amendment:?}");
|
||||
}
|
||||
|
||||
let response = CommandExecutionRequestApprovalResponse {
|
||||
decision: CommandExecutionApprovalDecision::Accept,
|
||||
};
|
||||
self.send_server_request_response(request_id, &response)?;
|
||||
println!("< approved commandExecution request for item {item_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn approve_file_change_request(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
params: FileChangeRequestApprovalParams,
|
||||
) -> Result<()> {
|
||||
let FileChangeRequestApprovalParams {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
reason,
|
||||
grant_root,
|
||||
} = params;
|
||||
|
||||
println!(
|
||||
"\n< fileChange approval requested for thread {thread_id}, turn {turn_id}, item {item_id}"
|
||||
);
|
||||
if let Some(reason) = reason.as_deref() {
|
||||
println!("< reason: {reason}");
|
||||
}
|
||||
if let Some(grant_root) = grant_root.as_deref() {
|
||||
println!("< grant root: {}", grant_root.display());
|
||||
}
|
||||
|
||||
let response = FileChangeRequestApprovalResponse {
|
||||
decision: FileChangeApprovalDecision::Accept,
|
||||
};
|
||||
self.send_server_request_response(request_id, &response)?;
|
||||
println!("< approved fileChange request for item {item_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_server_request_response<T>(&mut self, request_id: RequestId, response: &T) -> Result<()>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let message = JSONRPCMessage::Response(JSONRPCResponse {
|
||||
id: request_id,
|
||||
result: serde_json::to_value(response)?,
|
||||
});
|
||||
self.write_jsonrpc_message(message)
|
||||
}
|
||||
|
||||
fn write_jsonrpc_message(&mut self, message: JSONRPCMessage) -> Result<()> {
|
||||
let payload = serde_json::to_string(&message)?;
|
||||
let pretty = serde_json::to_string_pretty(&message)?;
|
||||
print_multiline_with_prefix("> ", &pretty);
|
||||
|
||||
if let Some(stdin) = self.stdin.as_mut() {
|
||||
writeln!(stdin, "{payload}")?;
|
||||
stdin
|
||||
.flush()
|
||||
.context("failed to flush response to codex app-server")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
bail!("codex app-server stdin closed")
|
||||
}
|
||||
}
|
||||
|
||||
fn print_multiline_with_prefix(prefix: &str, payload: &str) {
|
||||
for line in payload.lines() {
|
||||
println!("{prefix}{line}");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CodexClient {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stdin.take();
|
||||
|
||||
if let Ok(Some(status)) = self.child.try_wait() {
|
||||
println!("[codex app-server exited: {status}]");
|
||||
return;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
if let Ok(Some(status)) = self.child.try_wait() {
|
||||
println!("[codex app-server exited: {status}]");
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
codex_app_server_test_client::run()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- [Skills](#skills)
|
||||
- [Apps](#apps)
|
||||
- [Auth endpoints](#auth-endpoints)
|
||||
- [Adding an experimental field](#adding-an-experimental-field)
|
||||
|
||||
## Protocol
|
||||
|
||||
@@ -768,3 +769,31 @@ 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
|
||||
# Include experimental API fields/methods in fixtures.
|
||||
just write-app-server-schema --experimental
|
||||
```
|
||||
|
||||
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;
|
||||
|
||||
@@ -21,6 +21,7 @@ clap = { workspace = true, features = ["derive"] }
|
||||
clap_complete = { workspace = true }
|
||||
codex-app-server = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-app-server-test-client = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-chatgpt = { workspace = true }
|
||||
codex-cloud-tasks = { path = "../cloud-tasks" }
|
||||
|
||||
@@ -102,9 +102,11 @@ enum Subcommand {
|
||||
Completion(CompletionCommand),
|
||||
|
||||
/// Run commands within a Codex-provided sandbox.
|
||||
#[clap(visible_alias = "debug")]
|
||||
Sandbox(SandboxArgs),
|
||||
|
||||
/// Debugging tools.
|
||||
Debug(DebugCommand),
|
||||
|
||||
/// Execpolicy tooling.
|
||||
#[clap(hide = true)]
|
||||
Execpolicy(ExecpolicyCommand),
|
||||
@@ -142,6 +144,25 @@ struct CompletionCommand {
|
||||
shell: Shell,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct DebugCommand {
|
||||
#[command(subcommand)]
|
||||
subcommand: DebugSubcommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
enum DebugSubcommand {
|
||||
/// Tooling: helps debug the app server.
|
||||
AppServer(DebugAppServerCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct DebugAppServerCommand {
|
||||
/// Message to send through codex-app-server-test-client send-message-v2.
|
||||
#[arg(value_name = "USER_MESSAGE", required = true, num_args = 1.., trailing_var_arg = true)]
|
||||
user_message_parts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ResumeCommand {
|
||||
/// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
|
||||
@@ -303,6 +324,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 +335,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)]
|
||||
@@ -409,6 +438,12 @@ fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> {
|
||||
cmd.run()
|
||||
}
|
||||
|
||||
fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> {
|
||||
let user_message = cmd.user_message_parts.join(" ");
|
||||
let codex_bin = std::env::current_exe()?;
|
||||
codex_app_server_test_client::send_message_v2(&codex_bin, &[], user_message)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Parser, Clone)]
|
||||
struct FeatureToggles {
|
||||
/// Enable a feature (repeatable). Equivalent to `-c features.<name>=true`.
|
||||
@@ -539,13 +574,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 {
|
||||
@@ -665,6 +708,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand {
|
||||
DebugSubcommand::AppServer(cmd) => {
|
||||
run_debug_app_server_command(cmd)?;
|
||||
}
|
||||
},
|
||||
Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub {
|
||||
ExecpolicySubcommand::Check(cmd) => run_execpolicycheck(cmd)?,
|
||||
},
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -27,12 +27,100 @@ pub fn command_might_be_dangerous(command: &[String]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_git_global_option_with_value(arg: &str) -> bool {
|
||||
matches!(
|
||||
arg,
|
||||
"-C" | "-c"
|
||||
| "--config-env"
|
||||
| "--exec-path"
|
||||
| "--git-dir"
|
||||
| "--namespace"
|
||||
| "--super-prefix"
|
||||
| "--work-tree"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_git_global_option_with_inline_value(arg: &str) -> bool {
|
||||
matches!(
|
||||
arg,
|
||||
s if s.starts_with("--config-env=")
|
||||
|| s.starts_with("--exec-path=")
|
||||
|| s.starts_with("--git-dir=")
|
||||
|| s.starts_with("--namespace=")
|
||||
|| s.starts_with("--super-prefix=")
|
||||
|| s.starts_with("--work-tree=")
|
||||
) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
|
||||
}
|
||||
|
||||
/// Find the first matching git subcommand, skipping known global options that
|
||||
/// may appear before it (e.g., `-C`, `-c`, `--git-dir`).
|
||||
///
|
||||
/// Shared with `is_safe_command` to avoid git-global-option bypasses.
|
||||
pub(crate) fn find_git_subcommand<'a>(
|
||||
command: &'a [String],
|
||||
subcommands: &[&str],
|
||||
) -> Option<(usize, &'a str)> {
|
||||
let cmd0 = command.first().map(String::as_str)?;
|
||||
if !cmd0.ends_with("git") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut skip_next = false;
|
||||
for (idx, arg) in command.iter().enumerate().skip(1) {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let arg = arg.as_str();
|
||||
|
||||
if is_git_global_option_with_inline_value(arg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_git_global_option_with_value(arg) {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if arg == "--" || arg.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if subcommands.contains(&arg) {
|
||||
return Some((idx, arg));
|
||||
}
|
||||
|
||||
// In git, the first non-option token is the subcommand. If it isn't
|
||||
// one of the subcommands we're looking for, we must stop scanning to
|
||||
// avoid misclassifying later positional args (e.g., branch names).
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
|
||||
let cmd0 = command.first().map(String::as_str);
|
||||
|
||||
match cmd0 {
|
||||
Some(cmd) if cmd.ends_with("git") || cmd.ends_with("/git") => {
|
||||
matches!(command.get(1).map(String::as_str), Some("reset" | "rm"))
|
||||
Some(cmd) if cmd.ends_with("git") => {
|
||||
let Some((subcommand_idx, subcommand)) =
|
||||
find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match subcommand {
|
||||
"reset" | "rm" => true,
|
||||
"branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
|
||||
"push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
|
||||
"clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
|
||||
other => {
|
||||
debug_assert!(false, "unexpected git subcommand from matcher: {other}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some("rm") => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf")),
|
||||
@@ -45,6 +133,48 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn git_branch_is_delete(branch_args: &[String]) -> bool {
|
||||
// Git allows stacking short flags (for example, `-dv` or `-vd`). Treat any
|
||||
// short-flag group containing `d`/`D` as a delete flag.
|
||||
branch_args.iter().map(String::as_str).any(|arg| {
|
||||
matches!(arg, "-d" | "-D" | "--delete")
|
||||
|| arg.starts_with("--delete=")
|
||||
|| short_flag_group_contains(arg, 'd')
|
||||
|| short_flag_group_contains(arg, 'D')
|
||||
})
|
||||
}
|
||||
|
||||
fn short_flag_group_contains(arg: &str, target: char) -> bool {
|
||||
arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
|
||||
}
|
||||
|
||||
fn git_push_is_dangerous(push_args: &[String]) -> bool {
|
||||
push_args.iter().map(String::as_str).any(|arg| {
|
||||
matches!(
|
||||
arg,
|
||||
"--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d"
|
||||
) || arg.starts_with("--force-with-lease=")
|
||||
|| arg.starts_with("--force-if-includes=")
|
||||
|| arg.starts_with("--delete=")
|
||||
|| short_flag_group_contains(arg, 'f')
|
||||
|| short_flag_group_contains(arg, 'd')
|
||||
|| git_push_refspec_is_dangerous(arg)
|
||||
})
|
||||
}
|
||||
|
||||
fn git_push_refspec_is_dangerous(arg: &str) -> bool {
|
||||
// `+<refspec>` forces updates and `:<dst>` deletes remote refs.
|
||||
(arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
|
||||
}
|
||||
|
||||
fn git_clean_is_force(clean_args: &[String]) -> bool {
|
||||
clean_args.iter().map(String::as_str).any(|arg| {
|
||||
matches!(arg, "--force" | "-f")
|
||||
|| arg.starts_with("--force=")
|
||||
|| short_flag_group_contains(arg, 'f')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -63,7 +193,7 @@ mod tests {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git reset --hard"
|
||||
"git reset --hard",
|
||||
])));
|
||||
}
|
||||
|
||||
@@ -72,7 +202,7 @@ mod tests {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"zsh",
|
||||
"-lc",
|
||||
"git reset --hard"
|
||||
"git reset --hard",
|
||||
])));
|
||||
}
|
||||
|
||||
@@ -86,14 +216,14 @@ mod tests {
|
||||
assert!(!command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git status"
|
||||
"git status",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sudo_git_reset_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"sudo", "git", "reset", "--hard"
|
||||
"sudo", "git", "reset", "--hard",
|
||||
])));
|
||||
}
|
||||
|
||||
@@ -102,7 +232,141 @@ mod tests {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"/usr/bin/git",
|
||||
"reset",
|
||||
"--hard"
|
||||
"--hard",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_delete_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-d", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-D", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git branch --delete feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-dv", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-vd", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-vD", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-Dvv", "feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_delete_with_global_options_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "-C", ".", "branch", "-d", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git",
|
||||
"-c",
|
||||
"color.ui=false",
|
||||
"branch",
|
||||
"-D",
|
||||
"feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git -C . branch -d feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_checkout_reset_is_not_dangerous() {
|
||||
// The first non-option token is "checkout", so later positional args
|
||||
// like branch names must not be treated as subcommands.
|
||||
assert!(!command_might_be_dangerous(&vec_str(&[
|
||||
"git", "checkout", "reset",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_force_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "--force", "origin", "main",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "-f", "origin", "main",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git",
|
||||
"-C",
|
||||
".",
|
||||
"push",
|
||||
"--force-with-lease",
|
||||
"origin",
|
||||
"main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_plus_refspec_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "origin", "+main",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git",
|
||||
"push",
|
||||
"origin",
|
||||
"+refs/heads/main:refs/heads/main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_delete_flag_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "--delete", "origin", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "-d", "origin", "feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_delete_refspec_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "origin", ":feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git push origin :feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_without_force_is_not_dangerous() {
|
||||
assert!(!command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "origin", "main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "clean", "-fdx",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "clean", "-xdf",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "clean", "--force",
|
||||
])));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use crate::bash::parse_shell_lc_plain_commands;
|
||||
// Find the first matching git subcommand, skipping known global options that
|
||||
// may appear before it (e.g., `-C`, `-c`, `--git-dir`).
|
||||
// Implemented in `is_dangerous_command` and shared here.
|
||||
use crate::command_safety::is_dangerous_command::find_git_subcommand;
|
||||
use crate::command_safety::windows_safe_commands::is_safe_command_windows;
|
||||
|
||||
pub fn is_known_safe_command(command: &[String]) -> bool {
|
||||
@@ -131,13 +135,36 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool {
|
||||
}
|
||||
|
||||
// Git
|
||||
Some("git") => matches!(
|
||||
command.get(1).map(String::as_str),
|
||||
Some("branch" | "status" | "log" | "diff" | "show")
|
||||
),
|
||||
Some("git") => {
|
||||
// Global config overrides like `-c core.pager=...` can force git
|
||||
// to execute arbitrary external commands. With no sandboxing, we
|
||||
// should always prompt in those cases.
|
||||
if git_has_config_override_global_option(command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rust
|
||||
Some("cargo") if command.get(1).map(String::as_str) == Some("check") => true,
|
||||
let Some((subcommand_idx, subcommand)) =
|
||||
find_git_subcommand(command, &["status", "log", "diff", "show", "branch"])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let subcommand_args = &command[subcommand_idx + 1..];
|
||||
|
||||
match subcommand {
|
||||
"status" | "log" | "diff" | "show" => {
|
||||
git_subcommand_args_are_read_only(subcommand_args)
|
||||
}
|
||||
"branch" => {
|
||||
git_subcommand_args_are_read_only(subcommand_args)
|
||||
&& git_branch_is_read_only(subcommand_args)
|
||||
}
|
||||
other => {
|
||||
debug_assert!(false, "unexpected git subcommand from matcher: {other}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special-case `sed -n {N|M,N}p`
|
||||
Some("sed")
|
||||
@@ -155,6 +182,60 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Treat `git branch` as safe only when the arguments clearly indicate
|
||||
// a read-only query, not a branch mutation (create/rename/delete).
|
||||
fn git_branch_is_read_only(branch_args: &[String]) -> bool {
|
||||
if branch_args.is_empty() {
|
||||
// `git branch` with no additional args lists branches.
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut saw_read_only_flag = false;
|
||||
for arg in branch_args.iter().map(String::as_str) {
|
||||
match arg {
|
||||
"--list" | "-l" | "--show-current" | "-a" | "--all" | "-r" | "--remotes" | "-v"
|
||||
| "-vv" | "--verbose" => {
|
||||
saw_read_only_flag = true;
|
||||
}
|
||||
_ if arg.starts_with("--format=") => {
|
||||
saw_read_only_flag = true;
|
||||
}
|
||||
_ => {
|
||||
// Any other flag or positional argument may create, rename, or delete branches.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saw_read_only_flag
|
||||
}
|
||||
|
||||
fn git_has_config_override_global_option(command: &[String]) -> bool {
|
||||
command.iter().map(String::as_str).any(|arg| {
|
||||
matches!(arg, "-c" | "--config-env")
|
||||
|| (arg.starts_with("-c") && arg.len() > 2)
|
||||
|| arg.starts_with("--config-env=")
|
||||
})
|
||||
}
|
||||
|
||||
fn git_subcommand_args_are_read_only(args: &[String]) -> bool {
|
||||
// Flags that can write to disk or execute external tools should never be
|
||||
// auto-approved on an unsandboxed machine.
|
||||
const UNSAFE_GIT_FLAGS: &[&str] = &[
|
||||
"--output",
|
||||
"--ext-diff",
|
||||
"--textconv",
|
||||
"--exec",
|
||||
"--paginate",
|
||||
];
|
||||
|
||||
!args.iter().map(String::as_str).any(|arg| {
|
||||
UNSAFE_GIT_FLAGS.contains(&arg)
|
||||
|| arg.starts_with("--output=")
|
||||
|| arg.starts_with("--exec=")
|
||||
})
|
||||
}
|
||||
|
||||
// (bash parsing helpers implemented in crate::bash)
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
@@ -207,6 +288,12 @@ mod tests {
|
||||
fn known_safe_examples() {
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&["ls"])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&["git", "status"])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&["git", "branch"])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&[
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&["base64"])));
|
||||
assert!(is_safe_to_call_with_exec(&vec_str(&[
|
||||
"sed", "-n", "1,5p", "file.txt"
|
||||
@@ -231,6 +318,86 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_mutating_flags_are_not_safe() {
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git", "branch", "-d", "feature"
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"branch",
|
||||
"new-branch"
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_global_options_respect_safety_rules() {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
assert_eq!(
|
||||
is_known_safe_command(&vec_str(&["git", "-C", ".", "branch", "--show-current"])),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
is_known_safe_command(&vec_str(&["git", "-C", ".", "branch", "-d", "feature"])),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
is_known_safe_command(&vec_str(&["bash", "-lc", "git -C . branch -d feature",])),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_first_positional_is_the_subcommand() {
|
||||
// In git, the first non-option token is the subcommand. Later positional
|
||||
// args (like branch names) must not be treated as subcommands.
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git", "checkout", "status",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_output_and_config_override_flags_are_not_safe() {
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"log",
|
||||
"--output=/tmp/git-log-out-test",
|
||||
"-n",
|
||||
"1",
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"diff",
|
||||
"--output",
|
||||
"/tmp/git-diff-out-test",
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"show",
|
||||
"--output=/tmp/git-show-out-test",
|
||||
"HEAD",
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"-c",
|
||||
"core.pager=cat",
|
||||
"log",
|
||||
"-n",
|
||||
"1",
|
||||
])));
|
||||
assert!(!is_known_safe_command(&vec_str(&[
|
||||
"git",
|
||||
"-ccore.pager=cat",
|
||||
"status",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_check_is_not_safe() {
|
||||
assert!(!is_known_safe_command(&vec_str(&["cargo", "check"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zsh_lc_safe_command_sequence() {
|
||||
assert!(is_known_safe_command(&vec_str(&["zsh", "-lc", "ls"])));
|
||||
|
||||
@@ -1280,6 +1280,30 @@ prefix_rule(
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dangerous_git_push_requires_approval_in_danger_full_access() {
|
||||
let command = vec_str(&["git", "push", "origin", "+main"]);
|
||||
let manager = ExecPolicyManager::default();
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
features: &Features::with_defaults(),
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn vec_str(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(std::string::ToString::to_string).collect()
|
||||
}
|
||||
|
||||
@@ -520,7 +520,11 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::Collab,
|
||||
key: "collab",
|
||||
stage: Stage::UnderDevelopment,
|
||||
stage: Stage::Experimental {
|
||||
name: "Sub-agents",
|
||||
menu_description: "Ask Codex to spawn multiple agents to parallelize the work and win in efficiency.",
|
||||
announcement: "NEW: Sub-agents can now be spawned by Codex. Enable in /experimental and restart Codex!",
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
|
||||
@@ -120,6 +120,7 @@ pub use rollout::list::parse_cursor;
|
||||
pub use rollout::list::read_head_for_summary;
|
||||
pub use rollout::list::read_session_meta_line;
|
||||
pub use rollout::rollout_date_parts;
|
||||
pub use rollout::session_index::find_thread_names_by_ids;
|
||||
pub use transport_manager::TransportManager;
|
||||
mod function_tool;
|
||||
mod state;
|
||||
|
||||
@@ -15,7 +15,9 @@ use uuid::Uuid;
|
||||
|
||||
use super::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use super::SESSIONS_SUBDIR;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::session_prefix::is_session_prefix_content;
|
||||
use crate::state_db;
|
||||
use codex_file_search as file_search;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -243,9 +245,7 @@ impl serde::Serialize for Cursor {
|
||||
{
|
||||
let ts_str = self
|
||||
.ts
|
||||
.format(&format_description!(
|
||||
"[year]-[month]-[day]T[hour]-[minute]-[second]"
|
||||
))
|
||||
.format(&Rfc3339)
|
||||
.map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?;
|
||||
serializer.serialize_str(&format!("{ts_str}|{}", self.id))
|
||||
}
|
||||
@@ -628,9 +628,13 @@ pub fn parse_cursor(token: &str) -> Option<Cursor> {
|
||||
return None;
|
||||
};
|
||||
|
||||
let format: &[FormatItem] =
|
||||
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
|
||||
let ts = PrimitiveDateTime::parse(file_ts, format).ok()?.assume_utc();
|
||||
let ts = OffsetDateTime::parse(file_ts, &Rfc3339).ok().or_else(|| {
|
||||
let format: &[FormatItem] =
|
||||
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
|
||||
PrimitiveDateTime::parse(file_ts, format)
|
||||
.ok()
|
||||
.map(PrimitiveDateTime::assume_utc)
|
||||
})?;
|
||||
|
||||
Some(Cursor::new(ts, uuid))
|
||||
}
|
||||
@@ -967,10 +971,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
|
||||
RolloutItem::SessionMeta(session_meta_line) => {
|
||||
summary.source = Some(session_meta_line.meta.source.clone());
|
||||
summary.model_provider = session_meta_line.meta.model_provider.clone();
|
||||
summary.created_at = summary
|
||||
.created_at
|
||||
.clone()
|
||||
.or_else(|| Some(rollout_line.timestamp.clone()));
|
||||
summary.created_at = Some(session_meta_line.meta.timestamp.clone());
|
||||
summary.saw_session_meta = true;
|
||||
if summary.head.len() < head_limit
|
||||
&& let Ok(val) = serde_json::to_value(session_meta_line)
|
||||
@@ -983,6 +984,14 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
|
||||
.created_at
|
||||
.clone()
|
||||
.or_else(|| Some(rollout_line.timestamp.clone()));
|
||||
if let codex_protocol::models::ResponseItem::Message { role, content, .. } = &item
|
||||
&& role == "user"
|
||||
&& !UserInstructions::is_user_instructions(content.as_slice())
|
||||
&& !is_session_prefix_content(content.as_slice())
|
||||
{
|
||||
tracing::warn!("Item: {item:#?}");
|
||||
summary.saw_user_event = true;
|
||||
}
|
||||
if summary.head.len() < head_limit
|
||||
&& let Ok(val) = serde_json::to_value(item)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
@@ -8,6 +10,7 @@ use std::path::PathBuf;
|
||||
use codex_protocol::ThreadId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const SESSION_INDEX_FILE: &str = "session_index.jsonl";
|
||||
@@ -76,6 +79,38 @@ pub async fn find_thread_name_by_id(
|
||||
Ok(entry.map(|entry| entry.thread_name))
|
||||
}
|
||||
|
||||
/// Find the latest thread names for a batch of thread ids.
|
||||
pub async fn find_thread_names_by_ids(
|
||||
codex_home: &Path,
|
||||
thread_ids: &HashSet<ThreadId>,
|
||||
) -> std::io::Result<HashMap<ThreadId, String>> {
|
||||
let path = session_index_path(codex_home);
|
||||
if thread_ids.is_empty() || !path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let file = tokio::fs::File::open(&path).await?;
|
||||
let reader = tokio::io::BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let mut names = HashMap::with_capacity(thread_ids.len());
|
||||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
let name = entry.thread_name.trim();
|
||||
if !name.is_empty() && thread_ids.contains(&entry.id) {
|
||||
names.insert(entry.id, name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Find the most recently updated thread id for a thread name, if any.
|
||||
pub async fn find_thread_id_by_name(
|
||||
codex_home: &Path,
|
||||
@@ -197,6 +232,8 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use tempfile::TempDir;
|
||||
fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()> {
|
||||
let mut out = String::new();
|
||||
@@ -279,6 +316,44 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_thread_names_by_ids_prefers_latest_entry() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id1 = ThreadId::new();
|
||||
let id2 = ThreadId::new();
|
||||
let lines = vec![
|
||||
SessionIndexEntry {
|
||||
id: id1,
|
||||
thread_name: "first".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id: id2,
|
||||
thread_name: "other".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id: id1,
|
||||
thread_name: "latest".to_string(),
|
||||
updated_at: "2024-01-02T00:00:00Z".to_string(),
|
||||
},
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
ids.insert(id1);
|
||||
ids.insert(id2);
|
||||
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert(id1, "latest".to_string());
|
||||
expected.insert(id2, "other".to_string());
|
||||
|
||||
let found = find_thread_names_by_ids(temp.path(), &ids).await?;
|
||||
assert_eq!(found, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
|
||||
/// Helpers for identifying model-visible "session prefix" messages.
|
||||
///
|
||||
/// A session prefix is a user-role message that carries configuration or state needed by
|
||||
@@ -13,3 +15,12 @@ pub(crate) fn is_session_prefix(text: &str) -> bool {
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG)
|
||||
}
|
||||
|
||||
/// Returns true if `text` starts with a session prefix marker (case-insensitive).
|
||||
pub(crate) fn is_session_prefix_content(content: &[ContentItem]) -> bool {
|
||||
if let [ContentItem::InputText { text }] = content {
|
||||
is_session_prefix(text)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct ShellSnapshot {
|
||||
}
|
||||
|
||||
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7 days retention.
|
||||
const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 3); // 3 days retention.
|
||||
const SNAPSHOT_DIR: &str = "shell_snapshots";
|
||||
const EXCLUDED_EXPORT_VARS: &[&str] = &["PWD", "OLDPWD"];
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ pub async fn apply_rollout_items(
|
||||
pub fn record_discrepancy(stage: &str, reason: &str) {
|
||||
// We access the global metric because the call sites might not have access to the broader
|
||||
// OtelManager.
|
||||
tracing::warn!("state db record_discrepancy: {stage}{reason}");
|
||||
tracing::warn!("state db record_discrepancy: {stage}, {reason}");
|
||||
if let Some(metric) = codex_otel::metrics::global() {
|
||||
let _ = metric.counter(
|
||||
DB_METRIC_COMPARE_ERROR,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod config_types;
|
||||
pub mod custom_prompts;
|
||||
pub mod dynamic_tools;
|
||||
pub mod items;
|
||||
pub mod mcp;
|
||||
pub mod message_history;
|
||||
pub mod models;
|
||||
pub mod num_format;
|
||||
|
||||
324
codex-rs/protocol/src/mcp.rs
Normal file
324
codex-rs/protocol/src/mcp.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Types used when representing Model Context Protocol (MCP) values inside the
|
||||
/// Codex protocol.
|
||||
///
|
||||
/// We intentionally keep these types TS/JSON-schema friendly (via `ts-rs` and
|
||||
/// `schemars`) so they can be embedded in Codex's own protocol structures.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(untagged)]
|
||||
pub enum RequestId {
|
||||
String(String),
|
||||
#[ts(type = "number")]
|
||||
Integer(i64),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RequestId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RequestId::String(s) => f.write_str(s),
|
||||
RequestId::Integer(i) => i.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tool {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: serde_json::Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub output_schema: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Resource {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mime_type: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
#[ts(type = "number")]
|
||||
pub size: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
pub uri: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceTemplate {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
pub uri_template: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallToolResult {
|
||||
pub content: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub structured_content: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub is_error: Option<bool>,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// === Adapter helpers ===
|
||||
//
|
||||
// These types and conversions intentionally live in `codex-protocol` so other crates can convert
|
||||
// “wire-shaped” MCP JSON (typically coming from rmcp model structs serialized with serde) into our
|
||||
// TS/JsonSchema-friendly protocol types without depending on `mcp-types`.
|
||||
|
||||
fn deserialize_lossy_opt_i64<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
match Option::<serde_json::Number>::deserialize(deserializer)? {
|
||||
Some(number) => {
|
||||
if let Some(v) = number.as_i64() {
|
||||
Ok(Some(v))
|
||||
} else if let Some(v) = number.as_u64() {
|
||||
Ok(i64::try_from(v).ok())
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ToolSerde {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default, rename = "inputSchema", alias = "input_schema")]
|
||||
input_schema: serde_json::Value,
|
||||
#[serde(default, rename = "outputSchema", alias = "output_schema")]
|
||||
output_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
annotations: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl From<ToolSerde> for Tool {
|
||||
fn from(value: ToolSerde) -> Self {
|
||||
let ToolSerde {
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
input_schema,
|
||||
output_schema,
|
||||
annotations,
|
||||
icons,
|
||||
meta,
|
||||
} = value;
|
||||
Self {
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
input_schema,
|
||||
output_schema,
|
||||
annotations,
|
||||
icons,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResourceSerde {
|
||||
#[serde(default)]
|
||||
annotations: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(rename = "mimeType", alias = "mime_type", default)]
|
||||
mime_type: Option<String>,
|
||||
name: String,
|
||||
#[serde(default, deserialize_with = "deserialize_lossy_opt_i64")]
|
||||
size: Option<i64>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
uri: String,
|
||||
#[serde(default)]
|
||||
icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl From<ResourceSerde> for Resource {
|
||||
fn from(value: ResourceSerde) -> Self {
|
||||
let ResourceSerde {
|
||||
annotations,
|
||||
description,
|
||||
mime_type,
|
||||
name,
|
||||
size,
|
||||
title,
|
||||
uri,
|
||||
icons,
|
||||
meta,
|
||||
} = value;
|
||||
Self {
|
||||
annotations,
|
||||
description,
|
||||
mime_type,
|
||||
name,
|
||||
size,
|
||||
title,
|
||||
uri,
|
||||
icons,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResourceTemplateSerde {
|
||||
#[serde(default)]
|
||||
annotations: Option<serde_json::Value>,
|
||||
#[serde(rename = "uriTemplate", alias = "uri_template")]
|
||||
uri_template: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(rename = "mimeType", alias = "mime_type", default)]
|
||||
mime_type: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ResourceTemplateSerde> for ResourceTemplate {
|
||||
fn from(value: ResourceTemplateSerde) -> Self {
|
||||
let ResourceTemplateSerde {
|
||||
annotations,
|
||||
uri_template,
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
mime_type,
|
||||
} = value;
|
||||
Self {
|
||||
annotations,
|
||||
uri_template,
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
mime_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(serde_json::from_value::<ToolSerde>(value)?.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Resource {
|
||||
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(serde_json::from_value::<ResourceSerde>(value)?.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceTemplate {
|
||||
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(serde_json::from_value::<ResourceTemplateSerde>(value)?.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_size_deserializes_without_narrowing() {
|
||||
let resource = serde_json::json!({
|
||||
"name": "big",
|
||||
"uri": "file:///tmp/big",
|
||||
"size": 5_000_000_000u64,
|
||||
});
|
||||
|
||||
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
|
||||
assert_eq!(parsed.size, Some(5_000_000_000));
|
||||
|
||||
let resource = serde_json::json!({
|
||||
"name": "negative",
|
||||
"uri": "file:///tmp/negative",
|
||||
"size": -1,
|
||||
});
|
||||
|
||||
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
|
||||
assert_eq!(parsed.size, Some(-1));
|
||||
|
||||
let resource = serde_json::json!({
|
||||
"name": "too_big_for_i64",
|
||||
"uri": "file:///tmp/too_big_for_i64",
|
||||
"size": 18446744073709551615u64,
|
||||
});
|
||||
|
||||
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
|
||||
assert_eq!(parsed.size, None);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ clap = { workspace = true, features = ["derive", "env"] }
|
||||
codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
log = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -17,6 +17,8 @@ use chrono::Utc;
|
||||
use codex_otel::OtelManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use log::LevelFilter;
|
||||
use sqlx::ConnectOptions;
|
||||
use sqlx::QueryBuilder;
|
||||
use sqlx::Row;
|
||||
use sqlx::Sqlite;
|
||||
@@ -511,7 +513,8 @@ async fn open_sqlite(path: &Path) -> anyhow::Result<SqlitePool> {
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.busy_timeout(Duration::from_secs(5));
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.log_statements(LevelFilter::Off);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
|
||||
@@ -2939,6 +2939,7 @@ mod tests {
|
||||
app.chat_widget.current_model(),
|
||||
event,
|
||||
is_first,
|
||||
None,
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//!
|
||||
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
|
||||
//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions).
|
||||
//! - Promoting typed slash commands into atomic elements when the command name is completed.
|
||||
//! - Handling submit vs newline on Enter.
|
||||
//! - Turning raw key streams into explicit paste operations on platforms where terminals
|
||||
//! don't provide reliable bracketed paste (notably Windows).
|
||||
@@ -36,6 +37,8 @@
|
||||
//!
|
||||
//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion
|
||||
//! and attachment pruning, and clears pending paste state on success.
|
||||
//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so
|
||||
//! pasted content and text elements are preserved when extracting args.
|
||||
//!
|
||||
//! # Non-bracketed Paste Bursts
|
||||
//!
|
||||
@@ -164,6 +167,7 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -184,7 +188,7 @@ pub enum InputResult {
|
||||
text_elements: Vec<TextElement>,
|
||||
},
|
||||
Command(SlashCommand),
|
||||
CommandWithArgs(SlashCommand, String),
|
||||
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
|
||||
None,
|
||||
}
|
||||
|
||||
@@ -747,6 +751,7 @@ impl ChatComposer {
|
||||
/// Move the cursor to the end of the current text buffer.
|
||||
pub(crate) fn move_cursor_to_end(&mut self) {
|
||||
self.textarea.set_cursor(self.textarea.text().len());
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
|
||||
@@ -1235,6 +1240,7 @@ impl ChatComposer {
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
self.textarea.input(input);
|
||||
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
@@ -1798,7 +1804,12 @@ impl ChatComposer {
|
||||
|
||||
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
|
||||
/// On success, clears pending paste payloads because placeholders have been expanded.
|
||||
fn prepare_submission_text(&mut self) -> Option<(String, Vec<TextElement>)> {
|
||||
///
|
||||
/// When `record_history` is true, the final submission is stored for ↑/↓ recall.
|
||||
fn prepare_submission_text(
|
||||
&mut self,
|
||||
record_history: bool,
|
||||
) -> Option<(String, Vec<TextElement>)> {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let original_text_elements = self.textarea.text_elements();
|
||||
@@ -1896,7 +1907,7 @@ impl ChatComposer {
|
||||
if text.is_empty() && self.attached_images.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !text.is_empty() || !self.attached_images.is_empty() {
|
||||
if record_history && (!text.is_empty() || !self.attached_images.is_empty()) {
|
||||
let local_image_paths = self
|
||||
.attached_images
|
||||
.iter()
|
||||
@@ -1978,7 +1989,7 @@ impl ChatComposer {
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
if let Some((text, text_elements)) = self.prepare_submission_text() {
|
||||
if let Some((text, text_elements)) = self.prepare_submission_text(true) {
|
||||
if should_queue {
|
||||
(
|
||||
InputResult::Queued {
|
||||
@@ -2026,6 +2037,9 @@ impl ChatComposer {
|
||||
self.windows_degraded_sandbox_active,
|
||||
)
|
||||
{
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
Some(InputResult::Command(cmd))
|
||||
} else {
|
||||
@@ -2039,28 +2053,104 @@ impl ChatComposer {
|
||||
if !self.slash_commands_enabled() {
|
||||
return None;
|
||||
}
|
||||
let original_input = self.textarea.text().to_string();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
|
||||
if !input_starts_with_space {
|
||||
let text = self.textarea.text().to_string();
|
||||
if let Some((name, rest, _rest_offset)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some(cmd) = slash_commands::find_builtin_command(
|
||||
name,
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
self.personality_command_enabled,
|
||||
self.windows_degraded_sandbox_active,
|
||||
)
|
||||
&& matches!(cmd, SlashCommand::Review | SlashCommand::Rename)
|
||||
{
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
return Some(InputResult::CommandWithArgs(cmd, rest.to_string()));
|
||||
}
|
||||
let text = self.textarea.text().to_string();
|
||||
if text.starts_with(' ') {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
|
||||
let (name, rest, rest_offset) = parse_slash_name(&text)?;
|
||||
if rest.is_empty() || name.contains('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cmd = slash_commands::find_builtin_command(
|
||||
name,
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
self.personality_command_enabled,
|
||||
self.windows_degraded_sandbox_active,
|
||||
)?;
|
||||
|
||||
if !cmd.supports_inline_args() {
|
||||
return None;
|
||||
}
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
|
||||
let mut args_elements =
|
||||
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
|
||||
let trimmed_rest = rest.trim();
|
||||
args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements);
|
||||
Some(InputResult::CommandWithArgs(
|
||||
cmd,
|
||||
trimmed_rest.to_string(),
|
||||
args_elements,
|
||||
))
|
||||
}
|
||||
|
||||
/// Expand pending placeholders and extract normalized inline-command args.
|
||||
///
|
||||
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
|
||||
/// not consume user input. Once a command is accepted, this helper performs the usual
|
||||
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
|
||||
/// full-text offsets to command-arg offsets.
|
||||
pub(crate) fn prepare_inline_args_submission(
|
||||
&mut self,
|
||||
record_history: bool,
|
||||
) -> Option<(String, Vec<TextElement>)> {
|
||||
let (prepared_text, prepared_elements) = self.prepare_submission_text(record_history)?;
|
||||
let (_, prepared_rest, prepared_rest_offset) = parse_slash_name(&prepared_text)?;
|
||||
let mut args_elements = Self::slash_command_args_elements(
|
||||
prepared_rest,
|
||||
prepared_rest_offset,
|
||||
&prepared_elements,
|
||||
);
|
||||
let trimmed_rest = prepared_rest.trim();
|
||||
args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements);
|
||||
Some((trimmed_rest.to_string(), args_elements))
|
||||
}
|
||||
|
||||
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
|
||||
if !self.is_task_running || cmd.available_during_task() {
|
||||
return false;
|
||||
}
|
||||
let message = format!(
|
||||
"'/{}' is disabled while a task is in progress.",
|
||||
cmd.command()
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(message),
|
||||
)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Translate full-text element ranges into command-argument ranges.
|
||||
///
|
||||
/// `rest_offset` is the byte offset where `rest` begins in the full text.
|
||||
fn slash_command_args_elements(
|
||||
rest: &str,
|
||||
rest_offset: usize,
|
||||
text_elements: &[TextElement],
|
||||
) -> Vec<TextElement> {
|
||||
if rest.is_empty() || text_elements.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| {
|
||||
if elem.byte_range.end <= rest_offset {
|
||||
return None;
|
||||
}
|
||||
let start = elem.byte_range.start.saturating_sub(rest_offset);
|
||||
let mut end = elem.byte_range.end.saturating_sub(rest_offset);
|
||||
if start >= rest.len() {
|
||||
return None;
|
||||
}
|
||||
end = end.min(rest.len());
|
||||
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Handle key event when no popup is visible.
|
||||
@@ -2441,6 +2531,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
fn sync_popups(&mut self) {
|
||||
self.sync_slash_command_elements();
|
||||
if !self.popups_enabled() {
|
||||
self.active_popup = ActivePopup::None;
|
||||
return;
|
||||
@@ -2507,6 +2598,88 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep slash command elements aligned with the current first line.
|
||||
fn sync_slash_command_elements(&mut self) {
|
||||
if !self.slash_commands_enabled() {
|
||||
return;
|
||||
}
|
||||
let text = self.textarea.text();
|
||||
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||||
let first_line = &text[..first_line_end];
|
||||
let desired_range = self.slash_command_element_range(first_line);
|
||||
// Slash commands are only valid at byte 0 of the first line.
|
||||
// Any slash-shaped element not matching the current desired prefix is stale.
|
||||
let mut has_desired = false;
|
||||
let mut stale_ranges = Vec::new();
|
||||
for elem in self.textarea.text_elements() {
|
||||
let Some(payload) = elem.placeholder(text) else {
|
||||
continue;
|
||||
};
|
||||
if payload.strip_prefix('/').is_none() {
|
||||
continue;
|
||||
}
|
||||
let range = elem.byte_range.start..elem.byte_range.end;
|
||||
if desired_range.as_ref() == Some(&range) {
|
||||
has_desired = true;
|
||||
} else {
|
||||
stale_ranges.push(range);
|
||||
}
|
||||
}
|
||||
|
||||
for range in stale_ranges {
|
||||
self.textarea.remove_element_range(range);
|
||||
}
|
||||
|
||||
if let Some(range) = desired_range
|
||||
&& !has_desired
|
||||
{
|
||||
self.textarea.add_element_range(range);
|
||||
}
|
||||
}
|
||||
|
||||
fn slash_command_element_range(&self, first_line: &str) -> Option<Range<usize>> {
|
||||
let (name, _rest, _rest_offset) = parse_slash_name(first_line)?;
|
||||
if name.contains('/') {
|
||||
return None;
|
||||
}
|
||||
let element_end = 1 + name.len();
|
||||
let has_space_after = first_line
|
||||
.get(element_end..)
|
||||
.and_then(|tail| tail.chars().next())
|
||||
.is_some_and(char::is_whitespace);
|
||||
if !has_space_after {
|
||||
return None;
|
||||
}
|
||||
if self.is_known_slash_name(name) {
|
||||
Some(0..element_end)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_known_slash_name(&self, name: &str) -> bool {
|
||||
let is_builtin = slash_commands::find_builtin_command(
|
||||
name,
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
self.personality_command_enabled,
|
||||
self.windows_degraded_sandbox_active,
|
||||
)
|
||||
.is_some();
|
||||
if is_builtin {
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = name.strip_prefix(PROMPTS_CMD_PREFIX)
|
||||
&& let Some(prompt_name) = rest.strip_prefix(':')
|
||||
{
|
||||
return self
|
||||
.custom_prompts
|
||||
.iter()
|
||||
.any(|prompt| prompt.name == prompt_name);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// If the cursor is currently within a slash command on the first line,
|
||||
/// extract the command name and the rest of the line after it.
|
||||
/// Returns None if the cursor is outside a slash command.
|
||||
@@ -4582,7 +4755,7 @@ mod tests {
|
||||
InputResult::Command(cmd) => {
|
||||
assert_eq!(cmd.command(), "init");
|
||||
}
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
InputResult::CommandWithArgs(_, _, _) => {
|
||||
panic!("expected command dispatch without args for '/init'")
|
||||
}
|
||||
InputResult::Submitted { text, .. } => {
|
||||
@@ -4596,6 +4769,49 @@ mod tests {
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_disabled_while_task_running_keeps_text() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_task_running(true);
|
||||
composer
|
||||
.textarea
|
||||
.set_text_clearing_elements("/review these changes");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("/review these changes", composer.textarea.text());
|
||||
|
||||
let mut found_error = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
let message = cell
|
||||
.display_lines(80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(message.contains("disabled while a task is in progress"));
|
||||
found_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_error, "expected error history cell to be sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_args_supports_quoted_paths_single_arg() {
|
||||
let args = extract_positional_args_for_prompt_line(
|
||||
@@ -4683,7 +4899,7 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
InputResult::CommandWithArgs(_, _, _) => {
|
||||
panic!("expected command dispatch without args for '/diff'")
|
||||
}
|
||||
InputResult::Submitted { text, .. } => {
|
||||
@@ -4697,6 +4913,77 @@ mod tests {
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_elementizes_on_space() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_collaboration_modes_enabled(true);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
let elements = composer.textarea.text_elements();
|
||||
assert_eq!(text, "/plan ");
|
||||
assert_eq!(elements.len(), 1);
|
||||
assert_eq!(elements[0].placeholder(&text), Some("/plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_elementizes_only_known_commands() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_collaboration_modes_enabled(true);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
let elements = composer.textarea.text_elements();
|
||||
assert_eq!(text, "/Users ");
|
||||
assert!(elements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_element_removed_when_not_at_start() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
let elements = composer.textarea.text_elements();
|
||||
assert_eq!(text, "/review ");
|
||||
assert_eq!(elements.len(), 1);
|
||||
|
||||
composer.textarea.set_cursor(0);
|
||||
type_chars_humanlike(&mut composer, &['x']);
|
||||
|
||||
let text = composer.textarea.text().to_string();
|
||||
let elements = composer.textarea.text_elements();
|
||||
assert_eq!(text, "x/review ");
|
||||
assert!(elements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_mention_dispatches_command_and_inserts_at() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -4722,7 +5009,7 @@ mod tests {
|
||||
InputResult::Command(cmd) => {
|
||||
assert_eq!(cmd.command(), "mention");
|
||||
}
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
InputResult::CommandWithArgs(_, _, _) => {
|
||||
panic!("expected command dispatch without args for '/mention'")
|
||||
}
|
||||
InputResult::Submitted { text, .. } => {
|
||||
@@ -4738,6 +5025,44 @@ mod tests {
|
||||
assert_eq!(composer.textarea.text(), "@");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_plan_args_preserve_text_elements() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_collaboration_modes_enabled(true);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
|
||||
let placeholder = local_image_label_text(1);
|
||||
composer.attach_image(PathBuf::from("/tmp/plan.png"));
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::CommandWithArgs(cmd, args, text_elements) => {
|
||||
assert_eq!(cmd.command(), "plan");
|
||||
assert_eq!(args, placeholder);
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(
|
||||
text_elements[0].placeholder(&args),
|
||||
Some(placeholder.as_str())
|
||||
);
|
||||
}
|
||||
_ => panic!("expected CommandWithArgs for /plan with args"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Behavior: multiple paste operations can coexist; placeholders should be expanded to their
|
||||
/// original content on submission.
|
||||
#[test]
|
||||
|
||||
@@ -175,11 +175,16 @@ impl BottomPaneView for ExperimentalFeaturesView {
|
||||
..
|
||||
} => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
code: KeyCode::Char(' '),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.toggle_selected(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.on_ctrl_c();
|
||||
@@ -287,9 +292,9 @@ impl Renderable for ExperimentalFeaturesView {
|
||||
fn experimental_popup_hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"Press ".into(),
|
||||
key_hint::plain(KeyCode::Char(' ')).into(),
|
||||
" to select or ".into(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to toggle or ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to save for next conversation".into(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -218,6 +218,12 @@ impl BottomPane {
|
||||
self.composer.take_mention_paths()
|
||||
}
|
||||
|
||||
/// Clear pending attachments and mention paths e.g. when a slash command doesn't submit text.
|
||||
pub(crate) fn drain_pending_submission_state(&mut self) {
|
||||
let _ = self.take_recent_submission_images_with_placeholders();
|
||||
let _ = self.take_mention_paths();
|
||||
}
|
||||
|
||||
pub fn set_steer_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_steer_enabled(enabled);
|
||||
}
|
||||
@@ -404,6 +410,7 @@ impl BottomPane {
|
||||
) {
|
||||
self.composer
|
||||
.set_text_content(text, text_elements, local_image_paths);
|
||||
self.composer.move_cursor_to_end();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -787,6 +794,13 @@ impl BottomPane {
|
||||
.take_recent_submission_images_with_placeholders()
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_inline_args_submission(
|
||||
&mut self,
|
||||
record_history: bool,
|
||||
) -> Option<(String, Vec<TextElement>)> {
|
||||
self.composer.prepare_inline_args_submission(record_history)
|
||||
}
|
||||
|
||||
fn as_renderable(&'_ self) -> RenderableItem<'_> {
|
||||
if let Some(view) = self.active_view() {
|
||||
RenderableItem::Borrowed(view)
|
||||
|
||||
@@ -844,6 +844,46 @@ impl TextArea {
|
||||
self.set_cursor(end);
|
||||
}
|
||||
|
||||
/// Mark an existing text range as an atomic element without changing the text.
|
||||
///
|
||||
/// This is used to convert already-typed tokens (like `/plan`) into elements
|
||||
/// so they render and edit atomically. Overlapping or duplicate ranges are ignored.
|
||||
pub fn add_element_range(&mut self, range: Range<usize>) {
|
||||
let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len()));
|
||||
let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len()));
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.elements
|
||||
.iter()
|
||||
.any(|e| e.range.start == start && e.range.end == end)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.elements
|
||||
.iter()
|
||||
.any(|e| start < e.range.end && end > e.range.start)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.elements.push(TextElement { range: start..end });
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
}
|
||||
|
||||
pub fn remove_element_range(&mut self, range: Range<usize>) -> bool {
|
||||
let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len()));
|
||||
let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len()));
|
||||
if start >= end {
|
||||
return false;
|
||||
}
|
||||
let len_before = self.elements.len();
|
||||
self.elements
|
||||
.retain(|elem| elem.range.start != start || elem.range.end != end);
|
||||
len_before != self.elements.len()
|
||||
}
|
||||
|
||||
fn add_element(&mut self, range: Range<usize>) {
|
||||
let elem = TextElement { range };
|
||||
self.elements.push(elem);
|
||||
|
||||
@@ -815,6 +815,9 @@ impl ChatWidget {
|
||||
&model_for_header,
|
||||
event,
|
||||
self.show_welcome_banner,
|
||||
self.auth_manager
|
||||
.auth_cached()
|
||||
.and_then(|auth| auth.account_plan_type()),
|
||||
);
|
||||
self.apply_session_info_cell(session_info_cell);
|
||||
|
||||
@@ -2732,8 +2735,8 @@ impl ChatWidget {
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
InputResult::CommandWithArgs(cmd, args, text_elements) => {
|
||||
self.dispatch_command_with_args(cmd, args, text_elements);
|
||||
}
|
||||
InputResult::None => {}
|
||||
},
|
||||
@@ -2783,6 +2786,7 @@ impl ChatWidget {
|
||||
cmd.command()
|
||||
);
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
@@ -3019,7 +3023,16 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) {
|
||||
fn dispatch_command_with_args(
|
||||
&mut self,
|
||||
cmd: SlashCommand,
|
||||
args: String,
|
||||
_text_elements: Vec<TextElement>,
|
||||
) {
|
||||
if !cmd.supports_inline_args() {
|
||||
self.dispatch_command(cmd);
|
||||
return;
|
||||
}
|
||||
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
||||
let message = format!(
|
||||
"'/{}' is disabled while a task is in progress.",
|
||||
@@ -3033,7 +3046,12 @@ impl ChatWidget {
|
||||
let trimmed = args.trim();
|
||||
match cmd {
|
||||
SlashCommand::Rename if !trimmed.is_empty() => {
|
||||
let Some(name) = codex_core::util::normalize_thread_name(trimmed) else {
|
||||
let Some((prepared_args, _prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else {
|
||||
self.add_error_message("Thread name cannot be empty.".to_string());
|
||||
return;
|
||||
};
|
||||
@@ -3042,20 +3060,50 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
SlashCommand::Collab | SlashCommand::Plan => {
|
||||
let _ = trimmed;
|
||||
SlashCommand::Plan if !trimmed.is_empty() => {
|
||||
self.dispatch_command(cmd);
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
return;
|
||||
}
|
||||
let Some((prepared_args, prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(true)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let user_message = UserMessage {
|
||||
text: prepared_args,
|
||||
local_images: self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders(),
|
||||
text_elements: prepared_elements,
|
||||
mention_paths: self.bottom_pane.take_mention_paths(),
|
||||
};
|
||||
if self.is_session_configured() {
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.set_status_header(String::from("Working"));
|
||||
self.submit_user_message(user_message);
|
||||
} else {
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
}
|
||||
SlashCommand::Review if !trimmed.is_empty() => {
|
||||
let Some((prepared_args, _prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.submit_op(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: trimmed.to_string(),
|
||||
instructions: prepared_args,
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
});
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
_ => self.dispatch_command(cmd),
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@ expression: popup
|
||||
› [ ] Ghost snapshots Capture undo snapshots each turn.
|
||||
[x] Shell tool Allow the model to run shell commands.
|
||||
|
||||
Press enter to toggle or esc to save for next conversation
|
||||
Press space to select or enter to save for next conversation
|
||||
|
||||
@@ -2316,6 +2316,50 @@ async fn plan_slash_command_switches_to_plan_mode() {
|
||||
assert_eq!(chat.current_collaboration_mode(), &initial);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: None,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "configured".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
let items = match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => items,
|
||||
other => panic!("expected Op::UserTurn, got {other:?}"),
|
||||
};
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(
|
||||
items[0],
|
||||
UserInput::Text {
|
||||
text: "build the plan".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collaboration_modes_defaults_to_code_on_startup() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
@@ -2992,14 +3036,14 @@ async fn experimental_features_toggle_saves_on_exit() {
|
||||
);
|
||||
chat.bottom_pane.show_view(Box::new(view));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"expected no updates until exiting the popup"
|
||||
"expected no updates until saving the popup"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let mut updates = None;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
|
||||
@@ -46,6 +46,7 @@ use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::web_search::web_search_detail;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
@@ -943,6 +944,7 @@ pub(crate) fn new_session_info(
|
||||
requested_model: &str,
|
||||
event: SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
auth_plan: Option<PlanType>,
|
||||
) -> SessionInfoCell {
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
@@ -995,7 +997,7 @@ pub(crate) fn new_session_info(
|
||||
parts.push(Box::new(PlainHistoryCell { lines: help_lines }));
|
||||
} else {
|
||||
if config.show_tooltips
|
||||
&& let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new)
|
||||
&& let Some(tooltips) = tooltips::get_tooltip(auth_plan).map(TooltipHistoryCell::new)
|
||||
{
|
||||
parts.push(Box::new(tooltips));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -11,6 +12,7 @@ use codex_core::RolloutRecorder;
|
||||
use codex_core::ThreadItem;
|
||||
use codex_core::ThreadSortKey;
|
||||
use codex_core::ThreadsPage;
|
||||
use codex_core::find_thread_names_by_ids;
|
||||
use codex_core::path_utils;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use color_eyre::eyre::Result;
|
||||
@@ -34,12 +36,12 @@ use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
|
||||
const PAGE_SIZE: usize = 25;
|
||||
const LOAD_NEAR_THRESHOLD: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionSelection {
|
||||
StartFresh,
|
||||
@@ -97,8 +99,9 @@ enum BackgroundEvent {
|
||||
}
|
||||
|
||||
/// Interactive session picker that lists recorded rollout files with simple
|
||||
/// search and pagination. Shows the first user input as the preview, relative
|
||||
/// time (e.g., "5 seconds ago"), and the absolute path.
|
||||
/// search and pagination. Shows the session name when available, otherwise the
|
||||
/// first user input as the preview, relative time (e.g., "5 seconds ago"), and
|
||||
/// the absolute path.
|
||||
pub async fn run_resume_picker(
|
||||
tui: &mut Tui,
|
||||
codex_home: &Path,
|
||||
@@ -210,7 +213,7 @@ async fn run_session_picker(
|
||||
}
|
||||
}
|
||||
Some(event) = background_events.next() => {
|
||||
state.handle_background_event(event)?;
|
||||
state.handle_background_event(event).await?;
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
@@ -257,6 +260,7 @@ struct PickerState {
|
||||
show_all: bool,
|
||||
filter_cwd: Option<PathBuf>,
|
||||
action: SessionPickerAction,
|
||||
thread_name_cache: HashMap<ThreadId, Option<String>>,
|
||||
}
|
||||
|
||||
struct PaginationState {
|
||||
@@ -312,12 +316,32 @@ impl SearchState {
|
||||
struct Row {
|
||||
path: PathBuf,
|
||||
preview: String,
|
||||
thread_id: Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
created_at: Option<DateTime<Utc>>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
cwd: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn display_preview(&self) -> &str {
|
||||
self.thread_name.as_deref().unwrap_or(&self.preview)
|
||||
}
|
||||
|
||||
fn matches_query(&self, query: &str) -> bool {
|
||||
if self.preview.to_lowercase().contains(query) {
|
||||
return true;
|
||||
}
|
||||
if let Some(thread_name) = self.thread_name.as_ref()
|
||||
&& thread_name.to_lowercase().contains(query)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerState {
|
||||
fn new(
|
||||
codex_home: PathBuf,
|
||||
@@ -352,6 +376,7 @@ impl PickerState {
|
||||
show_all,
|
||||
filter_cwd,
|
||||
action,
|
||||
thread_name_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +478,7 @@ impl PickerState {
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> {
|
||||
async fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> {
|
||||
match event {
|
||||
BackgroundEvent::PageLoaded {
|
||||
request_token,
|
||||
@@ -470,6 +495,7 @@ impl PickerState {
|
||||
self.pagination.loading = LoadingState::Idle;
|
||||
let page = page.map_err(color_eyre::Report::from)?;
|
||||
self.ingest_page(page);
|
||||
self.update_thread_names().await;
|
||||
let completed_token = pending.search_token.or(search_token);
|
||||
self.continue_search_if_token_matches(completed_token);
|
||||
}
|
||||
@@ -508,6 +534,48 @@ impl PickerState {
|
||||
self.apply_filter();
|
||||
}
|
||||
|
||||
async fn update_thread_names(&mut self) {
|
||||
let mut missing_ids = HashSet::new();
|
||||
for row in &self.all_rows {
|
||||
let Some(thread_id) = row.thread_id else {
|
||||
continue;
|
||||
};
|
||||
if self.thread_name_cache.contains_key(&thread_id) {
|
||||
continue;
|
||||
}
|
||||
missing_ids.insert(thread_id);
|
||||
}
|
||||
|
||||
if missing_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let names = find_thread_names_by_ids(&self.codex_home, &missing_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for thread_id in missing_ids {
|
||||
let thread_name = names.get(&thread_id).cloned();
|
||||
self.thread_name_cache.insert(thread_id, thread_name);
|
||||
}
|
||||
|
||||
let mut updated = false;
|
||||
for row in self.all_rows.iter_mut() {
|
||||
let Some(thread_id) = row.thread_id else {
|
||||
continue;
|
||||
};
|
||||
let thread_name = self.thread_name_cache.get(&thread_id).cloned().flatten();
|
||||
if row.thread_name == thread_name {
|
||||
continue;
|
||||
}
|
||||
row.thread_name = thread_name;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated {
|
||||
self.apply_filter();
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
let base_iter = self
|
||||
.all_rows
|
||||
@@ -517,10 +585,7 @@ impl PickerState {
|
||||
self.filtered_rows = base_iter.cloned().collect();
|
||||
} else {
|
||||
let q = self.query.to_lowercase();
|
||||
self.filtered_rows = base_iter
|
||||
.filter(|r| r.preview.to_lowercase().contains(&q))
|
||||
.cloned()
|
||||
.collect();
|
||||
self.filtered_rows = base_iter.filter(|r| r.matches_query(&q)).cloned().collect();
|
||||
}
|
||||
if self.selected >= self.filtered_rows.len() {
|
||||
self.selected = self.filtered_rows.len().saturating_sub(1);
|
||||
@@ -712,7 +777,7 @@ fn head_to_row(item: &ThreadItem) -> Row {
|
||||
.and_then(parse_timestamp_str)
|
||||
.or(created_at);
|
||||
|
||||
let (cwd, git_branch) = extract_session_meta_from_head(&item.head);
|
||||
let (cwd, git_branch, thread_id) = extract_session_meta_from_head(&item.head);
|
||||
let preview = preview_from_head(&item.head)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -721,6 +786,8 @@ fn head_to_row(item: &ThreadItem) -> Row {
|
||||
Row {
|
||||
path: item.path.clone(),
|
||||
preview,
|
||||
thread_id,
|
||||
thread_name: None,
|
||||
created_at,
|
||||
updated_at,
|
||||
cwd,
|
||||
@@ -728,15 +795,18 @@ fn head_to_row(item: &ThreadItem) -> Row {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option<PathBuf>, Option<String>) {
|
||||
fn extract_session_meta_from_head(
|
||||
head: &[serde_json::Value],
|
||||
) -> (Option<PathBuf>, Option<String>, Option<ThreadId>) {
|
||||
for value in head {
|
||||
if let Ok(meta_line) = serde_json::from_value::<SessionMetaLine>(value.clone()) {
|
||||
let cwd = Some(meta_line.meta.cwd);
|
||||
let git_branch = meta_line.git.and_then(|git| git.branch);
|
||||
return (cwd, git_branch);
|
||||
let thread_id = Some(meta_line.meta.id);
|
||||
return (cwd, git_branch, thread_id);
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
(None, None, None)
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
@@ -909,7 +979,7 @@ fn render_list(
|
||||
if add_leading_gap {
|
||||
preview_width = preview_width.saturating_sub(2);
|
||||
}
|
||||
let preview = truncate_text(&row.preview, preview_width);
|
||||
let preview = truncate_text(row.display_preview(), preview_width);
|
||||
let mut spans: Vec<Span> = vec![marker];
|
||||
if let Some(updated) = updated_span {
|
||||
spans.push(updated);
|
||||
@@ -1252,6 +1322,22 @@ mod tests {
|
||||
assert_eq!(row.updated_at, Some(expected_updated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_display_preview_prefers_thread_name() {
|
||||
let row = Row {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
preview: String::from("first message"),
|
||||
thread_id: None,
|
||||
thread_name: Some(String::from("My session")),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
};
|
||||
|
||||
assert_eq!(row.display_preview(), "My session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_table_snapshot() {
|
||||
use crate::custom_terminal::Terminal;
|
||||
@@ -1275,6 +1361,8 @@ mod tests {
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
preview: String::from("Fix resume picker timestamps"),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
created_at: Some(now - Duration::minutes(16)),
|
||||
updated_at: Some(now - Duration::seconds(42)),
|
||||
cwd: None,
|
||||
@@ -1283,6 +1371,8 @@ mod tests {
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/b.jsonl"),
|
||||
preview: String::from("Investigate lazy pagination cap"),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
created_at: Some(now - Duration::hours(1)),
|
||||
updated_at: Some(now - Duration::minutes(35)),
|
||||
cwd: None,
|
||||
@@ -1291,6 +1381,8 @@ mod tests {
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/c.jsonl"),
|
||||
preview: String::from("Explain the codebase"),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
created_at: Some(now - Duration::hours(2)),
|
||||
updated_at: Some(now - Duration::hours(2)),
|
||||
cwd: None,
|
||||
@@ -1488,6 +1580,104 @@ mod tests {
|
||||
assert_snapshot!("resume_picker_screen", snapshot);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_picker_thread_names_snapshot() {
|
||||
use crate::custom_terminal::Terminal;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let session_index_path = tempdir.path().join("session_index.jsonl");
|
||||
|
||||
let id1 =
|
||||
ThreadId::from_string("11111111-1111-1111-1111-111111111111").expect("thread id 1");
|
||||
let id2 =
|
||||
ThreadId::from_string("22222222-2222-2222-2222-222222222222").expect("thread id 2");
|
||||
let entries = vec![
|
||||
json!({
|
||||
"id": id1,
|
||||
"thread_name": "Keep this for now",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
}),
|
||||
json!({
|
||||
"id": id2,
|
||||
"thread_name": "Named thread",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
}),
|
||||
];
|
||||
let mut out = String::new();
|
||||
for entry in entries {
|
||||
out.push_str(&serde_json::to_string(&entry).expect("session index entry"));
|
||||
out.push('\n');
|
||||
}
|
||||
std::fs::write(&session_index_path, out).expect("write session index");
|
||||
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state = PickerState::new(
|
||||
tempdir.path().to_path_buf(),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
String::from("openai"),
|
||||
true,
|
||||
None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
|
||||
let now = Utc::now();
|
||||
let rows = vec![
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
preview: String::from("First message preview"),
|
||||
thread_id: Some(id1),
|
||||
thread_name: None,
|
||||
created_at: None,
|
||||
updated_at: Some(now - Duration::days(2)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
},
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/b.jsonl"),
|
||||
preview: String::from("Second message preview"),
|
||||
thread_id: Some(id2),
|
||||
thread_name: None,
|
||||
created_at: None,
|
||||
updated_at: Some(now - Duration::days(3)),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
},
|
||||
];
|
||||
state.all_rows = rows.clone();
|
||||
state.filtered_rows = rows;
|
||||
state.view_rows = Some(2);
|
||||
state.selected = 0;
|
||||
state.scroll_top = 0;
|
||||
state.update_view_rows(2);
|
||||
|
||||
state.update_thread_names().await;
|
||||
|
||||
let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all);
|
||||
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 5;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
|
||||
{
|
||||
let mut frame = terminal.get_frame();
|
||||
let area = frame.area();
|
||||
let segments =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
|
||||
render_column_headers(&mut frame, segments[0], &metrics);
|
||||
render_list(&mut frame, segments[1], &state, &metrics);
|
||||
}
|
||||
terminal.flush().expect("flush");
|
||||
|
||||
let snapshot = terminal.backend().to_string();
|
||||
assert_snapshot!("resume_picker_thread_names", snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pageless_scrolling_deduplicates_and_keeps_order() {
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
@@ -1674,8 +1864,8 @@ mod tests {
|
||||
assert_eq!(state.selected, state.filtered_rows.len().saturating_sub(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_query_loads_until_match_and_respects_scan_cap() {
|
||||
#[tokio::test]
|
||||
async fn set_query_loads_until_match_and_respects_scan_cap() {
|
||||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let request_sink = recorded_requests.clone();
|
||||
let loader: PageLoader = Arc::new(move |req: PageLoadRequest| {
|
||||
@@ -1726,6 +1916,7 @@ mod tests {
|
||||
false,
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let second_request = {
|
||||
@@ -1753,6 +1944,7 @@ mod tests {
|
||||
false,
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!state.filtered_rows.is_empty());
|
||||
@@ -1772,6 +1964,7 @@ mod tests {
|
||||
search_token: second_request.search_token,
|
||||
page: Ok(page(Vec::new(), None, 0, false)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recorded_requests.lock().unwrap().len(), 1);
|
||||
|
||||
@@ -1781,6 +1974,7 @@ mod tests {
|
||||
search_token: active_request.search_token,
|
||||
page: Ok(page(Vec::new(), None, 3, true)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(state.filtered_rows.is_empty());
|
||||
|
||||
@@ -87,6 +87,14 @@ impl SlashCommand {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// Whether this command supports inline args (for example `/review ...`).
|
||||
pub fn supports_inline_args(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this command can be run while a task is in progress.
|
||||
pub fn available_during_task(self) -> bool {
|
||||
match self {
|
||||
@@ -103,6 +111,7 @@ impl SlashCommand {
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Rename
|
||||
@@ -117,7 +126,6 @@ impl SlashCommand {
|
||||
| SlashCommand::Exit => true,
|
||||
SlashCommand::Rollout => true,
|
||||
SlashCommand::TestApproval => true,
|
||||
SlashCommand::Plan => true,
|
||||
SlashCommand::Collab => true,
|
||||
SlashCommand::Agent => true,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
assertion_line: 1683
|
||||
expression: snapshot
|
||||
---
|
||||
Updated Branch CWD Conversation
|
||||
> 2 days ago - - Keep this for now
|
||||
3 days ago - - Named thread
|
||||
@@ -1,9 +1,18 @@
|
||||
use codex_core::features::FEATURES;
|
||||
use codex_protocol::account::PlanType;
|
||||
use lazy_static::lazy_static;
|
||||
use rand::Rng;
|
||||
|
||||
const ANNOUNCEMENT_TIP_URL: &str =
|
||||
"https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml";
|
||||
|
||||
const PAID_TOOLTIP: &str =
|
||||
"*New* Try the **Codex App** with 2x rate limits until *April 2nd*. https://chatgpt.com/codex";
|
||||
const OTHER_TOOLTIP: &str =
|
||||
"*New* Build faster with the **Codex App**. Try it now. https://chatgpt.com/codex";
|
||||
const FREE_GO_TOOLTIP: &str =
|
||||
"*New* Codex is included in your plan for free through *March 2nd* – let’s build together.";
|
||||
|
||||
const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt");
|
||||
|
||||
lazy_static! {
|
||||
@@ -28,11 +37,30 @@ fn experimental_tooltips() -> Vec<&'static str> {
|
||||
}
|
||||
|
||||
/// Pick a random tooltip to show to the user when starting Codex.
|
||||
pub(crate) fn random_tooltip() -> Option<String> {
|
||||
pub(crate) fn get_tooltip(plan: Option<PlanType>) -> Option<String> {
|
||||
let mut rng = rand::rng();
|
||||
|
||||
// Leave small chance for a random tooltip to be shown.
|
||||
if rng.random_ratio(8, 10) {
|
||||
match plan {
|
||||
Some(PlanType::Plus)
|
||||
| Some(PlanType::Business)
|
||||
| Some(PlanType::Team)
|
||||
| Some(PlanType::Enterprise)
|
||||
| Some(PlanType::Pro) => {
|
||||
return Some(PAID_TOOLTIP.to_string());
|
||||
}
|
||||
Some(PlanType::Go) | Some(PlanType::Free) => {
|
||||
return Some(FREE_GO_TOOLTIP.to_string());
|
||||
}
|
||||
_ => return Some(OTHER_TOOLTIP.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(announcement) = announcement::fetch_announcement_tip() {
|
||||
return Some(announcement);
|
||||
}
|
||||
let mut rng = rand::rng();
|
||||
|
||||
pick_tooltip(&mut rng).map(str::to_string)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,6 +48,8 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl
|
||||
history navigation, etc).
|
||||
- After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the
|
||||
latest text + cursor.
|
||||
- When a slash command name is completed and the user types a space, the `/command` token is
|
||||
promoted into a text element so it renders distinctly and edits atomically.
|
||||
|
||||
### History navigation (↑/↓)
|
||||
|
||||
@@ -105,6 +107,9 @@ There are multiple submission paths, but they share the same core rules:
|
||||
5. Clears pending pastes on success and suppresses submission if the final text is empty and there
|
||||
are no attachments.
|
||||
|
||||
The same preparation path is reused for slash commands with arguments (for example `/plan` and
|
||||
`/review`) so pasted content and text elements are preserved when extracting args.
|
||||
|
||||
### Numeric auto-submit path
|
||||
|
||||
When the slash popup is open and the first line matches a numeric-only custom prompt with
|
||||
|
||||
4
justfile
4
justfile
@@ -69,8 +69,8 @@ write-config-schema:
|
||||
cargo run -p codex-core --bin codex-write-config-schema
|
||||
|
||||
# Regenerate vendored app-server protocol schema artifacts.
|
||||
write-app-server-schema:
|
||||
cargo run -p codex-app-server-protocol --bin write_schema_fixtures
|
||||
write-app-server-schema *args:
|
||||
cargo run -p codex-app-server-protocol --bin write_schema_fixtures -- "$@"
|
||||
|
||||
# Tail logs from the state SQLite database
|
||||
log *args:
|
||||
|
||||
Reference in New Issue
Block a user