Compare commits

...

19 Commits

Author SHA1 Message Date
Celia Chen
6210111383 Merge branch 'main' into dev/cc/new-debug-command 2026-02-02 13:20:49 -08:00
Eric Traut
0f15ed4325 Updated labeler workflow prompt to include "app" label (#10411)
Support for desktop app issues
2026-02-02 13:13:14 -08:00
celia-oai
6ac69ce640 changes 2026-02-02 12:40:59 -08:00
celia-oai
c0dbd84533 changes 2026-02-02 12:36:07 -08:00
celia-oai
a5b8225480 change 2026-02-02 12:36:07 -08:00
viyatb-oai
f50c8b2f81 fix: unsafe auto-approval of git commands (#10258)
fixes https://github.com/openai/codex/issues/10160 and some more.

## Description

Hardens Git command safety to prevent approval bypasses for destructive
or write-capable invocations (branch delete, risky push forms,
output/config-override flags), so these commands no longer auto-run as
“safe.”

- `git branch -d` variants (especially in worktrees / with global
options like -C / -c)
- `git show|diff|log --output` ... style file-write flags
- risky Git config override flags (-c, --config-env) that can trigger
external execution
- dangerous push forms that weren’t fully caught (`--force*`,
`--delete`, `+refspec`, `:refspec`)
- grouped short-flag delete forms (e.g. stacked branch flags containing
`d/D`)

will fast follow with a common git policy to bring windows to parity.

---------

Co-authored-by: Eric Traut <etraut@openai.com>
2026-02-02 12:30:17 -08:00
jif-oai
059d386f03 feat: add --experimental to generate-ts (#10402)
Adding a `--experimental` flag to the `generate-ts` fct in the
app-sever.

It can be called through one of those 2 command
```
just write-app-server-schema --experimental
codex app-server generate-ts --experimental
```
2026-02-02 20:30:01 +00:00
pakrym-oai
74327fa59c Select experimental features with space (#10281) 2026-02-02 11:35:11 -08:00
jif-oai
34c0534f6e feat: drop sqlx logging (#10398) 2026-02-02 19:26:58 +00:00
jif-oai
0b460eda32 chore: ignore synthetic messages (#10394)
This will be fixed once this is settled:
https://www.notion.so/openai/Artificial-context-management-2fb8e50b62b080db8b8ed93b3b19d1a2#2fb8e50b62b080d2bffce2dd1e60972b
2026-02-02 18:13:48 +00:00
pakrym-oai
9d976962ec Add credits tooltip (#10274) 2026-02-02 10:06:43 -08:00
Charley Cunningham
3392c5af24 Nicer highlighting of slash commands, /plan accepts prompt args and pasted images (#10269)
## Summary
- Make typed slash commands become text elements when the user hits
space, including paste‑burst spaces.
- Enable `/plan` to accept inline args and submit them in plan mode,
mirroring `/review` behavior and blocking submission while a task is
running.
- Preserve text elements/attachments for slash commands that take args.

<img width="1510" height="500" alt="image"
src="https://github.com/user-attachments/assets/446024df-b69a-4249-85db-1a85110e07f1"
/>

## Changes
- Add safe helper to insert element ranges in the textarea.
- Extend command‑with‑args pipeline to carry text elements and reuse
submission prep.
- Update `/plan` dispatch to switch to plan mode then submit prompt +
elements.
- Document new composer behavior and add tests.

## Notes
- `/plan` is blocked during active tasks (same as `/review`).
- Slash‑command elementization recognizes built‑ins and `/prompts:`
custom commands only.

## Codex author
`codex fork 019c16d3-4520-7bb0-9b9d-48720d40a8ab`
2026-02-02 09:53:29 -08:00
Michael Bolin
d1e71cd202 feat: add MCP protocol types and rmcp adapters (#10356)
Currently, types from our custom `mcp-types` crate are part of some of
our APIs:


03fcd12e77/codex-rs/app-server-protocol/src/protocol/v2.rs (L43-L46)

To eliminate this crate in #10349 by switching to `rmcp`, we need our
own wrappers for the `rmcp` types that we can use in our API, which is
what this PR does.

Note this PR introduces the new API types, but we do not make use of
them until #10349.





---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/10356).
* #10357
* #10349
* __->__ #10356
2026-02-02 08:41:02 -08:00
jif-oai
4f1cfaf892 fix: Rfc3339 casting (#10386) 2026-02-02 13:33:28 +00:00
jif-oai
e9a774e7ae fix: thread listing (#10383) 2026-02-02 12:52:49 +00:00
jif-oai
4971e96a98 nit: shell snapshot retention to 3 days (#10382) 2026-02-02 12:52:45 +00:00
jif-oai
3cc9122ee2 feat: experimental flags (#10231)
## Problem being solved
- We need a single, reliable way to mark app-server API surface as
experimental so that:
  1. the runtime can reject experimental usage unless the client opts in
2. generated TS/JSON schemas can exclude experimental methods/fields for
stable clients.

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

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

## How the macro solves it
- The new derive macro lives in
`codex-rs/codex-experimental-api-macros/src/lib.rs` and is used via
`#[derive(ExperimentalApi)]` plus `#[experimental("reason")]`
attributes.
- **Structs**:
- Generates `ExperimentalApi::experimental_reason(&self)` that checks
only annotated fields.
  - The “presence” check is type-aware:
    - `Option<T>`: `is_some_and(...)` recursively checks inner.
    - `Vec`/`HashMap`/`BTreeMap`: must be non-empty.
    - `bool`: must be `true`.
    - Other types: considered present (returns `true`).
- Registers each experimental field in an `inventory` with `(type_name,
serialized field name, reason)` and exposes `EXPERIMENTAL_FIELDS` for
that type. Field names are converted from `snake_case` to `camelCase`
for schema/TS filtering.
- **Enums**:
- Generates an exhaustive `match` returning `Some(reason)` for annotated
variants and `None` otherwise (no wildcard arm).
- **Wiring**:
- Runtime gating uses `ExperimentalApi::experimental_reason()` in
`codex-rs/app-server/src/message_processor.rs` to reject requests unless
`InitializeParams.capabilities.experimental_api == true`.
- Schema/TS export filters use the inventory list and
`EXPERIMENTAL_CLIENT_METHODS` from `client_request_definitions!` to
strip experimental methods/fields when `experimental_api` is false.
2026-02-02 11:06:50 +00:00
jif-oai
9513f18bfe chore: collab experimental (#10381) 2026-02-02 10:57:44 +00:00
pap-openai
1644cbfc6d Session picker shows thread_name if set (#10340)
- shows names of threads in the ResumePicker used by `/resume` and
`codex resume` if set, default to preview (previous behaviour) if none
- adds a `find_thread_names_by_ids` that maps names to IDs in
`codex-rs/core/src/rollout/session_index.rs`. It reads sequentially in
normal (instead of reverse order in `codex resume <name>`) the index
mapping file. This function is called from a list of session (default
page is 25, pages loaded depends of height of terminal), for which most
of them will always have at least one session unnamed and require the
whole file to be read therefore. Could be better and sqlite integration
will make this better
- those reads won't be needed when leveraging sqlite
 

Opened questions:
- We could rename the TUI "Conversation" column to "Name" or "Thread"
that would feel more accurate. Could be a fast-follow if we implement
auto-naming as it'll always be a name instead?
2026-02-02 08:13:17 +00:00
71 changed files with 5432 additions and 2140 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,7 @@ members = [
"codex-client",
"codex-api",
"state",
"codex-experimental-api-macros",
]
resolver = "2"
@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
)
})
}

View File

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

View File

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

View File

@@ -1,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;

View File

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

View File

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

View File

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

View File

@@ -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(())
}

View 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 &notification_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();
}
}

View File

@@ -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 &notification_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()
}

View File

@@ -15,6 +15,7 @@
- [Skills](#skills)
- [Apps](#apps)
- [Auth endpoints](#auth-endpoints)
- [Adding an experimental field](#adding-an-experimental-field)
## Protocol
@@ -768,3 +769,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
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" }

View File

@@ -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)?,
},

View File

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

View File

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

View File

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

View File

@@ -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",
])));
}

View File

@@ -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"])));

View File

@@ -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()
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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)
{

View File

@@ -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()?;

View File

@@ -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
}
}

View File

@@ -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"];

View File

@@ -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,

View File

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

View File

@@ -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;

View 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);
}
}

View File

@@ -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 }

View File

@@ -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)

View File

@@ -2939,6 +2939,7 @@ mod tests {
app.chat_widget.current_model(),
event,
is_first,
None,
)) as Arc<dyn HistoryCell>
};

View File

@@ -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]

View File

@@ -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(),
])
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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),
}

View File

@@ -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

View File

@@ -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() {

View File

@@ -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));
}

View File

@@ -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());

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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* lets 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)
}

View File

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

View File

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

View File

@@ -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

View File

@@ -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: