Compare commits

..

1 Commits

Author SHA1 Message Date
Dylan Hurd
f5e52075af test 2026-01-16 22:00:21 -08:00
175 changed files with 1104 additions and 7517 deletions

View File

@@ -59,7 +59,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.92
- uses: dtolnay/rust-toolchain@1.90
with:
components: rustfmt
- name: cargo fmt
@@ -77,7 +77,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.92
- uses: dtolnay/rust-toolchain@1.90
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: cargo-shear
@@ -177,7 +177,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.92
- uses: dtolnay/rust-toolchain@1.90
with:
targets: ${{ matrix.target }}
components: clippy
@@ -416,7 +416,7 @@ jobs:
- name: Install DotSlash
uses: facebook/install-dotslash@v2
- uses: dtolnay/rust-toolchain@1.92
- uses: dtolnay/rust-toolchain@1.90
with:
targets: ${{ matrix.target }}

View File

@@ -80,7 +80,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.92
- uses: dtolnay/rust-toolchain@1.90
with:
targets: ${{ matrix.target }}

View File

@@ -24,7 +24,7 @@ jobs:
node-version: 22
cache: pnpm
- uses: dtolnay/rust-toolchain@1.92
- uses: dtolnay/rust-toolchain@1.90
- name: build codex
run: cargo build --bin codex

View File

@@ -93,7 +93,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.92
- uses: dtolnay/rust-toolchain@1.90
with:
targets: ${{ matrix.target }}

47
codex-rs/Cargo.lock generated
View File

@@ -360,19 +360,16 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "arc-swap"
version = "1.8.0"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e"
dependencies = [
"rustversion",
]
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "arrayvec"
@@ -864,9 +861,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.43"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -1301,7 +1298,7 @@ dependencies = [
"codex-windows-sandbox",
"core-foundation 0.9.4",
"core_test_support",
"ctor 0.6.3",
"ctor 0.5.0",
"dirs",
"dunce",
"encoding_rs",
@@ -1684,7 +1681,7 @@ dependencies = [
"anyhow",
"clap",
"codex-process-hardening",
"ctor 0.6.3",
"ctor 0.5.0",
"libc",
"reqwest",
"serde",
@@ -2265,9 +2262,9 @@ dependencies = [
[[package]]
name = "ctor"
version = "0.6.3"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e"
checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb"
dependencies = [
"ctor-proc-macro",
"dtor",
@@ -2275,9 +2272,9 @@ dependencies = [
[[package]]
name = "ctor-proc-macro"
version = "0.0.7"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]]
name = "darling"
@@ -2832,7 +2829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2929,7 +2926,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.0.8",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -3870,7 +3867,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -4163,9 +4160,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "logos"
@@ -5381,7 +5378,7 @@ dependencies = [
"once_cell",
"socket2 0.6.1",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5760,7 +5757,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -5773,7 +5770,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7074,9 +7071,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.49.0"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"bytes",
"libc",
@@ -8091,7 +8088,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]

View File

@@ -122,12 +122,12 @@ axum = { version = "0.8", default-features = false }
base64 = "0.22.1"
bytes = "1.10.1"
chardetng = "0.1.17"
chrono = "0.4.43"
chrono = "0.4.42"
clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
crossterm = "0.28.1"
ctor = "0.6.3"
ctor = "0.5.0"
derive_more = "2"
diffy = "0.4.2"
dirs = "6"

View File

@@ -133,10 +133,6 @@ client_request_definitions! {
params: v2::SkillsListParams,
response: v2::SkillsListResponse,
},
SkillsConfigWrite => "skills/config/write" {
params: v2::SkillsConfigWriteParams,
response: v2::SkillsConfigWriteResponse,
},
TurnStart => "turn/start" {
params: v2::TurnStartParams,
response: v2::TurnStartResponse,
@@ -154,11 +150,6 @@ client_request_definitions! {
params: v2::ModelListParams,
response: v2::ModelListResponse,
},
/// EXPERIMENTAL - list collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
params: v2::CollaborationModeListParams,
response: v2::CollaborationModeListResponse,
},
McpServerOauthLogin => "mcpServer/oauth/login" {
params: v2::McpServerOauthLoginParams,
@@ -510,12 +501,6 @@ server_request_definitions! {
response: v2::FileChangeRequestApprovalResponse,
},
/// EXPERIMENTAL - Request input from the user for a tool call.
ToolRequestUserInput => "item/tool/requestUserInput" {
params: v2::ToolRequestUserInputParams,
response: v2::ToolRequestUserInputResponse,
},
/// DEPRECATED APIs below
/// Request to approve a patch.
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
@@ -889,21 +874,4 @@ mod tests {
);
Ok(())
}
#[test]
fn serialize_list_collaboration_modes() -> Result<()> {
let request = ClientRequest::CollaborationModeList {
request_id: RequestId::Integer(7),
params: v2::CollaborationModeListParams::default(),
};
assert_eq!(
json!({
"method": "collaborationMode/list",
"id": 7,
"params": {}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
}

View File

@@ -128,7 +128,6 @@ pub struct ConversationSummary {
pub path: PathBuf,
pub preview: String,
pub timestamp: Option<String>,
pub updated_at: Option<String>,
pub model_provider: String,
pub cwd: PathBuf,
pub cli_version: String,

View File

@@ -4,7 +4,6 @@ use std::path::PathBuf;
use crate::protocol::common::AuthMode;
use codex_protocol::account::PlanType;
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode as CoreSandboxMode;
@@ -917,20 +916,6 @@ pub struct ModelListResponse {
pub next_cursor: Option<String>,
}
/// EXPERIMENTAL - list collaboration mode presets.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CollaborationModeListParams {}
/// EXPERIMENTAL - collaboration mode presets response.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CollaborationModeListResponse {
pub data: Vec<CollaborationMode>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1197,21 +1182,11 @@ pub struct ThreadListParams {
pub cursor: Option<String>,
/// Optional page size; defaults to a reasonable server-side value.
pub limit: Option<u32>,
/// Optional sort key; defaults to created_at.
pub sort_key: Option<ThreadSortKey>,
/// Optional provider filter; when set, only sessions recorded under these
/// providers are returned. When present but empty, includes all providers.
pub model_providers: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub enum ThreadSortKey {
CreatedAt,
UpdatedAt,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1289,7 +1264,6 @@ pub struct SkillMetadata {
pub interface: Option<SkillInterface>,
pub path: PathBuf,
pub scope: SkillScope,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1327,21 +1301,6 @@ pub struct SkillsListEntry {
pub errors: Vec<SkillErrorInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsConfigWriteParams {
pub path: PathBuf,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsConfigWriteResponse {
pub effective_enabled: bool,
}
impl From<CoreSkillMetadata> for SkillMetadata {
fn from(value: CoreSkillMetadata) -> Self {
Self {
@@ -1351,7 +1310,6 @@ impl From<CoreSkillMetadata> for SkillMetadata {
interface: value.interface.map(SkillInterface::from),
path: value.path,
scope: value.scope.into(),
enabled: true,
}
}
}
@@ -1401,9 +1359,6 @@ pub struct Thread {
/// Unix timestamp (in seconds) when the thread was created.
#[ts(type = "number")]
pub created_at: i64,
/// Unix timestamp (in seconds) when the thread was last updated.
#[ts(type = "number")]
pub updated_at: i64,
/// [UNSTABLE] Path to the thread on disk.
pub path: PathBuf,
/// Working directory captured for the thread.
@@ -1553,10 +1508,6 @@ pub struct TurnStartParams {
pub summary: Option<ReasoningSummary>,
/// Optional JSON Schema used to constrain the final assistant message for this turn.
pub output_schema: Option<JsonValue>,
/// EXPERIMENTAL - set a pre-set collaboration mode.
/// Takes precedence over model, reasoning_effort, and developer instructions if set.
pub collaboration_mode: Option<CollaborationMode>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -2277,54 +2228,6 @@ pub struct FileChangeRequestApprovalResponse {
pub decision: FileChangeApprovalDecision,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL. Defines a single selectable option for request_user_input.
pub struct ToolRequestUserInputOption {
pub label: String,
pub description: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL. Represents one request_user_input question and its optional options.
pub struct ToolRequestUserInputQuestion {
pub id: String,
pub header: String,
pub question: String,
pub options: Option<Vec<ToolRequestUserInputOption>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL. Params sent with a request_user_input event.
pub struct ToolRequestUserInputParams {
pub thread_id: String,
pub turn_id: String,
pub item_id: String,
pub questions: Vec<ToolRequestUserInputQuestion>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL. Captures a user's answer to a request_user_input question.
pub struct ToolRequestUserInputAnswer {
pub selected: Vec<String>,
pub other: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL. Response payload mapping question ids to answers.
pub struct ToolRequestUserInputResponse {
pub answers: HashMap<String, ToolRequestUserInputAnswer>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View File

@@ -86,11 +86,8 @@ Example (from OpenAI's official VSCode extension):
- `review/start` — kick off Codexs automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `model/list` — list available models (with reasoning effort options).
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination).
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `skills/config/write` — write user-level skill config by path.
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 13 short questions for a tool call and return their answers (experimental).
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id.
@@ -141,11 +138,10 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
### Example: List threads (with pagination & filters)
`thread/list` lets you render a history UI. Results default to `createdAt` (newest first) descending. Pass any combination of:
`thread/list` lets you render a history UI. Pass any combination of:
- `cursor` — opaque string from a prior response; omit for the first page.
- `limit` — server defaults to a reasonable page size if unset.
- `sortKey``created_at` (default) or `updated_at`.
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
Example:
@@ -154,12 +150,11 @@ Example:
{ "method": "thread/list", "id": 20, "params": {
"cursor": null,
"limit": 25,
"sortKey": "created_at"
} }
{ "id": 20, "result": {
"data": [
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111 },
{ "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000 }
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111 },
{ "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000 }
],
"nextCursor": "opaque-token-or-null"
} }
@@ -471,15 +466,8 @@ Invoke a skill by including `$<skill-name>` in the text input. Add a `skill` inp
"params": {
"threadId": "thread-1",
"input": [
{
"type": "text",
"text": "$skill-creator Add a new skill for triaging flaky CI."
},
{
"type": "skill",
"name": "skill-creator",
"path": "/Users/me/.codex/skills/skill-creator/SKILL.md"
}
{ "type": "text", "text": "$skill-creator Add a new skill for triaging flaky CI." },
{ "type": "skill", "name": "skill-creator", "path": "/Users/me/.codex/skills/skill-creator/SKILL.md" }
]
}
}
@@ -493,37 +481,20 @@ Example:
$skill-creator Add a new skill for triaging flaky CI and include step-by-step usage.
```
Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`).
Use `skills/list` to fetch the available skills (optionally scoped by `cwd` and/or with `forceReload`).
```json
{ "method": "skills/list", "id": 25, "params": {
"cwds": ["/Users/me/project"],
"cwd": "/Users/me/project",
"forceReload": false
} }
{ "id": 25, "result": {
"data": [{
"cwd": "/Users/me/project",
"skills": [
{ "name": "skill-creator", "description": "Create or update a Codex skill", "enabled": true }
],
"errors": []
}]
"skills": [
{ "name": "skill-creator", "description": "Create or update a Codex skill" }
]
} }
```
To enable or disable a skill by path:
```json
{
"method": "skills/config/write",
"id": 26,
"params": {
"path": "/Users/me/.codex/skills/skill-creator/SKILL.md",
"enabled": false
}
}
```
## Auth endpoints
The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.

View File

@@ -54,10 +54,6 @@ use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::ToolRequestUserInputOption;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnDiffUpdatedNotification;
@@ -87,8 +83,6 @@ use codex_core::review_prompts;
use codex_protocol::ThreadId;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::path::PathBuf;
@@ -264,57 +258,6 @@ pub(crate) async fn apply_bespoke_event_handling(
});
}
},
EventMsg::RequestUserInput(request) => {
if matches!(api_version, ApiVersion::V2) {
let questions = request
.questions
.into_iter()
.map(|question| ToolRequestUserInputQuestion {
id: question.id,
header: question.header,
question: question.question,
options: question.options.map(|options| {
options
.into_iter()
.map(|option| ToolRequestUserInputOption {
label: option.label,
description: option.description,
})
.collect()
}),
})
.collect();
let params = ToolRequestUserInputParams {
thread_id: conversation_id.to_string(),
turn_id: request.turn_id,
item_id: request.call_id,
questions,
};
let rx = outgoing
.send_request(ServerRequestPayload::ToolRequestUserInput(params))
.await;
tokio::spawn(async move {
on_request_user_input_response(event_turn_id, rx, conversation).await;
});
} else {
error!(
"request_user_input is only supported on api v2 (call_id: {})",
request.call_id
);
let empty = CoreRequestUserInputResponse {
answers: HashMap::new(),
};
if let Err(err) = conversation
.submit(Op::UserInputAnswer {
id: event_turn_id,
response: empty,
})
.await
{
error!("failed to submit UserInputAnswer: {err}");
}
}
}
// TODO(celia): properly construct McpToolCall TurnItem in core.
EventMsg::McpToolCallBegin(begin_event) => {
let notification = construct_mcp_tool_call_notification(
@@ -1404,66 +1347,6 @@ async fn on_exec_approval_response(
}
}
async fn on_request_user_input_response(
event_turn_id: String,
receiver: oneshot::Receiver<JsonValue>,
conversation: Arc<CodexThread>,
) {
let response = receiver.await;
let value = match response {
Ok(value) => value,
Err(err) => {
error!("request failed: {err:?}");
let empty = CoreRequestUserInputResponse {
answers: HashMap::new(),
};
if let Err(err) = conversation
.submit(Op::UserInputAnswer {
id: event_turn_id,
response: empty,
})
.await
{
error!("failed to submit UserInputAnswer: {err}");
}
return;
}
};
let response =
serde_json::from_value::<ToolRequestUserInputResponse>(value).unwrap_or_else(|err| {
error!("failed to deserialize ToolRequestUserInputResponse: {err}");
ToolRequestUserInputResponse {
answers: HashMap::new(),
}
});
let response = CoreRequestUserInputResponse {
answers: response
.answers
.into_iter()
.map(|(id, answer)| {
(
id,
CoreRequestUserInputAnswer {
selected: answer.selected,
other: answer.other,
},
)
})
.collect(),
};
if let Err(err) = conversation
.submit(Op::UserInputAnswer {
id: event_turn_id,
response,
})
.await
{
error!("failed to submit UserInputAnswer: {err}");
}
}
const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response.";
fn render_review_output_text(output: &ReviewOutputEvent) -> String {

View File

@@ -6,7 +6,6 @@ use crate::models::supported_models;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotification;
use chrono::DateTime;
use chrono::SecondsFormat;
use chrono::Utc;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
@@ -23,8 +22,6 @@ use codex_app_server_protocol::CancelLoginAccountResponse;
use codex_app_server_protocol::CancelLoginAccountStatus;
use codex_app_server_protocol::CancelLoginChatGptResponse;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
@@ -87,8 +84,6 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SessionConfiguredNotification;
use codex_app_server_protocol::SetDefaultModelParams;
use codex_app_server_protocol::SetDefaultModelResponse;
use codex_app_server_protocol::SkillsConfigWriteParams;
use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread;
@@ -104,7 +99,6 @@ use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
@@ -129,13 +123,11 @@ use codex_core::NewThread;
use codex_core::RolloutRecorder;
use codex_core::SessionMeta;
use codex_core::ThreadManager;
use codex_core::ThreadSortKey as CoreThreadSortKey;
use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigService;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::default_client::get_codex_user_agent;
@@ -405,9 +397,6 @@ impl CodexMessageProcessor {
ClientRequest::SkillsList { request_id, params } => {
self.skills_list(request_id, params).await;
}
ClientRequest::SkillsConfigWrite { request_id, params } => {
self.skills_config_write(request_id, params).await;
}
ClientRequest::TurnStart { request_id, params } => {
self.turn_start(request_id, params).await;
}
@@ -438,15 +427,6 @@ impl CodexMessageProcessor {
Self::list_models(outgoing, thread_manager, config, request_id, params).await;
});
}
ClientRequest::CollaborationModeList { request_id, params } => {
let outgoing = self.outgoing.clone();
let thread_manager = self.thread_manager.clone();
tokio::spawn(async move {
Self::list_collaboration_modes(outgoing, thread_manager, request_id, params)
.await;
});
}
ClientRequest::McpServerOauthLogin { request_id, params } => {
self.mcp_server_oauth_login(request_id, params).await;
}
@@ -1618,7 +1598,6 @@ impl CodexMessageProcessor {
let ThreadListParams {
cursor,
limit,
sort_key,
model_providers,
} = params;
@@ -1626,12 +1605,8 @@ impl CodexMessageProcessor {
.map(|value| value as usize)
.unwrap_or(THREAD_LIST_DEFAULT_LIMIT)
.clamp(1, THREAD_LIST_MAX_LIMIT);
let core_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) {
ThreadSortKey::CreatedAt => CoreThreadSortKey::CreatedAt,
ThreadSortKey::UpdatedAt => CoreThreadSortKey::UpdatedAt,
};
let (summaries, next_cursor) = match self
.list_threads_common(requested_page_size, cursor, model_providers, core_sort_key)
.list_threads_common(requested_page_size, cursor, model_providers)
.await
{
Ok(r) => r,
@@ -2196,12 +2171,7 @@ impl CodexMessageProcessor {
.clamp(1, THREAD_LIST_MAX_LIMIT);
match self
.list_threads_common(
requested_page_size,
cursor,
model_providers,
CoreThreadSortKey::UpdatedAt,
)
.list_threads_common(requested_page_size, cursor, model_providers)
.await
{
Ok((items, next_cursor)) => {
@@ -2219,18 +2189,8 @@ impl CodexMessageProcessor {
requested_page_size: usize,
cursor: Option<String>,
model_providers: Option<Vec<String>>,
sort_key: CoreThreadSortKey,
) -> Result<(Vec<ConversationSummary>, Option<String>), JSONRPCErrorError> {
let mut cursor_obj: Option<RolloutCursor> = match cursor.as_ref() {
Some(cursor_str) => {
Some(parse_cursor(cursor_str).ok_or_else(|| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid cursor: {cursor_str}"),
data: None,
})?)
}
None => None,
};
let mut cursor_obj: Option<RolloutCursor> = cursor.as_ref().and_then(|s| parse_cursor(s));
let mut last_cursor = cursor_obj.clone();
let mut remaining = requested_page_size;
let mut items = Vec::with_capacity(requested_page_size);
@@ -2254,7 +2214,6 @@ impl CodexMessageProcessor {
&self.config.codex_home,
page_size,
cursor_obj.as_ref(),
sort_key,
INTERACTIVE_SESSION_SOURCES,
model_provider_filter.as_deref(),
fallback_provider.as_str(),
@@ -2270,7 +2229,6 @@ impl CodexMessageProcessor {
.items
.into_iter()
.filter_map(|it| {
let updated_at = it.updated_at.clone();
let session_meta_line = it.head.first().and_then(|first| {
serde_json::from_value::<SessionMetaLine>(first.clone()).ok()
})?;
@@ -2280,7 +2238,6 @@ impl CodexMessageProcessor {
&session_meta_line.meta,
session_meta_line.git.as_ref(),
fallback_provider.as_str(),
updated_at,
)
})
.collect::<Vec<_>>();
@@ -2382,18 +2339,6 @@ impl CodexMessageProcessor {
outgoing.send_response(request_id, response).await;
}
async fn list_collaboration_modes(
outgoing: Arc<OutgoingMessageSender>,
thread_manager: Arc<ThreadManager>,
request_id: RequestId,
params: CollaborationModeListParams,
) {
let CollaborationModeListParams {} = params;
let items = thread_manager.list_collaboration_modes();
let response = CollaborationModeListResponse { data: items };
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,
@@ -3254,7 +3199,6 @@ impl CodexMessageProcessor {
effort,
summary,
final_output_json_schema: output_schema,
collaboration_mode: None,
})
.await;
@@ -3276,7 +3220,7 @@ impl CodexMessageProcessor {
for cwd in cwds {
let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await;
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths);
let skills = skills_to_info(&outcome.skills);
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills,
@@ -3288,37 +3232,6 @@ impl CodexMessageProcessor {
.await;
}
async fn skills_config_write(&self, request_id: RequestId, params: SkillsConfigWriteParams) {
let SkillsConfigWriteParams { path, enabled } = params;
let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }];
let result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await;
match result {
Ok(()) => {
self.thread_manager.skills_manager().clear_cache();
self.outgoing
.send_response(
request_id,
SkillsConfigWriteResponse {
effective_enabled: enabled,
},
)
.await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to update skill settings: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
}
async fn interrupt_conversation(
&mut self,
request_id: RequestId,
@@ -3368,8 +3281,7 @@ impl CodexMessageProcessor {
|| params.sandbox_policy.is_some()
|| params.model.is_some()
|| params.effort.is_some()
|| params.summary.is_some()
|| params.collaboration_mode.is_some();
|| params.summary.is_some();
// If any overrides are provided, update the session turn context first.
if has_any_overrides {
@@ -3381,7 +3293,6 @@ impl CodexMessageProcessor {
model: params.model,
effort: params.effort.map(Some),
summary: params.summary,
collaboration_mode: params.collaboration_mode,
})
.await;
}
@@ -3981,30 +3892,25 @@ impl CodexMessageProcessor {
fn skills_to_info(
skills: &[codex_core::skills::SkillMetadata],
disabled_paths: &std::collections::HashSet<PathBuf>,
) -> Vec<codex_app_server_protocol::SkillMetadata> {
skills
.iter()
.map(|skill| {
let enabled = !disabled_paths.contains(&skill.path);
codex_app_server_protocol::SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| {
codex_app_server_protocol::SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}
}),
path: skill.path.clone(),
scope: skill.scope.into(),
enabled,
}
.map(|skill| codex_app_server_protocol::SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| {
codex_app_server_protocol::SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}
}),
path: skill.path.clone(),
scope: skill.scope.into(),
})
.collect()
}
@@ -4076,19 +3982,12 @@ pub(crate) async fn read_summary_from_rollout(
git,
} = session_meta_line;
let created_at = if session_meta.timestamp.is_empty() {
None
} else {
Some(session_meta.timestamp.as_str())
};
let updated_at = read_updated_at(path, created_at).await;
if let Some(summary) = extract_conversation_summary(
path.to_path_buf(),
&head,
&session_meta,
git.as_ref(),
fallback_provider,
updated_at.clone(),
) {
return Ok(summary);
}
@@ -4103,12 +4002,10 @@ pub(crate) async fn read_summary_from_rollout(
.clone()
.unwrap_or_else(|| fallback_provider.to_string());
let git_info = git.as_ref().map(map_git_info);
let updated_at = updated_at.or_else(|| timestamp.clone());
Ok(ConversationSummary {
conversation_id: session_meta.id,
timestamp,
updated_at,
path: path.to_path_buf(),
preview: String::new(),
model_provider,
@@ -4143,7 +4040,6 @@ fn extract_conversation_summary(
session_meta: &SessionMeta,
git: Option<&CoreGitInfo>,
fallback_provider: &str,
updated_at: Option<String>,
) -> Option<ConversationSummary> {
let preview = head
.iter()
@@ -4169,12 +4065,10 @@ fn extract_conversation_summary(
.clone()
.unwrap_or_else(|| fallback_provider.to_string());
let git_info = git.map(map_git_info);
let updated_at = updated_at.or_else(|| timestamp.clone());
Some(ConversationSummary {
conversation_id,
timestamp,
updated_at,
path,
preview: preview.to_string(),
model_provider,
@@ -4201,25 +4095,12 @@ fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
})
}
async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option<String> {
let updated_at = tokio::fs::metadata(path)
.await
.ok()
.and_then(|meta| meta.modified().ok())
.map(|modified| {
let updated_at: DateTime<Utc> = modified.into();
updated_at.to_rfc3339_opts(SecondsFormat::Secs, true)
});
updated_at.or_else(|| created_at.map(str::to_string))
}
pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
let ConversationSummary {
conversation_id,
path,
preview,
timestamp,
updated_at,
model_provider,
cwd,
cli_version,
@@ -4228,7 +4109,6 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
} = summary;
let created_at = parse_datetime(timestamp.as_deref());
let updated_at = parse_datetime(updated_at.as_deref()).or(created_at);
let git_info = git_info.map(|info| ApiGitInfo {
sha: info.sha,
branch: info.branch,
@@ -4240,7 +4120,6 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
preview,
model_provider,
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0),
path,
cwd,
cli_version,
@@ -4272,6 +4151,7 @@ mod tests {
"cwd": "/",
"originator": "codex",
"cli_version": "0.0.0",
"instructions": null,
"model_provider": "test-provider"
}),
json!({
@@ -4294,20 +4174,13 @@ mod tests {
let session_meta = serde_json::from_value::<SessionMeta>(head[0].clone())?;
let summary = extract_conversation_summary(
path.clone(),
&head,
&session_meta,
None,
"test-provider",
timestamp.clone(),
)
.expect("summary");
let summary =
extract_conversation_summary(path.clone(), &head, &session_meta, None, "test-provider")
.expect("summary");
let expected = ConversationSummary {
conversation_id,
timestamp: timestamp.clone(),
updated_at: timestamp,
timestamp,
path,
preview: "Count to 5".to_string(),
model_provider: "test-provider".to_string(),
@@ -4327,7 +4200,6 @@ mod tests {
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMetaLine;
use std::fs;
use std::fs::FileTimes;
let temp_dir = TempDir::new()?;
let path = temp_dir.path().join("rollout.jsonl");
@@ -4351,19 +4223,12 @@ mod tests {
};
fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?;
let parsed = chrono::DateTime::parse_from_rfc3339(&timestamp)?.with_timezone(&Utc);
let times = FileTimes::new().set_modified(parsed.into());
std::fs::OpenOptions::new()
.append(true)
.open(&path)?
.set_times(times)?;
let summary = read_summary_from_rollout(path.as_path(), "fallback").await?;
let expected = ConversationSummary {
conversation_id,
timestamp: Some(timestamp.clone()),
updated_at: Some("2025-09-05T16:53:11Z".to_string()),
timestamp: Some(timestamp),
path: path.clone(),
preview: String::new(),
model_provider: "fallback".to_string(),

View File

@@ -27,11 +27,9 @@ pub use models_cache::write_models_cache_with_models;
pub use responses::create_apply_patch_sse_response;
pub use responses::create_exec_command_sse_response;
pub use responses::create_final_assistant_message_sse_response;
pub use responses::create_request_user_input_sse_response;
pub use responses::create_shell_command_sse_response;
pub use rollout::create_fake_rollout;
pub use rollout::create_fake_rollout_with_text_elements;
pub use rollout::rollout_path;
use serde::de::DeserializeOwned;
pub fn to_response<T: DeserializeOwned>(response: JSONRPCResponse) -> anyhow::Result<T> {

View File

@@ -17,7 +17,6 @@ use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::CancelLoginChatGptParams;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
@@ -397,15 +396,6 @@ impl McpProcess {
self.send_request("model/list", params).await
}
/// Send a `collaborationMode/list` JSON-RPC request.
pub async fn send_list_collaboration_modes_request(
&mut self,
params: CollaborationModeListParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("collaborationMode/list", params).await
}
/// Send a `resumeConversation` JSON-RPC request.
pub async fn send_resume_conversation_request(
&mut self,

View File

@@ -60,26 +60,3 @@ pub fn create_exec_command_sse_response(call_id: &str) -> anyhow::Result<String>
responses::ev_completed("resp-1"),
]))
}
pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result<String> {
let tool_call_arguments = serde_json::to_string(&json!({
"questions": [{
"id": "confirm_path",
"header": "Confirm",
"question": "Proceed with the plan?",
"options": [{
"label": "Yes (Recommended)",
"description": "Continue the current plan."
}, {
"label": "No",
"description": "Stop and revisit the approach."
}]
}]
}))?;
Ok(responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "request_user_input", &tool_call_arguments),
responses::ev_completed("resp-1"),
]))
}

View File

@@ -6,23 +6,10 @@ use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use serde_json::json;
use std::fs;
use std::fs::FileTimes;
use std::path::Path;
use std::path::PathBuf;
use uuid::Uuid;
pub fn rollout_path(codex_home: &Path, filename_ts: &str, thread_id: &str) -> PathBuf {
let year = &filename_ts[0..4];
let month = &filename_ts[5..7];
let day = &filename_ts[8..10];
codex_home
.join("sessions")
.join(year)
.join(month)
.join(day)
.join(format!("rollout-{filename_ts}-{thread_id}.jsonl"))
}
/// Create a minimal rollout file under `CODEX_HOME/sessions/YYYY/MM/DD/`.
///
/// - `filename_ts` is the filename timestamp component in `YYYY-MM-DDThh-mm-ss` format.
@@ -43,20 +30,23 @@ pub fn create_fake_rollout(
let uuid_str = uuid.to_string();
let conversation_id = ThreadId::from_string(&uuid_str)?;
let file_path = rollout_path(codex_home, filename_ts, &uuid_str);
let dir = file_path
.parent()
.ok_or_else(|| anyhow::anyhow!("missing rollout parent directory"))?;
fs::create_dir_all(dir)?;
// sessions/YYYY/MM/DD derived from filename_ts (YYYY-MM-DDThh-mm-ss)
let year = &filename_ts[0..4];
let month = &filename_ts[5..7];
let day = &filename_ts[8..10];
let dir = codex_home.join("sessions").join(year).join(month).join(day);
fs::create_dir_all(&dir)?;
let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl"));
// Build JSONL lines
let meta = SessionMeta {
id: conversation_id,
forked_from_id: None,
timestamp: meta_rfc3339.to_string(),
cwd: PathBuf::from("/"),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
instructions: None,
source: SessionSource::Cli,
model_provider: model_provider.map(str::to_string),
};
@@ -94,13 +84,7 @@ pub fn create_fake_rollout(
.to_string(),
];
fs::write(&file_path, lines.join("\n") + "\n")?;
let parsed = chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc);
let times = FileTimes::new().set_modified(parsed.into());
std::fs::OpenOptions::new()
.append(true)
.open(&file_path)?
.set_times(times)?;
fs::write(file_path, lines.join("\n") + "\n")?;
Ok(uuid_str)
}
@@ -129,11 +113,11 @@ pub fn create_fake_rollout_with_text_elements(
// Build JSONL lines
let meta = SessionMeta {
id: conversation_id,
forked_from_id: None,
timestamp: meta_rfc3339.to_string(),
cwd: PathBuf::from("/"),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
instructions: None,
source: SessionSource::Cli,
model_provider: model_provider.map(str::to_string),
};

View File

@@ -1,86 +0,0 @@
//! Validates that the collaboration mode list endpoint returns the expected default presets.
//!
//! The test drives the app server through the MCP harness and asserts that the list response
//! includes the plan, pair programming, and execute modes with their default model and reasoning
//! effort settings, which keeps the API contract visible in one place.
#![allow(clippy::unwrap_used)]
use std::time::Duration;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_core::models_manager::test_builtin_collaboration_mode_presets;
use codex_protocol::config_types::CollaborationMode;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
/// Confirms the server returns the default collaboration mode presets in a stable order.
#[tokio::test]
async fn list_collaboration_modes_returns_presets() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_list_collaboration_modes_request(CollaborationModeListParams {})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let CollaborationModeListResponse { data: items } =
to_response::<CollaborationModeListResponse>(response)?;
let expected = vec![plan_preset(), pair_programming_preset(), execute_preset()];
assert_eq!(expected, items);
Ok(())
}
/// Builds the plan preset that the list response is expected to return.
///
/// If the defaults change in the app server, this helper should be updated alongside the
/// contract, or the test will fail in ways that imply a regression in the API.
fn plan_preset() -> CollaborationMode {
let presets = test_builtin_collaboration_mode_presets();
presets
.into_iter()
.find(|p| matches!(p, CollaborationMode::Plan(_)))
.unwrap()
}
/// Builds the pair programming preset that the list response is expected to return.
///
/// The helper keeps the expected model and reasoning defaults co-located with the test
/// so that mismatches point directly at the API contract being exercised.
fn pair_programming_preset() -> CollaborationMode {
let presets = test_builtin_collaboration_mode_presets();
presets
.into_iter()
.find(|p| matches!(p, CollaborationMode::PairProgramming(_)))
.unwrap()
}
/// Builds the execute preset that the list response is expected to return.
///
/// The execute preset uses a different reasoning effort to capture the higher-effort
/// execution contract the server currently exposes.
fn execute_preset() -> CollaborationMode {
let presets = test_builtin_collaboration_mode_presets();
presets
.into_iter()
.find(|p| matches!(p, CollaborationMode::Execute(_)))
.unwrap()
}

View File

@@ -1,12 +1,10 @@
mod account;
mod analytics;
mod collaboration_mode_list;
mod config_rpc;
mod initialize;
mod model_list;
mod output_schema;
mod rate_limits;
mod request_user_input;
mod review;
mod thread_archive;
mod thread_fork;

View File

@@ -1,127 +0,0 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_request_user_input_sse_response;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
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::UserInput as V2UserInput;
use codex_protocol::openai_models::ReasoningEffort;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn request_user_input_round_trip() -> Result<()> {
let codex_home = tempfile::TempDir::new()?;
let responses = vec![
create_request_user_input_sse_response("call1")?,
create_final_assistant_message_sse_response("done")?,
];
let server = create_mock_responses_server_sequence(responses).await;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
let turn_start_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "ask something".to_string(),
text_elements: Vec::new(),
}],
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
..Default::default()
})
.await?;
let turn_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
)
.await??;
let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?;
let server_req = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::ToolRequestUserInput { request_id, params } = server_req else {
panic!("expected ToolRequestUserInput request, got: {server_req:?}");
};
assert_eq!(params.thread_id, thread.id);
assert_eq!(params.turn_id, turn.id);
assert_eq!(params.item_id, "call1");
assert_eq!(params.questions.len(), 1);
mcp.send_response(
request_id,
serde_json::json!({
"answers": {
"confirm_path": { "selected": ["yes"], "other": serde_json::Value::Null }
}
}),
)
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
fn create_config_toml(codex_home: &std::path::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 = "untrusted"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[features]
collaboration_modes = true
[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

@@ -1,27 +1,17 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_fake_rollout;
use app_test_support::rollout_path;
use app_test_support::to_response;
use chrono::DateTime;
use chrono::Utc;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use pretty_assertions::assert_eq;
use std::cmp::Reverse;
use std::fs::FileTimes;
use std::fs::OpenOptions;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::time::timeout;
use uuid::Uuid;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
@@ -36,22 +26,11 @@ async fn list_threads(
cursor: Option<String>,
limit: Option<u32>,
providers: Option<Vec<String>>,
) -> Result<ThreadListResponse> {
list_threads_with_sort(mcp, cursor, limit, providers, None).await
}
async fn list_threads_with_sort(
mcp: &mut McpProcess,
cursor: Option<String>,
limit: Option<u32>,
providers: Option<Vec<String>>,
sort_key: Option<ThreadSortKey>,
) -> Result<ThreadListResponse> {
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
cursor,
limit,
sort_key,
model_providers: providers,
})
.await?;
@@ -103,16 +82,6 @@ fn timestamp_at(
)
}
fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> {
let parsed = DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc);
let times = FileTimes::new().set_modified(parsed.into());
OpenOptions::new()
.append(true)
.open(path)?
.set_times(times)?;
Ok(())
}
#[tokio::test]
async fn thread_list_basic_empty() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -194,7 +163,6 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
assert_eq!(thread.preview, "Hello");
assert_eq!(thread.model_provider, "mock_provider");
assert!(thread.created_at > 0);
assert_eq!(thread.updated_at, thread.created_at);
assert_eq!(thread.cwd, PathBuf::from("/"));
assert_eq!(thread.cli_version, "0.0.0");
assert_eq!(thread.source, SessionSource::Cli);
@@ -218,7 +186,6 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
assert_eq!(thread.preview, "Hello");
assert_eq!(thread.model_provider, "mock_provider");
assert!(thread.created_at > 0);
assert_eq!(thread.updated_at, thread.created_at);
assert_eq!(thread.cwd, PathBuf::from("/"));
assert_eq!(thread.cli_version, "0.0.0");
assert_eq!(thread.source, SessionSource::Cli);
@@ -269,7 +236,6 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
assert_eq!(thread.model_provider, "other_provider");
let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp();
assert_eq!(thread.created_at, expected_ts);
assert_eq!(thread.updated_at, expected_ts);
assert_eq!(thread.cwd, PathBuf::from("/"));
assert_eq!(thread.cli_version, "0.0.0");
assert_eq!(thread.source, SessionSource::Cli);
@@ -463,351 +429,3 @@ async fn thread_list_includes_git_info() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_list_default_sorts_by_created_at() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_a = create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let id_b = create_fake_rollout(
codex_home.path(),
"2025-01-01T13-00-00",
"2025-01-01T13:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let id_c = create_fake_rollout(
codex_home.path(),
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads_with_sort(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
)
.await?;
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
Ok(())
}
#[tokio::test]
async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_old = create_fake_rollout(
codex_home.path(),
"2025-01-01T10-00-00",
"2025-01-01T10:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let id_mid = create_fake_rollout(
codex_home.path(),
"2025-01-01T11-00-00",
"2025-01-01T11:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let id_new = create_fake_rollout(
codex_home.path(),
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(),
"2025-01-03T00:00:00Z",
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-01-01T11-00-00", &id_mid).as_path(),
"2025-01-02T00:00:00Z",
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-01-01T12-00-00", &id_new).as_path(),
"2025-01-01T00:00:00Z",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads_with_sort(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(ThreadSortKey::UpdatedAt),
)
.await?;
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![id_old.as_str(), id_mid.as_str(), id_new.as_str()]);
Ok(())
}
#[tokio::test]
async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_a = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let id_b = create_fake_rollout(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let id_c = create_fake_rollout(
codex_home.path(),
"2025-02-01T12-00-00",
"2025-02-01T12:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(),
"2025-02-03T00:00:00Z",
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(),
"2025-02-02T00:00:00Z",
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T12-00-00", &id_c).as_path(),
"2025-02-01T00:00:00Z",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse {
data: page1,
next_cursor: cursor1,
} = list_threads_with_sort(
&mut mcp,
None,
Some(2),
Some(vec!["mock_provider".to_string()]),
Some(ThreadSortKey::UpdatedAt),
)
.await?;
let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids_page1, vec![id_a.as_str(), id_b.as_str()]);
let cursor1 = cursor1.expect("expected nextCursor on first page");
let ThreadListResponse {
data: page2,
next_cursor: cursor2,
} = list_threads_with_sort(
&mut mcp,
Some(cursor1),
Some(2),
Some(vec!["mock_provider".to_string()]),
Some(ThreadSortKey::UpdatedAt),
)
.await?;
let ids_page2: Vec<_> = page2.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids_page2, vec![id_c.as_str()]);
assert_eq!(cursor2, None);
Ok(())
}
#[tokio::test]
async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_a = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let id_b = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
)
.await?;
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
let mut expected = [id_a, id_b];
expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse")));
let expected: Vec<_> = expected.iter().map(String::as_str).collect();
assert_eq!(ids, expected);
Ok(())
}
#[tokio::test]
async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_a = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let id_b = create_fake_rollout(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
let updated_at = "2025-02-03T00:00:00Z";
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(),
updated_at,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(),
updated_at,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads_with_sort(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(ThreadSortKey::UpdatedAt),
)
.await?;
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
let mut expected = [id_a, id_b];
expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse")));
let expected: Vec<_> = expected.iter().map(String::as_str).collect();
assert_eq!(ids, expected);
Ok(())
}
#[tokio::test]
async fn thread_list_updated_at_uses_mtime() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let thread_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
None,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T10-00-00", &thread_id).as_path(),
"2025-02-05T00:00:00Z",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads_with_sort(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(ThreadSortKey::UpdatedAt),
)
.await?;
let thread = data
.iter()
.find(|item| item.id == thread_id)
.expect("expected thread for created rollout");
let expected_created =
chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z")?.timestamp();
let expected_updated =
chrono::DateTime::parse_from_rfc3339("2025-02-05T00:00:00Z")?.timestamp();
assert_eq!(thread.created_at, expected_created);
assert_eq!(thread.updated_at, expected_updated);
Ok(())
}
#[tokio::test]
async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
cursor: Some("not-a-cursor".to_string()),
limit: Some(2),
sort_key: None,
model_providers: Some(vec!["mock_provider".to_string()]),
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert_eq!(error.error.message, "invalid cursor: not-a-cursor");
Ok(())
}

View File

@@ -35,10 +35,7 @@ use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Settings;
use codex_protocol::openai_models::ReasoningEffort;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use std::path::Path;
@@ -308,77 +305,6 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
Ok(())
}
#[tokio::test]
async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock = responses::mount_sse_once(&server, body).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let collaboration_mode = CollaborationMode::Custom(Settings {
model: "mock-model-collab".to_string(),
reasoning_effort: Some(ReasoningEffort::High),
developer_instructions: None,
});
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
model: Some("mock-model-override".to_string()),
effort: Some(ReasoningEffort::Low),
summary: Some(ReasoningSummary::Auto),
output_schema: None,
collaboration_mode: Some(collaboration_mode),
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let request = response_mock.single_request();
let payload = request.body_json();
assert_eq!(payload["model"].as_str(), Some("mock-model-collab"));
Ok(())
}
#[tokio::test]
async fn turn_start_accepts_local_image_input() -> Result<()> {
// Two Codex turns hit the mock model (session start + turn/start).
@@ -777,7 +703,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
output_schema: None,
collaboration_mode: None,
})
.await?;
timeout(
@@ -807,7 +732,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
output_schema: None,
collaboration_mode: None,
})
.await?;
timeout(
@@ -1471,18 +1395,8 @@ unified_exec = true
unreachable!("loop ensures we break on command execution items");
};
assert_eq!(completed_id, "uexec-1");
assert!(
matches!(
completed_status,
CommandExecutionStatus::Completed | CommandExecutionStatus::Failed
),
"unexpected command execution status: {completed_status:?}"
);
if completed_status == CommandExecutionStatus::Completed {
assert_eq!(exit_code, Some(0));
} else {
assert!(exit_code.is_some(), "expected exit_code for failed command");
}
assert_eq!(completed_status, CommandExecutionStatus::Completed);
assert_eq!(exit_code, Some(0));
assert_eq!(
completed_process_id.as_deref(),
Some(started_process_id.as_str())

View File

@@ -459,8 +459,8 @@ enum FeaturesSubcommand {
fn stage_str(stage: codex_core::features::Stage) -> &'static str {
use codex_core::features::Stage;
match stage {
Stage::Beta => "experimental",
Stage::Experimental { .. } => "beta",
Stage::Experimental => "experimental",
Stage::Beta { .. } => "beta",
Stage::Stable => "stable",
Stage::Deprecated => "deprecated",
Stage::Removed => "removed",

View File

@@ -42,10 +42,6 @@ pub enum ResponseEvent {
Created,
OutputItemDone(ResponseItem),
OutputItemAdded(ResponseItem),
/// Emitted when `X-Reasoning-Included: true` is present on the response,
/// meaning the server already accounted for past reasoning tokens and the
/// client should not re-estimate them.
ServerReasoningIncluded(bool),
Completed {
response_id: String,
token_usage: Option<TokenUsage>,

View File

@@ -157,9 +157,6 @@ impl Stream for AggregatedStream {
return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item))));
}
Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included)))) => {
return Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included))));
}
Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => {
return Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot))));
}

View File

@@ -29,21 +29,18 @@ use url::Url;
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included";
pub struct ResponsesWebsocketConnection {
stream: Arc<Mutex<Option<WsStream>>>,
// TODO (pakrym): is this the right place for timeout?
idle_timeout: Duration,
server_reasoning_included: bool,
}
impl ResponsesWebsocketConnection {
fn new(stream: WsStream, idle_timeout: Duration, server_reasoning_included: bool) -> Self {
fn new(stream: WsStream, idle_timeout: Duration) -> Self {
Self {
stream: Arc::new(Mutex::new(Some(stream))),
idle_timeout,
server_reasoning_included,
}
}
@@ -59,17 +56,11 @@ impl ResponsesWebsocketConnection {
mpsc::channel::<std::result::Result<ResponseEvent, ApiError>>(1600);
let stream = Arc::clone(&self.stream);
let idle_timeout = self.idle_timeout;
let server_reasoning_included = self.server_reasoning_included;
let request_body = serde_json::to_value(&request).map_err(|err| {
ApiError::Stream(format!("failed to encode websocket request: {err}"))
})?;
tokio::spawn(async move {
if server_reasoning_included {
let _ = tx_event
.send(Ok(ResponseEvent::ServerReasoningIncluded(true)))
.await;
}
let mut guard = stream.lock().await;
let Some(ws_stream) = guard.as_mut() else {
let _ = tx_event
@@ -120,12 +111,10 @@ impl<A: AuthProvider> ResponsesWebsocketClient<A> {
headers.extend(extra_headers);
apply_auth_headers(&mut headers, &self.auth);
let (stream, server_reasoning_included) =
connect_websocket(ws_url, headers, turn_state).await?;
let stream = connect_websocket(ws_url, headers, turn_state).await?;
Ok(ResponsesWebsocketConnection::new(
stream,
self.provider.stream_idle_timeout,
server_reasoning_included,
))
}
}
@@ -148,7 +137,7 @@ async fn connect_websocket(
url: Url,
headers: HeaderMap,
turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<(WsStream, bool), ApiError> {
) -> Result<WsStream, ApiError> {
let mut request = url
.clone()
.into_client_request()
@@ -158,7 +147,6 @@ async fn connect_websocket(
let (stream, response) = tokio_tungstenite::connect_async(request)
.await
.map_err(|err| map_ws_error(err, &url))?;
let reasoning_included = response.headers().contains_key(X_REASONING_INCLUDED_HEADER);
if let Some(turn_state) = turn_state
&& let Some(header_value) = response
.headers()
@@ -167,7 +155,7 @@ async fn connect_websocket(
{
let _ = turn_state.set(header_value.to_string());
}
Ok((stream, reasoning_included))
Ok(stream)
}
fn map_ws_error(err: WsError, url: &Url) -> ApiError {

View File

@@ -25,8 +25,6 @@ pub enum ApiError {
},
#[error("rate limit: {0}")]
RateLimit(String),
#[error("invalid request: {message}")]
InvalidRequest { message: String },
}
impl From<RateLimitError> for ApiError {

View File

@@ -25,8 +25,6 @@ use tokio_util::io::ReaderStream;
use tracing::debug;
use tracing::trace;
const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included";
/// Streams SSE events from an on-disk fixture for tests.
pub fn stream_from_fixture(
path: impl AsRef<Path>,
@@ -60,10 +58,6 @@ pub fn spawn_response_stream(
.get("X-Models-Etag")
.and_then(|v| v.to_str().ok())
.map(ToString::to_string);
let reasoning_included = stream_response
.headers
.get(X_REASONING_INCLUDED_HEADER)
.is_some();
if let Some(turn_state) = turn_state.as_ref()
&& let Some(header_value) = stream_response
.headers
@@ -80,11 +74,6 @@ pub fn spawn_response_stream(
if let Some(etag) = models_etag {
let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await;
}
if reasoning_included {
let _ = tx_event
.send(Ok(ResponseEvent::ServerReasoningIncluded(true)))
.await;
}
process_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await;
});
@@ -228,11 +217,6 @@ pub fn process_responses_event(
response_error = ApiError::QuotaExceeded;
} else if is_usage_not_included(&error) {
response_error = ApiError::UsageNotIncluded;
} else if is_invalid_prompt_error(&error) {
let message = error
.message
.unwrap_or_else(|| "Invalid request.".to_string());
response_error = ApiError::InvalidRequest { message };
} else {
let delay = try_parse_retry_after(&error);
let message = error.message.unwrap_or_default();
@@ -412,10 +396,6 @@ fn is_usage_not_included(error: &Error) -> bool {
error.code.as_deref() == Some("usage_not_included")
}
fn is_invalid_prompt_error(error: &Error) -> bool {
error.code.as_deref() == Some("invalid_prompt")
}
fn rate_limit_regex() -> &'static regex_lite::Regex {
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
#[expect(clippy::unwrap_used)]
@@ -731,27 +711,6 @@ mod tests {
assert_matches!(events[0], Err(ApiError::QuotaExceeded));
}
#[tokio::test]
async fn invalid_prompt_without_type_is_invalid_request() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#;
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
let events = collect_events(&[sse1.as_bytes()]).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::InvalidRequest { message }) => {
assert_eq!(
message,
"Invalid prompt: we've limited access to this content for safety reasons."
);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn table_driven_event_kinds() {
struct TestCase {

View File

@@ -42,12 +42,9 @@ impl RateLimitStatusPayload {
}
}
#[derive(
Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default,
)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
pub enum PlanType {
#[serde(rename = "guest")]
#[default]
Guest,
#[serde(rename = "free")]
Free,
@@ -74,3 +71,9 @@ pub enum PlanType {
#[serde(rename = "edu")]
Edu,
}
impl Default for PlanType {
fn default() -> PlanType {
Self::Guest
}
}

View File

@@ -18,7 +18,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
arc-swap = "1.8.0"
arc-swap = "1.7.1"
async-channel = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }

View File

@@ -75,15 +75,9 @@
"apply_patch_freeform": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
"collab": {
"type": "boolean"
},
"collaboration_modes": {
"type": "boolean"
},
"elevated_windows_sandbox": {
"type": "boolean"
},
@@ -105,6 +99,9 @@
"experimental_windows_sandbox": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
"include_apply_patch_tool": {
"type": "boolean"
},
@@ -359,12 +356,12 @@
},
"shell_environment_policy": {
"default": {
"inherit": null,
"ignore_default_excludes": null,
"exclude": null,
"set": null,
"experimental_use_profile": null,
"ignore_default_excludes": null,
"include_only": null,
"experimental_use_profile": null
"inherit": null,
"set": null
},
"allOf": [
{
@@ -376,14 +373,6 @@
"description": "When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. Defaults to `false`.",
"type": "boolean"
},
"skills": {
"description": "User-level skill config entries keyed by SKILL.md path.",
"allOf": [
{
"$ref": "#/definitions/SkillsConfig"
}
]
},
"tool_output_token_limit": {
"description": "Token budget applied when storing tool/function outputs in the context manager.",
"type": "integer",
@@ -554,15 +543,9 @@
"apply_patch_freeform": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
"collab": {
"type": "boolean"
},
"collaboration_modes": {
"type": "boolean"
},
"elevated_windows_sandbox": {
"type": "boolean"
},
@@ -584,6 +567,9 @@
"experimental_windows_sandbox": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
"include_apply_patch_tool": {
"type": "boolean"
},
@@ -1302,34 +1288,6 @@
},
"additionalProperties": false
},
"SkillConfig": {
"type": "object",
"required": [
"enabled",
"path"
],
"properties": {
"enabled": {
"type": "boolean"
},
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
}
},
"additionalProperties": false
},
"SkillsConfig": {
"type": "object",
"properties": {
"config": {
"type": "array",
"items": {
"$ref": "#/definitions/SkillConfig"
}
}
},
"additionalProperties": false
},
"ToolsToml": {
"type": "object",
"properties": {

View File

@@ -85,6 +85,7 @@ impl AgentControl {
result
}
#[allow(dead_code)] // Will be used for collab tools.
/// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable.
pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus {
let Ok(state) = self.upgrade() else {

View File

@@ -28,7 +28,6 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
url: None,
request_id: None,
}),
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
ApiError::Transport(transport) => match transport {
TransportError::Http {
status,

View File

@@ -49,8 +49,6 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use futures::future::BoxFuture;
@@ -100,7 +98,6 @@ use crate::error::Result as CodexResult;
use crate::exec::StreamOutput;
use crate::exec_policy::ExecPolicyUpdateError;
use crate::feedback_tags;
use crate::instructions::UserInstructions;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
@@ -120,7 +117,6 @@ use crate::protocol::Op;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::ReasoningContentDeltaEvent;
use crate::protocol::ReasoningRawContentDeltaEvent;
use crate::protocol::RequestUserInputEvent;
use crate::protocol::ReviewDecision;
use crate::protocol::SandboxPolicy;
use crate::protocol::SessionConfiguredEvent;
@@ -159,17 +155,17 @@ use crate::tools::spec::ToolsConfig;
use crate::tools::spec::ToolsConfigParams;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::user_instructions::UserInstructions;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use codex_async_utils::OrCancelExt;
use codex_otel::OtelManager;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::Settings;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::user_input::UserInput;
@@ -254,8 +250,7 @@ impl Codex {
);
}
let enabled_skills = loaded_skills.enabled_skills();
let user_instructions = get_user_instructions(&config, Some(&enabled_skills)).await;
let user_instructions = get_user_instructions(&config, Some(&loaded_skills.skills)).await;
let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack)
.await
@@ -275,16 +270,10 @@ impl Codex {
crate::models_manager::manager::RefreshStrategy::OnlineIfUncached,
)
.await;
// TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode
// to avoid extracting these fields separately and constructing CollaborationMode here.
let collaboration_mode = CollaborationMode::Custom(Settings {
model: model.clone(),
reasoning_effort: config.model_reasoning_effort,
developer_instructions: None,
});
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
model: model.clone(),
model_reasoning_effort: config.model_reasoning_effort,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions,
@@ -433,7 +422,10 @@ pub(crate) struct SessionConfiguration {
/// Provider identifier ("openai", "openrouter", ...).
provider: ModelProviderInfo,
collaboration_mode: CollaborationMode,
/// If not specified, server will use its default model.
model: String,
model_reasoning_effort: Option<ReasoningEffortConfig>,
model_reasoning_summary: ReasoningSummaryConfig,
/// Developer instructions that supplement the base instructions.
@@ -471,8 +463,11 @@ pub(crate) struct SessionConfiguration {
impl SessionConfiguration {
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult<Self> {
let mut next_configuration = self.clone();
if let Some(collaboration_mode) = updates.collaboration_mode.clone() {
next_configuration.collaboration_mode = collaboration_mode;
if let Some(model) = updates.model.clone() {
next_configuration.model = model;
}
if let Some(effort) = updates.reasoning_effort {
next_configuration.model_reasoning_effort = effort;
}
if let Some(summary) = updates.reasoning_summary {
next_configuration.model_reasoning_summary = summary;
@@ -495,7 +490,8 @@ pub(crate) struct SessionSettingsUpdate {
pub(crate) cwd: Option<PathBuf>,
pub(crate) approval_policy: Option<AskForApproval>,
pub(crate) sandbox_policy: Option<SandboxPolicy>,
pub(crate) collaboration_mode: Option<CollaborationMode>,
pub(crate) model: Option<String>,
pub(crate) reasoning_effort: Option<Option<ReasoningEffortConfig>>,
pub(crate) reasoning_summary: Option<ReasoningSummaryConfig>,
pub(crate) final_output_json_schema: Option<Option<Value>>,
}
@@ -506,8 +502,7 @@ impl Session {
// todo(aibrahim): store this state somewhere else so we don't need to mut config
let config = session_configuration.original_config_do_not_use.clone();
let mut per_turn_config = (*config).clone();
per_turn_config.model_reasoning_effort =
session_configuration.collaboration_mode.reasoning_effort();
per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort;
per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary;
per_turn_config.features = config.features.clone();
per_turn_config
@@ -525,9 +520,10 @@ impl Session {
sub_id: String,
) -> TurnContext {
let otel_manager = otel_manager.clone().with_model(
session_configuration.collaboration_mode.model(),
session_configuration.model.as_str(),
model_info.slug.as_str(),
);
let per_turn_config = Arc::new(per_turn_config);
let client = ModelClient::new(
per_turn_config.clone(),
@@ -535,7 +531,7 @@ impl Session {
model_info.clone(),
otel_manager,
provider,
session_configuration.collaboration_mode.reasoning_effort(),
session_configuration.model_reasoning_effort,
session_configuration.model_reasoning_summary,
conversation_id,
session_configuration.session_source.clone(),
@@ -583,8 +579,7 @@ impl Session {
) -> anyhow::Result<Arc<Self>> {
debug!(
"Configuring session: model={}; provider={:?}",
session_configuration.collaboration_mode.model(),
session_configuration.provider
session_configuration.model, session_configuration.provider
);
if !session_configuration.cwd.is_absolute() {
return Err(anyhow::anyhow!(
@@ -593,14 +588,16 @@ impl Session {
));
}
let forked_from_id = initial_history.forked_from_id();
let (conversation_id, rollout_params) = match &initial_history {
InitialHistory::New | InitialHistory::Forked(_) => {
let conversation_id = ThreadId::default();
(
conversation_id,
RolloutRecorderParams::new(conversation_id, forked_from_id, session_source),
RolloutRecorderParams::new(
conversation_id,
session_configuration.user_instructions.clone(),
session_source,
),
)
}
InitialHistory::Resumed(resumed_history) => (
@@ -655,8 +652,8 @@ impl Session {
let auth = auth.as_ref();
let otel_manager = OtelManager::new(
conversation_id,
session_configuration.collaboration_mode.model(),
session_configuration.collaboration_mode.model(),
session_configuration.model.as_str(),
session_configuration.model.as_str(),
auth.and_then(CodexAuth::get_account_id),
auth.and_then(CodexAuth::get_account_email),
auth.map(|a| a.mode),
@@ -680,7 +677,7 @@ impl Session {
otel_manager.conversation_starts(
config.model_provider.name.as_str(),
session_configuration.collaboration_mode.reasoning_effort(),
config.model_reasoning_effort,
config.model_reasoning_summary,
config.model_context_window,
config.model_auto_compact_token_limit,
@@ -736,13 +733,12 @@ impl Session {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id,
model: session_configuration.collaboration_mode.model().to_string(),
model: session_configuration.model.clone(),
model_provider_id: config.model_provider_id.clone(),
approval_policy: session_configuration.approval_policy.value(),
sandbox_policy: session_configuration.sandbox_policy.get().clone(),
cwd: session_configuration.cwd.clone(),
reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(),
reasoning_effort: session_configuration.model_reasoning_effort,
history_log_id,
history_entry_count,
initial_messages,
@@ -809,7 +805,7 @@ impl Session {
async fn get_total_token_usage(&self) -> i64 {
let state = self.state.lock().await;
state.get_total_token_usage(state.server_reasoning_included())
state.get_total_token_usage()
}
async fn record_initial_history(&self, conversation_history: InitialHistory) {
@@ -817,7 +813,7 @@ impl Session {
match conversation_history {
InitialHistory::New => {
// Build and record initial items (user instructions + environment context)
let items = self.build_initial_context(&turn_context).await;
let items = self.build_initial_context(&turn_context);
self.record_conversation_items(&turn_context, &items).await;
// Ensure initial items are visible to immediate readers (e.g., tests, forks).
self.flush_rollout().await;
@@ -855,9 +851,8 @@ impl Session {
}
// Always add response items to conversation history
let reconstructed_history = self
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
.await;
let reconstructed_history =
self.reconstruct_history_from_rollout(&turn_context, &rollout_items);
if !reconstructed_history.is_empty() {
self.record_into_history(&reconstructed_history, &turn_context)
.await;
@@ -876,7 +871,7 @@ impl Session {
}
// Append the current session's initial context after the reconstructed history.
let initial_context = self.build_initial_context(&turn_context).await;
let initial_context = self.build_initial_context(&turn_context);
self.record_conversation_items(&turn_context, &initial_context)
.await;
// Flush after seeding history and any persisted rollout copy.
@@ -979,10 +974,7 @@ impl Session {
let model_info = self
.services
.models_manager
.get_model_info(
session_configuration.collaboration_mode.model(),
&per_turn_config,
)
.get_model_info(session_configuration.model.as_str(), &per_turn_config)
.await;
let mut turn_context: TurnContext = Self::make_turn_context(
Some(Arc::clone(&self.services.auth_manager)),
@@ -1064,50 +1056,6 @@ impl Session {
)
}
fn build_collaboration_mode_update_item(
&self,
previous_collaboration_mode: &CollaborationMode,
next_collaboration_mode: Option<&CollaborationMode>,
) -> Option<ResponseItem> {
if let Some(next_mode) = next_collaboration_mode {
if previous_collaboration_mode == next_mode {
return None;
}
// If the next mode has empty developer instructions, this returns None and we emit no
// update, so prior collaboration instructions remain in the prompt history.
Some(DeveloperInstructions::from_collaboration_mode(next_mode)?.into())
} else {
None
}
}
fn build_settings_update_items(
&self,
previous_context: Option<&Arc<TurnContext>>,
current_context: &TurnContext,
previous_collaboration_mode: &CollaborationMode,
next_collaboration_mode: Option<&CollaborationMode>,
) -> Vec<ResponseItem> {
let mut update_items = Vec::new();
if let Some(env_item) =
self.build_environment_update_item(previous_context, current_context)
{
update_items.push(env_item);
}
if let Some(permissions_item) =
self.build_permissions_update_item(previous_context, current_context)
{
update_items.push(permissions_item);
}
if let Some(collaboration_mode_item) = self.build_collaboration_mode_update_item(
previous_collaboration_mode,
next_collaboration_mode,
) {
update_items.push(collaboration_mode_item);
}
update_items
}
/// Persist the event to rollout and send it to clients.
pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) {
let legacy_source = msg.clone();
@@ -1299,63 +1247,6 @@ impl Session {
rx_approve
}
pub async fn request_user_input(
&self,
turn_context: &TurnContext,
call_id: String,
args: RequestUserInputArgs,
) -> Option<RequestUserInputResponse> {
let sub_id = turn_context.sub_id.clone();
let (tx_response, rx_response) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_user_input(sub_id, tx_response)
}
None => None,
}
};
if prev_entry.is_some() {
warn!("Overwriting existing pending user input for sub_id: {event_id}");
}
let event = EventMsg::RequestUserInput(RequestUserInputEvent {
call_id,
turn_id: turn_context.sub_id.clone(),
questions: args.questions,
});
self.send_event(turn_context, event).await;
rx_response.await.ok()
}
pub async fn notify_user_input_response(
&self,
sub_id: &str,
response: RequestUserInputResponse,
) {
let entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.remove_pending_user_input(sub_id)
}
None => None,
}
};
match entry {
Some(tx_response) => {
tx_response.send(response).ok();
}
None => {
warn!("No pending user input found for sub_id: {sub_id}");
}
}
}
pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) {
let entry = {
let mut active = self.active_turn.lock().await;
@@ -1403,7 +1294,7 @@ impl Session {
self.send_raw_response_items(turn_context, items).await;
}
async fn reconstruct_history_from_rollout(
fn reconstruct_history_from_rollout(
&self,
turn_context: &TurnContext,
rollout_items: &[RolloutItem],
@@ -1423,7 +1314,7 @@ impl Session {
} else {
let user_messages = collect_user_messages(history.raw_items());
let rebuilt = compact::build_compacted_history(
self.build_initial_context(turn_context).await,
self.build_initial_context(turn_context),
&user_messages,
&compacted.message,
);
@@ -1450,9 +1341,6 @@ impl Session {
}
pub(crate) async fn record_model_warning(&self, message: impl Into<String>, ctx: &TurnContext) {
self.services
.otel_manager
.counter("codex.model_warning", 1, &[]);
let item = ResponseItem::Message {
id: None,
role: "user".to_string(),
@@ -1496,10 +1384,7 @@ impl Session {
}
}
pub(crate) async fn build_initial_context(
&self,
turn_context: &TurnContext,
) -> Vec<ResponseItem> {
pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
let mut items = Vec::<ResponseItem>::with_capacity(4);
let shell = self.user_shell();
items.push(
@@ -1513,16 +1398,6 @@ impl Session {
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
}
// Add developer instructions from collaboration_mode if they exist and are non-empty
let collaboration_mode = {
let state = self.state.lock().await;
state.session_configuration.collaboration_mode.clone()
};
if let Some(collab_instructions) =
DeveloperInstructions::from_collaboration_mode(&collaboration_mode)
{
items.push(collab_instructions.into());
}
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
items.push(
UserInstructions {
@@ -1618,11 +1493,6 @@ impl Session {
self.send_token_count_event(turn_context).await;
}
pub(crate) async fn set_server_reasoning_included(&self, included: bool) {
let mut state = self.state.lock().await;
state.set_server_reasoning_included(included);
}
async fn send_token_count_event(&self, turn_context: &TurnContext) {
let (info, rate_limits) = {
let state = self.state.lock().await;
@@ -1962,18 +1832,7 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
model,
effort,
summary,
collaboration_mode,
} => {
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
collab_mode
} else {
let state = sess.state.lock().await;
state.session_configuration.collaboration_mode.with_updates(
model.clone(),
effort,
None,
)
};
handlers::override_turn_context(
&sess,
sub.id.clone(),
@@ -1981,7 +1840,8 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
cwd,
approval_policy,
sandbox_policy,
collaboration_mode: Some(collaboration_mode),
model,
reasoning_effort: effort,
reasoning_summary: summary,
..Default::default()
},
@@ -1998,9 +1858,6 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
Op::PatchApproval { id, decision } => {
handlers::patch_approval(&sess, id, decision).await;
}
Op::UserInputAnswer { id, response } => {
handlers::request_user_input_response(&sess, id, response).await;
}
Op::AddToHistory { text } => {
handlers::add_to_history(&sess, &config, text).await;
}
@@ -2090,11 +1947,8 @@ mod handlers {
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::request_user_input::RequestUserInputResponse;
use crate::context_manager::is_user_turn_boundary;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Settings;
use codex_protocol::user_input::UserInput;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
@@ -2113,18 +1967,6 @@ mod handlers {
sub_id: String,
updates: SessionSettingsUpdate,
) {
let previous_context = sess
.new_default_turn_with_sub_id(sess.next_internal_sub_id())
.await;
let previous_collaboration_mode = sess
.state
.lock()
.await
.session_configuration
.collaboration_mode
.clone();
let next_collaboration_mode = updates.collaboration_mode.clone();
if let Err(err) = sess.update_settings(updates).await {
sess.send_event_raw(Event {
id: sub_id,
@@ -2134,19 +1976,6 @@ mod handlers {
}),
})
.await;
return;
}
let current_context = sess.new_default_turn_with_sub_id(sub_id).await;
let update_items = sess.build_settings_update_items(
Some(&previous_context),
&current_context,
&previous_collaboration_mode,
next_collaboration_mode.as_ref(),
);
if !update_items.is_empty() {
sess.record_conversation_items(&current_context, &update_items)
.await;
}
}
@@ -2166,27 +1995,18 @@ mod handlers {
summary,
final_output_json_schema,
items,
collaboration_mode,
} => {
let collaboration_mode = collaboration_mode.or_else(|| {
Some(CollaborationMode::Custom(Settings {
model: model.clone(),
reasoning_effort: effort,
developer_instructions: None,
}))
});
(
items,
SessionSettingsUpdate {
cwd: Some(cwd),
approval_policy: Some(approval_policy),
sandbox_policy: Some(sandbox_policy),
collaboration_mode,
reasoning_summary: Some(summary),
final_output_json_schema: Some(final_output_json_schema),
},
)
}
} => (
items,
SessionSettingsUpdate {
cwd: Some(cwd),
approval_policy: Some(approval_policy),
sandbox_policy: Some(sandbox_policy),
model: Some(model),
reasoning_effort: Some(effort),
reasoning_summary: Some(summary),
final_output_json_schema: Some(final_output_json_schema),
},
),
Op::UserInput {
items,
final_output_json_schema,
@@ -2200,14 +2020,6 @@ mod handlers {
_ => unreachable!(),
};
let previous_collaboration_mode = sess
.state
.lock()
.await
.session_configuration
.collaboration_mode
.clone();
let next_collaboration_mode = updates.collaboration_mode.clone();
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else {
// new_turn_with_sub_id already emits the error event.
return;
@@ -2219,12 +2031,17 @@ mod handlers {
// Attempt to inject input into current task
if let Err(items) = sess.inject_input(items).await {
let update_items = sess.build_settings_update_items(
previous_context.as_ref(),
&current_context,
&previous_collaboration_mode,
next_collaboration_mode.as_ref(),
);
let mut update_items = Vec::new();
if let Some(env_item) =
sess.build_environment_update_item(previous_context.as_ref(), &current_context)
{
update_items.push(env_item);
}
if let Some(permissions_item) =
sess.build_permissions_update_item(previous_context.as_ref(), &current_context)
{
update_items.push(permissions_item);
}
if !update_items.is_empty() {
sess.record_conversation_items(&current_context, &update_items)
.await;
@@ -2319,14 +2136,6 @@ mod handlers {
}
}
pub async fn request_user_input_response(
sess: &Arc<Session>,
id: String,
response: RequestUserInputResponse,
) {
sess.notify_user_input_response(&id, response).await;
}
pub async fn add_to_history(sess: &Arc<Session>, config: &Arc<Config>, text: String) {
let id = sess.conversation_id;
let config = Arc::clone(config);
@@ -2432,8 +2241,7 @@ mod handlers {
for cwd in cwds {
let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await;
let errors = super::errors_to_info(&outcome.errors);
let enabled_skills = outcome.enabled_skills();
let skills_metadata = super::skills_to_info(&enabled_skills);
let skills_metadata = super::skills_to_info(&outcome.skills);
skills.push(SkillsListEntry {
cwd,
skills: skills_metadata,
@@ -3154,9 +2962,6 @@ async fn try_run_sampling_request(
active_item = Some(tracked_item);
}
}
ResponseEvent::ServerReasoningIncluded(included) => {
sess.set_server_reasoning_included(included).await;
}
ResponseEvent::RateLimits(snapshot) => {
// Update internal state with latest rate limits, but defer sending until
// token usage is available to avoid duplicate TokenCount events.
@@ -3346,11 +3151,9 @@ mod tests {
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
let reconstructed = session
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
.await;
let reconstructed = session.reconstruct_history_from_rollout(&turn_context, &rollout_items);
assert_eq!(expected, reconstructed);
}
@@ -3358,7 +3161,7 @@ mod tests {
#[tokio::test]
async fn record_initial_history_reconstructs_resumed_transcript() {
let (session, turn_context) = make_session_and_context().await;
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context);
session
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
@@ -3368,7 +3171,7 @@ mod tests {
}))
.await;
expected.extend(session.build_initial_context(&turn_context).await);
expected.extend(session.build_initial_context(&turn_context));
let history = session.state.lock().await.clone_history();
assert_eq!(expected, history.raw_items());
}
@@ -3376,7 +3179,7 @@ mod tests {
#[tokio::test]
async fn record_initial_history_seeds_token_info_from_rollout() {
let (session, turn_context) = make_session_and_context().await;
let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context).await;
let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context);
let info1 = TokenUsageInfo {
total_token_usage: TokenUsage {
@@ -3453,13 +3256,13 @@ mod tests {
#[tokio::test]
async fn record_initial_history_reconstructs_forked_transcript() {
let (session, turn_context) = make_session_and_context().await;
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context);
session
.record_initial_history(InitialHistory::Forked(rollout_items))
.await;
expected.extend(session.build_initial_context(&turn_context).await);
expected.extend(session.build_initial_context(&turn_context));
let history = session.state.lock().await.clone_history();
assert_eq!(expected, history.raw_items());
}
@@ -3468,7 +3271,7 @@ mod tests {
async fn thread_rollback_drops_last_turn_from_history() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref()).await;
let initial_context = sess.build_initial_context(tc.as_ref());
sess.record_into_history(&initial_context, tc.as_ref())
.await;
@@ -3525,7 +3328,7 @@ mod tests {
async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref()).await;
let initial_context = sess.build_initial_context(tc.as_ref());
sess.record_into_history(&initial_context, tc.as_ref())
.await;
@@ -3551,7 +3354,7 @@ mod tests {
async fn thread_rollback_fails_when_turn_in_progress() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref()).await;
let initial_context = sess.build_initial_context(tc.as_ref());
sess.record_into_history(&initial_context, tc.as_ref())
.await;
@@ -3572,7 +3375,7 @@ mod tests {
async fn thread_rollback_fails_when_num_turns_is_zero() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref()).await;
let initial_context = sess.build_initial_context(tc.as_ref());
sess.record_into_history(&initial_context, tc.as_ref())
.await;
@@ -3595,15 +3398,10 @@ mod tests {
let config = build_test_config(codex_home.path()).await;
let config = Arc::new(config);
let model = ModelsManager::get_model_offline(config.model.as_deref());
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode::Custom(Settings {
model,
reasoning_effort,
developer_instructions: None,
});
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
model,
model_reasoning_effort: config.model_reasoning_effort,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
@@ -3666,15 +3464,10 @@ mod tests {
let config = build_test_config(codex_home.path()).await;
let config = Arc::new(config);
let model = ModelsManager::get_model_offline(config.model.as_deref());
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode::Custom(Settings {
model,
reasoning_effort,
developer_instructions: None,
});
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
model,
model_reasoning_effort: config.model_reasoning_effort,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
@@ -3921,15 +3714,10 @@ mod tests {
let exec_policy = ExecPolicyManager::default();
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
let model = ModelsManager::get_model_offline(config.model.as_deref());
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode::Custom(Settings {
model,
reasoning_effort,
developer_instructions: None,
});
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
model,
model_reasoning_effort: config.model_reasoning_effort,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
@@ -3943,7 +3731,7 @@ mod tests {
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline(
session_configuration.collaboration_mode.model(),
session_configuration.model.as_str(),
&per_turn_config,
);
let otel_manager = otel_manager(
@@ -4021,15 +3809,10 @@ mod tests {
let exec_policy = ExecPolicyManager::default();
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
let model = ModelsManager::get_model_offline(config.model.as_deref());
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode::Custom(Settings {
model,
reasoning_effort,
developer_instructions: None,
});
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
model,
model_reasoning_effort: config.model_reasoning_effort,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
@@ -4043,7 +3826,7 @@ mod tests {
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline(
session_configuration.collaboration_mode.model(),
session_configuration.model.as_str(),
&per_turn_config,
);
let otel_manager = otel_manager(
@@ -4358,14 +4141,14 @@ mod tests {
}
}
async fn sample_rollout(
fn sample_rollout(
session: &Session,
turn_context: &TurnContext,
) -> (Vec<RolloutItem>, Vec<ResponseItem>) {
let mut rollout_items = Vec::new();
let mut live_history = ContextManager::new();
let initial_context = session.build_initial_context(turn_context).await;
let initial_context = session.build_initial_context(turn_context);
for item in &initial_context {
rollout_items.push(RolloutItem::ResponseItem(item.clone()));
}
@@ -4395,7 +4178,7 @@ mod tests {
let snapshot1 = live_history.clone().for_prompt();
let user_messages1 = collect_user_messages(&snapshot1);
let rebuilt1 = compact::build_compacted_history(
session.build_initial_context(turn_context).await,
session.build_initial_context(turn_context),
&user_messages1,
summary1,
);
@@ -4429,7 +4212,7 @@ mod tests {
let snapshot2 = live_history.clone().for_prompt();
let user_messages2 = collect_user_messages(&snapshot2);
let rebuilt2 = compact::build_compacted_history(
session.build_initial_context(turn_context).await,
session.build_initial_context(turn_context),
&user_messages2,
summary2,
);
@@ -4447,7 +4230,7 @@ mod tests {
}],
};
live_history.record_items(std::iter::once(&user3), turn_context.truncation_policy);
rollout_items.push(RolloutItem::ResponseItem(user3));
rollout_items.push(RolloutItem::ResponseItem(user3.clone()));
let assistant3 = ResponseItem::Message {
id: None,
@@ -4457,7 +4240,7 @@ mod tests {
}],
};
live_history.record_items(std::iter::once(&assistant3), turn_context.truncation_policy);
rollout_items.push(RolloutItem::ResponseItem(assistant3));
rollout_items.push(RolloutItem::ResponseItem(assistant3.clone()));
(rollout_items, live_history.for_prompt())
}

View File

@@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
@@ -10,12 +9,9 @@ use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RequestUserInputEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::Submission;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use std::time::Duration;
use tokio::time::timeout;
@@ -233,20 +229,6 @@ async fn forward_events(
)
.await;
}
Event {
id,
msg: EventMsg::RequestUserInput(event),
} => {
handle_request_user_input(
&codex,
id,
&parent_session,
&parent_ctx,
event,
&cancel_token,
)
.await;
}
other => {
match tx_sub.send(other).or_cancel(&cancel_token).await {
Ok(Ok(())) => {}
@@ -352,55 +334,6 @@ async fn handle_patch_approval(
let _ = codex.submit(Op::PatchApproval { id, decision }).await;
}
async fn handle_request_user_input(
codex: &Codex,
id: String,
parent_session: &Session,
parent_ctx: &TurnContext,
event: RequestUserInputEvent,
cancel_token: &CancellationToken,
) {
let args = RequestUserInputArgs {
questions: event.questions,
};
let response_fut =
parent_session.request_user_input(parent_ctx, parent_ctx.sub_id.clone(), args);
let response = await_user_input_with_cancel(
response_fut,
parent_session,
&parent_ctx.sub_id,
cancel_token,
)
.await;
let _ = codex.submit(Op::UserInputAnswer { id, response }).await;
}
async fn await_user_input_with_cancel<F>(
fut: F,
parent_session: &Session,
sub_id: &str,
cancel_token: &CancellationToken,
) -> RequestUserInputResponse
where
F: core::future::Future<Output = Option<RequestUserInputResponse>>,
{
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
let empty = RequestUserInputResponse {
answers: HashMap::new(),
};
parent_session
.notify_user_input_response(sub_id, empty.clone())
.await;
empty
}
response = fut => response.unwrap_or_else(|| RequestUserInputResponse {
answers: HashMap::new(),
}),
}
}
/// Await an approval decision, aborting on cancellation.
async fn await_approval_with_cancel<F>(
fut: F,

View File

@@ -167,7 +167,7 @@ async fn run_compact_task_inner(
let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
let user_messages = collect_user_messages(history_items);
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
let initial_context = sess.build_initial_context(turn_context.as_ref());
let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
let ghost_snapshots: Vec<ResponseItem> = history_items
.iter()
@@ -316,9 +316,6 @@ async fn drain_to_completed(
sess.record_into_history(std::slice::from_ref(&item), turn_context)
.await;
}
Ok(ResponseEvent::ServerReasoningIncluded(included)) => {
sess.set_server_reasoning_included(included).await;
}
Ok(ResponseEvent::RateLimits(snapshot)) => {
sess.update_rate_limits(turn_context, snapshot).await;
}

View File

@@ -1,16 +1,14 @@
use crate::config::CONFIG_TOML_FILE;
use crate::config::types::McpServerConfig;
use crate::config::types::Notice;
use crate::path_utils::resolve_symlink_write_paths;
use crate::path_utils::write_atomically;
use anyhow::Context;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use tokio::task;
use toml_edit::ArrayOfTables;
use toml_edit::DocumentMut;
use toml_edit::Item as TomlItem;
use toml_edit::Table as TomlTable;
@@ -38,8 +36,6 @@ pub enum ConfigEdit {
RecordModelMigrationSeen { from: String, to: String },
/// Replace the entire `[mcp_servers]` table.
ReplaceMcpServers(BTreeMap<String, McpServerConfig>),
/// Set or clear a skill config entry under `[[skills.config]]`.
SetSkillConfig { path: PathBuf, enabled: bool },
/// Set trust_level under `[projects."<path>"]`,
/// migrating inline tables to explicit tables.
SetProjectTrustLevel { path: PathBuf, level: TrustLevel },
@@ -302,9 +298,6 @@ impl ConfigDocument {
value(*acknowledged),
)),
ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)),
ConfigEdit::SetSkillConfig { path, enabled } => {
Ok(self.set_skill_config(path.as_path(), *enabled))
}
ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),
ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
ConfigEdit::SetProjectTrustLevel { path, level } => {
@@ -394,113 +387,6 @@ impl ConfigDocument {
true
}
fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool {
let normalized_path = normalize_skill_config_path(path);
let mut remove_skills_table = false;
let mut mutated = false;
{
let root = self.doc.as_table_mut();
let skills_item = match root.get_mut("skills") {
Some(item) => item,
None => {
if enabled {
return false;
}
root.insert(
"skills",
TomlItem::Table(document_helpers::new_implicit_table()),
);
let Some(item) = root.get_mut("skills") else {
return false;
};
item
}
};
if document_helpers::ensure_table_for_write(skills_item).is_none() {
if enabled {
return false;
}
*skills_item = TomlItem::Table(document_helpers::new_implicit_table());
}
let Some(skills_table) = skills_item.as_table_mut() else {
return false;
};
let config_item = match skills_table.get_mut("config") {
Some(item) => item,
None => {
if enabled {
return false;
}
skills_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new()));
let Some(item) = skills_table.get_mut("config") else {
return false;
};
item
}
};
if !matches!(config_item, TomlItem::ArrayOfTables(_)) {
if enabled {
return false;
}
*config_item = TomlItem::ArrayOfTables(ArrayOfTables::new());
}
let TomlItem::ArrayOfTables(overrides) = config_item else {
return false;
};
let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| {
table
.get("path")
.and_then(|item| item.as_str())
.map(Path::new)
.map(normalize_skill_config_path)
.filter(|value| *value == normalized_path)
.map(|_| idx)
});
if enabled {
if let Some(index) = existing_index {
overrides.remove(index);
mutated = true;
if overrides.is_empty() {
skills_table.remove("config");
if skills_table.is_empty() {
remove_skills_table = true;
}
}
}
} else if let Some(index) = existing_index {
for (idx, table) in overrides.iter_mut().enumerate() {
if idx == index {
table["path"] = value(normalized_path);
table["enabled"] = value(false);
mutated = true;
break;
}
}
} else {
let mut entry = TomlTable::new();
entry.set_implicit(false);
entry["path"] = value(normalized_path);
entry["enabled"] = value(false);
overrides.push(entry);
mutated = true;
}
}
if remove_skills_table {
let root = self.doc.as_table_mut();
root.remove("skills");
}
mutated
}
fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec<String> {
let resolved: Vec<String> = segments
.iter()
@@ -608,13 +494,6 @@ impl ConfigDocument {
}
}
fn normalize_skill_config_path(path: &Path) -> String {
dunce::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string()
}
/// Persist edits using a blocking strategy.
pub fn apply_blocking(
codex_home: &Path,
@@ -626,14 +505,10 @@ pub fn apply_blocking(
}
let config_path = codex_home.join(CONFIG_TOML_FILE);
let write_paths = resolve_symlink_write_paths(&config_path)?;
let serialized = match write_paths.read_path {
Some(path) => match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(err.into()),
},
None => String::new(),
let serialized = match std::fs::read_to_string(&config_path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(err.into()),
};
let doc = if serialized.is_empty() {
@@ -659,13 +534,22 @@ pub fn apply_blocking(
return Ok(());
}
write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| {
std::fs::create_dir_all(codex_home).with_context(|| {
format!(
"failed to persist config.toml at {}",
write_paths.write_path.display()
"failed to create Codex home directory at {}",
codex_home.display()
)
})?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), document.doc.to_string()).with_context(|| {
format!(
"failed to write temporary config file at {}",
tmp.path().display()
)
})?;
tmp.persist(config_path)?;
Ok(())
}
@@ -809,8 +693,6 @@ mod tests {
use crate::config::types::McpServerTransportConfig;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tempfile::tempdir;
use toml::Value as TomlValue;
@@ -855,54 +737,6 @@ model_reasoning_effort = "high"
assert_eq!(contents, "enabled = true\n");
}
#[test]
fn set_skill_config_writes_disabled_entry() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_edits([ConfigEdit::SetSkillConfig {
path: PathBuf::from("/tmp/skills/demo/SKILL.md"),
enabled: false,
}])
.apply_blocking()
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[[skills.config]]
path = "/tmp/skills/demo/SKILL.md"
enabled = false
"#;
assert_eq!(contents, expected);
}
#[test]
fn set_skill_config_removes_entry_when_enabled() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[[skills.config]]
path = "/tmp/skills/demo/SKILL.md"
enabled = false
"#,
)
.expect("seed config");
ConfigEditsBuilder::new(codex_home)
.with_edits([ConfigEdit::SetSkillConfig {
path: PathBuf::from("/tmp/skills/demo/SKILL.md"),
enabled: true,
}])
.apply_blocking()
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, "");
}
#[test]
fn blocking_set_model_preserves_inline_table_contents() {
let tmp = tempdir().expect("tmpdir");
@@ -950,71 +784,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
);
}
#[cfg(unix)]
#[test]
fn blocking_set_model_writes_through_symlink_chain() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
let target_dir = tempdir().expect("target dir");
let target_path = target_dir.path().join(CONFIG_TOML_FILE);
let link_path = codex_home.join("config-link.toml");
let config_path = codex_home.join(CONFIG_TOML_FILE);
symlink(&target_path, &link_path).expect("symlink link");
symlink("config-link.toml", &config_path).expect("symlink config");
apply_blocking(
codex_home,
None,
&[ConfigEdit::SetModel {
model: Some("gpt-5.1-codex".to_string()),
effort: Some(ReasoningEffort::High),
}],
)
.expect("persist");
let meta = std::fs::symlink_metadata(&config_path).expect("config metadata");
assert!(meta.file_type().is_symlink());
let contents = std::fs::read_to_string(&target_path).expect("read target");
let expected = r#"model = "gpt-5.1-codex"
model_reasoning_effort = "high"
"#;
assert_eq!(contents, expected);
}
#[cfg(unix)]
#[test]
fn blocking_set_model_replaces_symlink_on_cycle() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
let link_a = codex_home.join("a.toml");
let link_b = codex_home.join("b.toml");
let config_path = codex_home.join(CONFIG_TOML_FILE);
symlink("b.toml", &link_a).expect("symlink a");
symlink("a.toml", &link_b).expect("symlink b");
symlink("a.toml", &config_path).expect("symlink config");
apply_blocking(
codex_home,
None,
&[ConfigEdit::SetModel {
model: Some("gpt-5.1-codex".to_string()),
effort: None,
}],
)
.expect("persist");
let meta = std::fs::symlink_metadata(&config_path).expect("config metadata");
assert!(!meta.file_type().is_symlink());
let contents = std::fs::read_to_string(&config_path).expect("read config");
let expected = r#"model = "gpt-5.1-codex"
"#;
assert_eq!(contents, expected);
}
#[test]
fn batch_write_table_upsert_preserves_inline_comments() {
let tmp = tempdir().expect("tmpdir");

View File

@@ -1,6 +1,4 @@
use crate::auth::AuthCredentialsStoreMode;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::types::DEFAULT_OTEL_ENVIRONMENT;
use crate::config::types::History;
use crate::config::types::McpServerConfig;
@@ -15,7 +13,6 @@ use crate::config::types::SandboxWorkspaceWrite;
use crate::config::types::ScrollInputMode;
use crate::config::types::ShellEnvironmentPolicy;
use crate::config::types::ShellEnvironmentPolicyToml;
use crate::config::types::SkillsConfig;
use crate::config::types::Tui;
use crate::config::types::UriBasedFileOpener;
use crate::config_loader::ConfigLayerStack;
@@ -753,17 +750,30 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R
));
}
}
let config_path = codex_home.join(CONFIG_TOML_FILE);
// Read existing config or create empty string if file doesn't exist
let content = match std::fs::read_to_string(&config_path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
// Parse as DocumentMut for editing while preserving structure
let mut doc = content.parse::<DocumentMut>().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to parse config.toml: {e}"),
)
})?;
// Set the default_oss_provider at root level
use toml_edit::value;
doc["oss_provider"] = value(provider);
let edits = [ConfigEdit::SetPath {
segments: vec!["oss_provider".to_string()],
value: value(provider),
}];
ConfigEditsBuilder::new(codex_home)
.with_edits(edits)
.apply_blocking()
.map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}")))
// Write the modified document back
std::fs::write(&config_path, doc.to_string())?;
Ok(())
}
/// Base config deserialized from ~/.codex/config.toml.
@@ -901,9 +911,6 @@ pub struct ConfigToml {
/// Nested tools section for feature toggles
pub tools: Option<ToolsToml>,
/// User-level skill config entries keyed by SKILL.md path.
pub skills: Option<SkillsConfig>,
/// Centralized feature flags (new). Prefer this over individual toggles.
#[serde(default)]
// Injects known feature keys into the schema and forbids unknown keys.

View File

@@ -9,9 +9,6 @@ use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::config_loader::merge_toml_values;
use crate::path_utils;
use crate::path_utils::SymlinkWritePaths;
use crate::path_utils::resolve_symlink_write_paths;
use crate::path_utils::write_atomically;
use codex_app_server_protocol::Config as ApiConfig;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigLayerMetadata;
@@ -30,7 +27,6 @@ use std::borrow::Cow;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
use tokio::task;
use toml::Value as TomlValue;
use toml_edit::Item as TomlItem;
@@ -366,30 +362,19 @@ impl ConfigService {
async fn create_empty_user_layer(
config_toml: &AbsolutePathBuf,
) -> Result<ConfigLayerEntry, ConfigServiceError> {
let SymlinkWritePaths {
read_path,
write_path,
} = resolve_symlink_write_paths(config_toml.as_path())
.map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?;
let toml_value = match read_path {
Some(path) => match tokio::fs::read_to_string(&path).await {
Ok(contents) => toml::from_str(&contents).map_err(|e| {
ConfigServiceError::toml("failed to parse existing user config.toml", e)
})?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
write_empty_user_config(write_path.clone()).await?;
let toml_value = match tokio::fs::read_to_string(config_toml).await {
Ok(contents) => toml::from_str(&contents).map_err(|e| {
ConfigServiceError::toml("failed to parse existing user config.toml", e)
})?,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
tokio::fs::write(config_toml, "").await.map_err(|e| {
ConfigServiceError::io("failed to create empty user config.toml", e)
})?;
TomlValue::Table(toml::map::Map::new())
} else {
return Err(ConfigServiceError::io("failed to read user config.toml", e));
}
Err(err) => {
return Err(ConfigServiceError::io(
"failed to read user config.toml",
err,
));
}
},
None => {
write_empty_user_config(write_path).await?;
TomlValue::Table(toml::map::Map::new())
}
};
Ok(ConfigLayerEntry::new(
@@ -400,13 +385,6 @@ async fn create_empty_user_layer(
))
}
async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigServiceError> {
task::spawn_blocking(move || write_atomically(&write_path, ""))
.await
.map_err(|err| ConfigServiceError::anyhow("config persistence task panicked", err.into()))?
.map_err(|err| ConfigServiceError::io("failed to create empty user config.toml", err))
}
fn parse_value(value: JsonValue) -> Result<Option<TomlValue>, String> {
if value.is_null() {
return Ok(None);

View File

@@ -423,11 +423,10 @@ impl Default for Notifications {
/// Terminals generally encode both mouse wheels and trackpads as the same "scroll up/down" mouse
/// button events, without a magnitude. This setting controls whether Codex uses a heuristic to
/// infer wheel vs trackpad per stream, or forces a specific behavior.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ScrollInputMode {
/// Infer wheel vs trackpad behavior per scroll stream.
#[default]
Auto,
/// Always treat scroll events as mouse-wheel input (fixed lines per tick).
Wheel,
@@ -435,6 +434,12 @@ pub enum ScrollInputMode {
Trackpad,
}
impl Default for ScrollInputMode {
fn default() -> Self {
Self::Auto
}
}
/// Collection of settings that are specific to the TUI.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -600,20 +605,6 @@ impl Notice {
pub(crate) const TABLE_KEY: &'static str = "notice";
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SkillConfig {
pub path: AbsolutePathBuf,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SkillsConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config: Vec<SkillConfig>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SandboxWorkspaceWrite {

View File

@@ -1,12 +1,12 @@
use crate::codex::TurnContext;
use crate::context_manager::normalize;
use crate::instructions::SkillInstructions;
use crate::instructions::UserInstructions;
use crate::truncate::TruncationPolicy;
use crate::truncate::approx_token_count;
use crate::truncate::approx_tokens_from_byte_count;
use crate::truncate::truncate_function_output_items_with_policy;
use crate::truncate::truncate_text;
use crate::user_instructions::SkillInstructions;
use crate::user_instructions::UserInstructions;
use crate::user_shell_command::is_user_shell_command_text;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
@@ -235,19 +235,12 @@ impl ContextManager {
token_estimate as usize
}
/// When true, the server already accounted for past reasoning tokens and
/// the client should not re-estimate them.
pub(crate) fn get_total_token_usage(&self, server_reasoning_included: bool) -> i64 {
let last_tokens = self
.token_info
pub(crate) fn get_total_token_usage(&self) -> i64 {
self.token_info
.as_ref()
.map(|info| info.last_token_usage.total_tokens)
.unwrap_or(0);
if server_reasoning_included {
last_tokens
} else {
last_tokens.saturating_add(self.get_non_last_reasoning_items_tokens() as i64)
}
.unwrap_or(0)
.saturating_add(self.get_non_last_reasoning_items_tokens() as i64)
}
/// This function enforces a couple of invariants on the in-memory history:

View File

@@ -17,8 +17,8 @@ use codex_protocol::user_input::UserInput;
use tracing::warn;
use uuid::Uuid;
use crate::instructions::SkillInstructions;
use crate::instructions::UserInstructions;
use crate::user_instructions::SkillInstructions;
use crate::user_instructions::UserInstructions;
use crate::user_shell_command::is_user_shell_command_text;
fn is_session_prefix(text: &str) -> bool {

View File

@@ -21,33 +21,28 @@ pub(crate) use legacy::legacy_feature_keys;
/// High-level lifecycle stage for a feature.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
/// Closed beta features to be used while developing or within the company.
Beta,
/// Experimental features made available to users through the `/experimental` menu
Experimental {
Experimental,
Beta {
name: &'static str,
menu_description: &'static str,
announcement: &'static str,
},
/// Stable features. The feature flag is kept for ad-hoc enabling/disabling
Stable,
/// Deprecated feature that should not be used anymore.
Deprecated,
/// The feature flag is useless but kept for backward compatibility reason.
Removed,
}
impl Stage {
pub fn beta_menu_name(self) -> Option<&'static str> {
match self {
Stage::Experimental { name, .. } => Some(name),
Stage::Beta { name, .. } => Some(name),
_ => None,
}
}
pub fn beta_menu_description(self) -> Option<&'static str> {
match self {
Stage::Experimental {
Stage::Beta {
menu_description, ..
} => Some(menu_description),
_ => None,
@@ -56,7 +51,7 @@ impl Stage {
pub fn beta_announcement(self) -> Option<&'static str> {
match self {
Stage::Experimental { announcement, .. } => Some(announcement),
Stage::Beta { announcement, .. } => Some(announcement),
_ => None,
}
}
@@ -105,8 +100,6 @@ pub enum Feature {
Collab,
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
Steer,
/// Enable collaboration modes (Plan, Pair Programming, Execute).
CollaborationModes,
}
impl Feature {
@@ -341,14 +334,14 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::WebSearchCached,
key: "web_search_cached",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: false,
},
// Beta program. Rendered in the `/experimental` menu for users.
FeatureSpec {
id: Feature::UnifiedExec,
key: "unified_exec",
stage: Stage::Experimental {
stage: Stage::Beta {
name: "Background terminal",
menu_description: "Run long-running terminal commands in the background.",
announcement: "NEW! Try Background terminals for long-running commands. Enable in /experimental!",
@@ -358,7 +351,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::ShellSnapshot,
key: "shell_snapshot",
stage: Stage::Experimental {
stage: Stage::Beta {
name: "Shell snapshot",
menu_description: "Snapshot your shell environment to avoid re-running login scripts for every command.",
announcement: "NEW! Try shell snapshotting to make your Codex faster. Enable in /experimental!",
@@ -368,50 +361,50 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::ChildAgentsMd,
key: "child_agents_md",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::ApplyPatchFreeform,
key: "apply_patch_freeform",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::ExecPolicy,
key: "exec_policy",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::WindowsSandbox,
key: "experimental_windows_sandbox",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::WindowsSandboxElevated,
key: "elevated_windows_sandbox",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::RemoteCompaction,
key: "remote_compaction",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::RemoteModels,
key: "remote_models",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::PowershellUtf8,
key: "powershell_utf8",
#[cfg(windows)]
stage: Stage::Experimental {
stage: Stage::Beta {
name: "Powershell UTF-8 support",
menu_description: "Enable UTF-8 output in Powershell.",
announcement: "Codex now supports UTF-8 output in Powershell. If you are seeing problems, disable in /experimental.",
@@ -419,42 +412,36 @@ pub const FEATURES: &[FeatureSpec] = &[
#[cfg(windows)]
default_enabled: true,
#[cfg(not(windows))]
stage: Stage::Beta,
stage: Stage::Experimental,
#[cfg(not(windows))]
default_enabled: false,
},
FeatureSpec {
id: Feature::EnableRequestCompression,
key: "enable_request_compression",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Collab,
key: "collab",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Tui2,
key: "tui2",
stage: Stage::Beta,
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Steer,
key: "steer",
stage: Stage::Experimental {
stage: Stage::Beta {
name: "Steer conversation",
menu_description: "Enter submits immediately; Tab queues messages when a task is running.",
announcement: "NEW! Try Steer mode: Enter submits immediately, Tab queues. Enable in /experimental!",
},
default_enabled: false,
},
FeatureSpec {
id: Feature::CollaborationModes,
key: "collaboration_modes",
stage: Stage::Beta,
default_enabled: false,
},
];

View File

@@ -1,6 +0,0 @@
mod user_instructions;
pub(crate) use user_instructions::SkillInstructions;
pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG_LEGACY;
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
pub(crate) use user_instructions::UserInstructions;

View File

@@ -31,7 +31,6 @@ mod exec_policy;
pub mod features;
mod flags;
pub mod git_info;
pub mod instructions;
pub mod landlock;
pub mod mcp;
mod mcp_connection_manager;
@@ -51,6 +50,7 @@ mod text_encoding;
pub mod token_data;
mod truncate;
mod unified_exec;
mod user_instructions;
pub mod windows_sandbox;
pub use model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
@@ -100,7 +100,6 @@ pub use rollout::find_conversation_path_by_id_str;
pub use rollout::find_thread_path_by_id_str;
pub use rollout::list::Cursor;
pub use rollout::list::ThreadItem;
pub use rollout::list::ThreadSortKey;
pub use rollout::list::ThreadsPage;
pub use rollout::list::parse_cursor;
pub use rollout::list::read_head_for_summary;

View File

@@ -1,42 +0,0 @@
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Settings;
use codex_protocol::openai_models::ReasoningEffort;
const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md");
const COLLABORATION_MODE_PAIR_PROGRAMMING: &str =
include_str!("../../templates/collaboration_mode/pair_programming.md");
const COLLABORATION_MODE_EXECUTE: &str =
include_str!("../../templates/collaboration_mode/execute.md");
pub(super) fn builtin_collaboration_mode_presets() -> Vec<CollaborationMode> {
vec![plan_preset(), pair_programming_preset(), execute_preset()]
}
#[cfg(any(test, feature = "test-support"))]
pub fn test_builtin_collaboration_mode_presets() -> Vec<CollaborationMode> {
builtin_collaboration_mode_presets()
}
fn plan_preset() -> CollaborationMode {
CollaborationMode::Plan(Settings {
model: "gpt-5.2-codex".to_string(),
reasoning_effort: Some(ReasoningEffort::Medium),
developer_instructions: Some(COLLABORATION_MODE_PLAN.to_string()),
})
}
fn pair_programming_preset() -> CollaborationMode {
CollaborationMode::PairProgramming(Settings {
model: "gpt-5.2-codex".to_string(),
reasoning_effort: Some(ReasoningEffort::Medium),
developer_instructions: Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string()),
})
}
fn execute_preset() -> CollaborationMode {
CollaborationMode::Execute(Settings {
model: "gpt-5.2-codex".to_string(),
reasoning_effort: Some(ReasoningEffort::XHigh),
developer_instructions: Some(COLLABORATION_MODE_EXECUTE.to_string()),
})
}

View File

@@ -1,7 +1,6 @@
use codex_api::ModelsClient;
use codex_api::ReqwestTransport;
use codex_app_server_protocol::AuthMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelsResponse;
@@ -24,7 +23,6 @@ use crate::error::CodexErr;
use crate::error::Result as CoreResult;
use crate::features::Feature;
use crate::model_provider_info::ModelProviderInfo;
use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
use crate::models_manager::model_info;
use crate::models_manager::model_presets::builtin_model_presets;
@@ -92,13 +90,6 @@ impl ModelsManager {
self.build_available_models(remote_models)
}
/// List collaboration mode presets.
///
/// Returns a static set of presets seeded with the configured model.
pub fn list_collaboration_modes(&self) -> Vec<CollaborationMode> {
builtin_collaboration_mode_presets()
}
/// Attempt to list models without blocking, using the current cached state.
///
/// Returns an error if the internal lock cannot be acquired.

View File

@@ -1,8 +1,4 @@
pub mod cache;
pub mod collaboration_mode_presets;
pub mod manager;
pub mod model_info;
pub mod model_presets;
#[cfg(any(test, feature = "test-support"))]
pub use collaboration_mode_presets::test_builtin_collaboration_mode_presets;

View File

@@ -1,9 +1,5 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashSet;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use crate::env;
@@ -12,106 +8,6 @@ pub fn normalize_for_path_comparison(path: impl AsRef<Path>) -> std::io::Result<
Ok(normalize_for_wsl(canonical))
}
pub struct SymlinkWritePaths {
pub read_path: Option<PathBuf>,
pub write_path: PathBuf,
}
/// Resolve the final filesystem target for `path` while retaining a safe write path.
///
/// This follows symlink chains (including relative symlink targets) until it reaches a
/// non-symlink path. If the chain cycles or any metadata/link resolution fails, it
/// returns `read_path: None` and uses the original absolute path as `write_path`.
/// There is no fixed max-resolution count; cycles are detected via a visited set.
pub fn resolve_symlink_write_paths(path: &Path) -> io::Result<SymlinkWritePaths> {
let root = AbsolutePathBuf::from_absolute_path(path)
.map(AbsolutePathBuf::into_path_buf)
.unwrap_or_else(|_| path.to_path_buf());
let mut current = root.clone();
let mut visited = HashSet::new();
// Follow symlink chains while guarding against cycles.
loop {
let meta = match std::fs::symlink_metadata(&current) {
Ok(meta) => meta,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Ok(SymlinkWritePaths {
read_path: Some(current.clone()),
write_path: current,
});
}
Err(_) => {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
}
};
if !meta.file_type().is_symlink() {
return Ok(SymlinkWritePaths {
read_path: Some(current.clone()),
write_path: current,
});
}
// If we've already seen this path, the chain cycles.
if !visited.insert(current.clone()) {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
}
let target = match std::fs::read_link(&current) {
Ok(target) => target,
Err(_) => {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
}
};
let next = if target.is_absolute() {
AbsolutePathBuf::from_absolute_path(&target)
} else if let Some(parent) = current.parent() {
AbsolutePathBuf::resolve_path_against_base(&target, parent)
} else {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
};
let next = match next {
Ok(path) => path.into_path_buf(),
Err(_) => {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
}
};
current = next;
}
}
pub fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> {
let parent = write_path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path {} has no parent directory", write_path.display()),
)
})?;
std::fs::create_dir_all(parent)?;
let tmp = NamedTempFile::new_in(parent)?;
std::fs::write(tmp.path(), contents)?;
tmp.persist(write_path)?;
Ok(())
}
fn normalize_for_wsl(path: PathBuf) -> PathBuf {
normalize_for_wsl_with_flag(path, env::is_wsl())
}
@@ -188,29 +84,6 @@ fn lower_ascii_path(path: PathBuf) -> PathBuf {
#[cfg(test)]
mod tests {
#[cfg(unix)]
mod symlinks {
use super::super::resolve_symlink_write_paths;
use pretty_assertions::assert_eq;
use std::os::unix::fs::symlink;
#[test]
fn symlink_cycles_fall_back_to_root_write_path() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
let a = dir.path().join("a");
let b = dir.path().join("b");
symlink(&b, &a)?;
symlink(&a, &b)?;
let resolved = resolve_symlink_write_paths(&a)?;
assert_eq!(resolved.read_path, None);
assert_eq!(resolved.write_path, a);
Ok(())
}
}
#[cfg(target_os = "linux")]
mod wsl {
use super::super::normalize_for_wsl_with_flag;

View File

@@ -61,13 +61,13 @@ pub(crate) async fn get_user_instructions(
}
};
let skills_section = skills.and_then(render_skills_section);
if let Some(skills_section) = skills_section {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&skills_section);
}
// let skills_section = skills.and_then(render_skills_section);
// if let Some(skills_section) = skills_section {
// if !output.is_empty() {
// output.push_str("\n\n");
// }
// output.push_str(&skills_section);
// }
if config.features.enabled(Feature::ChildAgentsMd) {
if !output.is_empty() {

View File

@@ -1,13 +1,11 @@
use std::cmp::Reverse;
use std::io::{self};
use std::num::NonZero;
use std::ops::ControlFlow;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use async_trait::async_trait;
use time::OffsetDateTime;
use time::PrimitiveDateTime;
use time::format_description::FormatItem;
@@ -43,10 +41,8 @@ pub struct ThreadItem {
/// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line).
pub head: Vec<serde_json::Value>,
/// RFC3339 timestamp string for when the session was created, if available.
/// created_at comes from the filename timestamp with second precision.
pub created_at: Option<String>,
/// RFC3339 timestamp string for the most recent update (from file mtime).
/// updated_at is truncated to second precision to match created_at.
pub updated_at: Option<String>,
}
@@ -72,12 +68,6 @@ struct HeadTailSummary {
const MAX_SCAN_FILES: usize = 10000;
const HEAD_RECORD_LIMIT: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThreadSortKey {
CreatedAt,
UpdatedAt,
}
/// Pagination cursor identifying a file by timestamp and UUID.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cursor {
@@ -91,135 +81,6 @@ impl Cursor {
}
}
/// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest,
/// it ignores everything until it reaches the last seen item from the previous page, then
/// starts returning results after that. This makes paging stable even if new files show up during
/// pagination.
struct AnchorState {
ts: OffsetDateTime,
id: Uuid,
passed: bool,
}
impl AnchorState {
fn new(anchor: Option<Cursor>) -> Self {
match anchor {
Some(cursor) => Self {
ts: cursor.ts,
id: cursor.id,
passed: false,
},
None => Self {
ts: OffsetDateTime::UNIX_EPOCH,
id: Uuid::nil(),
passed: true,
},
}
}
fn should_skip(&mut self, ts: OffsetDateTime, id: Uuid) -> bool {
if self.passed {
return false;
}
if ts < self.ts || (ts == self.ts && id < self.id) {
self.passed = true;
false
} else {
true
}
}
}
/// Visitor interface to customize behavior when visiting each rollout file
/// in `walk_rollout_files`.
///
/// We need to apply different logic if we're ultimately going to be returning
/// threads ordered by created_at or updated_at.
#[async_trait]
trait RolloutFileVisitor {
async fn visit(
&mut self,
ts: OffsetDateTime,
id: Uuid,
path: PathBuf,
scanned: usize,
) -> ControlFlow<()>;
}
/// Collects thread items during directory traversal in created_at order,
/// applying pagination and filters inline.
struct FilesByCreatedAtVisitor<'a> {
items: &'a mut Vec<ThreadItem>,
page_size: usize,
anchor_state: AnchorState,
more_matches_available: bool,
allowed_sources: &'a [SessionSource],
provider_matcher: Option<&'a ProviderMatcher<'a>>,
}
#[async_trait]
impl<'a> RolloutFileVisitor for FilesByCreatedAtVisitor<'a> {
async fn visit(
&mut self,
ts: OffsetDateTime,
id: Uuid,
path: PathBuf,
scanned: usize,
) -> ControlFlow<()> {
if scanned >= MAX_SCAN_FILES && self.items.len() >= self.page_size {
self.more_matches_available = true;
return ControlFlow::Break(());
}
if self.anchor_state.should_skip(ts, id) {
return ControlFlow::Continue(());
}
if self.items.len() == self.page_size {
self.more_matches_available = true;
return ControlFlow::Break(());
}
let updated_at = file_modified_time(&path)
.await
.unwrap_or(None)
.and_then(format_rfc3339);
if let Some(item) = build_thread_item(
path,
self.allowed_sources,
self.provider_matcher,
updated_at,
)
.await
{
self.items.push(item);
}
ControlFlow::Continue(())
}
}
/// Collects lightweight file candidates (path + id + mtime).
/// Sorting after mtime happens after all files are collected.
struct FilesByUpdatedAtVisitor<'a> {
candidates: &'a mut Vec<ThreadCandidate>,
}
#[async_trait]
impl<'a> RolloutFileVisitor for FilesByUpdatedAtVisitor<'a> {
async fn visit(
&mut self,
_ts: OffsetDateTime,
id: Uuid,
path: PathBuf,
_scanned: usize,
) -> ControlFlow<()> {
let updated_at = file_modified_time(&path).await.unwrap_or(None);
self.candidates.push(ThreadCandidate {
path,
id,
updated_at,
});
ControlFlow::Continue(())
}
}
impl serde::Serialize for Cursor {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
@@ -247,13 +108,11 @@ impl<'de> serde::Deserialize<'de> for Cursor {
/// Retrieve recorded thread file paths with token pagination. The returned `next_cursor`
/// can be supplied on the next call to resume after the last returned item, resilient to
/// concurrent new sessions being appended. Ordering is stable by the requested sort key
/// (timestamp desc, then UUID desc).
/// concurrent new sessions being appended. Ordering is stable by timestamp desc, then UUID desc.
pub(crate) async fn get_threads(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
@@ -279,7 +138,6 @@ pub(crate) async fn get_threads(
root.clone(),
page_size,
anchor,
sort_key,
allowed_sources,
provider_matcher.as_ref(),
)
@@ -290,46 +148,8 @@ pub(crate) async fn get_threads(
/// Load thread file paths from disk using directory traversal.
///
/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
/// Returned newest (based on sort key) first.
/// Returned newest (latest) first.
async fn traverse_directories_for_paths(
root: PathBuf,
page_size: usize,
anchor: Option<Cursor>,
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
) -> io::Result<ThreadsPage> {
match sort_key {
ThreadSortKey::CreatedAt => {
traverse_directories_for_paths_created(
root,
page_size,
anchor,
allowed_sources,
provider_matcher,
)
.await
}
ThreadSortKey::UpdatedAt => {
traverse_directories_for_paths_updated(
root,
page_size,
anchor,
allowed_sources,
provider_matcher,
)
.await
}
}
}
/// Walk the rollout directory tree in reverse chronological order and
/// collect items until the page fills or the scan cap is hit.
///
/// Ordering comes from directory/filename sorting, so created_at is derived
/// from the filename timestamp. Pagination is handled by the anchor cursor
/// so we resume strictly after the last returned `(ts, id)` pair.
async fn traverse_directories_for_paths_created(
root: PathBuf,
page_size: usize,
anchor: Option<Cursor>,
@@ -338,83 +158,96 @@ async fn traverse_directories_for_paths_created(
) -> io::Result<ThreadsPage> {
let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
let mut more_matches_available = false;
let mut visitor = FilesByCreatedAtVisitor {
items: &mut items,
page_size,
anchor_state: AnchorState::new(anchor),
more_matches_available,
allowed_sources,
provider_matcher,
let mut anchor_passed = anchor.is_none();
let (anchor_ts, anchor_id) = match anchor {
Some(c) => (c.ts, c.id),
None => (OffsetDateTime::UNIX_EPOCH, Uuid::nil()),
};
walk_rollout_files(&root, &mut scanned_files, &mut visitor).await?;
more_matches_available = visitor.more_matches_available;
let reached_scan_cap = scanned_files >= MAX_SCAN_FILES;
if reached_scan_cap && !items.is_empty() {
more_matches_available = true;
}
let next = if more_matches_available {
build_next_cursor(&items, ThreadSortKey::CreatedAt)
} else {
None
};
Ok(ThreadsPage {
items,
next_cursor: next,
num_scanned_files: scanned_files,
reached_scan_cap,
})
}
/// Walk the rollout directory tree to collect files by updated_at, then sort by
/// file mtime (updated_at) and apply pagination/filtering in that order.
///
/// Because updated_at is not encoded in filenames, this path must scan all
/// files up to the scan cap, then sort and filter by the anchor cursor.
///
/// NOTE: This can be optimized in the future if we store additional state on disk
/// to cache updated_at timestamps.
async fn traverse_directories_for_paths_updated(
root: PathBuf,
page_size: usize,
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
) -> io::Result<ThreadsPage> {
let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
let mut anchor_state = AnchorState::new(anchor);
let mut more_matches_available = false;
let candidates = collect_files_by_updated_at(&root, &mut scanned_files).await?;
let mut candidates = candidates;
candidates.sort_by_key(|candidate| {
let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH);
(Reverse(ts), Reverse(candidate.id))
});
let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u16>().ok()).await?;
for candidate in candidates.into_iter() {
let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH);
if anchor_state.should_skip(ts, candidate.id) {
continue;
}
if items.len() == page_size {
more_matches_available = true;
'outer: for (_year, year_path) in year_dirs.iter() {
if scanned_files >= MAX_SCAN_FILES {
break;
}
let month_dirs = collect_dirs_desc(year_path, |s| s.parse::<u8>().ok()).await?;
for (_month, month_path) in month_dirs.iter() {
if scanned_files >= MAX_SCAN_FILES {
break 'outer;
}
let day_dirs = collect_dirs_desc(month_path, |s| s.parse::<u8>().ok()).await?;
for (_day, day_path) in day_dirs.iter() {
if scanned_files >= MAX_SCAN_FILES {
break 'outer;
}
let mut day_files = collect_files(day_path, |name_str, path| {
if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") {
return None;
}
let updated_at_fallback = candidate.updated_at.and_then(format_rfc3339);
if let Some(item) = build_thread_item(
candidate.path,
allowed_sources,
provider_matcher,
updated_at_fallback,
)
.await
{
items.push(item);
parse_timestamp_uuid_from_filename(name_str)
.map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf()))
})
.await?;
// Stable ordering within the same second: (timestamp desc, uuid desc)
day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid)));
for (ts, sid, _name_str, path) in day_files.into_iter() {
scanned_files += 1;
if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size {
more_matches_available = true;
break 'outer;
}
if !anchor_passed {
if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) {
anchor_passed = true;
} else {
continue;
}
}
if items.len() == page_size {
more_matches_available = true;
break 'outer;
}
// Read head and detect message events; stop once meta + user are found.
let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.iter().any(|s| s == &source))
{
continue;
}
if let Some(matcher) = provider_matcher
&& !matcher.matches(summary.model_provider.as_deref())
{
continue;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
head,
created_at,
mut updated_at,
..
} = summary;
if updated_at.is_none() {
updated_at = file_modified_rfc3339(&path)
.await
.unwrap_or(None)
.or_else(|| created_at.clone());
}
items.push(ThreadItem {
path,
head,
created_at,
updated_at,
});
}
}
}
}
}
@@ -424,7 +257,7 @@ async fn traverse_directories_for_paths_updated(
}
let next = if more_matches_available {
build_next_cursor(&items, ThreadSortKey::UpdatedAt)
build_next_cursor(&items)
} else {
None
};
@@ -436,9 +269,9 @@ async fn traverse_directories_for_paths_updated(
})
}
/// Pagination cursor token format: "<ts>|<uuid>" where `ts` uses
/// YYYY-MM-DDThh-mm-ss (UTC, second precision).
/// The cursor orders files by the requested sort key (timestamp desc, then UUID desc).
/// Pagination cursor token format: "<file_ts>|<uuid>" where `file_ts` matches the
/// filename timestamp portion (YYYY-MM-DDThh-mm-ss) used in rollout filenames.
/// The cursor orders files by timestamp desc, then UUID desc.
pub fn parse_cursor(token: &str) -> Option<Cursor> {
let (file_ts, uuid_str) = token.split_once('|')?;
@@ -453,63 +286,13 @@ pub fn parse_cursor(token: &str) -> Option<Cursor> {
Some(Cursor::new(ts, uuid))
}
fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option<Cursor> {
fn build_next_cursor(items: &[ThreadItem]) -> Option<Cursor> {
let last = items.last()?;
let file_name = last.path.file_name()?.to_string_lossy();
let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
let ts = match sort_key {
ThreadSortKey::CreatedAt => created_ts,
ThreadSortKey::UpdatedAt => {
let updated_at = last.updated_at.as_deref()?;
OffsetDateTime::parse(updated_at, &Rfc3339).ok()?
}
};
let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
Some(Cursor::new(ts, id))
}
async fn build_thread_item(
path: PathBuf,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
updated_at: Option<String>,
) -> Option<ThreadItem> {
// Read head and detect message events; stop once meta + user are found.
let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.contains(&source))
{
return None;
}
if let Some(matcher) = provider_matcher
&& !matcher.matches(summary.model_provider.as_deref())
{
return None;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
head,
created_at,
updated_at: mut summary_updated_at,
..
} = summary;
if summary_updated_at.is_none() {
summary_updated_at = updated_at.or_else(|| created_at.clone());
}
return Some(ThreadItem {
path,
head,
created_at,
updated_at: summary_updated_at,
});
}
None
}
/// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`,
/// and returns them sorted descending by the parsed key.
async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> io::Result<Vec<(T, PathBuf)>>
@@ -557,22 +340,6 @@ where
Ok(collected)
}
async fn collect_rollout_day_files(
day_path: &Path,
) -> io::Result<Vec<(OffsetDateTime, Uuid, PathBuf)>> {
let mut day_files = collect_files(day_path, |name_str, path| {
if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") {
return None;
}
parse_timestamp_uuid_from_filename(name_str).map(|(ts, id)| (ts, id, path.to_path_buf()))
})
.await?;
// Stable ordering within the same second: (timestamp desc, uuid desc)
day_files.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid)));
Ok(day_files)
}
fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> {
// Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl
let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
@@ -590,65 +357,6 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui
Some((ts, uuid))
}
struct ThreadCandidate {
path: PathBuf,
id: Uuid,
updated_at: Option<OffsetDateTime>,
}
async fn collect_files_by_updated_at(
root: &Path,
scanned_files: &mut usize,
) -> io::Result<Vec<ThreadCandidate>> {
let mut candidates = Vec::new();
let mut visitor = FilesByUpdatedAtVisitor {
candidates: &mut candidates,
};
walk_rollout_files(root, scanned_files, &mut visitor).await?;
Ok(candidates)
}
async fn walk_rollout_files(
root: &Path,
scanned_files: &mut usize,
visitor: &mut impl RolloutFileVisitor,
) -> io::Result<()> {
let year_dirs = collect_dirs_desc(root, |s| s.parse::<u16>().ok()).await?;
'outer: for (_year, year_path) in year_dirs.iter() {
if *scanned_files >= MAX_SCAN_FILES {
break;
}
let month_dirs = collect_dirs_desc(year_path, |s| s.parse::<u8>().ok()).await?;
for (_month, month_path) in month_dirs.iter() {
if *scanned_files >= MAX_SCAN_FILES {
break 'outer;
}
let day_dirs = collect_dirs_desc(month_path, |s| s.parse::<u8>().ok()).await?;
for (_day, day_path) in day_dirs.iter() {
if *scanned_files >= MAX_SCAN_FILES {
break 'outer;
}
let day_files = collect_rollout_day_files(day_path).await?;
for (ts, id, path) in day_files.into_iter() {
*scanned_files += 1;
if *scanned_files > MAX_SCAN_FILES {
break 'outer;
}
if let ControlFlow::Break(()) =
visitor.visit(ts, id, path, *scanned_files).await
{
break 'outer;
}
}
}
}
}
Ok(())
}
struct ProviderMatcher<'a> {
filters: &'a [String],
matches_default_provider: bool,
@@ -744,24 +452,14 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Va
Ok(summary.head)
}
async fn file_modified_time(path: &Path) -> io::Result<Option<OffsetDateTime>> {
async fn file_modified_rfc3339(path: &Path) -> io::Result<Option<String>> {
let meta = tokio::fs::metadata(path).await?;
let modified = meta.modified().ok();
let Some(modified) = modified else {
return Ok(None);
};
let dt = OffsetDateTime::from(modified);
// Truncate to seconds so ordering and cursor comparisons align with the
// cursor timestamp format (which exposes seconds), keeping pagination stable.
Ok(truncate_to_seconds(dt))
}
fn format_rfc3339(dt: OffsetDateTime) -> Option<String> {
dt.format(&Rfc3339).ok()
}
fn truncate_to_seconds(dt: OffsetDateTime) -> Option<OffsetDateTime> {
dt.replace_nanosecond(0).ok()
Ok(dt.format(&Rfc3339).ok())
}
/// Locate a recorded thread rollout file by its UUID string using the existing

View File

@@ -67,7 +67,6 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::ExecCommandOutputDelta(_)
| EventMsg::ExecCommandEnd(_)
| EventMsg::ExecApprovalRequest(_)
| EventMsg::RequestUserInput(_)
| EventMsg::ElicitationRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::BackgroundEvent(_)

View File

@@ -22,7 +22,6 @@ use tracing::warn;
use super::SESSIONS_SUBDIR;
use super::list::Cursor;
use super::list::ThreadSortKey;
use super::list::ThreadsPage;
use super::list::get_threads;
use super::policy::is_persisted_response_item;
@@ -57,7 +56,7 @@ pub struct RolloutRecorder {
pub enum RolloutRecorderParams {
Create {
conversation_id: ThreadId,
forked_from_id: Option<ThreadId>,
instructions: Option<String>,
source: SessionSource,
},
Resume {
@@ -79,12 +78,12 @@ enum RolloutCmd {
impl RolloutRecorderParams {
pub fn new(
conversation_id: ThreadId,
forked_from_id: Option<ThreadId>,
instructions: Option<String>,
source: SessionSource,
) -> Self {
Self::Create {
conversation_id,
forked_from_id,
instructions,
source,
}
}
@@ -100,7 +99,6 @@ impl RolloutRecorder {
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
@@ -109,7 +107,6 @@ impl RolloutRecorder {
codex_home,
page_size,
cursor,
sort_key,
allowed_sources,
model_providers,
default_provider,
@@ -118,24 +115,19 @@ impl RolloutRecorder {
}
/// Find the newest recorded thread path, optionally filtering to a matching cwd.
#[allow(clippy::too_many_arguments)]
pub async fn find_latest_thread_path(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
filter_cwd: Option<&Path>,
) -> std::io::Result<Option<PathBuf>> {
let mut cursor = cursor.cloned();
let mut cursor: Option<Cursor> = None;
loop {
let page = Self::list_threads(
codex_home,
page_size,
25,
cursor.as_ref(),
sort_key,
allowed_sources,
model_providers,
default_provider,
@@ -158,7 +150,7 @@ impl RolloutRecorder {
let (file, rollout_path, meta) = match params {
RolloutRecorderParams::Create {
conversation_id,
forked_from_id,
instructions,
source,
} => {
let LogFileInfo {
@@ -181,11 +173,11 @@ impl RolloutRecorder {
path,
Some(SessionMeta {
id: session_id,
forked_from_id,
timestamp,
cwd: config.cwd.clone(),
originator: originator().value,
cli_version: env!("CARGO_PKG_VERSION").to_string(),
instructions,
source,
model_provider: Some(config.model_provider_id.clone()),
}),

View File

@@ -1,13 +1,11 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::fs::File;
use std::fs::FileTimes;
use std::fs::{self};
use std::io::Write;
use std::path::Path;
use tempfile::TempDir;
use time::Duration;
use time::OffsetDateTime;
use time::PrimitiveDateTime;
use time::format_description::FormatItem;
@@ -17,7 +15,6 @@ use uuid::Uuid;
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
use crate::rollout::list::Cursor;
use crate::rollout::list::ThreadItem;
use crate::rollout::list::ThreadSortKey;
use crate::rollout::list::ThreadsPage;
use crate::rollout::list::get_threads;
use anyhow::Result;
@@ -86,6 +83,7 @@ fn write_session_file_with_provider(
let mut payload = serde_json::json!({
"id": uuid,
"timestamp": ts_str,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -124,8 +122,6 @@ fn write_session_file_with_provider(
});
writeln!(file, "{rec}")?;
}
let times = FileTimes::new().set_modified(dt.into());
file.set_times(times)?;
Ok((dt, uuid))
}
@@ -170,7 +166,6 @@ async fn test_list_conversations_latest_first() {
home,
10,
None,
ThreadSortKey::CreatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
@@ -201,6 +196,7 @@ async fn test_list_conversations_latest_first() {
let head_3 = vec![serde_json::json!({
"id": u3,
"timestamp": "2025-01-03T12-00-00",
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -210,6 +206,7 @@ async fn test_list_conversations_latest_first() {
let head_2 = vec![serde_json::json!({
"id": u2,
"timestamp": "2025-01-02T12-00-00",
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -219,6 +216,7 @@ async fn test_list_conversations_latest_first() {
let head_1 = vec![serde_json::json!({
"id": u1,
"timestamp": "2025-01-01T12-00-00",
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -317,7 +315,6 @@ async fn test_pagination_cursor() {
home,
2,
None,
ThreadSortKey::CreatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
@@ -339,6 +336,7 @@ async fn test_pagination_cursor() {
let head_5 = vec![serde_json::json!({
"id": u5,
"timestamp": "2025-03-05T09-00-00",
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -348,6 +346,7 @@ async fn test_pagination_cursor() {
let head_4 = vec![serde_json::json!({
"id": u4,
"timestamp": "2025-03-04T09-00-00",
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -383,7 +382,6 @@ async fn test_pagination_cursor() {
home,
2,
page1.next_cursor.as_ref(),
ThreadSortKey::CreatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
@@ -405,6 +403,7 @@ async fn test_pagination_cursor() {
let head_3 = vec![serde_json::json!({
"id": u3,
"timestamp": "2025-03-03T09-00-00",
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -414,6 +413,7 @@ async fn test_pagination_cursor() {
let head_2 = vec![serde_json::json!({
"id": u2,
"timestamp": "2025-03-02T09-00-00",
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -449,7 +449,6 @@ async fn test_pagination_cursor() {
home,
2,
page2.next_cursor.as_ref(),
ThreadSortKey::CreatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
@@ -465,6 +464,7 @@ async fn test_pagination_cursor() {
let head_1 = vec![serde_json::json!({
"id": u1,
"timestamp": "2025-03-01T09-00-00",
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -501,7 +501,6 @@ async fn test_get_thread_contents() {
home,
1,
None,
ThreadSortKey::CreatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
@@ -522,6 +521,7 @@ async fn test_get_thread_contents() {
let expected_head = vec![serde_json::json!({
"id": uuid,
"timestamp": ts,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -548,6 +548,7 @@ async fn test_get_thread_contents() {
"payload": {
"id": uuid,
"timestamp": ts,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -566,52 +567,6 @@ async fn test_get_thread_contents() {
assert_eq!(content, expected_content);
}
#[tokio::test]
async fn test_created_at_sort_uses_file_mtime_for_updated_at() -> Result<()> {
let temp = TempDir::new().unwrap();
let home = temp.path();
let ts = "2025-06-01T08-00-00";
let uuid = Uuid::from_u128(43);
write_session_file(home, ts, uuid, 0, Some(SessionSource::VSCode)).unwrap();
let created = PrimitiveDateTime::parse(
ts,
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"),
)?
.assume_utc();
let updated = created + Duration::hours(2);
let expected_updated = updated.format(&time::format_description::well_known::Rfc3339)?;
let file_path = home
.join("sessions")
.join("2025")
.join("06")
.join("01")
.join(format!("rollout-{ts}-{uuid}.jsonl"));
let file = std::fs::OpenOptions::new().write(true).open(&file_path)?;
let times = FileTimes::new().set_modified(updated.into());
file.set_times(times)?;
let provider_filter = provider_vec(&[TEST_PROVIDER]);
let page = get_threads(
home,
1,
None,
ThreadSortKey::CreatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
)
.await?;
let item = page.items.first().expect("conversation item");
assert_eq!(item.created_at.as_deref(), Some(ts));
assert_eq!(item.updated_at.as_deref(), Some(expected_updated.as_str()));
Ok(())
}
#[tokio::test]
async fn test_updated_at_uses_file_mtime() -> Result<()> {
let temp = TempDir::new().unwrap();
@@ -630,8 +585,8 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
id: conversation_id,
forked_from_id: None,
timestamp: ts.to_string(),
instructions: None,
cwd: ".".into(),
originator: "test_originator".into(),
cli_version: "test_version".into(),
@@ -675,7 +630,6 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
home,
1,
None,
ThreadSortKey::UpdatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
@@ -715,7 +669,6 @@ async fn test_stable_ordering_same_second_pagination() {
home,
2,
None,
ThreadSortKey::CreatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
@@ -739,6 +692,7 @@ async fn test_stable_ordering_same_second_pagination() {
vec![serde_json::json!({
"id": u,
"timestamp": ts,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -774,7 +728,6 @@ async fn test_stable_ordering_same_second_pagination() {
home,
2,
page1.next_cursor.as_ref(),
ThreadSortKey::CreatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
@@ -833,7 +786,6 @@ async fn test_source_filter_excludes_non_matching_sessions() {
home,
10,
None,
ThreadSortKey::CreatedAt,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
TEST_PROVIDER,
@@ -851,17 +803,9 @@ async fn test_source_filter_excludes_non_matching_sessions() {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
let all_sessions = get_threads(
home,
10,
None,
ThreadSortKey::CreatedAt,
NO_SOURCE_FILTER,
None,
TEST_PROVIDER,
)
.await
.unwrap();
let all_sessions = get_threads(home, 10, None, NO_SOURCE_FILTER, None, TEST_PROVIDER)
.await
.unwrap();
let all_paths: Vec<_> = all_sessions
.items
.into_iter()
@@ -917,7 +861,6 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<(
home,
10,
None,
ThreadSortKey::CreatedAt,
NO_SOURCE_FILTER,
Some(openai_filter.as_slice()),
"openai",
@@ -943,7 +886,6 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<(
home,
10,
None,
ThreadSortKey::CreatedAt,
NO_SOURCE_FILTER,
Some(beta_filter.as_slice()),
"openai",
@@ -964,7 +906,6 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<(
home,
10,
None,
ThreadSortKey::CreatedAt,
NO_SOURCE_FILTER,
Some(unknown_filter.as_slice()),
"openai",
@@ -972,16 +913,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<(
.await?;
assert!(unknown_sessions.items.is_empty());
let all_sessions = get_threads(
home,
10,
None,
ThreadSortKey::CreatedAt,
NO_SOURCE_FILTER,
None,
"openai",
)
.await?;
let all_sessions = get_threads(home, 10, None, NO_SOURCE_FILTER, None, "openai").await?;
assert_eq!(all_sessions.items.len(), 3);
Ok(())

View File

@@ -189,7 +189,7 @@ mod tests {
#[tokio::test]
async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() {
let (session, turn_context) = make_session_and_context().await;
let mut items = session.build_initial_context(&turn_context).await;
let mut items = session.build_initial_context(&turn_context);
items.push(user_msg("feature request"));
items.push(assistant_msg("ack"));
items.push(user_msg("second question"));

View File

@@ -340,7 +340,6 @@ mod detect_shell_type_tests {
#[cfg(unix)]
mod tests {
use super::*;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
@@ -351,7 +350,7 @@ mod tests {
let shell_path = zsh_shell.shell_path;
assert_eq!(shell_path, Path::new("/bin/zsh"));
assert_eq!(shell_path, PathBuf::from("/bin/zsh"));
}
#[test]
@@ -361,7 +360,7 @@ mod tests {
let shell_path = zsh_shell.shell_path;
assert_eq!(shell_path, Path::new("/bin/zsh"));
assert_eq!(shell_path, PathBuf::from("/bin/zsh"));
}
#[test]
@@ -370,9 +369,9 @@ mod tests {
let shell_path = bash_shell.shell_path;
assert!(
shell_path == Path::new("/bin/bash")
|| shell_path == Path::new("/usr/bin/bash")
|| shell_path == Path::new("/usr/local/bin/bash"),
shell_path == PathBuf::from("/bin/bash")
|| shell_path == PathBuf::from("/usr/bin/bash")
|| shell_path == PathBuf::from("/usr/local/bin/bash"),
"shell path: {shell_path:?}",
);
}
@@ -382,7 +381,7 @@ mod tests {
let sh_shell = get_shell(ShellType::Sh, None).unwrap();
let shell_path = sh_shell.shell_path;
assert!(
shell_path == Path::new("/bin/sh") || shell_path == Path::new("/usr/bin/sh"),
shell_path == PathBuf::from("/bin/sh") || shell_path == PathBuf::from("/usr/bin/sh"),
"shell path: {shell_path:?}",
);
}

View File

@@ -135,13 +135,6 @@ async fn run_shell_script_with_timeout(
// returns a ref of handler.
let mut handler = Command::new(&args[0]);
handler.args(&args[1..]);
#[cfg(unix)]
unsafe {
handler.pre_exec(|| {
codex_utils_pty::process_group::detach_from_tty()?;
Ok(())
});
}
handler.kill_on_drop(true);
let output = timeout(snapshot_timeout, handler.output())
.await

View File

@@ -1,9 +1,8 @@
use std::collections::HashSet;
use std::path::PathBuf;
use crate::instructions::SkillInstructions;
use crate::skills::SkillLoadOutcome;
use crate::skills::SkillMetadata;
use crate::user_instructions::SkillInstructions;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use tokio::fs;
@@ -26,8 +25,7 @@ pub(crate) async fn build_skill_injections(
return SkillInjections::default();
};
let mentioned_skills =
collect_explicit_skill_mentions(inputs, &outcome.skills, &outcome.disabled_paths);
let mentioned_skills = collect_explicit_skill_mentions(inputs, &outcome.skills);
if mentioned_skills.is_empty() {
return SkillInjections::default();
}
@@ -48,9 +46,9 @@ pub(crate) async fn build_skill_injections(
}
Err(err) => {
let message = format!(
"Failed to load skill {name} at {path}: {err:#}",
name = skill.name,
path = skill.path.display()
"Failed to load skill {} at {}: {err:#}",
skill.name,
skill.path.display()
);
result.warnings.push(message);
}
@@ -63,7 +61,6 @@ pub(crate) async fn build_skill_injections(
fn collect_explicit_skill_mentions(
inputs: &[UserInput],
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
) -> Vec<SkillMetadata> {
let mut selected: Vec<SkillMetadata> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
@@ -72,7 +69,6 @@ fn collect_explicit_skill_mentions(
if let UserInput::Skill { name, path } = input
&& seen.insert(name.clone())
&& let Some(skill) = skills.iter().find(|s| s.name == *name && s.path == *path)
&& !disabled_paths.contains(&skill.path)
{
selected.push(skill.clone());
}

View File

@@ -105,13 +105,13 @@ where
discover_skills_under_root(&root.path, root.scope, &mut outcome);
}
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut seen: HashSet<String> = HashSet::new();
outcome
.skills
.retain(|skill| seen.insert(skill.path.clone()));
.retain(|skill| seen.insert(skill.name.clone()));
fn scope_rank(scope: SkillScope) -> u8 {
// Higher-priority scopes first (matches root scan order for dedupe).
// Higher-priority scopes first (matches dedupe priority order).
match scope {
SkillScope::Repo => 0,
SkillScope::User => 1,
@@ -445,23 +445,10 @@ fn resolve_asset_path(
return None;
}
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
tracing::warn!("ignoring {field}: icon path must not contain '..'");
return None;
}
_ => {
tracing::warn!("ignoring {field}: icon path must be under assets/");
return None;
}
}
let mut components = path.components().peekable();
while matches!(components.peek(), Some(Component::CurDir)) {
components.next();
}
let mut components = normalized.components();
match components.next() {
Some(Component::Normal(component)) if component == "assets" => {}
_ => {
@@ -469,8 +456,12 @@ fn resolve_asset_path(
return None;
}
}
if components.any(|component| matches!(component, Component::ParentDir)) {
tracing::warn!("ignoring {field}: icon path must not contain '..'");
return None;
}
Some(skill_dir.join(normalized))
Some(skill_dir.join(path))
}
fn sanitize_single_line(raw: &str) -> String {
@@ -716,8 +707,8 @@ default_prompt = " default prompt "
interface: Some(SkillInterface {
display_name: Some("UI Skill".to_string()),
short_description: Some("short desc".to_string()),
icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")),
icon_large: Some(normalized_skill_dir.join("assets/large-logo.svg")),
icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")),
icon_large: Some(normalized_skill_dir.join("./assets/large-logo.svg")),
brand_color: Some("#3B82F6".to_string()),
default_prompt: Some("default prompt".to_string()),
}),
@@ -762,7 +753,7 @@ icon_large = "./assets/logo.svg"
display_name: Some("UI Skill".to_string()),
short_description: None,
icon_small: Some(normalized_skill_dir.join("assets/icon.png")),
icon_large: Some(normalized_skill_dir.join("assets/logo.svg")),
icon_large: Some(normalized_skill_dir.join("./assets/logo.svg")),
brand_color: None,
default_prompt: None,
}),
@@ -844,7 +835,7 @@ default_prompt = "{too_long}"
interface: Some(SkillInterface {
display_name: Some("UI Skill".to_string()),
short_description: None,
icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")),
icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")),
icon_large: None,
brand_color: None,
default_prompt: None,
@@ -1385,47 +1376,12 @@ icon_large = "./assets/../logo.svg"
}
#[tokio::test]
async fn deduplicates_by_path_preferring_first_root() {
let root = tempfile::tempdir().expect("tempdir");
let skill_path = write_skill_at(root.path(), "dupe", "dupe-skill", "from repo");
let outcome = load_skills_from_roots([
SkillRoot {
path: root.path().to_path_buf(),
scope: SkillScope::Repo,
},
SkillRoot {
path: root.path().to_path_buf(),
scope: SkillScope::User,
},
]);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from repo".to_string(),
short_description: None,
interface: None,
path: normalized(&skill_path),
scope: SkillScope::Repo,
}]
);
}
#[tokio::test]
async fn keeps_duplicate_names_from_repo_and_user() {
async fn deduplicates_by_name_preferring_repo_over_user() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
mark_as_git_repo(repo_dir.path());
let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
let _user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
let repo_skill_path = write_skill_at(
&repo_dir
.path()
@@ -1446,94 +1402,42 @@ icon_large = "./assets/../logo.svg"
);
assert_eq!(
outcome.skills,
vec![
SkillMetadata {
name: "dupe-skill".to_string(),
description: "from repo".to_string(),
short_description: None,
interface: None,
path: normalized(&repo_skill_path),
scope: SkillScope::Repo,
},
SkillMetadata {
name: "dupe-skill".to_string(),
description: "from user".to_string(),
short_description: None,
interface: None,
path: normalized(&user_skill_path),
scope: SkillScope::User,
},
]
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from repo".to_string(),
short_description: None,
interface: None,
path: normalized(&repo_skill_path),
scope: SkillScope::Repo,
}]
);
}
#[tokio::test]
async fn keeps_duplicate_names_from_nested_codex_dirs() {
async fn loads_system_skills_when_present() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
mark_as_git_repo(repo_dir.path());
let nested_dir = repo_dir.path().join("nested/inner");
fs::create_dir_all(&nested_dir).unwrap();
let _system_skill_path =
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
let root_skill_path = write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"root",
"dupe-skill",
"from root",
);
let nested_skill_path = write_skill_at(
&repo_dir
.path()
.join("nested")
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"nested",
"dupe-skill",
"from nested",
);
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
let cfg = make_config(&codex_home).await;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
let root_path =
canonicalize_path(&root_skill_path).unwrap_or_else(|_| root_skill_path.clone());
let nested_path =
canonicalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone());
let (first_path, second_path, first_description, second_description) =
if root_path <= nested_path {
(root_path, nested_path, "from root", "from nested")
} else {
(nested_path, root_path, "from nested", "from root")
};
assert_eq!(
outcome.skills,
vec![
SkillMetadata {
name: "dupe-skill".to_string(),
description: first_description.to_string(),
short_description: None,
interface: None,
path: first_path,
scope: SkillScope::Repo,
},
SkillMetadata {
name: "dupe-skill".to_string(),
description: second_description.to_string(),
short_description: None,
interface: None,
path: second_path,
scope: SkillScope::Repo,
},
]
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from user".to_string(),
short_description: None,
interface: None,
path: normalized(&user_skill_path),
scope: SkillScope::User,
}]
);
}
@@ -1544,7 +1448,7 @@ icon_large = "./assets/../logo.svg"
let repo_dir = outer_dir.path().join("repo");
fs::create_dir_all(&repo_dir).unwrap();
let _skill_path = write_skill_at(
write_skill_at(
&outer_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
@@ -1553,6 +1457,7 @@ icon_large = "./assets/../logo.svg"
"outer-skill",
"from outer",
);
mark_as_git_repo(&repo_dir);
let cfg = make_config_for_cwd(&codex_home, repo_dir).await;
@@ -1676,4 +1581,164 @@ icon_large = "./assets/../logo.svg"
}
assert_eq!(scopes, expected);
}
#[tokio::test]
async fn deduplicates_by_name_preferring_system_over_admin() {
let system_dir = tempfile::tempdir().expect("tempdir");
let admin_dir = tempfile::tempdir().expect("tempdir");
let system_skill_path =
write_skill_at(system_dir.path(), "system", "dupe-skill", "from system");
let _admin_skill_path =
write_skill_at(admin_dir.path(), "admin", "dupe-skill", "from admin");
let outcome = load_skills_from_roots([
SkillRoot {
path: system_dir.path().to_path_buf(),
scope: SkillScope::System,
},
SkillRoot {
path: admin_dir.path().to_path_buf(),
scope: SkillScope::Admin,
},
]);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from system".to_string(),
short_description: None,
interface: None,
path: normalized(&system_skill_path),
scope: SkillScope::System,
}]
);
}
#[tokio::test]
async fn deduplicates_by_name_preferring_user_over_system() {
let codex_home = tempfile::tempdir().expect("tempdir");
let work_dir = tempfile::tempdir().expect("tempdir");
let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
let _system_skill_path =
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from user".to_string(),
short_description: None,
interface: None,
path: normalized(&user_skill_path),
scope: SkillScope::User,
}]
);
}
#[tokio::test]
async fn deduplicates_by_name_preferring_repo_over_system() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
mark_as_git_repo(repo_dir.path());
let repo_skill_path = write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"repo",
"dupe-skill",
"from repo",
);
let _system_skill_path =
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from repo".to_string(),
short_description: None,
interface: None,
path: normalized(&repo_skill_path),
scope: SkillScope::Repo,
}]
);
}
#[tokio::test]
async fn deduplicates_by_name_preferring_nearest_project_codex_dir() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
mark_as_git_repo(repo_dir.path());
let nested_dir = repo_dir.path().join("nested/inner");
fs::create_dir_all(&nested_dir).unwrap();
let _root_skill_path = write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"root",
"dupe-skill",
"from root",
);
let nested_skill_path = write_skill_at(
&repo_dir
.path()
.join("nested")
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"nested",
"dupe-skill",
"from nested",
);
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
let expected_path =
canonicalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone());
assert_eq!(
vec![SkillMetadata {
name: "dupe-skill".to_string(),
description: "from nested".to_string(),
short_description: None,
interface: None,
path: expected_path,
scope: SkillScope::Repo,
}],
outcome.skills
);
}
}

View File

@@ -1,15 +1,12 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::RwLock;
use codex_utils_absolute_path::AbsolutePathBuf;
use toml::Value as TomlValue;
use tracing::warn;
use crate::config::Config;
use crate::config::types::SkillsConfig;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::skills::SkillLoadOutcome;
@@ -47,8 +44,7 @@ impl SkillsManager {
}
let roots = skill_roots_from_layer_stack(&config.config_layer_stack);
let mut outcome = load_skills_from_roots(roots);
outcome.disabled_paths = disabled_paths_from_stack(&config.config_layer_stack);
let outcome = load_skills_from_roots(roots);
match self.cache_by_cwd.write() {
Ok(mut cache) => {
cache.insert(cwd.to_path_buf(), outcome.clone());
@@ -104,8 +100,7 @@ impl SkillsManager {
};
let roots = skill_roots_from_layer_stack(&config_layer_stack);
let mut outcome = load_skills_from_roots(roots);
outcome.disabled_paths = disabled_paths_from_stack(&config_layer_stack);
let outcome = load_skills_from_roots(roots);
match self.cache_by_cwd.write() {
Ok(mut cache) => {
cache.insert(cwd.to_path_buf(), outcome.clone());
@@ -116,51 +111,6 @@ impl SkillsManager {
}
outcome
}
pub fn clear_cache(&self) {
match self.cache_by_cwd.write() {
Ok(mut cache) => cache.clear(),
Err(err) => err.into_inner().clear(),
}
}
}
fn disabled_paths_from_stack(
config_layer_stack: &crate::config_loader::ConfigLayerStack,
) -> HashSet<PathBuf> {
let mut disabled = HashSet::new();
let mut configs = HashMap::new();
// Skills config is user-layer only for now; higher-precedence layers are ignored.
let Some(user_layer) = config_layer_stack.get_user_layer() else {
return disabled;
};
let Some(skills_value) = user_layer.config.get("skills") else {
return disabled;
};
let skills: SkillsConfig = match skills_value.clone().try_into() {
Ok(skills) => skills,
Err(err) => {
warn!("invalid skills config: {err}");
return disabled;
}
};
for entry in skills.config {
let path = normalize_override_path(entry.path.as_path());
configs.insert(path, entry.enabled);
}
for (path, enabled) in configs {
if !enabled {
disabled.insert(path);
}
}
disabled
}
fn normalize_override_path(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]

View File

@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::path::PathBuf;
use codex_protocol::protocol::SkillScope;
@@ -33,25 +32,4 @@ pub struct SkillError {
pub struct SkillLoadOutcome {
pub skills: Vec<SkillMetadata>,
pub errors: Vec<SkillError>,
pub disabled_paths: HashSet<PathBuf>,
}
impl SkillLoadOutcome {
pub fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool {
!self.disabled_paths.contains(&skill.path)
}
pub fn enabled_skills(&self) -> Vec<SkillMetadata> {
self.skills
.iter()
.filter(|skill| self.is_skill_enabled(skill))
.cloned()
.collect()
}
pub fn skills_with_enabled(&self) -> impl Iterator<Item = (&SkillMetadata, bool)> {
self.skills
.iter()
.map(|skill| (skill, self.is_skill_enabled(skill)))
}
}

View File

@@ -66,12 +66,12 @@ pub(crate) async fn spawn_child_async(
#[cfg(unix)]
unsafe {
let detach_from_tty = matches!(stdio_policy, StdioPolicy::RedirectForShellTool);
let set_process_group = matches!(stdio_policy, StdioPolicy::RedirectForShellTool);
#[cfg(target_os = "linux")]
let parent_pid = libc::getpid();
cmd.pre_exec(move || {
if detach_from_tty {
codex_utils_pty::process_group::detach_from_tty()?;
if set_process_group {
codex_utils_pty::process_group::set_process_group()?;
}
// This relies on prctl(2), so it only works on Linux.

View File

@@ -14,7 +14,6 @@ pub(crate) struct SessionState {
pub(crate) session_configuration: SessionConfiguration,
pub(crate) history: ContextManager,
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
pub(crate) server_reasoning_included: bool,
}
impl SessionState {
@@ -25,7 +24,6 @@ impl SessionState {
session_configuration,
history,
latest_rate_limits: None,
server_reasoning_included: false,
}
}
@@ -80,17 +78,8 @@ impl SessionState {
self.history.set_token_usage_full(context_window);
}
pub(crate) fn get_total_token_usage(&self, server_reasoning_included: bool) -> i64 {
self.history
.get_total_token_usage(server_reasoning_included)
}
pub(crate) fn set_server_reasoning_included(&mut self, included: bool) {
self.server_reasoning_included = included;
}
pub(crate) fn server_reasoning_included(&self) -> bool {
self.server_reasoning_included
pub(crate) fn get_total_token_usage(&self) -> i64 {
self.history.get_total_token_usage()
}
}

View File

@@ -9,7 +9,6 @@ use tokio_util::sync::CancellationToken;
use tokio_util::task::AbortOnDropHandle;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::request_user_input::RequestUserInputResponse;
use tokio::sync::oneshot;
use crate::codex::TurnContext;
@@ -38,6 +37,7 @@ pub(crate) enum TaskKind {
Compact,
}
#[derive(Clone)]
pub(crate) struct RunningTask {
pub(crate) done: Arc<Notify>,
pub(crate) kind: TaskKind,
@@ -45,8 +45,6 @@ pub(crate) struct RunningTask {
pub(crate) cancellation_token: CancellationToken,
pub(crate) handle: Arc<AbortOnDropHandle<()>>,
pub(crate) turn_context: Arc<TurnContext>,
// Timer recorded when the task drops to capture the full turn duration.
pub(crate) _timer: Option<codex_otel::Timer>,
}
impl ActiveTurn {
@@ -69,7 +67,6 @@ impl ActiveTurn {
#[derive(Default)]
pub(crate) struct TurnState {
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
pending_input: Vec<ResponseInputItem>,
}
@@ -91,25 +88,9 @@ impl TurnState {
pub(crate) fn clear_pending(&mut self) {
self.pending_approvals.clear();
self.pending_user_input.clear();
self.pending_input.clear();
}
pub(crate) fn insert_pending_user_input(
&mut self,
key: String,
tx: oneshot::Sender<RequestUserInputResponse>,
) -> Option<oneshot::Sender<RequestUserInputResponse>> {
self.pending_user_input.insert(key, tx)
}
pub(crate) fn remove_pending_user_input(
&mut self,
key: &str,
) -> Option<oneshot::Sender<RequestUserInputResponse>> {
self.pending_user_input.remove(key)
}
pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) {
self.pending_input.push(input);
}

View File

@@ -144,12 +144,6 @@ impl Session {
})
};
let timer = turn_context
.client
.get_otel_manager()
.start_timer("codex.turn.e2e_duration_ms", &[])
.ok();
let running_task = RunningTask {
done,
handle: Arc::new(AbortOnDropHandle::new(handle)),
@@ -157,7 +151,6 @@ impl Session {
task,
cancellation_token,
turn_context: Arc::clone(&turn_context),
_timer: timer,
};
self.register_new_active_task(running_task).await;
}

View File

@@ -30,7 +30,6 @@ impl SessionTask for RegularTask {
) -> Option<String> {
let sess = session.clone_session();
let run_turn_span = trace_span!("run_turn");
sess.set_server_reasoning_included(false).await;
sess.services
.otel_manager
.apply_traceparent_parent(&run_turn_span);

View File

@@ -190,8 +190,8 @@ pub(crate) async fn exit_review_mode(
review_output: Option<ReviewOutputEvent>,
ctx: Arc<TurnContext>,
) {
const REVIEW_USER_MESSAGE_ID: &str = "review_rollout_user";
const REVIEW_ASSISTANT_MESSAGE_ID: &str = "review_rollout_assistant";
const REVIEW_USER_MESSAGE_ID: &str = "review:rollout:user";
const REVIEW_ASSISTANT_MESSAGE_ID: &str = "review:rollout:assistant";
let (user_message, assistant_message) = if let Some(out) = review_output.clone() {
let mut findings_str = String::new();
let text = out.overall_explanation.trim();

View File

@@ -19,7 +19,6 @@ use crate::rollout::RolloutRecorder;
use crate::rollout::truncation;
use crate::skills::SkillsManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpServerRefreshConfig;
@@ -158,10 +157,6 @@ impl ThreadManager {
.await
}
pub fn list_collaboration_modes(&self) -> Vec<CollaborationMode> {
self.state.models_manager.list_collaboration_modes()
}
pub async fn list_thread_ids(&self) -> Vec<ThreadId> {
self.state.threads.read().await.keys().copied().collect()
}
@@ -235,15 +230,6 @@ impl ThreadManager {
self.state.threads.write().await.remove(thread_id)
}
/// Closes all threads open in this ThreadManager
pub async fn remove_and_close_all_threads(&self) -> CodexResult<()> {
for thread in self.state.threads.read().await.values() {
thread.submit(Op::Shutdown).await?;
}
self.state.threads.write().await.clear();
Ok(())
}
/// Fork an existing thread by taking messages up to the given position (not including
/// the message at the given position) and starting a new thread with identical
/// configuration (unless overridden by the caller's `config`). The new thread will have
@@ -476,7 +462,7 @@ mod tests {
#[tokio::test]
async fn ignores_session_prefix_messages_when_truncating() {
let (session, turn_context) = make_session_and_context().await;
let mut items = session.build_initial_context(&turn_context).await;
let mut items = session.build_initial_context(&turn_context);
items.push(user_msg("feature request"));
items.push(assistant_msg("ack"));
items.push(user_msg("second question"));

View File

@@ -6,7 +6,6 @@ mod mcp;
mod mcp_resource;
mod plan;
mod read_file;
mod request_user_input;
mod shell;
mod test_sync;
mod unified_exec;
@@ -24,7 +23,6 @@ pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use plan::PlanHandler;
pub use read_file::ReadFileHandler;
pub use request_user_input::RequestUserInputHandler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;

View File

@@ -40,10 +40,9 @@ struct ReadFileArgs {
indentation: Option<IndentationArgs>,
}
#[derive(Deserialize, Default)]
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum ReadMode {
#[default]
Slice,
Indentation,
}
@@ -462,6 +461,12 @@ mod defaults {
}
}
impl Default for ReadMode {
fn default() -> Self {
Self::Slice
}
}
pub fn offset() -> usize {
1
}

View File

@@ -1,60 +0,0 @@
use async_trait::async_trait;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::request_user_input::RequestUserInputArgs;
pub struct RequestUserInputHandler;
#[async_trait]
impl ToolHandler for RequestUserInputHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
call_id,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"request_user_input handler received unsupported payload".to_string(),
));
}
};
let args: RequestUserInputArgs = parse_arguments(&arguments)?;
let response = session
.request_user_input(turn.as_ref(), call_id, args)
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"request_user_input was cancelled before receiving a response".to_string(),
)
})?;
let content = serde_json::to_string(&response).map_err(|err| {
FunctionCallError::Fatal(format!(
"failed to serialize request_user_input response: {err}"
))
})?;
Ok(ToolOutput::Function {
content,
content_items: None,
success: Some(true),
})
}
}

View File

@@ -27,7 +27,6 @@ pub(crate) struct ToolsConfig {
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub collab_tools: bool,
pub collaboration_modes_tools: bool,
pub experimental_supported_tools: Vec<String>,
}
@@ -46,7 +45,6 @@ impl ToolsConfig {
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_collab_tools = features.enabled(Feature::Collab);
let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes);
let shell_type = if !features.enabled(Feature::ShellTool) {
ConfigShellToolType::Disabled
@@ -78,7 +76,6 @@ impl ToolsConfig {
apply_patch_tool_type,
web_search_mode: *web_search_mode,
collab_tools: include_collab_tools,
collaboration_modes_tools: include_collaboration_modes_tools,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
}
}
@@ -535,88 +532,6 @@ fn create_wait_tool() -> ToolSpec {
})
}
fn create_request_user_input_tool() -> ToolSpec {
let mut option_props = BTreeMap::new();
option_props.insert(
"label".to_string(),
JsonSchema::String {
description: Some("User-facing label (1-5 words).".to_string()),
},
);
option_props.insert(
"description".to_string(),
JsonSchema::String {
description: Some(
"One short sentence explaining impact/tradeoff if selected.".to_string(),
),
},
);
let options_schema = JsonSchema::Array {
description: Some(
"Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Only include \"Other\" option if we want to include a free form option. If the question is free form in nature, please do not have any option."
.to_string(),
),
items: Box::new(JsonSchema::Object {
properties: option_props,
required: Some(vec!["label".to_string(), "description".to_string()]),
additional_properties: Some(false.into()),
}),
};
let mut question_props = BTreeMap::new();
question_props.insert(
"id".to_string(),
JsonSchema::String {
description: Some("Stable identifier for mapping answers (snake_case).".to_string()),
},
);
question_props.insert(
"header".to_string(),
JsonSchema::String {
description: Some(
"Short header label shown in the UI (12 or fewer chars).".to_string(),
),
},
);
question_props.insert(
"question".to_string(),
JsonSchema::String {
description: Some("Single-sentence prompt shown to the user.".to_string()),
},
);
question_props.insert("options".to_string(), options_schema);
let questions_schema = JsonSchema::Array {
description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
items: Box::new(JsonSchema::Object {
properties: question_props,
required: Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
]),
additional_properties: Some(false.into()),
}),
};
let mut properties = BTreeMap::new();
properties.insert("questions".to_string(), questions_schema);
ToolSpec::Function(ResponsesApiTool {
name: "request_user_input".to_string(),
description:
"Request user input for one to three short questions and wait for the response."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["questions".to_string()]),
additional_properties: Some(false.into()),
},
})
}
fn create_close_agent_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
@@ -1225,7 +1140,6 @@ pub(crate) fn build_specs(
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::ReadFileHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::TestSyncHandler;
@@ -1243,7 +1157,6 @@ pub(crate) fn build_specs(
let mcp_handler = Arc::new(McpHandler);
let mcp_resource_handler = Arc::new(McpResourceHandler);
let shell_command_handler = Arc::new(ShellCommandHandler);
let request_user_input_handler = Arc::new(RequestUserInputHandler);
match &config.shell_type {
ConfigShellToolType::Default => {
@@ -1284,11 +1197,6 @@ pub(crate) fn build_specs(
builder.push_spec(PLAN_TOOL.clone());
builder.register_handler("update_plan", plan_handler);
if config.collaboration_modes_tools {
builder.push_spec(create_request_user_input_tool());
builder.register_handler("request_user_input", request_user_input_handler);
}
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
match apply_patch_tool_type {
ApplyPatchToolType::Freeform => {
@@ -1490,7 +1398,6 @@ mod tests {
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::CollaborationModes);
let config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
@@ -1523,7 +1430,6 @@ mod tests {
create_list_mcp_resource_templates_tool(),
create_read_mcp_resource_tool(),
PLAN_TOOL.clone(),
create_request_user_input_tool(),
create_apply_patch_freeform_tool(),
ToolSpec::WebSearch {
external_web_access: Some(true),
@@ -1554,7 +1460,6 @@ mod tests {
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Collab);
features.enable(Feature::CollaborationModes);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
@@ -1567,33 +1472,6 @@ mod tests {
);
}
#[test]
fn request_user_input_requires_collaboration_modes_feature() {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.disable(Feature::CollaborationModes);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
assert!(
!tools.iter().any(|t| t.spec.name() == "request_user_input"),
"request_user_input should be disabled when collaboration_modes feature is off"
);
features.enable(Feature::CollaborationModes);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
assert_contains_tool_names(&tools, &["request_user_input"]);
}
fn assert_model_tools(
model_slug: &str,
features: &Features,
@@ -1658,11 +1536,9 @@ mod tests {
#[test]
fn test_build_specs_gpt5_codex_default() {
let mut features = Features::with_defaults();
features.enable(Feature::CollaborationModes);
assert_model_tools(
"gpt-5-codex",
&features,
&Features::with_defaults(),
Some(WebSearchMode::Cached),
&[
"shell_command",
@@ -1670,7 +1546,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
@@ -1680,11 +1555,9 @@ mod tests {
#[test]
fn test_build_specs_gpt51_codex_default() {
let mut features = Features::with_defaults();
features.enable(Feature::CollaborationModes);
assert_model_tools(
"gpt-5.1-codex",
&features,
&Features::with_defaults(),
Some(WebSearchMode::Cached),
&[
"shell_command",
@@ -1692,7 +1565,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
@@ -1702,12 +1574,9 @@ mod tests {
#[test]
fn test_build_specs_gpt5_codex_unified_exec_web_search() {
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::CollaborationModes);
assert_model_tools(
"gpt-5-codex",
&features,
Features::with_defaults().enable(Feature::UnifiedExec),
Some(WebSearchMode::Live),
&[
"exec_command",
@@ -1716,7 +1585,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
@@ -1726,12 +1594,9 @@ mod tests {
#[test]
fn test_build_specs_gpt51_codex_unified_exec_web_search() {
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::CollaborationModes);
assert_model_tools(
"gpt-5.1-codex",
&features,
Features::with_defaults().enable(Feature::UnifiedExec),
Some(WebSearchMode::Live),
&[
"exec_command",
@@ -1740,7 +1605,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
@@ -1750,11 +1614,9 @@ mod tests {
#[test]
fn test_codex_mini_defaults() {
let mut features = Features::with_defaults();
features.enable(Feature::CollaborationModes);
assert_model_tools(
"codex-mini-latest",
&features,
&Features::with_defaults(),
Some(WebSearchMode::Cached),
&[
"local_shell",
@@ -1762,7 +1624,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"web_search",
"view_image",
],
@@ -1771,11 +1632,9 @@ mod tests {
#[test]
fn test_codex_5_1_mini_defaults() {
let mut features = Features::with_defaults();
features.enable(Feature::CollaborationModes);
assert_model_tools(
"gpt-5.1-codex-mini",
&features,
&Features::with_defaults(),
Some(WebSearchMode::Cached),
&[
"shell_command",
@@ -1783,7 +1642,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
@@ -1793,11 +1651,9 @@ mod tests {
#[test]
fn test_gpt_5_defaults() {
let mut features = Features::with_defaults();
features.enable(Feature::CollaborationModes);
assert_model_tools(
"gpt-5",
&features,
&Features::with_defaults(),
Some(WebSearchMode::Cached),
&[
"shell",
@@ -1805,7 +1661,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"web_search",
"view_image",
],
@@ -1814,11 +1669,9 @@ mod tests {
#[test]
fn test_gpt_5_1_defaults() {
let mut features = Features::with_defaults();
features.enable(Feature::CollaborationModes);
assert_model_tools(
"gpt-5.1",
&features,
&Features::with_defaults(),
Some(WebSearchMode::Cached),
&[
"shell_command",
@@ -1826,7 +1679,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
@@ -1836,11 +1688,9 @@ mod tests {
#[test]
fn test_exp_5_1_defaults() {
let mut features = Features::with_defaults();
features.enable(Feature::CollaborationModes);
assert_model_tools(
"exp-5.1",
&features,
&Features::with_defaults(),
Some(WebSearchMode::Cached),
&[
"exec_command",
@@ -1849,7 +1699,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
@@ -1859,12 +1708,9 @@ mod tests {
#[test]
fn test_codex_mini_unified_exec_web_search() {
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::CollaborationModes);
assert_model_tools(
"codex-mini-latest",
&features,
Features::with_defaults().enable(Feature::UnifiedExec),
Some(WebSearchMode::Live),
&[
"exec_command",
@@ -1873,7 +1719,6 @@ mod tests {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"web_search",
"view_image",
],

View File

@@ -354,8 +354,6 @@ mod tests {
async fn unified_exec_timeouts() -> anyhow::Result<()> {
skip_if_sandbox!(Ok(()));
const TEST_VAR_VALUE: &str = "unified_exec_var_123";
let (session, turn) = test_session_and_turn().await;
let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?;
@@ -368,7 +366,7 @@ mod tests {
write_stdin(
&session,
process_id,
format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(),
"export CODEX_INTERACTIVE_SHELL_VAR=codex\n",
2_500,
)
.await?;
@@ -381,7 +379,7 @@ mod tests {
)
.await?;
assert!(
!out_2.output.contains(TEST_VAR_VALUE),
!out_2.output.contains("codex"),
"timeout too short should yield incomplete output"
);
@@ -390,7 +388,7 @@ mod tests {
let out_3 = write_stdin(&session, process_id, "", 100).await?;
assert!(
out_3.output.contains(TEST_VAR_VALUE),
out_3.output.contains("codex"),
"subsequent poll should retrieve output"
);

View File

@@ -14,12 +14,10 @@ You are Codex Orchestrator, based on GPT-5. You are running as an orchestration
* **Never stop monitoring workers.**
* **Do not rush workers. Be patient.**
* The orchestrator must not return unless the task is fully accomplished.
* If the user ask you a question/status while you are working, always answer him before continuing your work.
## Worker execution semantics
* While a worker is running, you cannot observe intermediate state.
* Workers are able to run commands, update/create/delete files etc. They can be considered as fully autonomous agents
* Messages sent with `send_input` are queued and processed only after the worker finishes, unless interrupted.
* Therefore:
* Do not send messages to “check status” or “ask for progress” unless being asked.
@@ -42,7 +40,7 @@ You are Codex Orchestrator, based on GPT-5. You are running as an orchestration
* verify correctness,
* check integration with other work,
* assess whether the global task is closer to completion.
5. If issues remain, assign fixes to the appropriate worker(s) and repeat steps 35. Do not fix yourself unless the fixes are very small.
5. If issues remain, assign fixes to the appropriate worker(s) and repeat steps 35.
6. Close agents only when no further work is required from them.
7. Return to the user only when the task is fully completed and verified.

View File

@@ -1,45 +0,0 @@
# Collaboration Style: Execute
You execute on a well-specified task independently and report progress.
You do not collaborate on decisions in this mode. You execute end-to-end.
You make reasonable assumptions when the user hasn't specified something, and you proceed without asking questions.
## Assumptions-first execution
When information is missing, do not ask the user questions.
Instead:
- Make a sensible assumption.
- Clearly state the assumption in the final message (briefly).
- Continue executing.
Group assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel.
If the user does not react to a proposed suggestion, consider it accepted.
## Execution principles
*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists.
*Use reasonable assumptions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted.
Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter."
Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration."
*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead.
Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. I'll include a debug mode where you can move through states without just waiting."
*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and would normally ask, make a reasonable assumption and continue.
Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. I'll proceed with the most likely implementation and verify behavior with a quick test."
## Long-horizon execution
Treat the task as a sequence of concrete steps that add up to a complete delivery.
- Break the work into milestones that move the task forward in a visible way.
- Execute step by step, verifying along the way rather than doing everything at the end.
- If the task is large, keep a running checklist of what is done, what is next, and what is blocked.
- Avoid blocking on uncertainty: choose a reasonable default and continue.
## Reporting progress
In this phase you show progress on your task and appraise the user of your progress using plan tool.
- Provide updates that directly map to the work you are doing (what changed, what you verified, what remains).
- If something fails, report what failed, what you tried, and what you will do next.
- When you finish, summarize what you delivered and how the user can validate it.
## Executing
Once you start working, you should execute independently. Your job is to deliver the task and report progress.

View File

@@ -1,7 +0,0 @@
# Collaboration Style: Pair Programming
## Build together as you go
You treat collaboration as pairing by default. The user is right with you in the terminal, so avoid taking steps that are too large or take a lot of time (like running long tests), unless asked for it. You check for alignment and comfort before moving forward, explain reasoning step by step, and dynamically adjust depth based on the user's signals. There is no need to ask multiple rounds of questions—build as you go. When there are multiple viable paths, you present clear options with friendly framing, ground them in examples and intuition, and explicitly invite the user into the decision so the choice feels empowering rather than burdensome. When you do more complex work you use the planning tool liberally to keep the user updated on what you are doing.
## Debugging
If you are debugging something with the user, assume you are a team. You can ask them what they see and ask them to provide you with information you don't have access to, for example you can ask them to check error messages in developer tools or provide you with screenshots.

View File

@@ -1,133 +0,0 @@
# Collaboration Style: Plan
You work in 2 distinct modes:
1. Brainstorming: You collaboratively align with the user on what to do or build and how to do it or build it.
2. Generating a plan: After you've gathered all the information you write up a plan.
You usually start with the brainstorming step. Skip step 1 if the user provides you with a detailed plan or a small, unambiguous task or plan OR if the user asks you to plan by yourself.
## Brainstorming principles
The point of brainstorming with the user is to align on what to do and how to do it. This phase is iterative and conversational. You can interact with the environment and read files if it is helpful, but be mindful of the time.
You MUST follow the principles below. Think about them carefully as you work with the user. Follow the structure and tone of the examples.
_State what you think the user cares about._ Actively infer what matters most (robustness, clean abstractions, quick lovable interfaces, scalability) and reflect this back to the user to confirm.
Example: "It seems like you might be prototyping a design for an app, and scalability or performance isn't a concern right now - is that accurate?"
_Think out loud._ Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists.
_Use reasonable suggestions._ When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted.
Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter - does that resonate?"
Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration - we can relax that if this is exploratory."
_Ask fewer, better questions._ Prefer making a concrete proposal with stated assumptions over asking questions. Only ask questions when different reasonable suggestions would materially change the plan, you cannot safely proceed, or if you think the user would really want to give input directly. Never ask a question if you already provided a suggestion. You can use `request_user_input` tool to ask questions.
_Think ahead._ What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead.
Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. Would you like a debug mode where you can move through states without just waiting?"
_Be mindful of time._ The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and think you need to do longer research, ask the user whether they want you to research, or want to give you a tip.
Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. If it's ok, I'll go and spend a bit more time exploring the code base?"
## Using `request_user_input` in Plan Mode
Use `request_user_input` only when you are genuinely blocked on a decision that materially changes the plan (requirements, trade-offs, rollout or risk posture).The maximum number of `request_user_input` tool calls should be **5**.
Only include an "Other" option when a free-form answer is truly useful. If the question is purely free-form, leave `options` unset entirely.
Do **not** use `request_user_input` to ask "is my plan ready?" or "should I proceed?".
### Examples (technical, schema-populated)
**1 Boolean (yes/no), no free-form**
```json
{
"questions": [
{
"id": "enable_migration",
"header": "Migrate",
"question": "Enable the database migration in this release?",
"options": [
{
"label": "Yes (Recommended)",
"description": "Ship the migration with this rollout."
},
{
"label": "No",
"description": "Defer the migration to a later release."
}
]
}
]
}
```
**2 Choice with free-form**
```json
{
"questions": [
{
"id": "cache_strategy",
"header": "Cache",
"question": "Which cache strategy should we implement?",
"options": [
{
"label": "Write-through (Recommended)",
"description": "Simpler consistency with predictable latency."
},
{
"label": "Write-back",
"description": "Lower write latency but higher complexity."
},
{
"label": "Other",
"description": "Provide a custom strategy or constraints."
}
]
}
]
}
```
**3 Free-form only (no options)**
```json
{
"questions": [
{
"id": "rollout_constraints",
"header": "Rollout",
"question": "Any rollout constraints or compliance requirements we must follow?"
}
]
}
```
## Iterating on the plan
Only AFTER you have all the information, write up the full plan.
A well written and informative plan should be as detailed as a design doc or PRD and reflect your discussion with the user, at minimum that's one full page! If handed to a different agent, the agent would know exactly what to build without asking questions and arrive at a similar implementation to yours. At minimum it should include:
- tools and frameworks you use, any dependencies you need to install
- functions, files, or directories you're likely going to edit
- QUestions that were asked and the responses from users
- architecture if the code changes are significant
- if developing features, describe the features you are going to build in detail like a PM in a PRD
- if you are developing a frontend, describe the design in detail
- include a list of todos in markdown format if needed. Please do not include a **plan** step given that we are planning here already
### Output schema - — MUST MATCH _exactly_
When you present the plan, format the final response as a JSON object with a single key, `plan`, whose value is the full plan text.
Example:
```json
{
"plan": "Title: Schema migration rollout\n\n1. Validate the current schema on staging...\n2. Add the new columns with nullable defaults...\n3. Backfill in batches with feature-flagged writes...\n4. Flip reads to the new fields and monitor...\n5. Remove legacy columns after one full release cycle..."
}
```
PLEASE DO NOT confirm the plan with the user before ending. The user will be responsible for telling us to update, iterate or execute the plan.

View File

@@ -279,7 +279,6 @@ impl TestCodex {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

View File

@@ -311,7 +311,6 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -899,7 +898,6 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -978,7 +976,6 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -1127,7 +1124,6 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -1189,7 +1185,6 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -1259,7 +1254,6 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -1329,7 +1323,6 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

View File

@@ -501,7 +501,6 @@ async fn submit_turn(
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

View File

@@ -88,7 +88,6 @@ async fn chat_mode_stream_cli() {
home.path(),
10,
None,
codex_core::ThreadSortKey::UpdatedAt,
&[],
Some(provider_filter.as_slice()),
"mock",

View File

@@ -22,9 +22,7 @@ use codex_core::protocol::Op;
use codex_core::protocol::SessionSource;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::Verbosity;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ReasoningItemContent;
@@ -46,7 +44,6 @@ use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use dunce::canonicalize as normalize_path;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::io::Write;
use std::sync::Arc;
@@ -868,60 +865,6 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
let TestCodex {
codex,
config,
session_configured,
..
} = test_codex()
.with_model("gpt-5.1-codex")
.build(&server)
.await?;
let collaboration_mode = CollaborationMode::Custom(Settings {
model: "gpt-5.1".to_string(),
reasoning_effort: Some(ReasoningEffort::High),
developer_instructions: None,
});
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
cwd: config.cwd.clone(),
approval_policy: config.approval_policy.value(),
sandbox_policy: config.sandbox_policy.get().clone(),
model: session_configured.model.clone(),
effort: Some(ReasoningEffort::Low),
summary: config.model_reasoning_summary,
collaboration_mode: Some(collaboration_mode),
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request_body = resp_mock.single_request().body_json();
assert_eq!(request_body["model"].as_str(), Some("gpt-5.1"));
assert_eq!(
request_body
.get("reasoning")
.and_then(|t| t.get("effort"))
.and_then(|v| v.as_str()),
Some("high")
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -15,12 +15,10 @@ use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use core_test_support::load_default_config_for_test;
use core_test_support::responses::WebSocketConnectionConfig;
use core_test_support::responses::WebSocketTestServer;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::start_websocket_server;
use core_test_support::responses::start_websocket_server_with_headers;
use core_test_support::skip_if_no_network;
use futures::StreamExt;
use pretty_assertions::assert_eq;
@@ -62,40 +60,6 @@ async fn responses_websocket_streams_request() {
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_emits_reasoning_included_event() {
skip_if_no_network!();
let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
requests: vec![vec![ev_response_created("resp-1"), ev_completed("resp-1")]],
response_headers: vec![("X-Reasoning-Included".to_string(), "true".to_string())],
}])
.await;
let harness = websocket_harness(&server).await;
let mut session = harness.client.new_session();
let prompt = prompt_with_input(vec![message_item("hello")]);
let mut stream = session
.stream(&prompt)
.await
.expect("websocket stream failed");
let mut saw_reasoning_included = false;
while let Some(event) = stream.next().await {
match event.expect("event") {
ResponseEvent::ServerReasoningIncluded(true) => {
saw_reasoning_included = true;
}
ResponseEvent::Completed { .. } => break,
_ => {}
}
}
assert!(saw_reasoning_included);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_appends_on_prefix() {
skip_if_no_network!();

View File

@@ -1,500 +0,0 @@
use anyhow::Result;
use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG;
use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Settings;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
fn sse_completed(id: &str) -> String {
sse(vec![ev_response_created(id), ev_completed(id)])
}
fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode {
CollaborationMode::Custom(Settings {
model: "gpt-5.1".to_string(),
reasoning_effort: None,
developer_instructions: instructions.map(str::to_string),
})
}
fn developer_texts(input: &[Value]) -> Vec<String> {
input
.iter()
.filter_map(|item| {
let role = item.get("role")?.as_str()?;
if role != "developer" {
return None;
}
let text = item
.get("content")?
.as_array()?
.first()?
.get("text")?
.as_str()?;
Some(text.to_string())
})
.collect()
}
fn collab_xml(text: &str) -> String {
format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}")
}
fn count_exact(texts: &[String], target: &str) -> usize {
texts.iter().filter(|text| text.as_str() == target).count()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn no_collaboration_instructions_by_default() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
let test = test_codex().build(&server).await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
assert_eq!(dev_texts.len(), 1);
assert!(dev_texts[0].contains("`approval_policy`"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_input_includes_collaboration_instructions_after_override() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
let test = test_codex().build(&server).await?;
let collab_text = "collab instructions";
let collaboration_mode = collab_mode_with_instructions(Some(collab_text));
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collaboration_mode),
})
.await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn collaboration_instructions_added_on_user_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
let test = test_codex().build(&server).await?;
let collab_text = "turn instructions";
let collaboration_mode = collab_mode_with_instructions(Some(collab_text));
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
cwd: test.config.cwd.clone(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: test.config.sandbox_policy.get().clone(),
model: test.session_configured.model.clone(),
effort: None,
summary: test.config.model_reasoning_summary,
collaboration_mode: Some(collaboration_mode),
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn override_then_user_turn_uses_updated_collaboration_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
let test = test_codex().build(&server).await?;
let collab_text = "override instructions";
let collaboration_mode = collab_mode_with_instructions(Some(collab_text));
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collaboration_mode),
})
.await?;
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
cwd: test.config.cwd.clone(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: test.config.sandbox_policy.get().clone(),
model: test.session_configured.model.clone(),
effort: None,
summary: test.config.model_reasoning_summary,
collaboration_mode: None,
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_turn_overrides_collaboration_instructions_after_override() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
let test = test_codex().build(&server).await?;
let base_text = "base instructions";
let base_mode = collab_mode_with_instructions(Some(base_text));
let turn_text = "turn override";
let turn_mode = collab_mode_with_instructions(Some(turn_text));
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(base_mode),
})
.await?;
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
cwd: test.config.cwd.clone(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: test.config.sandbox_policy.get().clone(),
model: test.session_configured.model.clone(),
effort: None,
summary: test.config.model_reasoning_summary,
collaboration_mode: Some(turn_mode),
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
let base_text = collab_xml(base_text);
let turn_text = collab_xml(turn_text);
assert_eq!(count_exact(&dev_texts, &base_text), 1);
assert_eq!(count_exact(&dev_texts, &turn_text), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
let test = test_codex().build(&server).await?;
let first_text = "first instructions";
let second_text = "second instructions";
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))),
})
.await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello 1".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))),
})
.await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello 2".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req2.single_request().input();
let dev_texts = developer_texts(&input);
let first_text = collab_xml(first_text);
let second_text = collab_xml(second_text);
assert_eq!(count_exact(&dev_texts, &first_text), 1);
assert_eq!(count_exact(&dev_texts, &second_text), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn collaboration_mode_update_noop_does_not_append() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
let test = test_codex().build(&server).await?;
let collab_text = "same instructions";
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))),
})
.await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello 1".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))),
})
.await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello 2".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req2.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_replays_collaboration_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
let mut builder = test_codex();
let initial = builder.build(&server).await?;
let rollout_path = initial.session_configured.rollout_path.clone();
let home = initial.home.clone();
let collab_text = "resume instructions";
initial
.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))),
})
.await?;
initial
.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let resumed = builder.resume(&server, home, rollout_path).await?;
resumed
.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "after resume".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req2.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn empty_collaboration_instructions_are_ignored() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
let test = test_codex().build(&server).await?;
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collab_mode_with_instructions(Some(""))),
})
.await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
assert_eq!(dev_texts.len(), 1);
let collab_text = collab_xml("");
assert_eq!(count_exact(&dev_texts, &collab_text), 0);
Ok(())
}

View File

@@ -32,13 +32,11 @@ use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_completed_with_tokens;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::mount_compact_json_once;
use core_test_support::responses::mount_response_sequence;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::sse_failed;
use core_test_support::responses::sse_response;
use core_test_support::responses::start_mock_server;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -1290,7 +1288,6 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
model: resumed.session_configured.model.clone(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await
.unwrap();
@@ -2149,85 +2146,3 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
"third turn should include compaction summary item"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auto_compact_runs_when_reasoning_header_clears_between_turns() {
skip_if_no_network!();
let server = start_mock_server().await;
let first_user = "SERVER_INCLUDED_FIRST";
let second_user = "SERVER_INCLUDED_SECOND";
let third_user = "SERVER_INCLUDED_THIRD";
let pre_last_reasoning_content = "a".repeat(2_400);
let post_last_reasoning_content = "b".repeat(4_000);
let first_turn = sse(vec![
ev_reasoning_item("pre-reasoning", &["pre"], &[&pre_last_reasoning_content]),
ev_completed_with_tokens("r1", 10),
]);
let second_turn = sse(vec![
ev_reasoning_item("post-reasoning", &["post"], &[&post_last_reasoning_content]),
ev_completed_with_tokens("r2", 80),
]);
let third_turn = sse(vec![
ev_assistant_message("m4", FINAL_REPLY),
ev_completed_with_tokens("r4", 1),
]);
let responses = vec![
sse_response(first_turn).insert_header("X-Reasoning-Included", "true"),
sse_response(second_turn),
sse_response(third_turn),
];
mount_response_sequence(&server, responses).await;
let compacted_history = vec![
codex_protocol::models::ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "REMOTE_COMPACT_SUMMARY".to_string(),
}],
},
codex_protocol::models::ResponseItem::Compaction {
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
},
];
let compact_mock =
mount_compact_json_once(&server, serde_json::json!({ "output": compacted_history })).await;
let codex = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
set_test_compact_prompt(config);
config.model_auto_compact_token_limit = Some(300);
config.features.enable(Feature::RemoteCompaction);
})
.build(&server)
.await
.expect("build codex")
.codex;
for user in [first_user, second_user, third_user] {
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: user.into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
}
let compact_requests = compact_mock.requests();
assert_eq!(
compact_requests.len(),
1,
"remote compaction should run once after the reasoning header clears"
);
}

View File

@@ -81,7 +81,6 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

View File

@@ -127,7 +127,6 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -206,7 +205,6 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

View File

@@ -85,7 +85,6 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

View File

@@ -24,7 +24,6 @@ mod cli_stream;
mod client;
mod client_websockets;
mod codex_delegate;
mod collaboration_instructions;
mod compact;
mod compact_remote;
mod compact_resume_fork;
@@ -53,7 +52,6 @@ mod quota_exceeded;
mod read_file;
mod remote_models;
mod request_compression;
mod request_user_input;
mod resume;
mod resume_warning;
mod review;

View File

@@ -40,7 +40,6 @@ async fn override_turn_context_does_not_persist_when_config_exists() {
model: Some("o3".to_string()),
effort: Some(Some(ReasoningEffort::High)),
summary: None,
collaboration_mode: None,
})
.await
.expect("submit override");
@@ -83,7 +82,6 @@ async fn override_turn_context_does_not_create_config_file() {
model: Some("o3".to_string()),
effort: Some(Some(ReasoningEffort::Medium)),
summary: None,
collaboration_mode: None,
})
.await
.expect("submit override");

View File

@@ -1,6 +1,5 @@
#![allow(clippy::unwrap_used)]
use codex_core::features::Feature;
use codex_protocol::config_types::WebSearchMode;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::responses;
@@ -37,10 +36,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec<String> {
let mut builder = test_codex()
.with_model(model)
// Keep tool expectations stable when the default web_search mode changes.
.with_config(|config| {
config.web_search_mode = Some(WebSearchMode::Cached);
config.features.enable(Feature::CollaborationModes);
});
.with_config(|config| config.web_search_mode = Some(WebSearchMode::Cached));
let test = builder
.build(&server)
.await
@@ -66,7 +62,6 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"request_user_input".to_string(),
"web_search".to_string(),
"view_image".to_string()
],
@@ -82,7 +77,6 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"request_user_input".to_string(),
"apply_patch".to_string(),
"web_search".to_string(),
"view_image".to_string()
@@ -99,7 +93,6 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"request_user_input".to_string(),
"apply_patch".to_string(),
"web_search".to_string(),
"view_image".to_string()
@@ -116,7 +109,6 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"request_user_input".to_string(),
"web_search".to_string(),
"view_image".to_string()
],
@@ -132,7 +124,6 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"request_user_input".to_string(),
"apply_patch".to_string(),
"web_search".to_string(),
"view_image".to_string()
@@ -149,7 +140,6 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"request_user_input".to_string(),
"apply_patch".to_string(),
"web_search".to_string(),
"view_image".to_string()

View File

@@ -98,7 +98,6 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
model: test.session_configured.model.clone(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

View File

@@ -109,7 +109,6 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

View File

@@ -1,216 +0,0 @@
use anyhow::Result;
use codex_core::config::Constrained;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG;
use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::RolloutItem;
use codex_core::protocol::RolloutLine;
use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Settings;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode {
CollaborationMode::Custom(Settings {
model: "gpt-5.1".to_string(),
reasoning_effort: None,
developer_instructions: instructions.map(str::to_string),
})
}
fn collab_xml(text: &str) -> String {
format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}")
}
async fn read_rollout_text(path: &Path) -> anyhow::Result<String> {
for _ in 0..50 {
if path.exists()
&& let Ok(text) = std::fs::read_to_string(path)
&& !text.trim().is_empty()
{
return Ok(text);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
Ok(std::fs::read_to_string(path)?)
}
fn rollout_developer_texts(text: &str) -> Vec<String> {
let mut texts = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let rollout: RolloutLine = match serde_json::from_str(trimmed) {
Ok(rollout) => rollout,
Err(_) => continue,
};
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) =
rollout.item
&& role == "developer"
{
for item in content {
if let ContentItem::InputText { text } = item {
texts.push(text);
}
}
}
}
texts
}
fn rollout_environment_texts(text: &str) -> Vec<String> {
let mut texts = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let rollout: RolloutLine = match serde_json::from_str(trimmed) {
Ok(rollout) => rollout,
Err(_) => continue,
};
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) =
rollout.item
&& role == "user"
{
for item in content {
if let ContentItem::InputText { text } = item
&& text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)
{
texts.push(text);
}
}
}
}
texts
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn override_turn_context_records_permissions_update() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let test = builder.build(&server).await?;
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(AskForApproval::Never),
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: None,
})
.await?;
test.codex.submit(Op::Shutdown).await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
let rollout_path = test.codex.rollout_path();
let rollout_text = read_rollout_text(&rollout_path).await?;
let developer_texts = rollout_developer_texts(&rollout_text);
let approval_texts: Vec<&String> = developer_texts
.iter()
.filter(|text| text.contains("`approval_policy`"))
.collect();
assert!(
approval_texts
.iter()
.any(|text| text.contains("`approval_policy` is `never`")),
"expected updated approval policy instructions in rollout"
);
let unique: HashSet<&String> = approval_texts.iter().copied().collect();
assert_eq!(unique.len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn override_turn_context_records_environment_update() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let test = test_codex().build(&server).await?;
let new_cwd = TempDir::new()?;
test.codex
.submit(Op::OverrideTurnContext {
cwd: Some(new_cwd.path().to_path_buf()),
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: None,
})
.await?;
test.codex.submit(Op::Shutdown).await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
let rollout_path = test.codex.rollout_path();
let rollout_text = read_rollout_text(&rollout_path).await?;
let env_texts = rollout_environment_texts(&rollout_text);
let new_cwd_text = new_cwd.path().display().to_string();
assert!(
env_texts.iter().any(|text| text.contains(&new_cwd_text)),
"expected environment update with new cwd in rollout"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn override_turn_context_records_collaboration_update() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let test = test_codex().build(&server).await?;
let collab_text = "override collaboration instructions";
let collaboration_mode = collab_mode_with_instructions(Some(collab_text));
test.codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collaboration_mode),
})
.await?;
test.codex.submit(Op::Shutdown).await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
let rollout_path = test.codex.rollout_path();
let rollout_text = read_rollout_text(&rollout_path).await?;
let developer_texts = rollout_developer_texts(&rollout_text);
let collab_text = collab_xml(collab_text);
let collab_count = developer_texts
.iter()
.filter(|text| text.as_str() == collab_text.as_str())
.count();
assert_eq!(collab_count, 1);
Ok(())
}

View File

@@ -109,7 +109,6 @@ async fn permissions_message_added_on_override_change() -> Result<()> {
model: None,
effort: None,
summary: None,
collaboration_mode: None,
})
.await?;
@@ -132,7 +131,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> {
let permissions_2 = permissions_texts(input2);
assert_eq!(permissions_1.len(), 1);
assert_eq!(permissions_2.len(), 3);
assert_eq!(permissions_2.len(), 2);
let unique = permissions_2.into_iter().collect::<HashSet<String>>();
assert_eq!(unique.len(), 2);
@@ -225,7 +224,6 @@ async fn resume_replays_permissions_messages() -> Result<()> {
model: None,
effort: None,
summary: None,
collaboration_mode: None,
})
.await?;
@@ -257,7 +255,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
let body3 = req3.single_request().body_json();
let input = body3["input"].as_array().expect("input array");
let permissions = permissions_texts(input);
assert_eq!(permissions.len(), 4);
assert_eq!(permissions.len(), 3);
let unique = permissions.into_iter().collect::<HashSet<String>>();
assert_eq!(unique.len(), 2);
@@ -302,7 +300,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
model: None,
effort: None,
summary: None,
collaboration_mode: None,
})
.await?;
@@ -321,7 +318,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
let body2 = req2.single_request().body_json();
let input2 = body2["input"].as_array().expect("input array");
let permissions_base = permissions_texts(input2);
assert_eq!(permissions_base.len(), 3);
assert_eq!(permissions_base.len(), 2);
builder = builder.with_config(|config| {
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);

View File

@@ -11,8 +11,6 @@ use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_core::shell::Shell;
use codex_core::shell::default_user_shell;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::user_input::UserInput;
@@ -24,7 +22,6 @@ use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn text_user_input(text: String) -> serde_json::Value {
@@ -92,7 +89,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
config.model = Some("gpt-5.1-codex-max".to_string());
// Keep tool expectations stable when the default web_search mode changes.
config.web_search_mode = Some(WebSearchMode::Cached);
config.features.enable(Feature::CollaborationModes);
})
.build(&server)
.await?;
@@ -136,7 +132,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
@@ -178,7 +173,6 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
.with_config(|config| {
config.user_instructions = Some("be consistent and helpful".to_string());
config.features.disable(Feature::ApplyPatchFreeform);
config.features.enable(Feature::CollaborationModes);
config.model = Some("codex-mini-latest".to_string());
})
.build(&server)
@@ -243,7 +237,6 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
let TestCodex { codex, config, .. } = test_codex()
.with_config(|config| {
config.user_instructions = Some("be consistent and helpful".to_string());
config.features.enable(Feature::CollaborationModes);
})
.build(&server)
.await?;
@@ -320,7 +313,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.user_instructions = Some("be consistent and helpful".to_string());
config.features.enable(Feature::CollaborationModes);
})
.build(&server)
.await?;
@@ -352,7 +344,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
model: Some("o3".to_string()),
effort: Some(Some(ReasoningEffort::High)),
summary: Some(ReasoningSummary::Detailed),
collaboration_mode: None,
})
.await?;
@@ -384,18 +375,15 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
"content": [ { "type": "input_text", "text": "hello 2" } ]
});
let expected_permissions_msg = body1["input"][0].clone();
// After overriding the turn context, emit a new permissions message.
let body1_input = body1["input"].as_array().expect("input array");
// After overriding the turn context, emit two updated permissions messages.
let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone();
let expected_permissions_msg_3 = body2["input"][body1_input.len() + 1].clone();
assert_ne!(
expected_permissions_msg_2, expected_permissions_msg,
"expected updated permissions message after override"
);
assert_eq!(expected_permissions_msg_2, expected_permissions_msg_3);
let mut expected_body2 = body1_input.to_vec();
let mut expected_body2 = body1["input"].as_array().expect("input array").to_vec();
expected_body2.push(expected_permissions_msg_2);
expected_body2.push(expected_permissions_msg_3);
expected_body2.push(expected_user_message_2);
assert_eq!(body2["input"], serde_json::Value::Array(expected_body2));
@@ -411,21 +399,14 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul
let TestCodex { codex, .. } = test_codex().build(&server).await?;
let collaboration_mode = CollaborationMode::Custom(Settings {
model: "gpt-5.1".to_string(),
reasoning_effort: Some(ReasoningEffort::High),
developer_instructions: None,
});
codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(AskForApproval::Never),
sandbox_policy: None,
model: Some("gpt-5.1-codex".to_string()),
effort: Some(Some(ReasoningEffort::Low)),
model: None,
effort: None,
summary: None,
collaboration_mode: Some(collaboration_mode),
})
.await?;
@@ -442,13 +423,6 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let body = req.single_request().body_json();
assert_eq!(body["model"].as_str(), Some("gpt-5.1"));
assert_eq!(
body.get("reasoning")
.and_then(|reasoning| reasoning.get("effort"))
.and_then(|value| value.as_str()),
Some("high")
);
let input = body["input"]
.as_array()
.expect("input array must be present");
@@ -543,7 +517,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.user_instructions = Some("be consistent and helpful".to_string());
config.features.enable(Feature::CollaborationModes);
})
.build(&server)
.await?;
@@ -581,7 +554,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
model: "o3".to_string(),
effort: Some(ReasoningEffort::High),
summary: ReasoningSummary::Detailed,
collaboration_mode: None,
final_output_json_schema: None,
})
.await?;
@@ -651,7 +623,6 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
} = test_codex()
.with_config(|config| {
config.user_instructions = Some("be consistent and helpful".to_string());
config.features.enable(Feature::CollaborationModes);
})
.build(&server)
.await?;
@@ -675,7 +646,6 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
model: default_model.clone(),
effort: default_effort,
summary: default_summary,
collaboration_mode: None,
final_output_json_schema: None,
})
.await?;
@@ -693,7 +663,6 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
model: default_model.clone(),
effort: default_effort,
summary: default_summary,
collaboration_mode: None,
final_output_json_schema: None,
})
.await?;
@@ -749,7 +718,6 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
} = test_codex()
.with_config(|config| {
config.user_instructions = Some("be consistent and helpful".to_string());
config.features.enable(Feature::CollaborationModes);
})
.build(&server)
.await?;
@@ -773,7 +741,6 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
model: default_model,
effort: default_effort,
summary: default_summary,
collaboration_mode: None,
final_output_json_schema: None,
})
.await?;
@@ -791,7 +758,6 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
model: "o3".to_string(),
effort: Some(ReasoningEffort::High),
summary: ReasoningSummary::Detailed,
collaboration_mode: None,
final_output_json_schema: None,
})
.await?;

View File

@@ -139,7 +139,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
model: Some(REMOTE_MODEL_SLUG.to_string()),
effort: None,
summary: None,
collaboration_mode: None,
})
.await?;
@@ -175,7 +174,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
model: REMOTE_MODEL_SLUG.to_string(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -365,7 +363,6 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
model: Some(model.to_string()),
effort: None,
summary: None,
collaboration_mode: None,
})
.await?;
@@ -382,7 +379,6 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
model: model.to_string(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

View File

@@ -1,155 +0,0 @@
#![allow(clippy::unwrap_used)]
use std::collections::HashMap;
use codex_core::features::Feature;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
fn call_output(req: &ResponsesRequest, call_id: &str) -> String {
let raw = req.function_call_output(call_id);
assert_eq!(
raw.get("call_id").and_then(Value::as_str),
Some(call_id),
"mismatched call_id in function_call_output"
);
let (content_opt, _success) = match req.function_call_output_content_and_success(call_id) {
Some(values) => values,
None => panic!("function_call_output present"),
};
match content_opt {
Some(content) => content,
None => panic!("function_call_output content present"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let builder = test_codex();
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder
.with_config(|config| {
config.features.enable(Feature::CollaborationModes);
})
.build(&server)
.await?;
let call_id = "user-input-call";
let request_args = json!({
"questions": [{
"id": "confirm_path",
"header": "Confirm",
"question": "Proceed with the plan?",
"options": [{
"label": "Yes (Recommended)",
"description": "Continue the current plan."
}, {
"label": "No",
"description": "Stop and revisit the approach."
}]
}]
})
.to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "request_user_input", &request_args),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
let second_response = sse(vec![
ev_assistant_message("msg-1", "thanks"),
ev_completed("resp-2"),
]);
let second_mock = responses::mount_sse_once(&server, second_response).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "please confirm".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
let request = wait_for_event_match(&codex, |event| match event {
EventMsg::RequestUserInput(request) => Some(request.clone()),
_ => None,
})
.await;
assert_eq!(request.call_id, call_id);
assert_eq!(request.questions.len(), 1);
let mut answers = HashMap::new();
answers.insert(
"confirm_path".to_string(),
RequestUserInputAnswer {
selected: vec!["yes".to_string()],
other: None,
},
);
let response = RequestUserInputResponse { answers };
codex
.submit(Op::UserInputAnswer {
id: request.turn_id.clone(),
response,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let req = second_mock.single_request();
let output_text = call_output(&req, call_id);
let output_json: Value = serde_json::from_str(&output_text)?;
assert_eq!(
output_json,
json!({
"answers": {
"confirm_path": { "selected": ["yes"], "other": Value::Null }
}
})
);
Ok(())
}

View File

@@ -515,6 +515,7 @@ async fn review_input_isolated_from_parent_history() {
"payload": {
"id": convo_id,
"timestamp": "2024-01-01T00:00:00Z",
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
@@ -819,7 +820,6 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() {
model: None,
effort: None,
summary: None,
collaboration_mode: None,
})
.await
.unwrap();

View File

@@ -118,7 +118,6 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -257,7 +256,6 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -454,7 +452,6 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -599,7 +596,6 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -755,7 +751,6 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;
@@ -943,7 +938,6 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
})
.await?;

Some files were not shown because too many files have changed in this diff Show More