Merge branch 'main' into fix/mcp-image-output-nonfirst

This commit is contained in:
K Bediako
2026-01-27 17:46:11 +11:00
committed by GitHub
112 changed files with 4444 additions and 1438 deletions
+3 -1
View File
@@ -303,7 +303,9 @@ jobs:
cp "$dest/$base" "$bundle_dir/$base"
cp "$runner_src" "$bundle_dir/codex-command-runner.exe"
cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe"
(cd "$bundle_dir" && 7z a "$dest/${base}.zip" .)
# Use an absolute path so bundle zips land in the real dist
# dir even when 7z runs from a temp directory.
(cd "$bundle_dir" && 7z a "$(pwd)/$dest/${base}.zip" .)
else
echo "warning: missing sandbox binaries; falling back to single-binary zip"
echo "warning: expected $runner_src and $setup_src"
+1
View File
@@ -11,6 +11,7 @@ In the codex-rs folder where the rust code lives:
- Always collapse if statements per https://rust-lang.github.io/rust-clippy/master/index.html#collapsible_if
- Always inline format! args when possible per https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args
- Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls
- When possible, make `match` statements exhaustive and avoid wildcard arms.
- When writing tests, prefer comparing the equality of entire objects over fields one by one.
- When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable.
- If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`.
+27 -30
View File
@@ -361,7 +361,7 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -616,9 +616,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.4"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
@@ -634,8 +634,7 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_core",
"serde_json",
"serde_path_to_error",
"sync_wrapper",
@@ -647,9 +646,9 @@ dependencies = [
[[package]]
name = "axum-core"
version = "0.5.2"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
@@ -658,7 +657,6 @@ dependencies = [
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
@@ -2006,6 +2004,7 @@ dependencies = [
"chrono",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-string",
"dirs-next",
"dunce",
"pretty_assertions",
@@ -2905,7 +2904,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]]
@@ -3005,7 +3004,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.0.8",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -3310,7 +3309,7 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows-link 0.2.0",
"windows-link 0.1.3",
"windows-result 0.3.4",
]
@@ -3384,9 +3383,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.16"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
@@ -3715,7 +3714,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.1",
"system-configuration",
"tokio",
"tower-service",
@@ -4092,7 +4091,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -5631,7 +5630,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.1",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -5668,9 +5667,9 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.1",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6365,7 +6364,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -6378,7 +6377,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7465,7 +7464,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.8",
"windows-sys 0.61.1",
"windows-sys 0.52.0",
]
[[package]]
@@ -7796,12 +7795,10 @@ dependencies = [
[[package]]
name = "tokio-test"
version = "0.4.4"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545"
dependencies = [
"async-stream",
"bytes",
"futures-core",
"tokio",
"tokio-stream",
@@ -8000,9 +7997,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.43"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
@@ -8035,9 +8032,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.35"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
@@ -8749,7 +8746,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.48.0",
]
[[package]]
+1 -1
View File
@@ -216,7 +216,7 @@ tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-nativ
tokio-util = "0.7.18"
toml = "0.9.5"
toml_edit = "0.24.0"
tracing = "0.1.43"
tracing = "0.1.44"
tracing-appender = "0.2.3"
tracing-subscriber = "0.3.22"
tracing-test = "0.2.5"
@@ -117,6 +117,10 @@ client_request_definitions! {
params: v2::ThreadArchiveParams,
response: v2::ThreadArchiveResponse,
},
ThreadUnarchive => "thread/unarchive" {
params: v2::ThreadUnarchiveParams,
response: v2::ThreadUnarchiveResponse,
},
ThreadRollback => "thread/rollback" {
params: v2::ThreadRollbackParams,
response: v2::ThreadRollbackResponse,
@@ -1223,6 +1223,20 @@ pub struct ThreadArchiveParams {
#[ts(export_to = "v2/")]
pub struct ThreadArchiveResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadUnarchiveParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadUnarchiveResponse {
pub thread: Thread,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1260,11 +1274,32 @@ pub struct ThreadListParams {
/// 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>>,
/// Optional source filter; when set, only sessions from these source kinds
/// are returned. When omitted or empty, defaults to interactive sources.
pub source_kinds: Option<Vec<ThreadSourceKind>>,
/// Optional archived filter; when set to true, only archived threads are returned.
/// If false or null, only non-archived threads are returned.
pub archived: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadSourceKind {
Cli,
#[serde(rename = "vscode")]
#[ts(rename = "vscode")]
VsCode,
Exec,
AppServer,
SubAgent,
SubAgentReview,
SubAgentCompact,
SubAgentThreadSpawn,
SubAgentOther,
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
@@ -2420,6 +2455,8 @@ pub struct ToolRequestUserInputQuestion {
pub id: String,
pub header: String,
pub question: String,
#[serde(default)]
pub is_other: bool,
pub options: Option<Vec<ToolRequestUserInputOption>>,
}
@@ -2584,6 +2621,7 @@ mod tests {
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::models::WebSearchAction;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::user_input::UserInput as CoreUserInput;
use pretty_assertions::assert_eq;
@@ -2691,6 +2729,9 @@ mod tests {
let search_item = TurnItem::WebSearch(WebSearchItem {
id: "search-1".to_string(),
query: "docs".to_string(),
action: WebSearchAction::Search {
query: Some("docs".to_string()),
},
});
assert_eq!(
+11
View File
@@ -81,6 +81,7 @@ Example (from OpenAI's official VSCode extension):
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`.
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success.
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
@@ -166,6 +167,7 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
- `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.
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default).
Example:
@@ -223,6 +225,15 @@ Use `thread/archive` to move the persisted rollout (stored as a JSONL file on di
An archived thread will not appear in `thread/list` unless `archived` is set to `true`.
### Example: Unarchive a thread
Use `thread/unarchive` to move an archived rollout back into the sessions directory.
```json
{ "method": "thread/unarchive", "id": 24, "params": { "threadId": "thr_b" } }
{ "id": 24, "result": { "thread": { "id": "thr_b" } } }
```
### Example: Start a turn (send user input)
Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions:
@@ -278,6 +278,7 @@ pub(crate) async fn apply_bespoke_event_handling(
id: question.id,
header: question.header,
question: question.question,
is_other: question.is_other,
options: question.options.map(|options| {
options
.into_iter()
@@ -111,9 +111,12 @@ 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::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
@@ -130,7 +133,6 @@ use codex_chatgpt::connectors;
use codex_core::AuthManager;
use codex_core::CodexThread;
use codex_core::Cursor as RolloutCursor;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::InitialHistory;
use codex_core::NewThread;
use codex_core::RolloutRecorder;
@@ -151,6 +153,7 @@ use codex_core::error::CodexErr;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
use codex_core::features::Feature;
use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_path_by_id_str;
use codex_core::git_info::git_diff_to_remote;
use codex_core::mcp::collect_mcp_snapshot;
@@ -164,6 +167,7 @@ use codex_core::protocol::ReviewTarget as CoreReviewTarget;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::read_head_for_summary;
use codex_core::read_session_meta_line;
use codex_core::rollout_date_parts;
use codex_core::sandboxing::SandboxPermissions;
use codex_feedback::CodexFeedback;
use codex_login::ServerOptions as LoginServerOptions;
@@ -205,6 +209,9 @@ use tracing::info;
use tracing::warn;
use uuid::Uuid;
use crate::filters::compute_source_filters;
use crate::filters::source_kind_matches;
type PendingInterruptQueue = Vec<(RequestId, ApiVersion)>;
pub(crate) type PendingInterrupts = Arc<Mutex<HashMap<ThreadId, PendingInterruptQueue>>>;
@@ -404,6 +411,9 @@ impl CodexMessageProcessor {
ClientRequest::ThreadArchive { request_id, params } => {
self.thread_archive(request_id, params).await;
}
ClientRequest::ThreadUnarchive { request_id, params } => {
self.thread_unarchive(request_id, params).await;
}
ClientRequest::ThreadRollback { request_id, params } => {
self.thread_rollback(request_id, params).await;
}
@@ -1643,6 +1653,150 @@ impl CodexMessageProcessor {
}
}
async fn thread_unarchive(&mut self, request_id: RequestId, params: ThreadUnarchiveParams) {
let thread_id = match ThreadId::from_string(&params.thread_id) {
Ok(id) => id,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let archived_path = match find_archived_thread_path_by_id_str(
&self.config.codex_home,
&thread_id.to_string(),
)
.await
{
Ok(Some(path)) => path,
Ok(None) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("no archived rollout found for thread id {thread_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("failed to locate archived thread id {thread_id}: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let rollout_path_display = archived_path.display().to_string();
let fallback_provider = self.config.model_provider_id.clone();
let archived_folder = self
.config
.codex_home
.join(codex_core::ARCHIVED_SESSIONS_SUBDIR);
let result: Result<Thread, JSONRPCErrorError> = async {
let canonical_archived_dir = tokio::fs::canonicalize(&archived_folder).await.map_err(
|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to unarchive thread: unable to resolve archived directory: {err}"
),
data: None,
},
)?;
let canonical_rollout_path = tokio::fs::canonicalize(&archived_path).await;
let canonical_rollout_path = if let Ok(path) = canonical_rollout_path
&& path.starts_with(&canonical_archived_dir)
{
path
} else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"rollout path `{rollout_path_display}` must be in archived directory"
),
data: None,
});
};
let required_suffix = format!("{thread_id}.jsonl");
let Some(file_name) = canonical_rollout_path.file_name().map(OsStr::to_owned) else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("rollout path `{rollout_path_display}` missing file name"),
data: None,
});
};
if !file_name
.to_string_lossy()
.ends_with(required_suffix.as_str())
{
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"rollout path `{rollout_path_display}` does not match thread id {thread_id}"
),
data: None,
});
}
let Some((year, month, day)) = rollout_date_parts(&file_name) else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"rollout path `{rollout_path_display}` missing filename timestamp"
),
data: None,
});
};
let sessions_folder = self.config.codex_home.join(codex_core::SESSIONS_SUBDIR);
let dest_dir = sessions_folder.join(year).join(month).join(day);
let restored_path = dest_dir.join(&file_name);
tokio::fs::create_dir_all(&dest_dir)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to unarchive thread: {err}"),
data: None,
})?;
tokio::fs::rename(&canonical_rollout_path, &restored_path)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to unarchive thread: {err}"),
data: None,
})?;
let summary =
read_summary_from_rollout(restored_path.as_path(), fallback_provider.as_str())
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to read unarchived thread: {err}"),
data: None,
})?;
Ok(summary_to_thread(summary))
}
.await;
match result {
Ok(thread) => {
let response = ThreadUnarchiveResponse { thread };
self.outgoing.send_response(request_id, response).await;
}
Err(err) => {
self.outgoing.send_error(request_id, err).await;
}
}
}
async fn thread_rollback(&mut self, request_id: RequestId, params: ThreadRollbackParams) {
let ThreadRollbackParams {
thread_id,
@@ -1694,6 +1848,7 @@ impl CodexMessageProcessor {
limit,
sort_key,
model_providers,
source_kinds,
archived,
} = params;
@@ -1710,6 +1865,7 @@ impl CodexMessageProcessor {
requested_page_size,
cursor,
model_providers,
source_kinds,
core_sort_key,
archived.unwrap_or(false),
)
@@ -2387,6 +2543,7 @@ impl CodexMessageProcessor {
requested_page_size,
cursor,
model_providers,
None,
CoreThreadSortKey::UpdatedAt,
false,
)
@@ -2407,6 +2564,7 @@ impl CodexMessageProcessor {
requested_page_size: usize,
cursor: Option<String>,
model_providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
sort_key: CoreThreadSortKey,
archived: bool,
) -> Result<(Vec<ConversationSummary>, Option<String>), JSONRPCErrorError> {
@@ -2436,6 +2594,8 @@ impl CodexMessageProcessor {
None => Some(vec![self.config.model_provider_id.clone()]),
};
let fallback_provider = self.config.model_provider_id.clone();
let (allowed_sources_vec, source_kind_filter) = compute_source_filters(source_kinds);
let allowed_sources = allowed_sources_vec.as_slice();
while remaining > 0 {
let page_size = remaining.min(THREAD_LIST_MAX_LIMIT);
@@ -2445,7 +2605,7 @@ impl CodexMessageProcessor {
page_size,
cursor_obj.as_ref(),
sort_key,
INTERACTIVE_SESSION_SOURCES,
allowed_sources,
model_provider_filter.as_deref(),
fallback_provider.as_str(),
)
@@ -2461,7 +2621,7 @@ impl CodexMessageProcessor {
page_size,
cursor_obj.as_ref(),
sort_key,
INTERACTIVE_SESSION_SOURCES,
allowed_sources,
model_provider_filter.as_deref(),
fallback_provider.as_str(),
)
@@ -2490,6 +2650,11 @@ impl CodexMessageProcessor {
updated_at,
)
})
.filter(|summary| {
source_kind_filter
.as_ref()
.is_none_or(|filter| source_kind_matches(&summary.source, filter))
})
.collect::<Vec<_>>();
if filtered.len() > remaining {
filtered.truncate(remaining);
@@ -2700,6 +2865,8 @@ impl CodexMessageProcessor {
}
};
let scopes = scopes.or_else(|| server.scopes.clone());
match perform_oauth_login_return_url(
&name,
&url,
+155
View File
@@ -0,0 +1,155 @@
use codex_app_server_protocol::ThreadSourceKind;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
pub(crate) fn compute_source_filters(
source_kinds: Option<Vec<ThreadSourceKind>>,
) -> (Vec<CoreSessionSource>, Option<Vec<ThreadSourceKind>>) {
let Some(source_kinds) = source_kinds else {
return (INTERACTIVE_SESSION_SOURCES.to_vec(), None);
};
if source_kinds.is_empty() {
return (INTERACTIVE_SESSION_SOURCES.to_vec(), None);
}
let requires_post_filter = source_kinds.iter().any(|kind| {
matches!(
kind,
ThreadSourceKind::Exec
| ThreadSourceKind::AppServer
| ThreadSourceKind::SubAgent
| ThreadSourceKind::SubAgentReview
| ThreadSourceKind::SubAgentCompact
| ThreadSourceKind::SubAgentThreadSpawn
| ThreadSourceKind::SubAgentOther
| ThreadSourceKind::Unknown
)
});
if requires_post_filter {
(Vec::new(), Some(source_kinds))
} else {
let interactive_sources = source_kinds
.iter()
.filter_map(|kind| match kind {
ThreadSourceKind::Cli => Some(CoreSessionSource::Cli),
ThreadSourceKind::VsCode => Some(CoreSessionSource::VSCode),
ThreadSourceKind::Exec
| ThreadSourceKind::AppServer
| ThreadSourceKind::SubAgent
| ThreadSourceKind::SubAgentReview
| ThreadSourceKind::SubAgentCompact
| ThreadSourceKind::SubAgentThreadSpawn
| ThreadSourceKind::SubAgentOther
| ThreadSourceKind::Unknown => None,
})
.collect::<Vec<_>>();
(interactive_sources, Some(source_kinds))
}
}
pub(crate) fn source_kind_matches(source: &CoreSessionSource, filter: &[ThreadSourceKind]) -> bool {
filter.iter().any(|kind| match kind {
ThreadSourceKind::Cli => matches!(source, CoreSessionSource::Cli),
ThreadSourceKind::VsCode => matches!(source, CoreSessionSource::VSCode),
ThreadSourceKind::Exec => matches!(source, CoreSessionSource::Exec),
ThreadSourceKind::AppServer => matches!(source, CoreSessionSource::Mcp),
ThreadSourceKind::SubAgent => matches!(source, CoreSessionSource::SubAgent(_)),
ThreadSourceKind::SubAgentReview => {
matches!(
source,
CoreSessionSource::SubAgent(CoreSubAgentSource::Review)
)
}
ThreadSourceKind::SubAgentCompact => {
matches!(
source,
CoreSessionSource::SubAgent(CoreSubAgentSource::Compact)
)
}
ThreadSourceKind::SubAgentThreadSpawn => matches!(
source,
CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn { .. })
),
ThreadSourceKind::SubAgentOther => matches!(
source,
CoreSessionSource::SubAgent(CoreSubAgentSource::Other(_))
),
ThreadSourceKind::Unknown => matches!(source, CoreSessionSource::Unknown),
})
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use uuid::Uuid;
#[test]
fn compute_source_filters_defaults_to_interactive_sources() {
let (allowed_sources, filter) = compute_source_filters(None);
assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec());
assert_eq!(filter, None);
}
#[test]
fn compute_source_filters_empty_means_interactive_sources() {
let (allowed_sources, filter) = compute_source_filters(Some(Vec::new()));
assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec());
assert_eq!(filter, None);
}
#[test]
fn compute_source_filters_interactive_only_skips_post_filtering() {
let source_kinds = vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode];
let (allowed_sources, filter) = compute_source_filters(Some(source_kinds.clone()));
assert_eq!(
allowed_sources,
vec![CoreSessionSource::Cli, CoreSessionSource::VSCode]
);
assert_eq!(filter, Some(source_kinds));
}
#[test]
fn compute_source_filters_subagent_variant_requires_post_filtering() {
let source_kinds = vec![ThreadSourceKind::SubAgentReview];
let (allowed_sources, filter) = compute_source_filters(Some(source_kinds.clone()));
assert_eq!(allowed_sources, Vec::new());
assert_eq!(filter, Some(source_kinds));
}
#[test]
fn source_kind_matches_distinguishes_subagent_variants() {
let parent_thread_id =
ThreadId::from_string(&Uuid::new_v4().to_string()).expect("valid thread id");
let review = CoreSessionSource::SubAgent(CoreSubAgentSource::Review);
let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
});
assert!(source_kind_matches(
&review,
&[ThreadSourceKind::SubAgentReview]
));
assert!(!source_kind_matches(
&review,
&[ThreadSourceKind::SubAgentThreadSpawn]
));
assert!(source_kind_matches(
&spawn,
&[ThreadSourceKind::SubAgentThreadSpawn]
));
assert!(!source_kind_matches(
&spawn,
&[ThreadSourceKind::SubAgentReview]
));
}
}
+7 -2
View File
@@ -42,6 +42,7 @@ mod codex_message_processor;
mod config_api;
mod dynamic_tools;
mod error_code;
mod filters;
mod fuzzy_file_search;
mod message_processor;
mod models;
@@ -134,7 +135,7 @@ fn project_config_warning(config: &Config) -> Option<ConfigWarningNotification>
.disabled_reason
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "Config folder disabled.".to_string()),
.unwrap_or_else(|| "config.toml is disabled.".to_string()),
));
}
}
@@ -143,7 +144,11 @@ fn project_config_warning(config: &Config) -> Option<ConfigWarningNotification>
return None;
}
let mut message = "The following config folders are disabled:\n".to_string();
let mut message = concat!(
"Project config.toml files are disabled in the following folders. ",
"Settings in those files are ignored, but skills and exec policies still load.\n",
)
.to_string();
for (index, (folder, reason)) in disabled_folders.iter().enumerate() {
let display_index = index + 1;
message.push_str(&format!(" {display_index}. {folder}\n"));
+1
View File
@@ -30,6 +30,7 @@ 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_source;
pub use rollout::create_fake_rollout_with_text_elements;
pub use rollout::rollout_path;
use serde::de::DeserializeOwned;
@@ -53,6 +53,7 @@ use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
use codex_core::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR;
@@ -365,6 +366,15 @@ impl McpProcess {
self.send_request("thread/archive", params).await
}
/// Send a `thread/unarchive` JSON-RPC request.
pub async fn send_thread_unarchive_request(
&mut self,
params: ThreadUnarchiveParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/unarchive", params).await
}
/// Send a `thread/rollback` JSON-RPC request.
pub async fn send_thread_rollback_request(
&mut self,
@@ -67,6 +67,7 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result<S
"id": "confirm_path",
"header": "Confirm",
"question": "Proceed with the plan?",
"isOther": false,
"options": [{
"label": "Yes (Recommended)",
"description": "Continue the current plan."
+22 -1
View File
@@ -38,6 +38,27 @@ pub fn create_fake_rollout(
preview: &str,
model_provider: Option<&str>,
git_info: Option<GitInfo>,
) -> Result<String> {
create_fake_rollout_with_source(
codex_home,
filename_ts,
meta_rfc3339,
preview,
model_provider,
git_info,
SessionSource::Cli,
)
}
/// Create a minimal rollout file with an explicit session source.
pub fn create_fake_rollout_with_source(
codex_home: &Path,
filename_ts: &str,
meta_rfc3339: &str,
preview: &str,
model_provider: Option<&str>,
git_info: Option<GitInfo>,
source: SessionSource,
) -> Result<String> {
let uuid = Uuid::new_v4();
let uuid_str = uuid.to_string();
@@ -57,7 +78,7 @@ pub fn create_fake_rollout(
cwd: PathBuf::from("/"),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
source,
model_provider: model_provider.map(str::to_string),
base_instructions: None,
};
@@ -108,6 +108,10 @@ async fn test_codex_jsonrpc_conversation_flow() -> Result<()> {
let AddConversationSubscriptionResponse { subscription_id } =
to_response::<AddConversationSubscriptionResponse>(add_listener_resp)?;
// Drop any buffered events from conversation setup to avoid
// matching an earlier task_complete.
mcp.clear_message_buffer();
// 3) sendUserMessage (should trigger notifications; we only validate an OK response)
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
@@ -125,13 +129,38 @@ async fn test_codex_jsonrpc_conversation_flow() -> Result<()> {
.await??;
let SendUserMessageResponse {} = to_response::<SendUserMessageResponse>(send_user_resp)?;
// Verify the task_finished notification is received.
// Note this also ensures that the final request to the server was made.
let task_finished_notification: JSONRPCNotification = timeout(
let task_started_notification: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("codex/event/task_started"),
)
.await??;
let task_started_event: Event = serde_json::from_value(
task_started_notification
.params
.clone()
.expect("task_started should have params"),
)
.expect("task_started should deserialize to Event");
// Verify the task_finished notification for this turn is received.
// Note this also ensures that the final request to the server was made.
let task_finished_notification: JSONRPCNotification = loop {
let notification: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
let event: Event = serde_json::from_value(
notification
.params
.clone()
.expect("task_complete should have params"),
)
.expect("task_complete should deserialize to Event");
if event.id == task_started_event.id {
break notification;
}
};
let serde_json::Value::Object(map) = task_finished_notification
.params
.expect("notification should have params")
@@ -18,5 +18,6 @@ mod thread_read;
mod thread_resume;
mod thread_rollback;
mod thread_start;
mod thread_unarchive;
mod turn_interrupt;
mod turn_start;
@@ -1,6 +1,7 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_fake_rollout;
use app_test_support::create_fake_rollout_with_source;
use app_test_support::rollout_path;
use app_test_support::to_response;
use chrono::DateTime;
@@ -12,8 +13,12 @@ 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_app_server_protocol::ThreadSourceKind;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use std::cmp::Reverse;
use std::fs;
@@ -38,9 +43,10 @@ async fn list_threads(
cursor: Option<String>,
limit: Option<u32>,
providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
archived: Option<bool>,
) -> Result<ThreadListResponse> {
list_threads_with_sort(mcp, cursor, limit, providers, None, archived).await
list_threads_with_sort(mcp, cursor, limit, providers, source_kinds, None, archived).await
}
async fn list_threads_with_sort(
@@ -48,6 +54,7 @@ async fn list_threads_with_sort(
cursor: Option<String>,
limit: Option<u32>,
providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
sort_key: Option<ThreadSortKey>,
archived: Option<bool>,
) -> Result<ThreadListResponse> {
@@ -57,6 +64,7 @@ async fn list_threads_with_sort(
limit,
sort_key,
model_providers: providers,
source_kinds,
archived,
})
.await?;
@@ -131,6 +139,7 @@ async fn thread_list_basic_empty() -> Result<()> {
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert!(data.is_empty());
@@ -194,6 +203,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
Some(2),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(data1.len(), 2);
@@ -219,6 +229,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
Some(2),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert!(data2.len() <= 2);
@@ -269,6 +280,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
Some(10),
Some(vec!["other_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(data.len(), 1);
@@ -287,6 +299,207 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"CLI",
Some("mock_provider"),
None,
)?;
let exec_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"Exec",
Some("mock_provider"),
None,
CoreSessionSource::Exec,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, next_cursor } = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(Vec::new()),
None,
)
.await?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![cli_id.as_str()]);
assert_ne!(cli_id, exec_id);
assert_eq!(data[0].source, SessionSource::Cli);
Ok(())
}
#[tokio::test]
async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"CLI",
Some("mock_provider"),
None,
)?;
let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let subagent_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"SubAgent",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
}),
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, next_cursor } = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
None,
)
.await?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![subagent_id.as_str()]);
assert_ne!(cli_id, subagent_id);
assert!(matches!(data[0].source, SessionSource::SubAgent(_)));
Ok(())
}
#[tokio::test]
async fn thread_list_filters_by_subagent_variant() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let review_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T09-00-00",
"2025-02-02T09:00:00Z",
"Review",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::Review),
)?;
let compact_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T10-00-00",
"2025-02-02T10:00:00Z",
"Compact",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::Compact),
)?;
let spawn_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T11-00-00",
"2025-02-02T11:00:00Z",
"Spawn",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
}),
)?;
let other_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T12-00-00",
"2025-02-02T12:00:00Z",
"Other",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::Other("custom".to_string())),
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let review = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentReview]),
None,
)
.await?;
let review_ids: Vec<_> = review
.data
.iter()
.map(|thread| thread.id.as_str())
.collect();
assert_eq!(review_ids, vec![review_id.as_str()]);
let compact = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentCompact]),
None,
)
.await?;
let compact_ids: Vec<_> = compact
.data
.iter()
.map(|thread| thread.id.as_str())
.collect();
assert_eq!(compact_ids, vec![compact_id.as_str()]);
let spawn = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
None,
)
.await?;
let spawn_ids: Vec<_> = spawn.data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(spawn_ids, vec![spawn_id.as_str()]);
let other = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentOther]),
None,
)
.await?;
let other_ids: Vec<_> = other.data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(other_ids, vec![other_id.as_str()]);
Ok(())
}
#[tokio::test]
async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -319,6 +532,7 @@ async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> {
Some(8),
Some(vec!["target_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(
@@ -364,6 +578,7 @@ async fn thread_list_enforces_max_limit() -> Result<()> {
Some(200),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(
@@ -410,6 +625,7 @@ async fn thread_list_stops_when_not_enough_filtered_results_exist() -> Result<()
Some(10),
Some(vec!["target_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(
@@ -457,6 +673,7 @@ async fn thread_list_includes_git_info() -> Result<()> {
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
let thread = data
@@ -516,6 +733,7 @@ async fn thread_list_default_sorts_by_created_at() -> Result<()> {
Some(vec!["mock_provider".to_string()]),
None,
None,
None,
)
.await?;
@@ -575,6 +793,7 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> {
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -639,6 +858,7 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> {
None,
Some(2),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -655,6 +875,7 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> {
Some(cursor1),
Some(2),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -696,6 +917,7 @@ async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> {
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
@@ -747,6 +969,7 @@ async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> {
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -787,6 +1010,7 @@ async fn thread_list_updated_at_uses_mtime() -> Result<()> {
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -846,6 +1070,7 @@ async fn thread_list_archived_filter() -> Result<()> {
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(data.len(), 1);
@@ -856,6 +1081,7 @@ async fn thread_list_archived_filter() -> Result<()> {
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
Some(true),
)
.await?;
@@ -878,6 +1104,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
limit: Some(2),
sort_key: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
})
.await?;
@@ -0,0 +1,101 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_path_by_id_str;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[tokio::test]
async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id)
.await?
.expect("expected rollout path for thread id to exist");
let archive_id = mcp
.send_thread_archive_request(ThreadArchiveParams {
thread_id: thread.id.clone(),
})
.await?;
let archive_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(archive_id)),
)
.await??;
let _: ThreadArchiveResponse = to_response::<ThreadArchiveResponse>(archive_resp)?;
let archived_path = find_archived_thread_path_by_id_str(codex_home.path(), &thread.id)
.await?
.expect("expected archived rollout path for thread id to exist");
let archived_path_display = archived_path.display();
assert!(
archived_path.exists(),
"expected {archived_path_display} to exist"
);
let unarchive_id = mcp
.send_thread_unarchive_request(ThreadUnarchiveParams {
thread_id: thread.id.clone(),
})
.await?;
let unarchive_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(unarchive_id)),
)
.await??;
let _: ThreadUnarchiveResponse = to_response::<ThreadUnarchiveResponse>(unarchive_resp)?;
let rollout_path_display = rollout_path.display();
assert!(
rollout_path.exists(),
"expected rollout path {rollout_path_display} to be restored"
);
assert!(
!archived_path.exists(),
"expected archived rollout path {archived_path_display} to be moved"
);
Ok(())
}
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(config_toml, config_contents())
}
fn config_contents() -> &'static str {
r#"model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
"#
}
+21 -3
View File
@@ -147,7 +147,7 @@ struct ResumeCommand {
session_id: Option<String>,
/// Continue the most recent session without showing the picker.
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
#[arg(long = "last", default_value_t = false)]
last: bool,
/// Show all sessions (disables cwd filtering and shows CWD column).
@@ -453,8 +453,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::UnderDevelopment => "under development",
Stage::Experimental { .. } => "experimental",
Stage::Stable => "stable",
Stage::Deprecated => "deprecated",
Stage::Removed => "removed",
@@ -932,6 +932,24 @@ mod tests {
finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
}
#[test]
fn exec_resume_last_accepts_prompt_positional() {
let cli =
MultitoolCli::try_parse_from(["codex", "exec", "--json", "resume", "--last", "2+2"])
.expect("parse should succeed");
let Some(Subcommand::Exec(exec)) = cli.subcommand else {
panic!("expected exec subcommand");
};
let Some(codex_exec::Command::Resume(args)) = exec.command else {
panic!("expected exec resume");
};
assert!(args.last);
assert_eq!(args.session_id, None);
assert_eq!(args.prompt.as_deref(), Some("2+2"));
}
fn app_server_from_args(args: &[&str]) -> AppServerCommand {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else {
+6
View File
@@ -247,6 +247,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
};
servers.insert(name.clone(), new_entry);
@@ -348,6 +349,11 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
_ => bail!("OAuth login is only supported for streamable HTTP servers."),
};
let mut scopes = scopes;
if scopes.is_empty() {
scopes = server.scopes.clone().unwrap_or_default();
}
perform_oauth_login(
&name,
&url,
+1 -1
View File
@@ -291,7 +291,7 @@ pub fn process_responses_event(
if let Ok(item) = serde_json::from_value::<ResponseItem>(item_val) {
return Ok(Some(ResponseEvent::OutputItemAdded(item)));
}
debug!("failed to parse ResponseItem from output_item.done");
debug!("failed to parse ResponseItem from output_item.added");
}
}
"response.reasoning_summary_part.added" => {
+11
View File
@@ -750,6 +750,13 @@
},
"type": "object"
},
"scopes": {
"default": null,
"items": {
"type": "string"
},
"type": "array"
},
"startup_timeout_ms": {
"default": null,
"format": "uint64",
@@ -1458,6 +1465,10 @@
],
"description": "User-level skill config entries keyed by SKILL.md path."
},
"suppress_unstable_features_warning": {
"description": "Suppress warnings about unstable (under development) features.",
"type": "boolean"
},
"tool_output_token_limit": {
"description": "Token budget applied when storing tool/function outputs in the context manager.",
"format": "uint",
+21 -7
View File
@@ -1,20 +1,22 @@
use crate::config::Config;
use crate::protocol::SandboxPolicy;
use codex_protocol::openai_models::ReasoningEffort;
use serde::Deserialize;
use serde::Serialize;
/// Base instructions for the orchestrator role.
const ORCHESTRATOR_PROMPT: &str = include_str!("../../templates/agents/orchestrator.md");
/// Base instructions for the worker role.
const WORKER_PROMPT: &str = include_str!("../../gpt-5.2-codex_prompt.md");
/// Default worker model override used by the worker role.
const WORKER_MODEL: &str = "gpt-5.2-codex";
/// Default model override used.
// TODO(jif) update when we have something smarter.
const EXPLORER_MODEL: &str = "gpt-5.2-codex";
/// Enumerated list of all supported agent roles.
const ALL_ROLES: [AgentRole; 3] = [
AgentRole::Default,
AgentRole::Orchestrator,
AgentRole::Explorer,
AgentRole::Worker,
// TODO(jif) add when we have stable prompts + models
// AgentRole::Orchestrator,
];
/// Hard-coded agent role selection used when spawning sub-agents.
@@ -27,6 +29,8 @@ pub enum AgentRole {
Orchestrator,
/// Task-executing agent with a fixed model override.
Worker,
/// Task-executing agent with a fixed model override.
Explorer,
}
/// Immutable profile data that drives per-agent configuration overrides.
@@ -36,6 +40,8 @@ pub struct AgentProfile {
pub base_instructions: Option<&'static str>,
/// Optional model override.
pub model: Option<&'static str>,
/// Optional reasoning effort override.
pub reasoning_effort: Option<ReasoningEffort>,
/// Whether to force a read-only sandbox policy.
pub read_only: bool,
}
@@ -58,8 +64,13 @@ impl AgentRole {
..Default::default()
},
AgentRole::Worker => AgentProfile {
base_instructions: Some(WORKER_PROMPT),
model: Some(WORKER_MODEL),
// base_instructions: Some(WORKER_PROMPT),
// model: Some(WORKER_MODEL),
..Default::default()
},
AgentRole::Explorer => AgentProfile {
model: Some(EXPLORER_MODEL),
reasoning_effort: Some(ReasoningEffort::Low),
..Default::default()
},
}
@@ -74,6 +85,9 @@ impl AgentRole {
if let Some(model) = profile.model {
config.model = Some(model.to_string());
}
if let Some(reasoning_effort) = profile.reasoning_effort {
config.model_reasoning_effort = Some(reasoning_effort)
}
if profile.read_only {
config
.sandbox_policy
+10 -10
View File
@@ -625,11 +625,13 @@ fn build_api_prompt(prompt: &Prompt, instructions: String, tools_json: Vec<Value
}
}
fn beta_feature_headers(config: &Config) -> ApiHeaderMap {
fn experimental_feature_headers(config: &Config) -> ApiHeaderMap {
let enabled = FEATURES
.iter()
.filter_map(|spec| {
if spec.stage.beta_menu_description().is_some() && config.features.enabled(spec.id) {
if spec.stage.experimental_menu_description().is_some()
&& config.features.enabled(spec.id)
{
Some(spec.key)
} else {
None
@@ -650,16 +652,14 @@ fn build_responses_headers(
config: &Config,
turn_state: Option<&Arc<OnceLock<String>>>,
) -> ApiHeaderMap {
let mut headers = beta_feature_headers(config);
let mut headers = experimental_feature_headers(config);
headers.insert(
WEB_SEARCH_ELIGIBLE_HEADER,
HeaderValue::from_static(
if matches!(config.web_search_mode, Some(WebSearchMode::Disabled)) {
"false"
} else {
"true"
},
),
HeaderValue::from_static(if config.web_search_mode == WebSearchMode::Disabled {
"false"
} else {
"true"
}),
);
if let Some(turn_state) = turn_state
&& let Some(state) = turn_state.get()
+15 -6
View File
@@ -12,6 +12,7 @@ use crate::CodexAuth;
use crate::SandboxState;
use crate::agent::AgentControl;
use crate::agent::AgentStatus;
use crate::agent::MAX_THREAD_SPAWN_DEPTH;
use crate::agent::agent_status_from_event;
use crate::compact;
use crate::compact::run_inline_auto_compact_task;
@@ -21,6 +22,7 @@ use crate::connectors;
use crate::exec_policy::ExecPolicyManager;
use crate::features::Feature;
use crate::features::Features;
use crate::features::maybe_push_unstable_features_warning;
use crate::models_manager::manager::ModelsManager;
use crate::parse_command::parse_command;
use crate::parse_turn_item;
@@ -52,6 +54,7 @@ use codex_protocol::protocol::RawResponseItemEvent;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnStartedEvent;
@@ -240,7 +243,7 @@ impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn spawn(
config: Config,
mut config: Config,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
skills_manager: Arc<SkillsManager>,
@@ -262,6 +265,12 @@ impl Codex {
);
}
if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source
&& depth >= MAX_THREAD_SPAWN_DEPTH
{
config.features.disable(Feature::Collab);
}
let enabled_skills = loaded_skills.enabled_skills();
let user_instructions = get_user_instructions(&config, Some(&enabled_skills)).await;
@@ -746,6 +755,7 @@ impl Session {
});
}
maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events);
maybe_push_unstable_features_warning(&config, &mut post_session_configured_events);
let auth = auth.as_ref();
let otel_manager = OtelManager::new(
@@ -2814,7 +2824,7 @@ async fn spawn_review_thread(
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &review_model_info,
features: &review_features,
web_search_mode: Some(review_web_search_mode),
web_search_mode: review_web_search_mode,
});
let review_prompt = resolved.prompt.clone();
@@ -2826,7 +2836,7 @@ async fn spawn_review_thread(
let mut per_turn_config = (*config).clone();
per_turn_config.model = Some(model.clone());
per_turn_config.features = review_features.clone();
per_turn_config.web_search_mode = Some(review_web_search_mode);
per_turn_config.web_search_mode = review_web_search_mode;
let otel_manager = parent_turn_context
.client
@@ -3429,10 +3439,8 @@ async fn try_run_sampling_request(
}
ResponseEvent::OutputItemAdded(item) => {
if let Some(turn_item) = handle_non_tool_response_item(&item).await {
let tracked_item = turn_item.clone();
sess.emit_turn_item_started(&turn_context, &turn_item).await;
active_item = Some(tracked_item);
active_item = Some(turn_item);
}
}
ResponseEvent::ServerReasoningIncluded(included) => {
@@ -3587,6 +3595,7 @@ mod tests {
use crate::shell::default_user_shell;
use crate::tools::format_exec_output_str;
use codex_protocol::ThreadId;
use codex_protocol::models::FunctionCallOutputPayload;
use crate::protocol::CompactedItem;
+11
View File
@@ -167,6 +167,11 @@ mod document_helpers {
{
entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned());
}
if let Some(scopes) = &config.scopes
&& !scopes.is_empty()
{
entry["scopes"] = array_from_iter(scopes.iter().cloned());
}
entry
}
@@ -1373,6 +1378,7 @@ gpt-5 = "gpt-5.1"
tool_timeout_sec: None,
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
disabled_tools: None,
scopes: None,
},
);
@@ -1395,6 +1401,7 @@ gpt-5 = "gpt-5.1"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: Some(vec!["forbidden".to_string()]),
scopes: None,
},
);
@@ -1460,6 +1467,7 @@ foo = { command = "cmd" }
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
@@ -1504,6 +1512,7 @@ foo = { command = "cmd" } # keep me
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
@@ -1547,6 +1556,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
@@ -1591,6 +1601,7 @@ foo = { command = "cmd" }
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
+45 -15
View File
@@ -304,8 +304,8 @@ pub struct Config {
/// model info's default preference.
pub include_apply_patch_tool: bool,
/// Explicit or feature-derived web search mode.
pub web_search_mode: Option<WebSearchMode>,
/// Explicit or feature-derived web search mode. Defaults to cached.
pub web_search_mode: WebSearchMode,
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
@@ -316,6 +316,9 @@ pub struct Config {
/// Centralized feature flags; source of truth for feature gating.
pub features: Features,
/// When `true`, suppress warnings about unstable (under development) features.
pub suppress_unstable_features_warning: bool,
/// The active profile name used to derive this `Config` (if any).
pub active_profile: Option<String>,
@@ -906,6 +909,9 @@ pub struct ConfigToml {
#[schemars(schema_with = "crate::config::schema::features_schema")]
pub features: Option<FeaturesToml>,
/// Suppress warnings about unstable (under development) features.
pub suppress_unstable_features_warning: Option<bool>,
/// Settings for ghost snapshots (used for undo).
#[serde(default)]
pub ghost_snapshot: Option<GhostSnapshotToml>,
@@ -1199,17 +1205,17 @@ fn resolve_web_search_mode(
config_toml: &ConfigToml,
config_profile: &ConfigProfile,
features: &Features,
) -> Option<WebSearchMode> {
) -> WebSearchMode {
if let Some(mode) = config_profile.web_search.or(config_toml.web_search) {
return Some(mode);
return mode;
}
if features.enabled(Feature::WebSearchCached) {
return Some(WebSearchMode::Cached);
return WebSearchMode::Cached;
}
if features.enabled(Feature::WebSearchRequest) {
return Some(WebSearchMode::Live);
return WebSearchMode::Live;
}
None
WebSearchMode::Cached
}
impl Config {
@@ -1564,6 +1570,9 @@ impl Config {
use_experimental_unified_exec_tool,
ghost_snapshot,
features,
suppress_unstable_features_warning: cfg
.suppress_unstable_features_warning
.unwrap_or(false),
active_profile: active_profile_name,
active_project,
windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false),
@@ -1772,6 +1781,7 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
}
}
@@ -1789,6 +1799,7 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
}
}
@@ -2253,12 +2264,15 @@ trust_level = "trusted"
}
#[test]
fn web_search_mode_uses_none_if_unset() {
fn web_search_mode_defaults_to_cached_if_unset() {
let cfg = ConfigToml::default();
let profile = ConfigProfile::default();
let features = Features::with_defaults();
assert_eq!(resolve_web_search_mode(&cfg, &profile, &features), None);
assert_eq!(
resolve_web_search_mode(&cfg, &profile, &features),
WebSearchMode::Cached
);
}
#[test]
@@ -2273,7 +2287,7 @@ trust_level = "trusted"
assert_eq!(
resolve_web_search_mode(&cfg, &profile, &features),
Some(WebSearchMode::Live)
WebSearchMode::Live
);
}
@@ -2289,7 +2303,7 @@ trust_level = "trusted"
assert_eq!(
resolve_web_search_mode(&cfg, &profile, &features),
Some(WebSearchMode::Disabled)
WebSearchMode::Disabled
);
}
@@ -2614,6 +2628,7 @@ profile = "project"
tool_timeout_sec: Some(Duration::from_secs(5)),
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
@@ -2768,6 +2783,7 @@ bearer_token = "secret"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -2837,6 +2853,7 @@ ZIG_VAR = "3"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -2886,6 +2903,7 @@ ZIG_VAR = "3"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -2933,6 +2951,7 @@ ZIG_VAR = "3"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -2996,6 +3015,7 @@ startup_timeout_sec = 2.0
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
apply_blocking(
@@ -3071,6 +3091,7 @@ X-Auth = "DOCS_AUTH"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -3099,6 +3120,7 @@ X-Auth = "DOCS_AUTH"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
apply_blocking(
@@ -3165,6 +3187,7 @@ url = "https://example.com/mcp"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
),
(
@@ -3183,6 +3206,7 @@ url = "https://example.com/mcp"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
),
]);
@@ -3264,6 +3288,7 @@ url = "https://example.com/mcp"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -3307,6 +3332,7 @@ url = "https://example.com/mcp"
tool_timeout_sec: None,
enabled_tools: Some(vec!["allowed".to_string()]),
disabled_tools: Some(vec!["blocked".to_string()]),
scopes: None,
},
)]);
@@ -3714,10 +3740,11 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: None,
web_search_mode: WebSearchMode::Cached,
use_experimental_unified_exec_tool: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
suppress_unstable_features_warning: false,
active_profile: Some("o3".to_string()),
active_project: ProjectConfig { trust_level: None },
windows_wsl_setup_acknowledged: false,
@@ -3796,10 +3823,11 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: None,
web_search_mode: WebSearchMode::Cached,
use_experimental_unified_exec_tool: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt3".to_string()),
active_project: ProjectConfig { trust_level: None },
windows_wsl_setup_acknowledged: false,
@@ -3893,10 +3921,11 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: None,
web_search_mode: WebSearchMode::Cached,
use_experimental_unified_exec_tool: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
suppress_unstable_features_warning: false,
active_profile: Some("zdr".to_string()),
active_project: ProjectConfig { trust_level: None },
windows_wsl_setup_acknowledged: false,
@@ -3976,10 +4005,11 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: None,
web_search_mode: WebSearchMode::Cached,
use_experimental_unified_exec_tool: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt5".to_string()),
active_project: ProjectConfig { trust_level: None },
windows_wsl_setup_acknowledged: false,
+8
View File
@@ -73,6 +73,10 @@ pub struct McpServerConfig {
/// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_tools: Option<Vec<String>>,
/// Optional OAuth scopes to request during MCP login.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
}
// Raw MCP config shape used for deserialization and JSON Schema generation.
@@ -113,6 +117,8 @@ pub(crate) struct RawMcpServerConfig {
pub enabled_tools: Option<Vec<String>>,
#[serde(default)]
pub disabled_tools: Option<Vec<String>>,
#[serde(default)]
pub scopes: Option<Vec<String>>,
}
impl<'de> Deserialize<'de> for McpServerConfig {
@@ -134,6 +140,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
let enabled = raw.enabled.unwrap_or_else(default_enabled);
let enabled_tools = raw.enabled_tools.clone();
let disabled_tools = raw.disabled_tools.clone();
let scopes = raw.scopes.clone();
fn throw_if_set<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
where
@@ -188,6 +195,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
disabled_reason: None,
enabled_tools,
disabled_tools,
scopes,
})
}
}
+15 -17
View File
@@ -512,10 +512,10 @@ impl ProjectTrustContext {
let user_config_file = self.user_config_file.as_path().display();
match decision.trust_level {
Some(TrustLevel::Untrusted) => Some(format!(
"{trust_key} is marked as untrusted in {user_config_file}. Mark it trusted to enable project config folders."
"{trust_key} is marked as untrusted in {user_config_file}. To load config.toml, mark it trusted."
)),
_ => Some(format!(
"Add {trust_key} as a trusted project in {user_config_file}."
"To load config.toml, add {trust_key} as a trusted project in {user_config_file}."
)),
}
}
@@ -526,21 +526,16 @@ fn project_layer_entry(
dot_codex_folder: &AbsolutePathBuf,
layer_dir: &AbsolutePathBuf,
config: TomlValue,
config_toml_exists: bool,
) -> ConfigLayerEntry {
match trust_context.disabled_reason_for_dir(layer_dir) {
Some(reason) => ConfigLayerEntry::new_disabled(
ConfigLayerSource::Project {
dot_codex_folder: dot_codex_folder.clone(),
},
config,
reason,
),
None => ConfigLayerEntry::new(
ConfigLayerSource::Project {
dot_codex_folder: dot_codex_folder.clone(),
},
config,
),
let source = ConfigLayerSource::Project {
dot_codex_folder: dot_codex_folder.clone(),
};
if config_toml_exists && let Some(reason) = trust_context.disabled_reason_for_dir(layer_dir) {
ConfigLayerEntry::new_disabled(source, config, reason)
} else {
ConfigLayerEntry::new(source, config)
}
}
@@ -715,13 +710,15 @@ async fn load_project_layers(
&dot_codex_abs,
&layer_dir,
TomlValue::Table(toml::map::Map::new()),
true,
));
continue;
}
};
let config =
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
let entry = project_layer_entry(trust_context, &dot_codex_abs, &layer_dir, config);
let entry =
project_layer_entry(trust_context, &dot_codex_abs, &layer_dir, config, true);
layers.push(entry);
}
Err(err) => {
@@ -734,6 +731,7 @@ async fn load_project_layers(
&dot_codex_abs,
&layer_dir,
TomlValue::Table(toml::map::Map::new()),
false,
));
} else {
let config_file_display = config_file.as_path().display();
+103 -14
View File
@@ -21,6 +21,7 @@ use crate::instructions::SkillInstructions;
use crate::instructions::UserInstructions;
use crate::session_prefix::is_session_prefix;
use crate::user_shell_command::is_user_shell_command_text;
use crate::web_search::web_search_action_detail;
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
if UserInstructions::is_user_instructions(message)
@@ -127,14 +128,17 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
raw_content,
}))
}
ResponseItem::WebSearchCall {
id,
action: WebSearchAction::Search { query },
..
} => Some(TurnItem::WebSearch(WebSearchItem {
id: id.clone().unwrap_or_default(),
query: query.clone().unwrap_or_default(),
})),
ResponseItem::WebSearchCall { id, action, .. } => {
let (action, query) = match action {
Some(action) => (action.clone(), web_search_action_detail(action)),
None => (WebSearchAction::Other, String::new()),
};
Some(TurnItem::WebSearch(WebSearchItem {
id: id.clone().unwrap_or_default(),
query,
action,
}))
}
_ => None,
}
}
@@ -144,6 +148,7 @@ mod tests {
use super::parse_turn_item;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::TurnItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
@@ -419,18 +424,102 @@ mod tests {
let item = ResponseItem::WebSearchCall {
id: Some("ws_1".to_string()),
status: Some("completed".to_string()),
action: WebSearchAction::Search {
action: Some(WebSearchAction::Search {
query: Some("weather".to_string()),
},
}),
};
let turn_item = parse_turn_item(&item).expect("expected web search turn item");
match turn_item {
TurnItem::WebSearch(search) => {
assert_eq!(search.id, "ws_1");
assert_eq!(search.query, "weather");
}
TurnItem::WebSearch(search) => assert_eq!(
search,
WebSearchItem {
id: "ws_1".to_string(),
query: "weather".to_string(),
action: WebSearchAction::Search {
query: Some("weather".to_string()),
},
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
}
}
#[test]
fn parses_web_search_open_page_call() {
let item = ResponseItem::WebSearchCall {
id: Some("ws_open".to_string()),
status: Some("completed".to_string()),
action: Some(WebSearchAction::OpenPage {
url: Some("https://example.com".to_string()),
}),
};
let turn_item = parse_turn_item(&item).expect("expected web search turn item");
match turn_item {
TurnItem::WebSearch(search) => assert_eq!(
search,
WebSearchItem {
id: "ws_open".to_string(),
query: "https://example.com".to_string(),
action: WebSearchAction::OpenPage {
url: Some("https://example.com".to_string()),
},
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
}
}
#[test]
fn parses_web_search_find_in_page_call() {
let item = ResponseItem::WebSearchCall {
id: Some("ws_find".to_string()),
status: Some("completed".to_string()),
action: Some(WebSearchAction::FindInPage {
url: Some("https://example.com".to_string()),
pattern: Some("needle".to_string()),
}),
};
let turn_item = parse_turn_item(&item).expect("expected web search turn item");
match turn_item {
TurnItem::WebSearch(search) => assert_eq!(
search,
WebSearchItem {
id: "ws_find".to_string(),
query: "'needle' in https://example.com".to_string(),
action: WebSearchAction::FindInPage {
url: Some("https://example.com".to_string()),
pattern: Some("needle".to_string()),
},
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
}
}
#[test]
fn parses_partial_web_search_call_without_action_as_other() {
let item = ResponseItem::WebSearchCall {
id: Some("ws_partial".to_string()),
status: Some("in_progress".to_string()),
action: None,
};
let turn_item = parse_turn_item(&item).expect("expected web search turn item");
match turn_item {
TurnItem::WebSearch(search) => assert_eq!(
search,
WebSearchItem {
id: "ws_partial".to_string(),
query: String::new(),
action: WebSearchAction::Other,
}
),
other => panic!("expected TurnItem::WebSearch, got {other:?}"),
}
}
+77 -20
View File
@@ -5,14 +5,20 @@
//! booleans through multiple types, call sites consult a single `Features`
//! container attached to `Config`.
use crate::config::CONFIG_TOML_FILE;
use crate::config::Config;
use crate::config::ConfigToml;
use crate::config::profile::ConfigProfile;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::WarningEvent;
use codex_otel::OtelManager;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use toml::Value as TomlValue;
mod legacy;
pub(crate) use legacy::LegacyFeatureToggles;
@@ -21,8 +27,8 @@ 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,
/// Features that are still under development, not ready for external use
UnderDevelopment,
/// Experimental features made available to users through the `/experimental` menu
Experimental {
name: &'static str,
@@ -38,14 +44,14 @@ pub enum Stage {
}
impl Stage {
pub fn beta_menu_name(self) -> Option<&'static str> {
pub fn experimental_menu_name(self) -> Option<&'static str> {
match self {
Stage::Experimental { name, .. } => Some(name),
_ => None,
}
}
pub fn beta_menu_description(self) -> Option<&'static str> {
pub fn experimental_menu_description(self) -> Option<&'static str> {
match self {
Stage::Experimental {
menu_description, ..
@@ -54,7 +60,7 @@ impl Stage {
}
}
pub fn beta_announcement(self) -> Option<&'static str> {
pub fn experimental_announcement(self) -> Option<&'static str> {
match self {
Stage::Experimental { announcement, .. } => Some(announcement),
_ => None,
@@ -343,10 +349,10 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::WebSearchCached,
key: "web_search_cached",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
// Beta program. Rendered in the `/experimental` menu for users.
// Experimental program. Rendered in the `/experimental` menu for users.
FeatureSpec {
id: Feature::UnifiedExec,
key: "unified_exec",
@@ -370,43 +376,43 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::ChildAgentsMd,
key: "child_agents_md",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ApplyPatchFreeform,
key: "apply_patch_freeform",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ExecPolicy,
key: "exec_policy",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: true,
},
FeatureSpec {
id: Feature::WindowsSandbox,
key: "experimental_windows_sandbox",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::WindowsSandboxElevated,
key: "elevated_windows_sandbox",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::RemoteCompaction,
key: "remote_compaction",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: true,
},
FeatureSpec {
id: Feature::RemoteModels,
key: "remote_models",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: true,
},
FeatureSpec {
@@ -421,26 +427,26 @@ pub const FEATURES: &[FeatureSpec] = &[
#[cfg(windows)]
default_enabled: true,
#[cfg(not(windows))]
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
#[cfg(not(windows))]
default_enabled: false,
},
FeatureSpec {
id: Feature::EnableRequestCompression,
key: "enable_request_compression",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::Collab,
key: "collab",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::Connectors,
key: "connectors",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
@@ -456,13 +462,64 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::CollaborationModes,
key: "collaboration_modes",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ResponsesWebsockets,
key: "responses_websockets",
stage: Stage::Beta,
stage: Stage::UnderDevelopment,
default_enabled: false,
},
];
/// Push a warning event if any under-development features are enabled.
pub fn maybe_push_unstable_features_warning(
config: &Config,
post_session_configured_events: &mut Vec<Event>,
) {
if config.suppress_unstable_features_warning {
return;
}
let mut under_development_feature_keys = Vec::new();
if let Some(table) = config
.config_layer_stack
.effective_config()
.get("features")
.and_then(TomlValue::as_table)
{
for (key, value) in table {
if value.as_bool() != Some(true) {
continue;
}
let Some(spec) = FEATURES.iter().find(|spec| spec.key == key.as_str()) else {
continue;
};
if !config.features.enabled(spec.id) {
continue;
}
if matches!(spec.stage, Stage::UnderDevelopment) {
under_development_feature_keys.push(spec.key.to_string());
}
}
}
if under_development_feature_keys.is_empty() {
return;
}
let under_development_feature_keys = under_development_feature_keys.join(", ");
let config_path = config
.codex_home
.join(CONFIG_TOML_FILE)
.display()
.to_string();
let message = format!(
"Under-development features enabled: {under_development_feature_keys}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {config_path}."
);
post_session_configured_events.push(Event {
id: "".to_owned(),
msg: EventMsg::Warning(WarningEvent { message }),
});
}
+3
View File
@@ -69,6 +69,7 @@ mod event_mapping;
pub mod review_format;
pub mod review_prompts;
mod thread_manager;
pub mod web_search;
pub use codex_protocol::protocol::InitialHistory;
pub use thread_manager::NewThread;
pub use thread_manager::ThreadManager;
@@ -98,6 +99,7 @@ pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;
pub use rollout::find_archived_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use rollout::find_conversation_path_by_id_str;
pub use rollout::find_thread_path_by_id_str;
@@ -108,6 +110,7 @@ pub use rollout::list::ThreadsPage;
pub use rollout::list::parse_cursor;
pub use rollout::list::read_head_for_summary;
pub use rollout::list::read_session_meta_line;
pub use rollout::rollout_date_parts;
mod function_tool;
mod state;
mod tasks;
+1
View File
@@ -97,6 +97,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
}
}
@@ -1182,6 +1182,7 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
auth_status: McpAuthStatus::Unsupported,
};
@@ -1227,6 +1228,7 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
auth_status: McpAuthStatus::Unsupported,
};
@@ -28,7 +28,7 @@ fn plan_preset() -> CollaborationModeMask {
name: "Plan".to_string(),
mode: Some(ModeKind::Plan),
model: None,
reasoning_effort: Some(Some(ReasoningEffort::High)),
reasoning_effort: Some(Some(ReasoningEffort::Medium)),
developer_instructions: Some(Some(COLLABORATION_MODE_PLAN.to_string())),
}
}
+33 -5
View File
@@ -1,4 +1,5 @@
use std::cmp::Reverse;
use std::ffi::OsStr;
use std::io::{self};
use std::num::NonZero;
use std::ops::ControlFlow;
@@ -15,6 +16,7 @@ use time::format_description::well_known::Rfc3339;
use time::macros::format_description;
use uuid::Uuid;
use super::ARCHIVED_SESSIONS_SUBDIR;
use super::SESSIONS_SUBDIR;
use crate::protocol::EventMsg;
use codex_file_search as file_search;
@@ -1054,11 +1056,9 @@ fn truncate_to_seconds(dt: OffsetDateTime) -> Option<OffsetDateTime> {
dt.replace_nanosecond(0).ok()
}
/// Locate a recorded thread rollout file by its UUID string using the existing
/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present
/// or the id is invalid.
pub async fn find_thread_path_by_id_str(
async fn find_thread_path_by_id_str_in_subdir(
codex_home: &Path,
subdir: &str,
id_str: &str,
) -> io::Result<Option<PathBuf>> {
// Validate UUID format early.
@@ -1067,7 +1067,7 @@ pub async fn find_thread_path_by_id_str(
}
let mut root = codex_home.to_path_buf();
root.push(SESSIONS_SUBDIR);
root.push(subdir);
if !root.exists() {
return Ok(None);
}
@@ -1099,3 +1099,31 @@ pub async fn find_thread_path_by_id_str(
.next()
.map(|m| root.join(m.path)))
}
/// Locate a recorded thread rollout file by its UUID string using the existing
/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present
/// or the id is invalid.
pub async fn find_thread_path_by_id_str(
codex_home: &Path,
id_str: &str,
) -> io::Result<Option<PathBuf>> {
find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str).await
}
/// Locate an archived thread rollout file by its UUID string.
pub async fn find_archived_thread_path_by_id_str(
codex_home: &Path,
id_str: &str,
) -> io::Result<Option<PathBuf>> {
find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str).await
}
/// Extract the `YYYY/MM/DD` directory components from a rollout filename.
pub fn rollout_date_parts(file_name: &OsStr) -> Option<(String, String, String)> {
let name = file_name.to_string_lossy();
let date = name.strip_prefix("rollout-")?.get(..10)?;
let year = date.get(..4)?.to_string();
let month = date.get(5..7)?.to_string();
let day = date.get(8..10)?.to_string();
Some((year, month, day))
}
+2
View File
@@ -15,9 +15,11 @@ pub(crate) mod truncation;
pub use codex_protocol::protocol::SessionMeta;
pub(crate) use error::map_session_init_error;
pub use list::find_archived_thread_path_by_id_str;
pub use list::find_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str;
pub use list::rollout_date_parts;
pub use recorder::RolloutRecorder;
pub use recorder::RolloutRecorderParams;
+12
View File
@@ -1,5 +1,6 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::ffi::OsStr;
use std::fs::File;
use std::fs::FileTimes;
use std::fs::{self};
@@ -21,6 +22,7 @@ use crate::rollout::list::ThreadItem;
use crate::rollout::list::ThreadSortKey;
use crate::rollout::list::ThreadsPage;
use crate::rollout::list::get_threads;
use crate::rollout::rollout_date_parts;
use anyhow::Result;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
@@ -43,6 +45,16 @@ fn provider_vec(providers: &[&str]) -> Vec<String> {
.collect()
}
#[test]
fn rollout_date_parts_extracts_directory_components() {
let file_name = OsStr::new("rollout-2025-03-01T09-00-00-123.jsonl");
let parts = rollout_date_parts(file_name);
assert_eq!(
parts,
Some(("2025".to_string(), "03".to_string(), "01".to_string()))
);
}
fn write_session_file(
root: &Path,
ts_str: &str,
+3 -16
View File
@@ -464,8 +464,6 @@ mod tests {
use pretty_assertions::assert_eq;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
#[cfg(target_os = "linux")]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::process::Command;
#[cfg(target_os = "linux")]
@@ -562,27 +560,16 @@ mod tests {
use tokio::time::sleep;
let dir = tempdir()?;
let shell_path = dir.path().join("hanging-shell.sh");
let pid_path = dir.path().join("pid");
let script = format!(
"#!/bin/sh\n\
echo $$ > {}\n\
sleep 30\n",
pid_path.display()
);
fs::write(&shell_path, script).await?;
let mut permissions = std::fs::metadata(&shell_path)?.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&shell_path, permissions)?;
let script = format!("echo $$ > \"{}\"; sleep 30", pid_path.display());
let shell = Shell {
shell_type: ShellType::Sh,
shell_path,
shell_path: PathBuf::from("/bin/sh"),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
let err = run_script_with_timeout(&shell, "ignored", Duration::from_millis(500), true)
let err = run_script_with_timeout(&shell, &script, Duration::from_secs(1), true)
.await
.expect_err("snapshot shell should time out");
assert!(
+143 -53
View File
@@ -35,7 +35,7 @@ struct SkillFrontmatterMetadata {
}
#[derive(Debug, Default, Deserialize)]
struct SkillToml {
struct SkillMetadataFile {
#[serde(default)]
interface: Option<Interface>,
}
@@ -51,6 +51,7 @@ struct Interface {
}
const SKILLS_FILENAME: &str = "SKILL.md";
const SKILLS_JSON_FILENAME: &str = "SKILL.json";
const SKILLS_TOML_FILENAME: &str = "SKILL.toml";
const SKILLS_DIR_NAME: &str = "skills";
const MAX_NAME_LEN: usize = 64;
@@ -370,62 +371,94 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
}
fn load_skill_interface(skill_path: &Path) -> Option<SkillInterface> {
// Fail open: optional SKILL.toml metadata should not block loading SKILL.md.
// Fail open: optional interface metadata should not block loading SKILL.md.
let skill_dir = skill_path.parent()?;
let interface_path = skill_dir.join(SKILLS_TOML_FILENAME);
if !interface_path.exists() {
return None;
let interface_paths = [
(skill_dir.join(SKILLS_JSON_FILENAME), InterfaceFormat::Json),
(skill_dir.join(SKILLS_TOML_FILENAME), InterfaceFormat::Toml),
];
for (interface_path, format) in interface_paths {
if !interface_path.exists() {
continue;
}
let contents = match fs::read_to_string(&interface_path) {
Ok(contents) => contents,
Err(error) => {
tracing::warn!(
"ignoring {path}: failed to read {label}: {error}",
path = interface_path.display(),
label = format.label()
);
continue;
}
};
let parsed: SkillMetadataFile = match format.parse(&contents) {
Ok(parsed) => parsed,
Err(error) => {
tracing::warn!(
"ignoring {path}: invalid {label}: {error}",
path = interface_path.display(),
label = format.label()
);
continue;
}
};
let interface = parsed.interface?;
let interface = SkillInterface {
display_name: resolve_str(
interface.display_name,
MAX_NAME_LEN,
"interface.display_name",
),
short_description: resolve_str(
interface.short_description,
MAX_SHORT_DESCRIPTION_LEN,
"interface.short_description",
),
icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small),
icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large),
brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"),
default_prompt: resolve_str(
interface.default_prompt,
MAX_DEFAULT_PROMPT_LEN,
"interface.default_prompt",
),
};
let has_fields = interface.display_name.is_some()
|| interface.short_description.is_some()
|| interface.icon_small.is_some()
|| interface.icon_large.is_some()
|| interface.brand_color.is_some()
|| interface.default_prompt.is_some();
return if has_fields { Some(interface) } else { None };
}
let contents = match fs::read_to_string(&interface_path) {
Ok(contents) => contents,
Err(error) => {
tracing::warn!(
"ignoring {path}: failed to read SKILL.toml: {error}",
path = interface_path.display()
);
return None;
}
};
let parsed: SkillToml = match toml::from_str(&contents) {
Ok(parsed) => parsed,
Err(error) => {
tracing::warn!(
"ignoring {path}: invalid TOML: {error}",
path = interface_path.display()
);
return None;
}
};
let interface = parsed.interface?;
None
}
let interface = SkillInterface {
display_name: resolve_str(
interface.display_name,
MAX_NAME_LEN,
"interface.display_name",
),
short_description: resolve_str(
interface.short_description,
MAX_SHORT_DESCRIPTION_LEN,
"interface.short_description",
),
icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small),
icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large),
brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"),
default_prompt: resolve_str(
interface.default_prompt,
MAX_DEFAULT_PROMPT_LEN,
"interface.default_prompt",
),
};
let has_fields = interface.display_name.is_some()
|| interface.short_description.is_some()
|| interface.icon_small.is_some()
|| interface.icon_large.is_some()
|| interface.brand_color.is_some()
|| interface.default_prompt.is_some();
if has_fields { Some(interface) } else { None }
#[derive(Clone, Copy)]
enum InterfaceFormat {
Json,
Toml,
}
impl InterfaceFormat {
fn label(self) -> &'static str {
match self {
InterfaceFormat::Json => "SKILL.json",
InterfaceFormat::Toml => "SKILL.toml",
}
}
fn parse(self, contents: &str) -> Result<SkillMetadataFile, String> {
match self {
InterfaceFormat::Json => serde_json::from_str(contents).map_err(|err| err.to_string()),
InterfaceFormat::Toml => toml::from_str(contents).map_err(|err| err.to_string()),
}
}
}
fn resolve_asset_path(
@@ -761,6 +794,12 @@ mod tests {
path
}
fn write_skill_interface_json_at(skill_dir: &Path, contents: &str) -> PathBuf {
let path = skill_dir.join(SKILLS_JSON_FILENAME);
fs::write(&path, contents).unwrap();
path
}
#[tokio::test]
async fn loads_skill_interface_metadata_happy_path() {
let codex_home = tempfile::tempdir().expect("tempdir");
@@ -809,6 +848,57 @@ default_prompt = " default prompt "
);
}
#[tokio::test]
async fn loads_skill_interface_metadata_from_json() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json");
let skill_dir = skill_path.parent().expect("skill dir");
let normalized_skill_dir = normalized(skill_dir);
write_skill_interface_json_at(
skill_dir,
r##"
{
"interface": {
"display_name": "UI Skill",
"short_description": " short desc ",
"icon_small": "./assets/small-400px.png",
"icon_large": "./assets/large-logo.svg",
"brand_color": "#3B82F6",
"default_prompt": " default prompt "
}
}
"##,
);
let cfg = make_config(&codex_home).await;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "ui-skill".to_string(),
description: "from json".to_string(),
short_description: None,
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")),
brand_color: Some("#3B82F6".to_string()),
default_prompt: Some("default prompt".to_string()),
}),
path: normalized(skill_path.as_path()),
scope: SkillScope::User,
}]
);
}
#[tokio::test]
async fn accepts_icon_paths_under_assets_dir() {
let codex_home = tempfile::tempdir().expect("tempdir");
+2 -2
View File
@@ -41,7 +41,7 @@ pub(crate) use undo::UndoTask;
pub(crate) use user_shell::UserShellCommandTask;
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn. Do not continue or repeat work from that turn unless the user explicitly asks. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
/// Thin wrapper that exposes the parts of [`Session`] task runners need.
#[derive(Clone)]
@@ -253,7 +253,7 @@ impl Session {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{TURN_ABORTED_OPEN_TAG}\n <turn_id>{sub_id}</turn_id>\n <reason>interrupted</reason>\n <guidance>{TURN_ABORTED_INTERRUPTED_GUIDANCE}</guidance>\n</turn_aborted>"
"{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n</turn_aborted>"
),
}],
end_turn: None,
+1 -1
View File
@@ -86,7 +86,7 @@ async fn start_review_conversation(
let mut sub_agent_config = config.as_ref().clone();
// Carry over review-only feature restrictions so the delegate cannot
// re-enable blocked tools (web search, view image).
sub_agent_config.web_search_mode = Some(WebSearchMode::Disabled);
sub_agent_config.web_search_mode = WebSearchMode::Disabled;
// Set explicit review rubric for the sub-agent
sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string());
+60 -13
View File
@@ -1,8 +1,10 @@
use crate::agent::AgentStatus;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
use crate::error::CodexErr;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
@@ -26,6 +28,8 @@ use serde::Serialize;
pub struct CollabHandler;
/// Minimum wait timeout to prevent tight polling loops from burning CPU.
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 300_000;
@@ -78,7 +82,7 @@ impl ToolHandler for CollabHandler {
mod spawn {
use super::*;
use crate::agent::AgentRole;
use crate::agent::MAX_THREAD_SPAWN_DEPTH;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::next_thread_spawn_depth;
use codex_protocol::protocol::SessionSource;
@@ -113,9 +117,9 @@ mod spawn {
let session_source = turn.client.get_session_source();
let child_depth = next_thread_spawn_depth(&session_source);
if exceeds_thread_spawn_depth_limit(child_depth) {
return Err(FunctionCallError::RespondToModel(format!(
"agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}"
)));
return Err(FunctionCallError::RespondToModel(
"Agent depth limit reached. Solve the task yourself.".to_string(),
));
}
session
.send_event(
@@ -128,8 +132,11 @@ mod spawn {
.into(),
)
.await;
let mut config =
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
let mut config = build_agent_spawn_config(
&session.get_base_instructions().await,
turn.as_ref(),
child_depth,
)?;
agent_role
.apply_to_config(&mut config)
.map_err(FunctionCallError::RespondToModel)?;
@@ -318,6 +325,8 @@ mod wait {
.collect::<Result<Vec<_>, _>>()?;
// Validate timeout.
// Very short timeouts encourage busy-polling loops in the orchestrator prompt and can
// cause high CPU usage even with a single active worker, so clamp to a minimum.
let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS);
let timeout_ms = match timeout_ms {
ms if ms <= 0 => {
@@ -325,7 +334,7 @@ mod wait {
"timeout_ms must be greater than zero".to_owned(),
));
}
ms => ms.min(MAX_WAIT_TIMEOUT_MS),
ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS),
};
session
@@ -582,6 +591,7 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError {
fn build_agent_spawn_config(
base_instructions: &BaseInstructions,
turn: &TurnContext,
child_depth: i32,
) -> Result<Config, FunctionCallError> {
let base_config = turn.client.config();
let mut config = (*base_config).clone();
@@ -607,6 +617,12 @@ fn build_agent_spawn_config(
.map_err(|err| {
FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}"))
})?;
// If the new agent will be at max depth:
if exceeds_thread_spawn_depth_limit(child_depth + 1) {
config.features.disable(Feature::Collab);
}
Ok(config)
}
@@ -778,9 +794,9 @@ mod tests {
};
assert_eq!(
err,
FunctionCallError::RespondToModel(format!(
"agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}"
))
FunctionCallError::RespondToModel(
"Agent depth limit reached. Solve the task yourself.".to_string()
)
);
}
@@ -1000,7 +1016,7 @@ mod tests {
"wait",
function_payload(json!({
"ids": [agent_id.to_string()],
"timeout_ms": 10
"timeout_ms": MIN_WAIT_TIMEOUT_MS
})),
);
let output = CollabHandler
@@ -1031,6 +1047,37 @@ mod tests {
.expect("shutdown should submit");
}
#[tokio::test]
async fn wait_clamps_short_timeouts_to_minimum() {
let (mut session, turn) = make_session_and_context().await;
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let config = turn.client.config().as_ref().clone();
let thread = manager.start_thread(config).await.expect("start thread");
let agent_id = thread.thread_id;
let invocation = invocation(
Arc::new(session),
Arc::new(turn),
"wait",
function_payload(json!({
"ids": [agent_id.to_string()],
"timeout_ms": 10
})),
);
let early = timeout(Duration::from_millis(50), CollabHandler.handle(invocation)).await;
assert!(
early.is_err(),
"wait should not return before the minimum timeout clamp"
);
let _ = thread
.thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn wait_returns_final_status_without_timeout() {
let (mut session, turn) = make_session_and_context().await;
@@ -1144,7 +1191,7 @@ mod tests {
turn.approval_policy = AskForApproval::Never;
turn.sandbox_policy = SandboxPolicy::DangerFullAccess;
let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config");
let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config");
let mut expected = (*turn.client.config()).clone();
expected.base_instructions = Some(base_instructions.text);
expected.model = Some(turn.client.get_model());
@@ -1189,7 +1236,7 @@ mod tests {
text: "base".to_string(),
};
let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config");
let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config");
assert_eq!(config.user_instructions, base_config.user_instructions);
}
@@ -36,12 +36,14 @@ impl ToolHandler for RequestUserInputHandler {
}
};
let disallowed_mode = match session.collaboration_mode().await.mode {
ModeKind::Execute => Some("Execute"),
ModeKind::Custom => Some("Custom"),
_ => None,
};
if let Some(mode_name) = disallowed_mode {
let mode = session.collaboration_mode().await.mode;
if !matches!(mode, ModeKind::Plan | ModeKind::PairProgramming) {
let mode_name = match mode {
ModeKind::Code => "Code",
ModeKind::Execute => "Execute",
ModeKind::Custom => "Custom",
ModeKind::Plan | ModeKind::PairProgramming => unreachable!(),
};
return Err(FunctionCallError::RespondToModel(format!(
"request_user_input is unavailable in {mode_name} mode"
)));
+45 -34
View File
@@ -8,6 +8,7 @@ use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS;
use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS;
use crate::tools::handlers::collab::MIN_WAIT_TIMEOUT_MS;
use crate::tools::registry::ToolRegistryBuilder;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolSpec;
@@ -26,7 +27,7 @@ use std::collections::HashMap;
pub(crate) struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub web_search_mode: WebSearchMode,
pub collab_tools: bool,
pub collaboration_modes_tools: bool,
pub experimental_supported_tools: Vec<String>,
@@ -35,7 +36,7 @@ pub(crate) struct ToolsConfig {
pub(crate) struct ToolsConfigParams<'a> {
pub(crate) model_info: &'a ModelInfo,
pub(crate) features: &'a Features,
pub(crate) web_search_mode: Option<WebSearchMode>,
pub(crate) web_search_mode: WebSearchMode,
}
impl ToolsConfig {
@@ -517,7 +518,7 @@ fn create_wait_tool() -> ToolSpec {
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(format!(
"Optional timeout in milliseconds. Defaults to {DEFAULT_WAIT_TIMEOUT_MS} and max {MAX_WAIT_TIMEOUT_MS}."
"Optional timeout in milliseconds. Defaults to {DEFAULT_WAIT_TIMEOUT_MS}, min {MIN_WAIT_TIMEOUT_MS}, and max {MAX_WAIT_TIMEOUT_MS}. Avoid tight polling loops; prefer longer waits (seconds to minutes)."
)),
},
);
@@ -555,7 +556,7 @@ fn create_request_user_input_tool() -> ToolSpec {
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."
"Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; use isOther on the question to request a free form choice. If the question is free form in nature, please do not have any option."
.to_string(),
),
items: Box::new(JsonSchema::Object {
@@ -586,6 +587,15 @@ fn create_request_user_input_tool() -> ToolSpec {
description: Some("Single-sentence prompt shown to the user.".to_string()),
},
);
question_props.insert(
"isOther".to_string(),
JsonSchema::Boolean {
description: Some(
"True when this question should include a free-form \"Other\" option. Otherwise false."
.to_string(),
),
},
);
question_props.insert("options".to_string(), options_schema);
let questions_schema = JsonSchema::Array {
@@ -596,6 +606,7 @@ fn create_request_user_input_tool() -> ToolSpec {
"id".to_string(),
"header".to_string(),
"question".to_string(),
"isOther".to_string(),
]),
additional_properties: Some(false.into()),
}),
@@ -1363,17 +1374,17 @@ pub(crate) fn build_specs(
}
match config.web_search_mode {
Some(WebSearchMode::Cached) => {
WebSearchMode::Cached => {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(false),
});
}
Some(WebSearchMode::Live) => {
WebSearchMode::Live => {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(true),
});
}
Some(WebSearchMode::Disabled) | None => {}
WebSearchMode::Disabled => {}
}
builder.push_spec_with_parallel_support(create_view_image_tool(), true);
@@ -1535,7 +1546,7 @@ mod tests {
let config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
web_search_mode: WebSearchMode::Live,
});
let (tools, _) = build_specs(&config, None, &[]).build();
@@ -1599,7 +1610,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert_contains_tool_names(
@@ -1617,7 +1628,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert!(
@@ -1629,7 +1640,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert_contains_tool_names(&tools, &["request_user_input"]);
@@ -1638,7 +1649,7 @@ mod tests {
fn assert_model_tools(
model_slug: &str,
features: &Features,
web_search_mode: Option<WebSearchMode>,
web_search_mode: WebSearchMode,
expected_tools: &[&str],
) {
let config = test_config();
@@ -1662,7 +1673,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
@@ -1684,7 +1695,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
web_search_mode: WebSearchMode::Live,
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
@@ -1704,7 +1715,7 @@ mod tests {
assert_model_tools(
"gpt-5-codex",
&features,
Some(WebSearchMode::Cached),
WebSearchMode::Cached,
&[
"shell_command",
"list_mcp_resources",
@@ -1726,7 +1737,7 @@ mod tests {
assert_model_tools(
"gpt-5.1-codex",
&features,
Some(WebSearchMode::Cached),
WebSearchMode::Cached,
&[
"shell_command",
"list_mcp_resources",
@@ -1749,7 +1760,7 @@ mod tests {
assert_model_tools(
"gpt-5-codex",
&features,
Some(WebSearchMode::Live),
WebSearchMode::Live,
&[
"exec_command",
"write_stdin",
@@ -1773,7 +1784,7 @@ mod tests {
assert_model_tools(
"gpt-5.1-codex",
&features,
Some(WebSearchMode::Live),
WebSearchMode::Live,
&[
"exec_command",
"write_stdin",
@@ -1796,7 +1807,7 @@ mod tests {
assert_model_tools(
"codex-mini-latest",
&features,
Some(WebSearchMode::Cached),
WebSearchMode::Cached,
&[
"local_shell",
"list_mcp_resources",
@@ -1817,7 +1828,7 @@ mod tests {
assert_model_tools(
"gpt-5.1-codex-mini",
&features,
Some(WebSearchMode::Cached),
WebSearchMode::Cached,
&[
"shell_command",
"list_mcp_resources",
@@ -1839,7 +1850,7 @@ mod tests {
assert_model_tools(
"gpt-5",
&features,
Some(WebSearchMode::Cached),
WebSearchMode::Cached,
&[
"shell",
"list_mcp_resources",
@@ -1860,7 +1871,7 @@ mod tests {
assert_model_tools(
"gpt-5.1",
&features,
Some(WebSearchMode::Cached),
WebSearchMode::Cached,
&[
"shell_command",
"list_mcp_resources",
@@ -1882,7 +1893,7 @@ mod tests {
assert_model_tools(
"exp-5.1",
&features,
Some(WebSearchMode::Cached),
WebSearchMode::Cached,
&[
"exec_command",
"write_stdin",
@@ -1906,7 +1917,7 @@ mod tests {
assert_model_tools(
"codex-mini-latest",
&features,
Some(WebSearchMode::Live),
WebSearchMode::Live,
&[
"exec_command",
"write_stdin",
@@ -1930,7 +1941,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
web_search_mode: WebSearchMode::Live,
});
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), &[]).build();
@@ -1952,7 +1963,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
@@ -1971,7 +1982,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
@@ -2002,7 +2013,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
web_search_mode: WebSearchMode::Live,
});
let (tools, _) = build_specs(
&tools_config,
@@ -2098,7 +2109,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
// Intentionally construct a map with keys that would sort alphabetically.
@@ -2175,7 +2186,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
@@ -2233,7 +2244,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
@@ -2288,7 +2299,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
@@ -2345,7 +2356,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
@@ -2458,7 +2469,7 @@ Examples of valid command strings:
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
&tools_config,
+24
View File
@@ -0,0 +1,24 @@
use codex_protocol::models::WebSearchAction;
pub fn web_search_action_detail(action: &WebSearchAction) -> String {
match action {
WebSearchAction::Search { query } => query.clone().unwrap_or_default(),
WebSearchAction::OpenPage { url } => url.clone().unwrap_or_default(),
WebSearchAction::FindInPage { url, pattern } => match (pattern, url) {
(Some(pattern), Some(url)) => format!("'{pattern}' in {url}"),
(Some(pattern), None) => format!("'{pattern}'"),
(None, Some(url)) => url.clone(),
(None, None) => String::new(),
},
WebSearchAction::Other => String::new(),
}
}
pub fn web_search_detail(action: Option<&WebSearchAction>, query: &str) -> String {
let detail = action.map(web_search_action_detail).unwrap_or_default();
if detail.is_empty() {
query.to_string()
} else {
detail
}
}
@@ -52,6 +52,7 @@ You are Codex Orchestrator, based on GPT-5. You are running as an orchestration
* Workers must not revert, overwrite, or conflict with others’ work.
* By default, workers must not spawn sub-agents unless explicitly allowed.
* When multiple workers are active, you may pass multiple IDs to `wait` to react to the first completion and keep the workflow event-driven and use a long timeout (e.g. 5 minutes).
* Do not busy-poll `wait` with very short timeouts. Prefer waits measured in seconds (or minutes) so the system is idle while workers run.
## Collab tools
@@ -1,41 +1,108 @@
# Plan Mode (Conversational)
You work in 2 phases and you should *chat your way* to a great plan before finalizing it.
You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions.
PHASE 1 — Intent chat (what they actually want)
- Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs.
- Bias toward questions over guessing: if any high‑impact ambiguity remains, do NOT plan yet—ask.
- Include a “Confirm my understanding” question in each round (so the user can correct you early).
## Mode rules (strict)
PHASE 2 — Implementation chat (what/how we’ll build)
- Once intent is stable, keep asking until the spec is decision‑complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints.
You are in **Plan Mode** until a developer message explicitly ends it.
Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it.
## Execution vs. mutation in Plan Mode
You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions.
### Allowed (non-mutating, plan-improving)
Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples:
* Reading or searching files, configs, schemas, types, manifests, and docs
* Static analysis, inspection, and repo exploration
* Dry-run style commands when they do not edit repo-tracked files
* Tests, builds, or checks that may write to caches or build artifacts (for example, `target/`, `.cache/`, or snapshots) so long as they do not edit repo-tracked files
### Not allowed (mutating, plan-executing)
Actions that implement the plan or change repo-tracked state. Examples:
* Editing or writing files
* Generating, updating, or accepting snapshots
* Running formatters or linters that rewrite files
* Applying patches, migrations, or codegen that updates repo-tracked files
* Side-effectful commands whose purpose is to carry out the plan rather than refine it
When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it.
## PHASE 1 — Ground in the environment (explore first, ask second)
Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged.
Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration.
## PHASE 2 — Intent chat (what they actually want)
* Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs.
* Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet—ask.
## PHASE 3 — Implementation chat (what/how we’ll build)
* Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints.
## Hard interaction rule (critical)
Every assistant turn MUST be exactly one of:
A) a `request_user_input` tool call (questions/options only), OR
B) the final output: a titled, plan‑only document.
B) a non-final status update with no questions and no plan content, OR
C) the final output: a titled, plan-only document.
Rules:
- No questions in free text (only via `request_user_input`).
- Never mix a `request_user_input` call with plan content.
- Internal tool/repo exploration is allowed privately before A or B.
* No questions in free text (only via `request_user_input`).
* Never mix a `request_user_input` call with plan content.
* Status updates must not include questions or plan content.
* Internal tool/repo exploration is allowed privately before A, B, or C.
Status updates should be frequent during exploration. Provide 1-2 sentence updates that summarize discoveries, assumption changes, or why you are changing direction. Use Parallel tools for exploration.
## Ask a lot, but never ask trivia
You SHOULD ask many questions, but each question must:
- materially change the spec/plan, OR
- confirm/lock an assumption, OR
- choose between meaningful tradeoffs.
Batch questions (e.g., 4–10) per `request_user_input` call to keep momentum.
* materially change the spec/plan, OR
* confirm/lock an assumption, OR
* choose between meaningful tradeoffs.
* not be answerable by non-mutating commands.
Use the `request_user_input` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration.
## Two kinds of unknowns (treat differently)
1) Discoverable facts (repo/system truth): explore first.
- Before asking, run ≥2 targeted searches (exact + variant) and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants).
- Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent.
- If asking, present concrete candidates (paths/service names) + recommend one.
2) Preferences/tradeoffs (not discoverable): ask early.
- Provide 2–4 mutually exclusive options + a recommended default.
- If unanswered, proceed with the recommended option and record it as an assumption in the final plan.
1. **Discoverable facts** (repo/system truth): explore first.
* Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants).
* Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent.
* If asking, present concrete candidates (paths/service names) + recommend one.
* Never ask questions you can answer from your environment (e.g., “where is this struct”).
2. **Preferences/tradeoffs** (not discoverable): ask early.
* These are intent or implementation preferences that cannot be derived from exploration.
* Provide 2–4 mutually exclusive options + a recommended default.
* If unanswered, proceed with the recommended option and record it as an assumption in the final plan.
## Finalization rule
Only output the final plan when remaining unknowns are low‑impact and explicitly listed as assumptions.
Final output must be plan‑only with a good title (no “should I proceed?”).
Only output the final plan when it is decision complete and leaves no decisions to the implementer.
The final plan must be plan-only and include:
* A clear title
* Exact file paths to change
* Exact structures or shapes to introduce or modify
* Exact function, method, type, and variable names and signatures
* Test cases
* Explicit assumptions and defaults chosen where needed
Do not ask "should I proceed?" in the final output.
Only produce the final answer when you are presenting the complete spec.
+2 -3
View File
@@ -494,14 +494,13 @@ pub fn ev_reasoning_text_delta(delta: &str) -> Value {
})
}
pub fn ev_web_search_call_added(id: &str, status: &str, query: &str) -> Value {
pub fn ev_web_search_call_added_partial(id: &str, status: &str) -> Value {
serde_json::json!({
"type": "response.output_item.added",
"item": {
"type": "web_search_call",
"id": id,
"status": status,
"action": {"type": "search", "query": query}
"status": status
}
})
}
+19 -3
View File
@@ -57,6 +57,7 @@ pub struct TestCodexBuilder {
config_mutators: Vec<Box<ConfigMutator>>,
auth: CodexAuth,
pre_build_hooks: Vec<Box<PreBuildHook>>,
home: Option<Arc<TempDir>>,
}
impl TestCodexBuilder {
@@ -88,8 +89,16 @@ impl TestCodexBuilder {
self
}
pub fn with_home(mut self, home: Arc<TempDir>) -> Self {
self.home = Some(home);
self
}
pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result<TestCodex> {
let home = Arc::new(TempDir::new()?);
let home = match self.home.clone() {
Some(home) => home,
None => Arc::new(TempDir::new()?),
};
self.build_with_home(server, home, None).await
}
@@ -98,7 +107,10 @@ impl TestCodexBuilder {
server: &StreamingSseServer,
) -> anyhow::Result<TestCodex> {
let base_url = server.uri();
let home = Arc::new(TempDir::new()?);
let home = match self.home.clone() {
Some(home) => home,
None => Arc::new(TempDir::new()?),
};
self.build_with_home_and_base_url(format!("{base_url}/v1"), home, None)
.await
}
@@ -108,7 +120,10 @@ impl TestCodexBuilder {
server: &WebSocketTestServer,
) -> anyhow::Result<TestCodex> {
let base_url = format!("{}/v1", server.uri());
let home = Arc::new(TempDir::new()?);
let home = match self.home.clone() {
Some(home) => home,
None => Arc::new(TempDir::new()?),
};
let base_url_clone = base_url.clone();
self.config_mutators.push(Box::new(move |config| {
config.model_provider.base_url = Some(base_url_clone);
@@ -432,5 +447,6 @@ pub fn test_codex() -> TestCodexBuilder {
config_mutators: vec![],
auth: CodexAuth::from_api_key("dummy"),
pre_build_hooks: vec![],
home: None,
}
}
+1 -1
View File
@@ -264,7 +264,7 @@ async fn responses_stream_includes_web_search_eligible_header_false_when_disable
let test = test_codex()
.with_config(|config| {
config.web_search_mode = Some(WebSearchMode::Disabled);
config.web_search_mode = WebSearchMode::Disabled;
})
.build(&server)
.await
+94 -197
View File
@@ -257,31 +257,19 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
// Configure Codex to resume from our file
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = model_provider;
// Also configure user instructions to ensure they are NOT delivered on resume.
config.user_instructions = Some("be nice".to_string());
let thread_manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let auth_manager =
codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let NewThread {
thread: codex,
session_configured,
..
} = thread_manager
.resume_thread_from_rollout(config, session_path.clone(), auth_manager)
let codex_home = Arc::new(TempDir::new().unwrap());
let mut builder = test_codex()
.with_home(codex_home.clone())
.with_config(|config| {
// Ensure user instructions are NOT delivered on resume.
config.user_instructions = Some("be nice".to_string());
});
let test = builder
.resume(&server, codex_home, session_path.clone())
.await
.expect("resume conversation");
let codex = test.codex.clone();
let session_configured = test.session_configured;
// 1) Assert initial_messages only includes existing EventMsg entries; response items are not converted
let initial_msgs = session_configured
@@ -367,30 +355,13 @@ async fn includes_conversation_id_and_model_headers_in_request() {
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
// Init session
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = model_provider;
let thread_manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let NewThread {
thread: codex,
thread_id: session_id,
session_configured: _,
..
} = thread_manager
.start_thread(config)
let mut builder = test_codex().with_auth(CodexAuth::from_api_key("Test API Key"));
let test = builder
.build(&server)
.await
.expect("create new conversation");
let codex = test.codex.clone();
let session_id = test.session_configured.session_id;
codex
.submit(Op::UserInput {
@@ -425,26 +396,16 @@ async fn includes_base_instructions_override_in_request() {
let server = MockServer::start().await;
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.base_instructions = Some("test instructions".to_string());
config.model_provider = model_provider;
let thread_manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let codex = thread_manager
.start_thread(config)
let mut builder = test_codex()
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(|config| {
config.base_instructions = Some("test instructions".to_string());
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.thread;
.codex;
codex
.submit(Op::UserInput {
@@ -479,29 +440,19 @@ async fn chatgpt_auth_sends_correct_request() {
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/api/codex", server.uri())),
..built_in_model_providers()["openai"].clone()
};
// Init session
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = model_provider;
let thread_manager = ThreadManager::with_models_provider_and_home(
create_dummy_codex_auth(),
config.model_provider.clone(),
config.codex_home.clone(),
);
let NewThread {
thread: codex,
thread_id,
session_configured: _,
..
} = thread_manager
.start_thread(config)
let mut model_provider = built_in_model_providers()["openai"].clone();
model_provider.base_url = Some(format!("{}/api/codex", server.uri()));
let mut builder = test_codex()
.with_auth(create_dummy_codex_auth())
.with_config(move |config| {
config.model_provider = model_provider;
});
let test = builder
.build(&server)
.await
.expect("create new conversation");
let codex = test.codex.clone();
let thread_id = test.session_configured.session_id;
codex
.submit(Op::UserInput {
@@ -617,26 +568,16 @@ async fn includes_user_instructions_message_in_request() {
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = model_provider;
config.user_instructions = Some("be nice".to_string());
let thread_manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let codex = thread_manager
.start_thread(config)
let mut builder = test_codex()
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(|config| {
config.user_instructions = Some("be nice".to_string());
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.thread;
.codex;
codex
.submit(Op::UserInput {
@@ -689,12 +630,7 @@ async fn skills_append_to_instructions() {
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let codex_home = TempDir::new().unwrap();
let codex_home = Arc::new(TempDir::new().unwrap());
let skill_dir = codex_home.path().join("skills/demo");
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
@@ -703,20 +639,18 @@ async fn skills_append_to_instructions() {
)
.expect("write skill");
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = model_provider;
config.cwd = codex_home.path().to_path_buf();
let thread_manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let codex = thread_manager
.start_thread(config)
let codex_home_path = codex_home.path().to_path_buf();
let mut builder = test_codex()
.with_home(codex_home.clone())
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config.cwd = codex_home_path;
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.thread;
.codex;
codex
.submit(Op::UserInput {
@@ -1131,28 +1065,17 @@ async fn includes_developer_instructions_message_in_request() {
let server = MockServer::start().await;
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = model_provider;
config.user_instructions = Some("be nice".to_string());
config.developer_instructions = Some("be useful".to_string());
let thread_manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let codex = thread_manager
.start_thread(config)
let mut builder = test_codex()
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(|config| {
config.user_instructions = Some("be nice".to_string());
config.developer_instructions = Some("be useful".to_string());
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.thread;
.codex;
codex
.submit(Op::UserInput {
@@ -1288,9 +1211,9 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
prompt.input.push(ResponseItem::WebSearchCall {
id: Some("web-search-id".into()),
status: Some("completed".into()),
action: WebSearchAction::Search {
action: Some(WebSearchAction::Search {
query: Some("weather".into()),
},
}),
});
prompt.input.push(ResponseItem::FunctionCall {
id: Some("function-id".into()),
@@ -1390,20 +1313,16 @@ async fn token_count_includes_rate_limits_snapshot() {
let mut provider = built_in_model_providers()["openai"].clone();
provider.base_url = Some(format!("{}/v1", server.uri()));
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = provider;
let thread_manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("test"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let codex = thread_manager
.start_thread(config)
let mut builder = test_codex()
.with_auth(CodexAuth::from_api_key("test"))
.with_config(move |config| {
config.model_provider = provider;
});
let codex = builder
.build(&server)
.await
.expect("create conversation")
.thread;
.codex;
codex
.submit(Op::UserInput {
@@ -1753,20 +1672,16 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
};
// Init session
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = provider;
let thread_manager = ThreadManager::with_models_provider_and_home(
create_dummy_codex_auth(),
config.model_provider.clone(),
config.codex_home.clone(),
);
let codex = thread_manager
.start_thread(config)
let mut builder = test_codex()
.with_auth(create_dummy_codex_auth())
.with_config(move |config| {
config.model_provider = provider;
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.thread;
.codex;
codex
.submit(Op::UserInput {
@@ -1837,20 +1752,16 @@ async fn env_var_overrides_loaded_auth() {
};
// Init session
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = provider;
let thread_manager = ThreadManager::with_models_provider_and_home(
create_dummy_codex_auth(),
config.model_provider.clone(),
config.codex_home.clone(),
);
let codex = thread_manager
.start_thread(config)
let mut builder = test_codex()
.with_auth(create_dummy_codex_auth())
.with_config(move |config| {
config.model_provider = provider;
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.thread;
.codex;
codex
.submit(Op::UserInput {
@@ -1905,26 +1816,12 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
let request_log = mount_sse_sequence(&server, vec![sse1.clone(), sse1.clone(), sse1]).await;
// Configure provider to point to mock server (Responses API) and use API key auth.
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
// Init session with isolated codex home.
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = model_provider;
let thread_manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let NewThread { thread: codex, .. } = thread_manager
.start_thread(config)
let mut builder = test_codex().with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = builder
.build(&server)
.await
.expect("create new conversation");
.expect("create new conversation")
.codex;
// Turn 1: user sends U1; wait for completion.
codex
+59 -117
View File
@@ -1,8 +1,6 @@
#![allow(clippy::expect_used)]
use codex_core::CodexAuth;
use codex_core::ModelProviderInfo;
use codex_core::NewThread;
use codex_core::ThreadManager;
use codex_core::built_in_model_providers;
use codex_core::compact::SUMMARIZATION_PROMPT;
use codex_core::compact::SUMMARY_PREFIX;
@@ -17,7 +15,6 @@ use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::WarningEvent;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::responses::ev_local_shell_call;
use core_test_support::responses::ev_reasoning_item;
use core_test_support::skip_if_no_network;
@@ -25,7 +22,6 @@ use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use std::collections::VecDeque;
use tempfile::TempDir;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -140,21 +136,14 @@ async fn summarize_context_three_requests_and_instructions() {
// Build config pointing to the mock server and spawn Codex.
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
set_test_compact_prompt(&mut config);
config.model_auto_compact_token_limit = Some(200_000);
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let NewThread {
thread: codex,
session_configured,
..
} = thread_manager.start_thread(config).await.unwrap();
let rollout_path = session_configured.rollout_path.expect("rollout path");
let mut builder = test_codex().with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_auto_compact_token_limit = Some(200_000);
});
let test = builder.build(&server).await.unwrap();
let codex = test.codex.clone();
let rollout_path = test.session_configured.rollout_path.expect("rollout path");
// 1) Normal user input – should hit server once.
codex
@@ -338,20 +327,15 @@ async fn manual_compact_uses_custom_prompt() {
let custom_prompt = "Use this compact prompt instead";
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
config.compact_prompt = Some(custom_prompt.to_string());
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let codex = thread_manager
.start_thread(config)
let mut builder = test_codex().with_config(move |config| {
config.model_provider = model_provider;
config.compact_prompt = Some(custom_prompt.to_string());
});
let codex = builder
.build(&server)
.await
.expect("create conversation")
.thread;
.codex;
codex.submit(Op::Compact).await.expect("trigger compact");
let warning_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await;
@@ -414,16 +398,11 @@ async fn manual_compact_emits_api_and_local_token_usage_events() {
mount_sse_once(&server, sse_compact).await;
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
set_test_compact_prompt(&mut config);
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let NewThread { thread: codex, .. } = thread_manager.start_thread(config).await.unwrap();
let mut builder = test_codex().with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
});
let codex = builder.build(&server).await.unwrap().codex;
// Trigger manual compact and collect TokenCount events for the compact turn.
codex.submit(Op::Compact).await.unwrap();
@@ -1039,16 +1018,12 @@ async fn auto_compact_runs_after_token_limit_hit() {
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
set_test_compact_prompt(&mut config);
config.model_auto_compact_token_limit = Some(200_000);
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let codex = thread_manager.start_thread(config).await.unwrap().thread;
let mut builder = test_codex().with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_auto_compact_token_limit = Some(200_000);
});
let codex = builder.build(&server).await.unwrap().codex;
codex
.submit(Op::UserInput {
@@ -1379,20 +1354,14 @@ async fn auto_compact_persists_rollout_entries() {
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
set_test_compact_prompt(&mut config);
config.model_auto_compact_token_limit = Some(200_000);
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let NewThread {
thread: codex,
session_configured,
..
} = thread_manager.start_thread(config).await.unwrap();
let mut builder = test_codex().with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_auto_compact_token_limit = Some(200_000);
});
let test = builder.build(&server).await.unwrap();
let codex = test.codex.clone();
let session_configured = test.session_configured;
codex
.submit(Op::UserInput {
@@ -1497,19 +1466,12 @@ async fn manual_compact_retries_after_context_window_error() {
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
set_test_compact_prompt(&mut config);
config.model_auto_compact_token_limit = Some(200_000);
let codex = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
)
.start_thread(config)
.await
.unwrap()
.thread;
let mut builder = test_codex().with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_auto_compact_token_limit = Some(200_000);
});
let codex = builder.build(&server).await.unwrap().codex;
codex
.submit(Op::UserInput {
@@ -1632,18 +1594,11 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
set_test_compact_prompt(&mut config);
let codex = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
)
.start_thread(config)
.await
.unwrap()
.thread;
let mut builder = test_codex().with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
});
let codex = builder.build(&server).await.unwrap().codex;
codex
.submit(Op::UserInput {
@@ -1700,12 +1655,11 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
&& item
.get("content")
.and_then(|v| v.as_array())
.map(|arr| {
.is_some_and(|arr| {
arr.iter().any(|entry| {
entry.get("text").and_then(|v| v.as_str()) == Some(expected)
})
})
.unwrap_or(false)
})
};
@@ -1843,16 +1797,12 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
set_test_compact_prompt(&mut config);
config.model_auto_compact_token_limit = Some(200);
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let codex = thread_manager.start_thread(config).await.unwrap().thread;
let mut builder = test_codex().with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_auto_compact_token_limit = Some(200);
});
let codex = builder.build(&server).await.unwrap().codex;
let mut auto_compact_lifecycle_events = Vec::new();
for user in [MULTI_AUTO_MSG, follow_up_user, final_user] {
@@ -1954,21 +1904,13 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
set_test_compact_prompt(&mut config);
config.model_context_window = Some(context_window);
config.model_auto_compact_token_limit = Some(limit);
let codex = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
)
.start_thread(config)
.await
.unwrap()
.thread;
let mut builder = test_codex().with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_context_window = Some(context_window);
config.model_auto_compact_token_limit = Some(limit);
});
let codex = builder.build(&server).await.unwrap().codex;
codex
.submit(Op::UserInput {
@@ -10,12 +10,8 @@
use super::compact::COMPACT_WARNING_MESSAGE;
use super::compact::FIRST_REPLY;
use super::compact::SUMMARY_TEXT;
use codex_core::CodexAuth;
use codex_core::CodexThread;
use codex_core::ModelProviderInfo;
use codex_core::NewThread;
use codex_core::ThreadManager;
use codex_core::built_in_model_providers;
use codex_core::compact::SUMMARIZATION_PROMPT;
use codex_core::config::Config;
use codex_core::protocol::EventMsg;
@@ -23,12 +19,12 @@ use codex_core::protocol::Op;
use codex_core::protocol::WarningEvent;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::sse;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -99,8 +95,7 @@ fn extract_summary_message(request: &Value, summary_text: &str) -> Value {
.and_then(|arr| arr.first())
.and_then(|entry| entry.get("text"))
.and_then(Value::as_str)
.map(|text| text.contains(summary_text))
.unwrap_or(false)
.is_some_and(|text| text.contains(summary_text))
})
})
.cloned()
@@ -117,21 +112,18 @@ fn normalize_compact_prompts(requests: &mut [Value]) {
{
return true;
}
let content = item
.get("content")
.and_then(Value::as_array)
.cloned()
let Some(content) = item.get("content").and_then(Value::as_array) else {
return false;
};
let Some(first) = content.first() else {
return false;
};
let text = first
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
if let Some(first) = content.first() {
let text = first
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
let normalized_text = normalize_line_endings_str(text);
!(text.is_empty() || normalized_text == normalized_summary_prompt)
} else {
false
}
let normalized_text = normalize_line_endings_str(text);
!(text.is_empty() || normalized_text == normalized_summary_prompt)
});
}
}
@@ -874,9 +866,7 @@ fn gather_request_bodies(request_log: &[ResponseMock]) -> Vec<Value> {
.flat_map(ResponseMock::requests)
.map(|request| request.body_json())
.collect::<Vec<_>>();
for body in &mut bodies {
normalize_line_endings(body);
}
bodies.iter_mut().for_each(normalize_line_endings);
bodies
}
@@ -960,29 +950,19 @@ async fn mount_second_compact_flow(server: &MockServer) -> Vec<ResponseMock> {
async fn start_test_conversation(
server: &MockServer,
model: Option<&str>,
) -> (TempDir, Config, ThreadManager, Arc<CodexThread>) {
let model_provider = ModelProviderInfo {
name: "Non-OpenAI Model provider".into(),
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let home = TempDir::new().expect("create temp dir");
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider;
config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string());
if let Some(model) = model {
config.model = Some(model.to_string());
}
let manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let NewThread { thread, .. } = manager
.start_thread(config.clone())
.await
.expect("create conversation");
(home, config, manager, thread)
) -> (Arc<TempDir>, Config, Arc<ThreadManager>, Arc<CodexThread>) {
let base_url = format!("{}/v1", server.uri());
let model = model.map(str::to_string);
let mut builder = test_codex().with_config(move |config| {
config.model_provider.name = "Non-OpenAI Model provider".to_string();
config.model_provider.base_url = Some(base_url);
config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string());
if let Some(model) = model {
config.model = Some(model);
}
});
let test = builder.build(server).await.expect("create conversation");
(test.home, test.config, test.thread_manager, test.codex)
}
async fn user_turn(conversation: &Arc<CodexThread>, text: &str) {
@@ -1021,13 +1001,14 @@ async fn resume_conversation(
config: &Config,
path: std::path::PathBuf,
) -> Arc<CodexThread> {
let auth_manager =
codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
let NewThread { thread, .. } = manager
let auth_manager = codex_core::AuthManager::from_auth_for_testing(
codex_core::CodexAuth::from_api_key("dummy"),
);
manager
.resume_thread_from_rollout(config.clone(), path, auth_manager)
.await
.expect("resume conversation");
thread
.expect("resume conversation")
.thread
}
#[cfg(test)]
@@ -1037,9 +1018,9 @@ async fn fork_thread(
path: std::path::PathBuf,
nth_user_message: usize,
) -> Arc<CodexThread> {
let NewThread { thread, .. } = manager
manager
.fork_thread(nth_user_message, config.clone(), path)
.await
.expect("fork conversation");
thread
.expect("fork conversation")
.thread
}
+6 -25
View File
@@ -1,8 +1,4 @@
use codex_core::CodexAuth;
use codex_core::ModelProviderInfo;
use codex_core::NewThread;
use codex_core::ThreadManager;
use codex_core::built_in_model_providers;
use codex_core::parse_turn_item;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
@@ -10,10 +6,9 @@ use codex_core::protocol::RolloutItem;
use codex_core::protocol::RolloutLine;
use codex_protocol::items::TurnItem;
use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -44,25 +39,11 @@ async fn fork_thread_twice_drops_to_first_message() {
.mount(&server)
.await;
// Configure Codex to use the mock server.
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home).await;
config.model_provider = model_provider.clone();
let config_for_fork = config.clone();
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let NewThread { thread: codex, .. } = thread_manager
.start_thread(config)
.await
.expect("create conversation");
let mut builder = test_codex();
let test = builder.build(&server).await.expect("create conversation");
let codex = test.codex.clone();
let thread_manager = test.thread_manager.clone();
let config_for_fork = test.config.clone();
// Send three user messages; wait for three completed turns.
for text in ["first", "second", "third"] {
+13 -10
View File
@@ -6,6 +6,7 @@ use codex_core::protocol::ItemCompletedEvent;
use codex_core::protocol::ItemStartedEvent;
use codex_core::protocol::Op;
use codex_protocol::items::TurnItem;
use codex_protocol::models::WebSearchAction;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
@@ -18,7 +19,7 @@ use core_test_support::responses::ev_reasoning_item_added;
use core_test_support::responses::ev_reasoning_summary_text_delta;
use core_test_support::responses::ev_reasoning_text_delta;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_web_search_call_added;
use core_test_support::responses::ev_web_search_call_added_partial;
use core_test_support::responses::ev_web_search_call_done;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
@@ -208,8 +209,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> {
let TestCodex { codex, .. } = test_codex().build(&server).await?;
let web_search_added =
ev_web_search_call_added("web-search-1", "in_progress", "weather seattle");
let web_search_added = ev_web_search_call_added_partial("web-search-1", "in_progress");
let web_search_done = ev_web_search_call_done("web-search-1", "completed", "weather seattle");
let first_response = sse(vec![
@@ -230,11 +230,8 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> {
})
.await?;
let started = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::WebSearch(item),
..
}) => Some(item.clone()),
let begin = wait_for_event_match(&codex, |ev| match ev {
EventMsg::WebSearchBegin(event) => Some(event.clone()),
_ => None,
})
.await;
@@ -247,8 +244,14 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> {
})
.await;
assert_eq!(started.id, completed.id);
assert_eq!(completed.query, "weather seattle");
assert_eq!(begin.call_id, "web-search-1");
assert_eq!(completed.id, begin.call_id);
assert_eq!(
completed.action,
WebSearchAction::Search {
query: Some("weather seattle".to_string()),
}
);
Ok(())
}
+1
View File
@@ -74,6 +74,7 @@ mod tools;
mod truncation;
mod undo;
mod unified_exec;
mod unstable_features_warning;
mod user_notification;
mod user_shell_cmd;
mod view_image;
+19 -36
View File
@@ -1,36 +1,28 @@
use codex_core::CodexAuth;
use codex_core::ThreadManager;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_protocol::openai_models::ReasoningEffort;
use core_test_support::load_default_config_for_test;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
const CONFIG_TOML: &str = "config.toml";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn override_turn_context_does_not_persist_when_config_exists() {
let codex_home = TempDir::new().unwrap();
let config_path = codex_home.path().join(CONFIG_TOML);
let server = start_mock_server().await;
let initial_contents = "model = \"gpt-4o\"\n";
tokio::fs::write(&config_path, initial_contents)
.await
.expect("seed config.toml");
let mut config = load_default_config_for_test(&codex_home).await;
config.model = Some("gpt-4o".to_string());
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
);
let codex = thread_manager
.start_thread(config)
.await
.expect("create conversation")
.thread;
let mut builder = test_codex()
.with_pre_build_hook(move |home| {
let config_path = home.join(CONFIG_TOML);
std::fs::write(config_path, initial_contents).expect("seed config.toml");
})
.with_config(|config| {
config.model = Some("gpt-4o".to_string());
});
let test = builder.build(&server).await.expect("create conversation");
let codex = test.codex.clone();
let config_path = test.home.path().join(CONFIG_TOML);
codex
.submit(Op::OverrideTurnContext {
@@ -57,25 +49,16 @@ async fn override_turn_context_does_not_persist_when_config_exists() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn override_turn_context_does_not_create_config_file() {
let codex_home = TempDir::new().unwrap();
let config_path = codex_home.path().join(CONFIG_TOML);
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build(&server).await.expect("create conversation");
let codex = test.codex.clone();
let config_path = test.home.path().join(CONFIG_TOML);
assert!(
!config_path.exists(),
"test setup should start without config"
);
let config = load_default_config_for_test(&codex_home).await;
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
);
let codex = thread_manager
.start_thread(config)
.await
.expect("create conversation")
.thread;
codex
.submit(Op::OverrideTurnContext {
cwd: None,
+1 -1
View File
@@ -38,7 +38,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec<String> {
.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.web_search_mode = WebSearchMode::Cached;
config.features.enable(Feature::CollaborationModes);
});
let test = builder
+1 -1
View File
@@ -92,7 +92,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
config.user_instructions = Some("be consistent and helpful".to_string());
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.web_search_mode = WebSearchMode::Cached;
config.features.enable(Feature::CollaborationModes);
})
.build(&server)
@@ -94,6 +94,7 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()>
"id": "confirm_path",
"header": "Confirm",
"question": "Proceed with the plan?",
"isOther": false,
"options": [{
"label": "Yes (Recommended)",
"description": "Continue the current plan."
@@ -213,6 +214,7 @@ where
"id": "confirm_path",
"header": "Confirm",
"question": "Proceed with the plan?",
"isOther": false,
"options": [{
"label": "Yes (Recommended)",
"description": "Continue the current plan."
@@ -284,6 +286,19 @@ async fn request_user_input_rejected_in_execute_mode() -> anyhow::Result<()> {
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_rejected_in_code_mode() -> anyhow::Result<()> {
assert_request_user_input_rejected("Code", |model| CollaborationMode {
mode: ModeKind::Code,
settings: Settings {
model,
reasoning_effort: None,
developer_instructions: None,
},
})
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_rejected_in_custom_mode() -> anyhow::Result<()> {
assert_request_user_input_rejected("Custom", |model| CollaborationMode {
+55 -63
View File
@@ -1,11 +1,7 @@
use codex_core::CodexAuth;
use codex_core::CodexThread;
use codex_core::ContentItem;
use codex_core::ModelProviderInfo;
use codex_core::REVIEW_PROMPT;
use codex_core::ResponseItem;
use codex_core::ThreadManager;
use codex_core::built_in_model_providers;
use codex_core::config::Config;
use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
use codex_core::protocol::EventMsg;
@@ -21,11 +17,11 @@ use codex_core::protocol::RolloutItem;
use codex_core::protocol::RolloutLine;
use codex_core::review_format::render_review_output_text;
use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id_from_str;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::mount_sse_sequence;
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::path::PathBuf;
@@ -73,8 +69,8 @@ async fn review_op_emits_lifecycle_and_review_output() {
let review_json_escaped = serde_json::to_string(&review_json).unwrap();
let sse_raw = sse_template.replace("__REVIEW__", &review_json_escaped);
let (server, _request_log) = start_responses_server_with_sse(&sse_raw, 1).await;
let codex_home = TempDir::new().unwrap();
let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await;
let codex_home = Arc::new(TempDir::new().unwrap());
let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await;
// Submit review request.
codex
@@ -174,6 +170,7 @@ async fn review_op_emits_lifecycle_and_review_output() {
"assistant review output contains user_action markup"
);
let _codex_home_guard = codex_home;
server.verify().await;
}
@@ -194,8 +191,8 @@ async fn review_op_with_plain_text_emits_review_fallback() {
{"type":"response.completed", "response": {"id": "__ID__"}}
]"#;
let (server, _request_log) = start_responses_server_with_sse(sse_raw, 1).await;
let codex_home = TempDir::new().unwrap();
let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await;
let codex_home = Arc::new(TempDir::new().unwrap());
let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await;
codex
.submit(Op::Review {
@@ -226,6 +223,7 @@ async fn review_op_with_plain_text_emits_review_fallback() {
assert_eq!(expected, review);
let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let _codex_home_guard = codex_home;
server.verify().await;
}
@@ -254,8 +252,8 @@ async fn review_filters_agent_message_related_events() {
{"type":"response.completed", "response": {"id": "__ID__"}}
]"#;
let (server, _request_log) = start_responses_server_with_sse(sse_raw, 1).await;
let codex_home = TempDir::new().unwrap();
let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await;
let codex_home = Arc::new(TempDir::new().unwrap());
let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await;
codex
.submit(Op::Review {
@@ -295,6 +293,7 @@ async fn review_filters_agent_message_related_events() {
.await;
assert!(saw_entered && saw_exited, "missing review lifecycle events");
let _codex_home_guard = codex_home;
server.verify().await;
}
@@ -335,8 +334,8 @@ async fn review_does_not_emit_agent_message_on_structured_output() {
let review_json_escaped = serde_json::to_string(&review_json).unwrap();
let sse_raw = sse_template.replace("__REVIEW__", &review_json_escaped);
let (server, _request_log) = start_responses_server_with_sse(&sse_raw, 1).await;
let codex_home = TempDir::new().unwrap();
let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await;
let codex_home = Arc::new(TempDir::new().unwrap());
let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await;
codex
.submit(Op::Review {
@@ -375,6 +374,7 @@ async fn review_does_not_emit_agent_message_on_structured_output() {
assert_eq!(1, agent_messages, "expected exactly one AgentMessage event");
assert!(saw_entered && saw_exited, "missing review lifecycle events");
let _codex_home_guard = codex_home;
server.verify().await;
}
@@ -389,9 +389,9 @@ async fn review_uses_custom_review_model_from_config() {
{"type":"response.completed", "response": {"id": "__ID__"}}
]"#;
let (server, request_log) = start_responses_server_with_sse(sse_raw, 1).await;
let codex_home = TempDir::new().unwrap();
let codex_home = Arc::new(TempDir::new().unwrap());
// Choose a review model different from the main model; ensure it is used.
let codex = new_conversation_for_server(&server, &codex_home, |cfg| {
let codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| {
cfg.model = Some("gpt-4.1".to_string());
cfg.review_model = Some("gpt-5.1".to_string());
})
@@ -428,6 +428,7 @@ async fn review_uses_custom_review_model_from_config() {
let body = request.body_json();
assert_eq!(body["model"].as_str().unwrap(), "gpt-5.1");
let _codex_home_guard = codex_home;
server.verify().await;
}
@@ -442,8 +443,8 @@ async fn review_uses_session_model_when_review_model_unset() {
{"type":"response.completed", "response": {"id": "__ID__"}}
]"#;
let (server, request_log) = start_responses_server_with_sse(sse_raw, 1).await;
let codex_home = TempDir::new().unwrap();
let codex = new_conversation_for_server(&server, &codex_home, |cfg| {
let codex_home = Arc::new(TempDir::new().unwrap());
let codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| {
cfg.model = Some("gpt-4.1".to_string());
cfg.review_model = None;
})
@@ -478,6 +479,7 @@ async fn review_uses_session_model_when_review_model_unset() {
let body = request.body_json();
assert_eq!(body["model"].as_str().unwrap(), "gpt-4.1");
let _codex_home_guard = codex_home;
server.verify().await;
}
@@ -497,12 +499,7 @@ async fn review_input_isolated_from_parent_history() {
let (server, request_log) = start_responses_server_with_sse(sse_raw, 1).await;
// Seed a parent session history via resume file with both user + assistant items.
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let codex_home = Arc::new(TempDir::new().unwrap());
let session_file = codex_home.path().join("resume.jsonl");
{
@@ -564,7 +561,8 @@ async fn review_input_isolated_from_parent_history() {
.unwrap();
}
let codex =
resume_conversation_for_server(&server, &codex_home, session_file.clone(), |_| {}).await;
resume_conversation_for_server(&server, codex_home.clone(), session_file.clone(), |_| {})
.await;
// Submit review request; it must start fresh (no parent history in `input`).
let review_prompt = "Please review only this".to_string();
@@ -657,6 +655,7 @@ async fn review_input_isolated_from_parent_history() {
"expected user interruption message in rollout"
);
let _codex_home_guard = codex_home;
server.verify().await;
}
@@ -675,8 +674,8 @@ async fn review_history_surfaces_in_parent_session() {
{"type":"response.completed", "response": {"id": "__ID__"}}
]"#;
let (server, request_log) = start_responses_server_with_sse(sse_raw, 2).await;
let codex_home = TempDir::new().unwrap();
let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await;
let codex_home = Arc::new(TempDir::new().unwrap());
let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await;
// 1) Run a review turn that produces an assistant message (isolated in child).
codex
@@ -755,6 +754,7 @@ async fn review_history_surfaces_in_parent_session() {
"review assistant output missing from parent turn input"
);
let _codex_home_guard = codex_home;
server.verify().await;
}
@@ -807,9 +807,10 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() {
.trim()
.to_string();
let codex_home = TempDir::new().unwrap();
let codex = new_conversation_for_server(&server, &codex_home, |config| {
config.cwd = initial_cwd.path().to_path_buf();
let codex_home = Arc::new(TempDir::new().unwrap());
let initial_cwd_path = initial_cwd.path().to_path_buf();
let codex = new_conversation_for_server(&server, codex_home.clone(), move |config| {
config.cwd = initial_cwd_path;
})
.await;
@@ -859,6 +860,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() {
"expected review prompt to include merge-base sha {head_sha}"
);
let _codex_home_guard = codex_home;
server.verify().await;
}
@@ -878,57 +880,47 @@ async fn start_responses_server_with_sse(
#[expect(clippy::expect_used)]
async fn new_conversation_for_server<F>(
server: &MockServer,
codex_home: &TempDir,
codex_home: Arc<TempDir>,
mutator: F,
) -> Arc<CodexThread>
where
F: FnOnce(&mut Config),
F: FnOnce(&mut Config) + Send + 'static,
{
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let mut config = load_default_config_for_test(codex_home).await;
config.model_provider = model_provider;
mutator(&mut config);
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
);
thread_manager
.start_thread(config)
let base_url = format!("{}/v1", server.uri());
let mut builder = test_codex()
.with_home(codex_home)
.with_config(move |config| {
config.model_provider.base_url = Some(base_url.clone());
mutator(config);
});
builder
.build(server)
.await
.expect("create conversation")
.thread
.codex
}
/// Create a conversation resuming from a rollout file, configured to talk to the provided mock server.
#[expect(clippy::expect_used)]
async fn resume_conversation_for_server<F>(
server: &MockServer,
codex_home: &TempDir,
codex_home: Arc<TempDir>,
resume_path: std::path::PathBuf,
mutator: F,
) -> Arc<CodexThread>
where
F: FnOnce(&mut Config),
F: FnOnce(&mut Config) + Send + 'static,
{
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let mut config = load_default_config_for_test(codex_home).await;
config.model_provider = model_provider;
mutator(&mut config);
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
);
let auth_manager =
codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
thread_manager
.resume_thread_from_rollout(config, resume_path, auth_manager)
let base_url = format!("{}/v1", server.uri());
let mut builder = test_codex()
.with_home(codex_home.clone())
.with_config(move |config| {
config.model_provider.base_url = Some(base_url.clone());
mutator(config);
});
builder
.resume(server, codex_home, resume_path)
.await
.expect("resume conversation")
.thread
.codex
}
+6
View File
@@ -93,6 +93,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -233,6 +234,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -431,6 +433,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -577,6 +580,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -734,6 +738,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -923,6 +928,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
+23 -3
View File
@@ -3,14 +3,15 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_path_by_id_str;
use tempfile::TempDir;
use uuid::Uuid;
/// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the
/// Create <subdir>/YYYY/MM/DD and write a minimal rollout file containing the
/// provided conversation id in the SessionMeta line. Returns the absolute path.
fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
let sessions = codex_home.join("sessions/2024/01/01");
fn write_minimal_rollout_with_id_in_subdir(codex_home: &Path, subdir: &str, id: Uuid) -> PathBuf {
let sessions = codex_home.join(subdir).join("2024/01/01");
std::fs::create_dir_all(&sessions).unwrap();
let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl"));
@@ -37,6 +38,12 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
file
}
/// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the
/// provided conversation id in the SessionMeta line. Returns the absolute path.
fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
write_minimal_rollout_with_id_in_subdir(codex_home, "sessions", id)
}
#[tokio::test]
async fn find_locates_rollout_file_by_id() {
let home = TempDir::new().unwrap();
@@ -79,3 +86,16 @@ async fn find_ignores_granular_gitignore_rules() {
assert_eq!(found, Some(expected));
}
#[tokio::test]
async fn find_archived_locates_rollout_file_by_id() {
let home = TempDir::new().unwrap();
let id = Uuid::new_v4();
let expected = write_minimal_rollout_with_id_in_subdir(home.path(), "archived_sessions", id);
let found = find_archived_thread_path_by_id_str(home.path(), &id.to_string())
.await
.unwrap();
assert_eq!(found, Some(expected));
}
+3
View File
@@ -431,6 +431,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -523,6 +524,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -786,6 +788,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -0,0 +1,90 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::NewThread;
use codex_core::ThreadManager;
use codex_core::config::CONFIG_TOML_FILE;
use codex_core::features::Feature;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InitialHistory;
use codex_core::protocol::WarningEvent;
use codex_utils_absolute_path::AbsolutePathBuf;
use core::time::Duration;
use core_test_support::load_default_config_for_test;
use core_test_support::wait_for_event;
use tempfile::TempDir;
use tokio::time::timeout;
use toml::toml;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn emits_warning_when_unstable_features_enabled_via_config() {
let home = TempDir::new().expect("tempdir");
let mut config = load_default_config_for_test(&home).await;
config.features.enable(Feature::ChildAgentsMd);
let user_config_path =
AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE))
.expect("absolute user config path");
config.config_layer_stack = config.config_layer_stack.with_user_config(
&user_config_path,
toml! { features = { child_agents_md = true } }.into(),
);
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("test"),
config.model_provider.clone(),
);
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let NewThread {
thread: conversation,
..
} = thread_manager
.resume_thread_with_history(config, InitialHistory::New, auth_manager)
.await
.expect("spawn conversation");
let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await;
let EventMsg::Warning(WarningEvent { message }) = warning else {
panic!("expected warning event");
};
assert!(message.contains("child_agents_md"));
assert!(message.contains("Under-development features enabled"));
assert!(message.contains("suppress_unstable_features_warning = true"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn suppresses_warning_when_configured() {
let home = TempDir::new().expect("tempdir");
let mut config = load_default_config_for_test(&home).await;
config.features.enable(Feature::ChildAgentsMd);
config.suppress_unstable_features_warning = true;
let user_config_path =
AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE))
.expect("absolute user config path");
config.config_layer_stack = config.config_layer_stack.with_user_config(
&user_config_path,
toml! { features = { child_agents_md = true } }.into(),
);
let thread_manager = ThreadManager::with_models_provider(
CodexAuth::from_api_key("test"),
config.model_provider.clone(),
);
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let NewThread {
thread: conversation,
..
} = thread_manager
.resume_thread_with_history(config, InitialHistory::New, auth_manager)
.await
.expect("spawn conversation");
let warning = timeout(
Duration::from_millis(150),
wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))),
)
.await;
assert!(warning.is_err());
}
+16 -24
View File
@@ -1,6 +1,4 @@
use anyhow::Context;
use codex_core::NewThread;
use codex_core::ThreadManager;
use codex_core::features::Feature;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandEndEvent;
@@ -10,7 +8,6 @@ use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TurnAbortReason;
use core_test_support::assert_regex_match;
use core_test_support::load_default_config_for_test;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -38,19 +35,17 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
.await
.expect("write temp file");
// Load config and pin cwd to the temp dir so ls/cat operate there.
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
config.cwd = cwd.path().to_path_buf();
let thread_manager = ThreadManager::with_models_provider(
codex_core::CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let NewThread { thread: codex, .. } = thread_manager
.start_thread(config)
// Pin cwd to the temp dir so ls/cat operate there.
let server = start_mock_server().await;
let cwd_path = cwd.path().to_path_buf();
let mut builder = test_codex().with_config(move |config| {
config.cwd = cwd_path;
});
let codex = builder
.build(&server)
.await
.expect("create new conversation");
.expect("create new conversation")
.codex;
// 1) shell command should list the file
let list_cmd = "ls".to_string();
@@ -97,16 +92,13 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
#[tokio::test]
async fn user_shell_cmd_can_be_interrupted() {
// Set up isolated config and conversation.
let codex_home = TempDir::new().unwrap();
let config = load_default_config_for_test(&codex_home).await;
let thread_manager = ThreadManager::with_models_provider(
codex_core::CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
);
let NewThread { thread: codex, .. } = thread_manager
.start_thread(config)
let server = start_mock_server().await;
let mut builder = test_codex();
let codex = builder
.build(&server)
.await
.expect("create new conversation");
.expect("create new conversation")
.codex;
// Start a long-running command and then interrupt it.
let sleep_cmd = "sleep 5".to_string();
@@ -35,7 +35,7 @@ async fn web_search_mode_cached_sets_external_web_access_false_in_request_body()
let mut builder = test_codex()
.with_model("gpt-5-codex")
.with_config(|config| {
config.web_search_mode = Some(WebSearchMode::Cached);
config.web_search_mode = WebSearchMode::Cached;
});
let test = builder
.build(&server)
@@ -67,7 +67,7 @@ async fn web_search_mode_takes_precedence_over_legacy_flags_in_request_body() {
.with_model("gpt-5-codex")
.with_config(|config| {
config.features.enable(Feature::WebSearchRequest);
config.web_search_mode = Some(WebSearchMode::Cached);
config.web_search_mode = WebSearchMode::Cached;
});
let test = builder
.build(&server)
+1
View File
@@ -173,6 +173,7 @@ impl AppServerClient {
limit: None,
sort_key: None,
model_providers: None,
source_kinds: None,
archived: None,
},
};
+4
View File
@@ -79,6 +79,10 @@ Interrupt a running turn: `interruptConversation`.
List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`.
For v2 threads, use `thread/list` with `archived: true` to list archived rollouts and
`thread/unarchive` to restore them to the active sessions directory (it returns the restored
thread summary).
## Models
Fetch the catalog of models available in the current Codex build with `model/list`. The request accepts optional pagination inputs:
+1 -1
View File
@@ -75,7 +75,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc
- `EventMsg`
- `EventMsg::AgentMessage` – Messages from the `Model`
- `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command
- `EventMsg::RequestUserInput` – Request user input for a tool call
- `EventMsg::RequestUserInput` – Request user input for a tool call (questions can include options plus `isOther` to add a free-form choice)
- `EventMsg::TurnComplete` – A turn completed successfully
- `EventMsg::Error` – A turn stopped with an error
- `EventMsg::Warning` – A non-fatal warning that the client should surface to the user
+69 -6
View File
@@ -1,3 +1,5 @@
use clap::Args;
use clap::FromArgMatches;
use clap::Parser;
use clap::ValueEnum;
use codex_common::CliConfigOverrides;
@@ -108,20 +110,22 @@ pub enum Command {
Review(ReviewArgs),
}
#[derive(Parser, Debug)]
pub struct ResumeArgs {
#[derive(Args, Debug)]
struct ResumeArgsRaw {
// Note: This is the direct clap shape. We reinterpret the positional when --last is set
// so "codex resume --last <prompt>" treats the positional as a prompt, not a session id.
/// Conversation/session id (UUID). When provided, resumes this session.
/// If omitted, use --last to pick the most recent recorded session.
#[arg(value_name = "SESSION_ID")]
pub session_id: Option<String>,
session_id: Option<String>,
/// Resume the most recent recorded session (newest) without specifying an id.
#[arg(long = "last", default_value_t = false)]
pub last: bool,
last: bool,
/// Show all sessions (disables cwd filtering).
#[arg(long = "all", default_value_t = false)]
pub all: bool,
all: bool,
/// Optional image(s) to attach to the prompt sent after resuming.
#[arg(
@@ -131,13 +135,72 @@ pub struct ResumeArgs {
value_delimiter = ',',
num_args = 1
)]
pub images: Vec<PathBuf>,
images: Vec<PathBuf>,
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
prompt: Option<String>,
}
#[derive(Debug)]
pub struct ResumeArgs {
/// Conversation/session id (UUID). When provided, resumes this session.
/// If omitted, use --last to pick the most recent recorded session.
pub session_id: Option<String>,
/// Resume the most recent recorded session (newest) without specifying an id.
pub last: bool,
/// Show all sessions (disables cwd filtering).
pub all: bool,
/// Optional image(s) to attach to the prompt sent after resuming.
pub images: Vec<PathBuf>,
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
pub prompt: Option<String>,
}
impl From<ResumeArgsRaw> for ResumeArgs {
fn from(raw: ResumeArgsRaw) -> Self {
// When --last is used without an explicit prompt, treat the positional as the prompt
// (clap can’t express this conditional positional meaning cleanly).
let (session_id, prompt) = if raw.last && raw.prompt.is_none() {
(None, raw.session_id)
} else {
(raw.session_id, raw.prompt)
};
Self {
session_id,
last: raw.last,
all: raw.all,
images: raw.images,
prompt,
}
}
}
impl Args for ResumeArgs {
fn augment_args(cmd: clap::Command) -> clap::Command {
ResumeArgsRaw::augment_args(cmd)
}
fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
ResumeArgsRaw::augment_args_for_update(cmd)
}
}
impl FromArgMatches for ResumeArgs {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
ResumeArgsRaw::from_arg_matches(matches).map(Self::from)
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
*self = ResumeArgsRaw::from_arg_matches(matches).map(Self::from)?;
Ok(())
}
}
#[derive(Parser, Debug)]
pub struct ReviewArgs {
/// Review staged, unstaged, and untracked changes.
@@ -3,7 +3,16 @@ use codex_common::elapsed::format_elapsed;
use codex_core::config::Config;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningRawContentEvent;
use codex_core::protocol::AgentStatus;
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::CollabAgentInteractionBeginEvent;
use codex_core::protocol::CollabAgentInteractionEndEvent;
use codex_core::protocol::CollabAgentSpawnBeginEvent;
use codex_core::protocol::CollabAgentSpawnEndEvent;
use codex_core::protocol::CollabCloseBeginEvent;
use codex_core::protocol::CollabCloseEndEvent;
use codex_core::protocol::CollabWaitingBeginEvent;
use codex_core::protocol::CollabWaitingEndEvent;
use codex_core::protocol::DeprecationNoticeEvent;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::Event;
@@ -23,6 +32,7 @@ use codex_core::protocol::TurnCompleteEvent;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::WarningEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_core::web_search::web_search_detail;
use codex_protocol::num_format::format_with_separators;
use owo_colors::OwoColorize;
use owo_colors::Style;
@@ -361,8 +371,20 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
}
}
EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => {
ts_msg!(self, "🌐 Searched: {query}");
EventMsg::WebSearchBegin(_) => {
ts_msg!(self, "🌐 Searching the web...");
}
EventMsg::WebSearchEnd(WebSearchEndEvent {
call_id: _,
query,
action,
}) => {
let detail = web_search_detail(Some(&action), &query);
if detail.is_empty() {
ts_msg!(self, "🌐 Searched the web");
} else {
ts_msg!(self, "🌐 Searched: {detail}");
}
}
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id,
@@ -571,19 +593,164 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::ContextCompacted(_) => {
ts_msg!(self, "context compacted");
}
EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_) => {
// TODO(jif) handle collab tools.
EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent {
call_id,
sender_thread_id: _,
prompt,
}) => {
ts_msg!(
self,
"{} {}",
"collab".style(self.magenta),
format_collab_invocation("spawn_agent", &call_id, Some(&prompt))
.style(self.bold)
);
}
EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent {
call_id,
sender_thread_id: _,
new_thread_id,
prompt,
status,
}) => {
let success = new_thread_id.is_some() && !is_collab_status_failure(&status);
let title_style = if success { self.green } else { self.red };
let title = format!(
"{} {}:",
format_collab_invocation("spawn_agent", &call_id, Some(&prompt)),
format_collab_status(&status)
);
ts_msg!(self, "{}", title.style(title_style));
if let Some(new_thread_id) = new_thread_id {
eprintln!(" agent: {}", new_thread_id.to_string().style(self.dimmed));
}
}
EventMsg::CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent {
call_id,
sender_thread_id: _,
receiver_thread_id,
prompt,
}) => {
ts_msg!(
self,
"{} {}",
"collab".style(self.magenta),
format_collab_invocation("send_input", &call_id, Some(&prompt))
.style(self.bold)
);
eprintln!(
" receiver: {}",
receiver_thread_id.to_string().style(self.dimmed)
);
}
EventMsg::CollabAgentInteractionEnd(CollabAgentInteractionEndEvent {
call_id,
sender_thread_id: _,
receiver_thread_id,
prompt,
status,
}) => {
let success = !is_collab_status_failure(&status);
let title_style = if success { self.green } else { self.red };
let title = format!(
"{} {}:",
format_collab_invocation("send_input", &call_id, Some(&prompt)),
format_collab_status(&status)
);
ts_msg!(self, "{}", title.style(title_style));
eprintln!(
" receiver: {}",
receiver_thread_id.to_string().style(self.dimmed)
);
}
EventMsg::CollabWaitingBegin(CollabWaitingBeginEvent {
sender_thread_id: _,
receiver_thread_ids,
call_id,
}) => {
ts_msg!(
self,
"{} {}",
"collab".style(self.magenta),
format_collab_invocation("wait", &call_id, None).style(self.bold)
);
eprintln!(
" receivers: {}",
format_receiver_list(&receiver_thread_ids).style(self.dimmed)
);
}
EventMsg::CollabWaitingEnd(CollabWaitingEndEvent {
sender_thread_id: _,
call_id,
statuses,
}) => {
if statuses.is_empty() {
ts_msg!(
self,
"{} {}:",
format_collab_invocation("wait", &call_id, None),
"timed out".style(self.yellow)
);
return CodexStatus::Running;
}
let success = !statuses.values().any(is_collab_status_failure);
let title_style = if success { self.green } else { self.red };
let title = format!(
"{} {} agents complete:",
format_collab_invocation("wait", &call_id, None),
statuses.len()
);
ts_msg!(self, "{}", title.style(title_style));
let mut sorted = statuses
.into_iter()
.map(|(thread_id, status)| (thread_id.to_string(), status))
.collect::<Vec<_>>();
sorted.sort_by(|(left, _), (right, _)| left.cmp(right));
for (thread_id, status) in sorted {
eprintln!(
" {} {}",
thread_id.style(self.dimmed),
format_collab_status(&status).style(style_for_agent_status(&status, self))
);
}
}
EventMsg::CollabCloseBegin(CollabCloseBeginEvent {
call_id,
sender_thread_id: _,
receiver_thread_id,
}) => {
ts_msg!(
self,
"{} {}",
"collab".style(self.magenta),
format_collab_invocation("close_agent", &call_id, None).style(self.bold)
);
eprintln!(
" receiver: {}",
receiver_thread_id.to_string().style(self.dimmed)
);
}
EventMsg::CollabCloseEnd(CollabCloseEndEvent {
call_id,
sender_thread_id: _,
receiver_thread_id,
status,
}) => {
let success = !is_collab_status_failure(&status);
let title_style = if success { self.green } else { self.red };
let title = format!(
"{} {}:",
format_collab_invocation("close_agent", &call_id, None),
format_collab_status(&status)
);
ts_msg!(self, "{}", title.style(title_style));
eprintln!(
" receiver: {}",
receiver_thread_id.to_string().style(self.dimmed)
);
}
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
EventMsg::WebSearchBegin(_)
| EventMsg::ExecApprovalRequest(_)
EventMsg::ExecApprovalRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::TerminalInteraction(_)
| EventMsg::ExecCommandOutputDelta(_)
@@ -654,6 +821,78 @@ fn format_file_change(change: &FileChange) -> &'static str {
}
}
fn format_collab_invocation(tool: &str, call_id: &str, prompt: Option<&str>) -> String {
let prompt = prompt
.map(str::trim)
.filter(|prompt| !prompt.is_empty())
.map(|prompt| truncate_preview(prompt, 120));
match prompt {
Some(prompt) => format!("{tool}({call_id}, prompt=\"{prompt}\")"),
None => format!("{tool}({call_id})"),
}
}
fn format_collab_status(status: &AgentStatus) -> String {
match status {
AgentStatus::PendingInit => "pending init".to_string(),
AgentStatus::Running => "running".to_string(),
AgentStatus::Completed(Some(message)) => {
let preview = truncate_preview(message.trim(), 120);
if preview.is_empty() {
"completed".to_string()
} else {
format!("completed: \"{preview}\"")
}
}
AgentStatus::Completed(None) => "completed".to_string(),
AgentStatus::Errored(message) => {
let preview = truncate_preview(message.trim(), 120);
if preview.is_empty() {
"errored".to_string()
} else {
format!("errored: \"{preview}\"")
}
}
AgentStatus::Shutdown => "shutdown".to_string(),
AgentStatus::NotFound => "not found".to_string(),
}
}
fn style_for_agent_status(
status: &AgentStatus,
processor: &EventProcessorWithHumanOutput,
) -> Style {
match status {
AgentStatus::PendingInit | AgentStatus::Shutdown => processor.dimmed,
AgentStatus::Running => processor.cyan,
AgentStatus::Completed(_) => processor.green,
AgentStatus::Errored(_) | AgentStatus::NotFound => processor.red,
}
}
fn is_collab_status_failure(status: &AgentStatus) -> bool {
matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound)
}
fn format_receiver_list(ids: &[codex_protocol::ThreadId]) -> String {
if ids.is_empty() {
return "none".to_string();
}
ids.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
fn truncate_preview(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let preview = text.chars().take(max_chars).collect::<String>();
format!("{preview}…")
}
fn format_mcp_invocation(invocation: &McpInvocation) -> String {
// Build fully-qualified tool name: server.tool
let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool);
@@ -6,6 +6,11 @@ use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use crate::event_processor::handle_last_message;
use crate::exec_events::AgentMessageItem;
use crate::exec_events::CollabAgentState;
use crate::exec_events::CollabAgentStatus;
use crate::exec_events::CollabTool;
use crate::exec_events::CollabToolCallItem;
use crate::exec_events::CollabToolCallStatus;
use crate::exec_events::CommandExecutionItem;
use crate::exec_events::CommandExecutionStatus;
use crate::exec_events::ErrorItem;
@@ -35,6 +40,16 @@ use crate::exec_events::Usage;
use crate::exec_events::WebSearchItem;
use codex_core::config::Config;
use codex_core::protocol;
use codex_core::protocol::AgentStatus as CoreAgentStatus;
use codex_core::protocol::CollabAgentInteractionBeginEvent;
use codex_core::protocol::CollabAgentInteractionEndEvent;
use codex_core::protocol::CollabAgentSpawnBeginEvent;
use codex_core::protocol::CollabAgentSpawnEndEvent;
use codex_core::protocol::CollabCloseBeginEvent;
use codex_core::protocol::CollabCloseEndEvent;
use codex_core::protocol::CollabWaitingBeginEvent;
use codex_core::protocol::CollabWaitingEndEvent;
use codex_protocol::models::WebSearchAction;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use serde_json::Value as JsonValue;
@@ -51,6 +66,8 @@ pub struct EventProcessorWithJsonOutput {
running_todo_list: Option<RunningTodoList>,
last_total_token_usage: Option<codex_core::protocol::TokenUsage>,
running_mcp_tool_calls: HashMap<String, RunningMcpToolCall>,
running_collab_tool_calls: HashMap<String, RunningCollabToolCall>,
running_web_search_calls: HashMap<String, String>,
last_critical_error: Option<ThreadErrorEvent>,
}
@@ -75,6 +92,12 @@ struct RunningMcpToolCall {
arguments: JsonValue,
}
#[derive(Debug, Clone)]
struct RunningCollabToolCall {
tool: CollabTool,
item_id: String,
}
impl EventProcessorWithJsonOutput {
pub fn new(last_message_path: Option<PathBuf>) -> Self {
Self {
@@ -85,6 +108,8 @@ impl EventProcessorWithJsonOutput {
running_todo_list: None,
last_total_token_usage: None,
running_mcp_tool_calls: HashMap::new(),
running_collab_tool_calls: HashMap::new(),
running_web_search_calls: HashMap::new(),
last_critical_error: None,
}
}
@@ -102,9 +127,21 @@ impl EventProcessorWithJsonOutput {
}
protocol::EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev),
protocol::EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev),
protocol::EventMsg::CollabAgentSpawnBegin(ev) => self.handle_collab_spawn_begin(ev),
protocol::EventMsg::CollabAgentSpawnEnd(ev) => self.handle_collab_spawn_end(ev),
protocol::EventMsg::CollabAgentInteractionBegin(ev) => {
self.handle_collab_interaction_begin(ev)
}
protocol::EventMsg::CollabAgentInteractionEnd(ev) => {
self.handle_collab_interaction_end(ev)
}
protocol::EventMsg::CollabWaitingBegin(ev) => self.handle_collab_wait_begin(ev),
protocol::EventMsg::CollabWaitingEnd(ev) => self.handle_collab_wait_end(ev),
protocol::EventMsg::CollabCloseBegin(ev) => self.handle_collab_close_begin(ev),
protocol::EventMsg::CollabCloseEnd(ev) => self.handle_collab_close_end(ev),
protocol::EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev),
protocol::EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev),
protocol::EventMsg::WebSearchBegin(_) => Vec::new(),
protocol::EventMsg::WebSearchBegin(ev) => self.handle_web_search_begin(ev),
protocol::EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev),
protocol::EventMsg::TokenCount(ev) => {
if let Some(info) = &ev.info {
@@ -161,11 +198,36 @@ impl EventProcessorWithJsonOutput {
})]
}
fn handle_web_search_end(&self, ev: &protocol::WebSearchEndEvent) -> Vec<ThreadEvent> {
fn handle_web_search_begin(&mut self, ev: &protocol::WebSearchBeginEvent) -> Vec<ThreadEvent> {
if self.running_web_search_calls.contains_key(&ev.call_id) {
return Vec::new();
}
let item_id = self.get_next_item_id();
self.running_web_search_calls
.insert(ev.call_id.clone(), item_id.clone());
let item = ThreadItem {
id: self.get_next_item_id(),
id: item_id,
details: ThreadItemDetails::WebSearch(WebSearchItem {
id: ev.call_id.clone(),
query: String::new(),
action: WebSearchAction::Other,
}),
};
vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })]
}
fn handle_web_search_end(&mut self, ev: &protocol::WebSearchEndEvent) -> Vec<ThreadEvent> {
let item_id = self
.running_web_search_calls
.remove(&ev.call_id)
.unwrap_or_else(|| self.get_next_item_id());
let item = ThreadItem {
id: item_id,
details: ThreadItemDetails::WebSearch(WebSearchItem {
id: ev.call_id.clone(),
query: ev.query.clone(),
action: ev.action.clone(),
}),
};
@@ -341,6 +403,219 @@ impl EventProcessorWithJsonOutput {
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
}
fn handle_collab_spawn_begin(&mut self, ev: &CollabAgentSpawnBeginEvent) -> Vec<ThreadEvent> {
self.start_collab_tool_call(
&ev.call_id,
CollabTool::SpawnAgent,
ev.sender_thread_id.to_string(),
Vec::new(),
Some(ev.prompt.clone()),
)
}
fn handle_collab_spawn_end(&mut self, ev: &CollabAgentSpawnEndEvent) -> Vec<ThreadEvent> {
let (receiver_thread_ids, agents_states) = match ev.new_thread_id {
Some(id) => {
let receiver_id = id.to_string();
let agent_state = CollabAgentState::from(ev.status.clone());
(
vec![receiver_id.clone()],
[(receiver_id, agent_state)].into_iter().collect(),
)
}
None => (Vec::new(), HashMap::new()),
};
let status = if ev.new_thread_id.is_some() && !is_collab_failure(&ev.status) {
CollabToolCallStatus::Completed
} else {
CollabToolCallStatus::Failed
};
self.finish_collab_tool_call(
&ev.call_id,
CollabTool::SpawnAgent,
ev.sender_thread_id.to_string(),
receiver_thread_ids,
Some(ev.prompt.clone()),
agents_states,
status,
)
}
fn handle_collab_interaction_begin(
&mut self,
ev: &CollabAgentInteractionBeginEvent,
) -> Vec<ThreadEvent> {
self.start_collab_tool_call(
&ev.call_id,
CollabTool::SendInput,
ev.sender_thread_id.to_string(),
vec![ev.receiver_thread_id.to_string()],
Some(ev.prompt.clone()),
)
}
fn handle_collab_interaction_end(
&mut self,
ev: &CollabAgentInteractionEndEvent,
) -> Vec<ThreadEvent> {
let receiver_id = ev.receiver_thread_id.to_string();
let agent_state = CollabAgentState::from(ev.status.clone());
let status = if is_collab_failure(&ev.status) {
CollabToolCallStatus::Failed
} else {
CollabToolCallStatus::Completed
};
self.finish_collab_tool_call(
&ev.call_id,
CollabTool::SendInput,
ev.sender_thread_id.to_string(),
vec![receiver_id.clone()],
Some(ev.prompt.clone()),
[(receiver_id, agent_state)].into_iter().collect(),
status,
)
}
fn handle_collab_wait_begin(&mut self, ev: &CollabWaitingBeginEvent) -> Vec<ThreadEvent> {
self.start_collab_tool_call(
&ev.call_id,
CollabTool::Wait,
ev.sender_thread_id.to_string(),
ev.receiver_thread_ids
.iter()
.map(ToString::to_string)
.collect(),
None,
)
}
fn handle_collab_wait_end(&mut self, ev: &CollabWaitingEndEvent) -> Vec<ThreadEvent> {
let status = if ev.statuses.values().any(is_collab_failure) {
CollabToolCallStatus::Failed
} else {
CollabToolCallStatus::Completed
};
let mut receiver_thread_ids = ev
.statuses
.keys()
.map(ToString::to_string)
.collect::<Vec<_>>();
receiver_thread_ids.sort();
let agents_states = ev
.statuses
.iter()
.map(|(thread_id, status)| {
(
thread_id.to_string(),
CollabAgentState::from(status.clone()),
)
})
.collect();
self.finish_collab_tool_call(
&ev.call_id,
CollabTool::Wait,
ev.sender_thread_id.to_string(),
receiver_thread_ids,
None,
agents_states,
status,
)
}
fn handle_collab_close_begin(&mut self, ev: &CollabCloseBeginEvent) -> Vec<ThreadEvent> {
self.start_collab_tool_call(
&ev.call_id,
CollabTool::CloseAgent,
ev.sender_thread_id.to_string(),
vec![ev.receiver_thread_id.to_string()],
None,
)
}
fn handle_collab_close_end(&mut self, ev: &CollabCloseEndEvent) -> Vec<ThreadEvent> {
let receiver_id = ev.receiver_thread_id.to_string();
let agent_state = CollabAgentState::from(ev.status.clone());
let status = if is_collab_failure(&ev.status) {
CollabToolCallStatus::Failed
} else {
CollabToolCallStatus::Completed
};
self.finish_collab_tool_call(
&ev.call_id,
CollabTool::CloseAgent,
ev.sender_thread_id.to_string(),
vec![receiver_id.clone()],
None,
[(receiver_id, agent_state)].into_iter().collect(),
status,
)
}
fn start_collab_tool_call(
&mut self,
call_id: &str,
tool: CollabTool,
sender_thread_id: String,
receiver_thread_ids: Vec<String>,
prompt: Option<String>,
) -> Vec<ThreadEvent> {
let item_id = self.get_next_item_id();
self.running_collab_tool_calls.insert(
call_id.to_string(),
RunningCollabToolCall {
tool: tool.clone(),
item_id: item_id.clone(),
},
);
let item = ThreadItem {
id: item_id,
details: ThreadItemDetails::CollabToolCall(CollabToolCallItem {
tool,
sender_thread_id,
receiver_thread_ids,
prompt,
agents_states: HashMap::new(),
status: CollabToolCallStatus::InProgress,
}),
};
vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })]
}
#[allow(clippy::too_many_arguments)]
fn finish_collab_tool_call(
&mut self,
call_id: &str,
tool: CollabTool,
sender_thread_id: String,
receiver_thread_ids: Vec<String>,
prompt: Option<String>,
agents_states: HashMap<String, CollabAgentState>,
status: CollabToolCallStatus,
) -> Vec<ThreadEvent> {
let (tool, item_id) = match self.running_collab_tool_calls.remove(call_id) {
Some(running) => (running.tool, running.item_id),
None => {
warn!(
call_id,
"Received collab tool end without begin; synthesizing new item"
);
(tool, self.get_next_item_id())
}
};
let item = ThreadItem {
id: item_id,
details: ThreadItemDetails::CollabToolCall(CollabToolCallItem {
tool,
sender_thread_id,
receiver_thread_ids,
prompt,
agents_states,
status,
}),
};
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
}
fn handle_patch_apply_begin(
&mut self,
ev: &protocol::PatchApplyBeginEvent,
@@ -512,6 +787,44 @@ impl EventProcessorWithJsonOutput {
}
}
fn is_collab_failure(status: &CoreAgentStatus) -> bool {
matches!(
status,
CoreAgentStatus::Errored(_) | CoreAgentStatus::NotFound
)
}
impl From<CoreAgentStatus> for CollabAgentState {
fn from(value: CoreAgentStatus) -> Self {
match value {
CoreAgentStatus::PendingInit => Self {
status: CollabAgentStatus::PendingInit,
message: None,
},
CoreAgentStatus::Running => Self {
status: CollabAgentStatus::Running,
message: None,
},
CoreAgentStatus::Completed(message) => Self {
status: CollabAgentStatus::Completed,
message,
},
CoreAgentStatus::Errored(message) => Self {
status: CollabAgentStatus::Errored,
message: Some(message),
},
CoreAgentStatus::Shutdown => Self {
status: CollabAgentStatus::Shutdown,
message: None,
},
CoreAgentStatus::NotFound => Self {
status: CollabAgentStatus::NotFound,
message: None,
},
}
}
}
impl EventProcessor for EventProcessorWithJsonOutput {
fn print_config_summary(&mut self, _: &Config, _: &str, ev: &protocol::SessionConfiguredEvent) {
self.process_event(protocol::Event {
+57
View File
@@ -1,7 +1,9 @@
use codex_protocol::models::WebSearchAction;
use mcp_types::ContentBlock as McpContentBlock;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use ts_rs::TS;
/// Top-level JSONL events emitted by codex exec
@@ -113,6 +115,9 @@ pub enum ThreadItemDetails {
/// Represents a call to an MCP tool. The item starts when the invocation is
/// dispatched and completes when the MCP server reports success or failure.
McpToolCall(McpToolCallItem),
/// Represents a call to a collab tool. The item starts when the collab tool is
/// invoked and completes when the collab tool reports success or failure.
CollabToolCall(CollabToolCallItem),
/// Captures a web search request. It starts when the search is kicked off
/// and completes when results are returned to the agent.
WebSearch(WebSearchItem),
@@ -198,6 +203,56 @@ pub enum McpToolCallStatus {
Failed,
}
/// The status of a collab tool call.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
#[serde(rename_all = "snake_case")]
pub enum CollabToolCallStatus {
#[default]
InProgress,
Completed,
Failed,
}
/// Supported collab tools.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum CollabTool {
SpawnAgent,
SendInput,
Wait,
CloseAgent,
}
/// The status of a collab agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum CollabAgentStatus {
PendingInit,
Running,
Completed,
Errored,
Shutdown,
NotFound,
}
/// Last known state of a collab agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct CollabAgentState {
pub status: CollabAgentStatus,
pub message: Option<String>,
}
/// A call to a collab tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct CollabToolCallItem {
pub tool: CollabTool,
pub sender_thread_id: String,
pub receiver_thread_ids: Vec<String>,
pub prompt: Option<String>,
pub agents_states: HashMap<String, CollabAgentState>,
pub status: CollabToolCallStatus,
}
/// Result payload produced by an MCP tool invocation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct McpToolCallItemResult {
@@ -226,7 +281,9 @@ pub struct McpToolCallItem {
/// A web search request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct WebSearchItem {
pub id: String,
pub query: String,
pub action: WebSearchAction,
}
/// An error notification.
@@ -1,6 +1,10 @@
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::AgentStatus;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::CollabAgentSpawnBeginEvent;
use codex_core::protocol::CollabAgentSpawnEndEvent;
use codex_core::protocol::CollabWaitingEndEvent;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
@@ -16,9 +20,15 @@ use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::WarningEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput;
use codex_exec::exec_events::AgentMessageItem;
use codex_exec::exec_events::CollabAgentState;
use codex_exec::exec_events::CollabAgentStatus;
use codex_exec::exec_events::CollabTool;
use codex_exec::exec_events::CollabToolCallItem;
use codex_exec::exec_events::CollabToolCallStatus;
use codex_exec::exec_events::CommandExecutionItem;
use codex_exec::exec_events::CommandExecutionStatus;
use codex_exec::exec_events::ErrorItem;
@@ -44,6 +54,8 @@ use codex_exec::exec_events::TurnFailedEvent;
use codex_exec::exec_events::TurnStartedEvent;
use codex_exec::exec_events::Usage;
use codex_exec::exec_events::WebSearchItem;
use codex_protocol::ThreadId;
use codex_protocol::models::WebSearchAction;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
@@ -114,11 +126,15 @@ fn task_started_produces_turn_started_event() {
fn web_search_end_emits_item_completed() {
let mut ep = EventProcessorWithJsonOutput::new(None);
let query = "rust async await".to_string();
let action = WebSearchAction::Search {
query: Some(query.clone()),
};
let out = ep.collect_thread_events(&event(
"w1",
EventMsg::WebSearchEnd(WebSearchEndEvent {
call_id: "call-123".to_string(),
query: query.clone(),
action: action.clone(),
}),
));
@@ -127,12 +143,82 @@ fn web_search_end_emits_item_completed() {
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
item: ThreadItem {
id: "item_0".to_string(),
details: ThreadItemDetails::WebSearch(WebSearchItem { query }),
details: ThreadItemDetails::WebSearch(WebSearchItem {
id: "call-123".to_string(),
query,
action,
}),
},
})]
);
}
#[test]
fn web_search_begin_emits_item_started() {
let mut ep = EventProcessorWithJsonOutput::new(None);
let out = ep.collect_thread_events(&event(
"w0",
EventMsg::WebSearchBegin(WebSearchBeginEvent {
call_id: "call-0".to_string(),
}),
));
assert_eq!(out.len(), 1);
let ThreadEvent::ItemStarted(ItemStartedEvent { item }) = &out[0] else {
panic!("expected ItemStarted");
};
assert!(item.id.starts_with("item_"));
assert_eq!(
item.details,
ThreadItemDetails::WebSearch(WebSearchItem {
id: "call-0".to_string(),
query: String::new(),
action: WebSearchAction::Other,
})
);
}
#[test]
fn web_search_begin_then_end_reuses_item_id() {
let mut ep = EventProcessorWithJsonOutput::new(None);
let begin = ep.collect_thread_events(&event(
"w0",
EventMsg::WebSearchBegin(WebSearchBeginEvent {
call_id: "call-1".to_string(),
}),
));
let ThreadEvent::ItemStarted(ItemStartedEvent { item: started_item }) = &begin[0] else {
panic!("expected ItemStarted");
};
let action = WebSearchAction::Search {
query: Some("rust async await".to_string()),
};
let end = ep.collect_thread_events(&event(
"w1",
EventMsg::WebSearchEnd(WebSearchEndEvent {
call_id: "call-1".to_string(),
query: "rust async await".to_string(),
action: action.clone(),
}),
));
let ThreadEvent::ItemCompleted(ItemCompletedEvent {
item: completed_item,
}) = &end[0]
else {
panic!("expected ItemCompleted");
};
assert_eq!(completed_item.id, started_item.id);
assert_eq!(
completed_item.details,
ThreadItemDetails::WebSearch(WebSearchItem {
id: "call-1".to_string(),
query: "rust async await".to_string(),
action,
})
);
}
#[test]
fn plan_update_emits_todo_list_started_updated_and_completed() {
let mut ep = EventProcessorWithJsonOutput::new(None);
@@ -444,6 +530,135 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
);
}
#[test]
fn collab_spawn_begin_and_end_emit_item_events() {
let mut ep = EventProcessorWithJsonOutput::new(None);
let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap();
let new_thread_id = ThreadId::from_string("9e107d9d-372b-4b8c-a2a4-1d9bb3fce0c1").unwrap();
let prompt = "draft a plan".to_string();
let begin = event(
"c1",
EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent {
call_id: "call-10".to_string(),
sender_thread_id,
prompt: prompt.clone(),
}),
);
let begin_events = ep.collect_thread_events(&begin);
assert_eq!(
begin_events,
vec![ThreadEvent::ItemStarted(ItemStartedEvent {
item: ThreadItem {
id: "item_0".to_string(),
details: ThreadItemDetails::CollabToolCall(CollabToolCallItem {
tool: CollabTool::SpawnAgent,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: Vec::new(),
prompt: Some(prompt.clone()),
agents_states: std::collections::HashMap::new(),
status: CollabToolCallStatus::InProgress,
}),
},
})]
);
let end = event(
"c2",
EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent {
call_id: "call-10".to_string(),
sender_thread_id,
new_thread_id: Some(new_thread_id),
prompt: prompt.clone(),
status: AgentStatus::Running,
}),
);
let end_events = ep.collect_thread_events(&end);
assert_eq!(
end_events,
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
item: ThreadItem {
id: "item_0".to_string(),
details: ThreadItemDetails::CollabToolCall(CollabToolCallItem {
tool: CollabTool::SpawnAgent,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![new_thread_id.to_string()],
prompt: Some(prompt),
agents_states: [(
new_thread_id.to_string(),
CollabAgentState {
status: CollabAgentStatus::Running,
message: None,
},
)]
.into_iter()
.collect(),
status: CollabToolCallStatus::Completed,
}),
},
})]
);
}
#[test]
fn collab_wait_end_without_begin_synthesizes_failed_item() {
let mut ep = EventProcessorWithJsonOutput::new(None);
let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap();
let running_thread_id = ThreadId::from_string("3f76d2a0-943e-4f43-8a38-b289c9c6c3d1").unwrap();
let failed_thread_id = ThreadId::from_string("c1dfd96e-1f0c-4f26-9b4f-1aa02c2d3c4d").unwrap();
let mut receiver_thread_ids = vec![running_thread_id.to_string(), failed_thread_id.to_string()];
receiver_thread_ids.sort();
let mut statuses = std::collections::HashMap::new();
statuses.insert(
running_thread_id,
AgentStatus::Completed(Some("done".to_string())),
);
statuses.insert(failed_thread_id, AgentStatus::Errored("boom".to_string()));
let end = event(
"c3",
EventMsg::CollabWaitingEnd(CollabWaitingEndEvent {
sender_thread_id,
call_id: "call-11".to_string(),
statuses: statuses.clone(),
}),
);
let events = ep.collect_thread_events(&end);
assert_eq!(
events,
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
item: ThreadItem {
id: "item_0".to_string(),
details: ThreadItemDetails::CollabToolCall(CollabToolCallItem {
tool: CollabTool::Wait,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids,
prompt: None,
agents_states: [
(
running_thread_id.to_string(),
CollabAgentState {
status: CollabAgentStatus::Completed,
message: Some("done".to_string()),
},
),
(
failed_thread_id.to_string(),
CollabAgentState {
status: CollabAgentStatus::Errored,
message: Some("boom".to_string()),
},
),
]
.into_iter()
.collect(),
status: CollabToolCallStatus::Failed,
}),
},
})]
);
}
#[test]
fn plan_update_after_complete_starts_new_todo_list_with_new_id() {
let mut ep = EventProcessorWithJsonOutput::new(None);
+1 -1
View File
@@ -14,7 +14,7 @@ codex-core = { path = "../core" }
reqwest = { version = "0.12", features = ["json", "stream"] }
serde_json = "1"
tokio = { version = "1", features = ["rt"] }
tracing = { version = "0.1.43", features = ["log"] }
tracing = { version = "0.1.44", features = ["log"] }
which = "8.0"
[dev-dependencies]
+1 -1
View File
@@ -94,8 +94,8 @@ pub enum Personality {
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum WebSearchMode {
#[default]
Disabled,
#[default]
Cached,
Live,
}
+4 -1
View File
@@ -1,3 +1,4 @@
use crate::models::WebSearchAction;
use crate::protocol::AgentMessageEvent;
use crate::protocol::AgentReasoningEvent;
use crate::protocol::AgentReasoningRawContentEvent;
@@ -49,10 +50,11 @@ pub struct ReasoningItem {
pub raw_content: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
pub struct WebSearchItem {
pub id: String,
pub query: String,
pub action: WebSearchAction,
}
impl UserMessageItem {
@@ -181,6 +183,7 @@ impl WebSearchItem {
EventMsg::WebSearchEnd(WebSearchEndEvent {
call_id: self.id.clone(),
query: self.query.clone(),
action: self.action.clone(),
})
}
}
+34 -11
View File
@@ -157,7 +157,9 @@ pub enum ResponseItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
status: Option<String>,
action: WebSearchAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
action: Option<WebSearchAction>,
},
// Generated by the harness but considered exactly as a model response.
GhostSnapshot {
@@ -1034,10 +1036,12 @@ mod tests {
"query": "weather seattle"
}
}"#,
WebSearchAction::Search {
None,
Some(WebSearchAction::Search {
query: Some("weather seattle".into()),
},
}),
Some("completed".into()),
true,
),
(
r#"{
@@ -1048,10 +1052,12 @@ mod tests {
"url": "https://example.com"
}
}"#,
WebSearchAction::OpenPage {
None,
Some(WebSearchAction::OpenPage {
url: Some("https://example.com".into()),
},
}),
Some("open".into()),
true,
),
(
r#"{
@@ -1063,26 +1069,43 @@ mod tests {
"pattern": "installation"
}
}"#,
WebSearchAction::FindInPage {
None,
Some(WebSearchAction::FindInPage {
url: Some("https://example.com/docs".into()),
pattern: Some("installation".into()),
},
}),
Some("in_progress".into()),
true,
),
(
r#"{
"type": "web_search_call",
"status": "in_progress",
"id": "ws_partial"
}"#,
Some("ws_partial".into()),
None,
Some("in_progress".into()),
false,
),
];
for (json_literal, expected_action, expected_status) in cases {
for (json_literal, expected_id, expected_action, expected_status, expect_roundtrip) in cases
{
let parsed: ResponseItem = serde_json::from_str(json_literal)?;
let expected = ResponseItem::WebSearchCall {
id: None,
id: expected_id.clone(),
status: expected_status.clone(),
action: expected_action.clone(),
};
assert_eq!(parsed, expected);
let serialized = serde_json::to_value(&parsed)?;
let original_value: serde_json::Value = serde_json::from_str(json_literal)?;
assert_eq!(serialized, original_value);
let mut expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?;
if !expect_roundtrip && let Some(obj) = expected_serialized.as_object_mut() {
obj.remove("id");
}
assert_eq!(serialized, expected_serialized);
}
Ok(())
+6
View File
@@ -24,6 +24,7 @@ use crate::message_history::HistoryEntry;
use crate::models::BaseInstructions;
use crate::models::ContentItem;
use crate::models::ResponseItem;
use crate::models::WebSearchAction;
use crate::num_format::format_with_separators;
use crate::openai_models::ReasoningEffort as ReasoningEffortConfig;
use crate::parse_command::ParsedCommand;
@@ -1041,6 +1042,7 @@ impl HasLegacyEvent for ReasoningRawContentDeltaEvent {
impl HasLegacyEvent for EventMsg {
fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {
match self {
EventMsg::ItemStarted(event) => event.as_legacy_events(show_raw_agent_reasoning),
EventMsg::ItemCompleted(event) => event.as_legacy_events(show_raw_agent_reasoning),
EventMsg::AgentMessageContentDelta(event) => {
event.as_legacy_events(show_raw_agent_reasoning)
@@ -1402,6 +1404,7 @@ pub struct WebSearchBeginEvent {
pub struct WebSearchEndEvent {
pub call_id: String,
pub query: String,
pub action: WebSearchAction,
}
// Conversation kept for backward compatibility.
@@ -2375,6 +2378,9 @@ mod tests {
item: TurnItem::WebSearch(WebSearchItem {
id: "search-1".into(),
query: "find docs".into(),
action: WebSearchAction::Search {
query: Some("find docs".into()),
},
}),
};
@@ -16,6 +16,10 @@ pub struct RequestUserInputQuestion {
pub id: String,
pub header: String,
pub question: String,
#[serde(rename = "isOther", default)]
#[schemars(rename = "isOther")]
#[ts(rename = "isOther")]
pub is_other: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<RequestUserInputQuestionOption>>,
}
+67 -5
View File
@@ -93,11 +93,12 @@ use tokio::sync::Mutex;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::unbounded_channel;
use toml::Value as TomlValue;
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 1024;
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;
#[derive(Debug, Clone)]
pub struct AppExitInfo {
@@ -201,7 +202,7 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config)
.disabled_reason
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "Config folder disabled.".to_string()),
.unwrap_or_else(|| "config.toml is disabled.".to_string()),
));
}
@@ -209,7 +210,11 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config)
return;
}
let mut message = "The following config folders are disabled:\n".to_string();
let mut message = concat!(
"Project config.toml files are disabled in the following folders. ",
"Settings in those files are ignored, but skills and exec policies still load.\n",
)
.to_string();
for (index, (folder, reason)) in disabled_folders.iter().enumerate() {
let display_index = index + 1;
message.push_str(&format!(" {display_index}. {folder}\n"));
@@ -710,8 +715,23 @@ impl App {
guard.active
};
if should_send && let Err(err) = sender.send(event).await {
tracing::warn!("thread {thread_id} event channel closed: {err}");
if should_send {
// Never await a bounded channel send on the main TUI loop: if the receiver falls behind,
// `send().await` can block and the UI stops drawing. If the channel is full, wait in a
// spawned task instead.
match sender.try_send(event) {
Ok(()) => {}
Err(TrySendError::Full(event)) => {
tokio::spawn(async move {
if let Err(err) = sender.send(event).await {
tracing::warn!("thread {thread_id} event channel closed: {err}");
}
});
}
Err(TrySendError::Closed(_)) => {
tracing::warn!("thread {thread_id} event channel closed");
}
}
}
Ok(())
}
@@ -2396,6 +2416,7 @@ mod tests {
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tempfile::tempdir;
use tokio::time;
#[test]
fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> {
@@ -2416,6 +2437,47 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(1));
app.set_thread_active(thread_id, true).await;
let event = Event {
id: String::new(),
msg: EventMsg::ShutdownComplete,
};
app.enqueue_thread_event(thread_id, event.clone()).await?;
time::timeout(
Duration::from_millis(50),
app.enqueue_thread_event(thread_id, event),
)
.await
.expect("enqueue_thread_event blocked on a full channel")?;
let mut rx = app
.thread_event_channels
.get_mut(&thread_id)
.expect("missing thread channel")
.receiver
.take()
.expect("missing receiver");
time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for first event")
.expect("channel closed unexpectedly");
time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for second event")
.expect("channel closed unexpectedly");
Ok(())
}
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
@@ -27,6 +27,22 @@ pub(crate) trait BottomPaneView: Renderable {
false
}
/// Flush any pending paste-burst state. Return true if state changed.
///
/// This lets a modal that reuses `ChatComposer` participate in the same
/// time-based paste burst flushing as the primary composer.
fn flush_paste_burst_if_due(&mut self) -> bool {
false
}
/// Whether the view is currently holding paste-burst transient state.
///
/// When `true`, the bottom pane will schedule a short delayed redraw to
/// give the burst time window a chance to flush.
fn is_in_paste_burst(&self) -> bool {
false
}
/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(
+151 -69
View File
@@ -109,6 +109,7 @@ use super::footer::toggle_shortcut_mode;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use super::skill_popup::SkillPopup;
use super::slash_commands;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::bottom_pane::prompt_args::expand_custom_prompt;
use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args;
@@ -120,7 +121,6 @@ use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::style::user_message_style;
use codex_common::fuzzy_match::fuzzy_match;
use codex_protocol::custom_prompts::CustomPrompt;
@@ -148,13 +148,6 @@ use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
@@ -197,6 +190,44 @@ enum PromptSelectionAction {
},
}
/// Feature flags for reusing the chat composer in other bottom-pane surfaces.
///
/// The default keeps today's behavior intact. Other call sites can opt out of
/// specific behaviors by constructing a config with those flags set to `false`.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ChatComposerConfig {
/// Whether command/file/skill popups are allowed to appear.
pub(crate) popups_enabled: bool,
/// Whether `/...` input is parsed and dispatched as slash commands.
pub(crate) slash_commands_enabled: bool,
/// Whether pasting a file path can attach local images.
pub(crate) image_paste_enabled: bool,
}
impl Default for ChatComposerConfig {
fn default() -> Self {
Self {
popups_enabled: true,
slash_commands_enabled: true,
image_paste_enabled: true,
}
}
}
impl ChatComposerConfig {
/// A minimal preset for plain-text inputs embedded in other surfaces.
///
/// This disables popups, slash commands, and image-path attachment behavior
/// so the composer behaves like a simple notes field.
pub(crate) const fn plain_text() -> Self {
Self {
popups_enabled: false,
slash_commands_enabled: false,
image_paste_enabled: false,
}
}
}
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
@@ -234,6 +265,7 @@ pub(crate) struct ChatComposer {
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
steer_enabled: bool,
collaboration_modes_enabled: bool,
config: ChatComposerConfig,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
personality_command_enabled: bool,
}
@@ -261,6 +293,28 @@ impl ChatComposer {
enhanced_keys_supported: bool,
placeholder_text: String,
disable_paste_burst: bool,
) -> Self {
Self::new_with_config(
has_input_focus,
app_event_tx,
enhanced_keys_supported,
placeholder_text,
disable_paste_burst,
ChatComposerConfig::default(),
)
}
/// Construct a composer with explicit feature gating.
///
/// This enables reuse in contexts like request-user-input where we want
/// the same visuals and editing behavior without slash commands or popups.
pub(crate) fn new_with_config(
has_input_focus: bool,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
placeholder_text: String,
disable_paste_burst: bool,
config: ChatComposerConfig,
) -> Self {
let use_shift_enter_hint = enhanced_keys_supported;
@@ -296,6 +350,7 @@ impl ChatComposer {
dismissed_skill_popup_token: None,
steer_enabled: false,
collaboration_modes_enabled: false,
config,
collaboration_mode_indicator: None,
personality_command_enabled: false,
};
@@ -333,6 +388,18 @@ impl ChatComposer {
self.personality_command_enabled = enabled;
}
/// Centralized feature gating keeps config checks out of call sites.
fn popups_enabled(&self) -> bool {
self.config.popups_enabled
}
fn slash_commands_enabled(&self) -> bool {
self.config.slash_commands_enabled
}
fn image_paste_enabled(&self) -> bool {
self.config.image_paste_enabled
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let footer_props = self.footer_props();
let footer_hint_height = self
@@ -417,7 +484,10 @@ impl ChatComposer {
let placeholder = self.next_large_paste_placeholder(char_count);
self.textarea.insert_element(&placeholder);
self.pending_pastes.push((placeholder, pasted));
} else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) {
} else if char_count > 1
&& self.image_paste_enabled()
&& self.handle_paste_image_path(pasted.clone())
{
self.textarea.insert_str(" ");
} else {
self.textarea.insert_str(&pasted);
@@ -613,6 +683,16 @@ impl ChatComposer {
self.sync_popups();
}
/// Update the placeholder text without changing input enablement.
pub(crate) fn set_placeholder_text(&mut self, placeholder: String) {
self.placeholder_text = placeholder;
}
/// Move the cursor to the end of the current text buffer.
pub(crate) fn move_cursor_to_end(&mut self) {
self.textarea.set_cursor(self.textarea.text().len());
}
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
if self.is_empty() {
return None;
@@ -1634,14 +1714,17 @@ impl ChatComposer {
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) {
if self.slash_commands_enabled()
&& let Some((name, _rest, _rest_offset)) = parse_slash_name(&text)
{
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin = Self::built_in_slash_commands_for_input(
let is_builtin = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.any(|(command_name, _)| command_name == name);
.is_some();
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
.strip_prefix(&prompt_prefix)
@@ -1670,26 +1753,28 @@ impl ChatComposer {
}
}
let expanded_prompt =
match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
return None;
}
};
if let Some(expanded) = expanded_prompt {
text = expanded.text;
text_elements = expanded.text_elements;
if self.slash_commands_enabled() {
let expanded_prompt =
match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
return None;
}
};
if let Some(expanded) = expanded_prompt {
text = expanded.text;
text_elements = expanded.text_elements;
}
}
// Custom prompt expansion can remove or rewrite image placeholders, so prune any
// attachments that no longer have a corresponding placeholder in the expanded text.
@@ -1729,14 +1814,15 @@ impl ChatComposer {
// If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat as paste inside a slash-command context.
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/');
let in_slash_context = self.slash_commands_enabled()
&& (matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/'));
if !self.disable_paste_burst
&& self.paste_burst.is_active()
&& !in_slash_context
@@ -1803,14 +1889,17 @@ impl ChatComposer {
/// Check if the first line is a bare slash command (no args) and dispatch it.
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> {
if !self.slash_commands_enabled() {
return None;
}
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line)
&& rest.is_empty()
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.find(|(n, _)| *n == name)
{
self.textarea.set_text_clearing_elements("");
Some(InputResult::Command(cmd))
@@ -1822,6 +1911,9 @@ impl ChatComposer {
/// Check if the input is a slash command with args (e.g., /review args) and dispatch it.
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_slash_command_with_args(&mut self) -> Option<InputResult> {
if !self.slash_commands_enabled() {
return None;
}
let original_input = self.textarea.text().to_string();
let input_starts_with_space = original_input.starts_with(' ');
@@ -1830,11 +1922,11 @@ impl ChatComposer {
if let Some((name, rest, _rest_offset)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.find(|(command_name, _)| *command_name == name)
&& cmd == SlashCommand::Review
{
self.textarea.set_text_clearing_elements("");
@@ -2188,6 +2280,10 @@ impl ChatComposer {
}
fn sync_popups(&mut self) {
if !self.popups_enabled() {
self.active_popup = ActivePopup::None;
return;
}
let file_token = Self::current_at_token(&self.textarea);
let browsing_history = self
.history
@@ -2200,7 +2296,8 @@ impl ChatComposer {
}
let skill_token = self.current_skill_token();
let allow_command_popup = file_token.is_none() && skill_token.is_none();
let allow_command_popup =
self.slash_commands_enabled() && file_token.is_none() && skill_token.is_none();
self.sync_command_popup(allow_command_popup);
if matches!(self.active_popup, ActivePopup::Command(_)) {
@@ -2261,24 +2358,24 @@ impl ChatComposer {
/// prefix for any known command (built-in or custom prompt).
/// Empty names only count when there is no extra content after the '/'.
fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool {
if !self.slash_commands_enabled() {
return false;
}
if name.is_empty() {
return rest_after_name.is_empty();
}
let builtin_match = Self::built_in_slash_commands_for_input(
if slash_commands::has_builtin_prefix(
name,
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
if builtin_match {
) {
return true;
}
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
self.custom_prompts
.iter()
.any(|p| fuzzy_match(&format!("{prompt_prefix}{}", p.name), name).is_some())
self.custom_prompts.iter().any(|prompt| {
fuzzy_match(&format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name), name).is_some()
})
}
/// Synchronize `self.command_popup` with the current text in the
@@ -2336,21 +2433,6 @@ impl ChatComposer {
}
}
}
fn built_in_slash_commands_for_input(
collaboration_modes_enabled: bool,
personality_command_enabled: bool,
) -> impl Iterator<Item = (&'static str, SlashCommand)> {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
built_in_slash_commands()
.into_iter()
.filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(move |(_, cmd)| {
personality_command_enabled || *cmd != SlashCommand::Personality
})
}
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
self.custom_prompts = prompts.clone();
if let ActivePopup::Command(popup) = &mut self.active_popup {
+6 -17
View File
@@ -6,21 +6,14 @@ use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use super::slash_commands;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use std::collections::HashSet;
fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// A selectable item in the popup: either a built-in command or a user prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
@@ -44,15 +37,11 @@ pub(crate) struct CommandPopupFlags {
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(|(_, cmd)| {
flags.personality_command_enabled || *cmd != SlashCommand::Personality
})
.collect();
// Keep built-in availability in sync with the composer.
let builtins = slash_commands::builtins_for_input(
flags.collaboration_modes_enabled,
flags.personality_command_enabled,
);
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));
@@ -29,7 +29,7 @@ use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
pub(crate) struct BetaFeatureItem {
pub(crate) struct ExperimentalFeatureItem {
pub feature: Feature,
pub name: String,
pub description: String,
@@ -37,7 +37,7 @@ pub(crate) struct BetaFeatureItem {
}
pub(crate) struct ExperimentalFeaturesView {
features: Vec<BetaFeatureItem>,
features: Vec<ExperimentalFeatureItem>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
@@ -46,11 +46,14 @@ pub(crate) struct ExperimentalFeaturesView {
}
impl ExperimentalFeaturesView {
pub(crate) fn new(features: Vec<BetaFeatureItem>, app_event_tx: AppEventSender) -> Self {
pub(crate) fn new(
features: Vec<ExperimentalFeatureItem>,
app_event_tx: AppEventSender,
) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Experimental features".bold()));
header.push(Line::from(
"Toggle beta features. Changes are saved to config.toml.".dim(),
"Toggle experimental features. Changes are saved to config.toml.".dim(),
));
let mut view = Self {
@@ -9,18 +9,15 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use super::selection_popup_common::render_menu_surface;
use super::selection_popup_common::wrap_styled_line;
use crate::app_event_sender::AppEventSender;
use crate::key_hint::KeyBinding;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -476,16 +473,16 @@ impl Renderable for ListSelectionView {
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area);
Block::default()
.style(user_message_style())
.render(content_area, buf);
let outer_content_area = content_area;
// Paint the shared menu surface and then layout inside the returned inset.
let content_area = render_menu_surface(outer_content_area, buf);
let header_height = self
.header
// Subtract 4 for the padding on the left and right of the header.
.desired_height(content_area.width.saturating_sub(4));
.desired_height(outer_content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_width = Self::rows_width(outer_content_area.width);
let rows_height = measure_rows_height(
&rows,
&self.state,
@@ -498,7 +495,7 @@ impl Renderable for ListSelectionView {
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(1, 2)));
.areas(content_area);
if header_area.height < header_height {
let [header_area, elision_area] =
+55 -13
View File
@@ -59,6 +59,7 @@ mod list_selection_view;
mod prompt_args;
mod skill_popup;
mod skills_toggle_view;
mod slash_commands;
pub(crate) use footer::CollaborationModeIndicator;
pub(crate) use list_selection_view::SelectionViewParams;
mod feedback_view;
@@ -104,11 +105,12 @@ pub(crate) enum CancellationEvent {
}
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use experimental_features_view::BetaFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
@@ -130,6 +132,8 @@ pub(crate) struct BottomPane {
frame_requester: FrameRequester,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
is_task_running: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
@@ -182,6 +186,8 @@ impl BottomPane {
app_event_tx,
frame_requester,
has_input_focus,
enhanced_keys_supported,
disable_paste_burst,
is_task_running: false,
status: None,
unified_exec_footer: UnifiedExecFooter::new(),
@@ -250,19 +256,37 @@ impl BottomPane {
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// If a modal/view is active, handle it here; otherwise forward to composer.
if let Some(view) = self.view_stack.last_mut() {
if key_event.code == KeyCode::Esc
&& matches!(view.on_ctrl_c(), CancellationEvent::Handled)
&& view.is_complete()
{
if !self.view_stack.is_empty() {
// We need three pieces of information after routing the key:
// whether Esc completed the view, whether the view finished for any
// reason, and whether a paste-burst timer should be scheduled.
let (ctrl_c_completed, view_complete, view_in_paste_burst) = {
let last_index = self.view_stack.len() - 1;
let view = &mut self.view_stack[last_index];
let ctrl_c_completed = key_event.code == KeyCode::Esc
&& matches!(view.on_ctrl_c(), CancellationEvent::Handled)
&& view.is_complete();
if ctrl_c_completed {
(true, true, false)
} else {
view.handle_key_event(key_event);
(false, view.is_complete(), view.is_in_paste_burst())
}
};
if ctrl_c_completed {
self.view_stack.pop();
self.on_active_view_complete();
} else {
view.handle_key_event(key_event);
if view.is_complete() {
self.view_stack.clear();
self.on_active_view_complete();
if let Some(next_view) = self.view_stack.last()
&& next_view.is_in_paste_burst()
{
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
}
} else if view_complete {
self.view_stack.clear();
self.on_active_view_complete();
} else if view_in_paste_burst {
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
}
self.request_redraw();
InputResult::None
@@ -628,7 +652,13 @@ impl BottomPane {
request
};
let modal = RequestUserInputOverlay::new(request, self.app_event_tx.clone());
let modal = RequestUserInputOverlay::new(
request,
self.app_event_tx.clone(),
self.has_input_focus,
self.enhanced_keys_supported,
self.disable_paste_burst,
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
false,
@@ -670,11 +700,23 @@ impl BottomPane {
}
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
// Give the active view the first chance to flush paste-burst state so
// overlays that reuse the composer behave consistently.
if let Some(view) = self.view_stack.last_mut()
&& view.flush_paste_burst_if_due()
{
return true;
}
self.composer.flush_paste_burst_if_due()
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
self.composer.is_in_paste_burst()
// A view can hold paste-burst state independently of the primary
// composer, so check it first.
self.view_stack
.last()
.is_some_and(|view| view.is_in_paste_burst())
|| self.composer.is_in_paste_burst()
}
pub(crate) fn on_history_entry_response(
@@ -19,122 +19,58 @@ pub(super) struct LayoutSections {
impl RequestUserInputOverlay {
/// Compute layout sections, collapsing notes and hints as space shrinks.
pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections {
let question_lines = self
.current_question()
.map(|q| {
textwrap::wrap(&q.question, area.width.max(1) as usize)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let question_text_height = question_lines.len() as u16;
let has_options = self.has_options();
let mut notes_input_height = self.notes_input_height(area.width);
// Keep the question + options visible first; notes and hints collapse as space shrinks.
let footer_lines = if self.unanswered_count() > 0 { 2 } else { 1 };
let mut notes_title_height = if has_options { 1 } else { 0 };
let footer_pref = if self.unanswered_count() > 0 { 2 } else { 1 };
let notes_pref_height = self.notes_input_height(area.width);
let mut question_lines = self.wrapped_question_lines(area.width);
let question_height = question_lines.len() as u16;
let mut cursor_y = area.y;
let progress_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: 1,
};
cursor_y = cursor_y.saturating_add(1);
let header_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: 1,
};
cursor_y = cursor_y.saturating_add(1);
let question_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: question_text_height,
};
cursor_y = cursor_y.saturating_add(question_text_height);
// Remaining height after progress/header/question areas.
let remaining = area.height.saturating_sub(cursor_y.saturating_sub(area.y));
let mut answer_title_height = if has_options { 1 } else { 0 };
let mut options_height = 0;
if has_options {
let remaining_content = remaining.saturating_sub(footer_lines);
let options_len = self.options_len() as u16;
if remaining_content == 0 {
answer_title_height = 0;
notes_title_height = 0;
notes_input_height = 0;
options_height = 0;
} else {
let min_notes = 1u16;
let full_notes = 3u16;
// Prefer to keep all options visible, then allocate notes height.
if remaining_content
>= options_len + answer_title_height + notes_title_height + full_notes
{
let max_notes = remaining_content
.saturating_sub(options_len)
.saturating_sub(answer_title_height)
.saturating_sub(notes_title_height);
notes_input_height = notes_input_height.min(max_notes).max(full_notes);
} else if remaining_content > options_len + answer_title_height + min_notes {
notes_title_height = 0;
notes_input_height = min_notes;
} else {
// Tight layout: hide section titles and shrink notes to one line.
answer_title_height = 0;
notes_title_height = 0;
notes_input_height = min_notes;
}
// Reserve notes/answer title area so options are scrollable if needed.
let reserved = answer_title_height
.saturating_add(notes_title_height)
.saturating_add(notes_input_height);
options_height = remaining_content.saturating_sub(reserved);
}
let (
question_height,
progress_height,
answer_title_height,
notes_title_height,
notes_height,
options_height,
footer_lines,
) = if has_options {
self.layout_with_options(
area.height,
area.width,
question_height,
notes_pref_height,
footer_pref,
&mut question_lines,
)
} else {
let max_notes = remaining.saturating_sub(footer_lines);
if max_notes == 0 {
notes_input_height = 0;
} else {
// When no options exist, notes are the primary input.
notes_input_height = notes_input_height.min(max_notes).max(3.min(max_notes));
}
}
self.layout_without_options(
area.height,
question_height,
notes_pref_height,
footer_pref,
&mut question_lines,
)
};
let answer_title_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: answer_title_height,
};
cursor_y = cursor_y.saturating_add(answer_title_height);
let options_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: options_height,
};
cursor_y = cursor_y.saturating_add(options_height);
let notes_title_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: notes_title_height,
};
cursor_y = cursor_y.saturating_add(notes_title_height);
let notes_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: notes_input_height,
};
let (
progress_area,
header_area,
question_area,
answer_title_area,
options_area,
notes_title_area,
notes_area,
) = self.build_layout_areas(
area,
LayoutHeights {
progress_height,
question_height,
answer_title_height,
options_height,
notes_title_height,
notes_height,
},
);
LayoutSections {
progress_area,
@@ -148,4 +84,281 @@ impl RequestUserInputOverlay {
footer_lines,
}
}
/// Layout calculation when options are present.
///
/// Handles both tight layout (when space is constrained) and normal layout
/// (when there's sufficient space for all elements).
///
/// Returns: (question_height, progress_height, answer_title_height, notes_title_height, notes_height, options_height, footer_lines)
fn layout_with_options(
&self,
available_height: u16,
width: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16) {
let options_required_height = self.options_required_height(width);
let min_options_height = 1u16;
let required = 1u16
.saturating_add(question_height)
.saturating_add(options_required_height);
if required > available_height {
self.layout_with_options_tight(
available_height,
question_height,
min_options_height,
question_lines,
)
} else {
self.layout_with_options_normal(
available_height,
question_height,
options_required_height,
notes_pref_height,
footer_pref,
)
}
}
/// Tight layout for options case: allocate header + question + options first
/// and drop everything else when space is constrained.
fn layout_with_options_tight(
&self,
available_height: u16,
question_height: u16,
min_options_height: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16) {
let max_question_height =
available_height.saturating_sub(1u16.saturating_add(min_options_height));
let adjusted_question_height = question_height.min(max_question_height);
question_lines.truncate(adjusted_question_height as usize);
let options_height =
available_height.saturating_sub(1u16.saturating_add(adjusted_question_height));
(adjusted_question_height, 0, 0, 0, 0, options_height, 0)
}
/// Normal layout for options case: allocate space for all elements with
/// preference order: notes, footer, labels, then progress.
fn layout_with_options_normal(
&self,
available_height: u16,
question_height: u16,
options_required_height: u16,
notes_pref_height: u16,
footer_pref: u16,
) -> (u16, u16, u16, u16, u16, u16, u16) {
let options_height = options_required_height;
let used = 1u16
.saturating_add(question_height)
.saturating_add(options_height);
let mut remaining = available_height.saturating_sub(used);
// Prefer notes next, then footer, then labels, with progress last.
let mut notes_height = notes_pref_height.min(remaining);
remaining = remaining.saturating_sub(notes_height);
let footer_lines = footer_pref.min(remaining);
remaining = remaining.saturating_sub(footer_lines);
let mut answer_title_height = 0;
if remaining > 0 {
answer_title_height = 1;
remaining = remaining.saturating_sub(1);
}
let mut notes_title_height = 0;
if remaining > 0 {
notes_title_height = 1;
remaining = remaining.saturating_sub(1);
}
let mut progress_height = 0;
if remaining > 0 {
progress_height = 1;
remaining = remaining.saturating_sub(1);
}
// Expand the notes composer with any leftover rows.
notes_height = notes_height.saturating_add(remaining);
(
question_height,
progress_height,
answer_title_height,
notes_title_height,
notes_height,
options_height,
footer_lines,
)
}
/// Layout calculation when no options are present.
///
/// Handles both tight layout (when space is constrained) and normal layout
/// (when there's sufficient space for all elements).
///
/// Returns: (question_height, progress_height, answer_title_height, notes_title_height, notes_height, options_height, footer_lines)
fn layout_without_options(
&self,
available_height: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16) {
let required = 1u16.saturating_add(question_height);
if required > available_height {
self.layout_without_options_tight(available_height, question_height, question_lines)
} else {
self.layout_without_options_normal(
available_height,
question_height,
notes_pref_height,
footer_pref,
)
}
}
/// Tight layout for no-options case: truncate question to fit available space.
fn layout_without_options_tight(
&self,
available_height: u16,
question_height: u16,
question_lines: &mut Vec<String>,
) -> (u16, u16, u16, u16, u16, u16, u16) {
let max_question_height = available_height.saturating_sub(1);
let adjusted_question_height = question_height.min(max_question_height);
question_lines.truncate(adjusted_question_height as usize);
(adjusted_question_height, 0, 0, 0, 0, 0, 0)
}
/// Normal layout for no-options case: allocate space for notes, footer, and progress.
fn layout_without_options_normal(
&self,
available_height: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
) -> (u16, u16, u16, u16, u16, u16, u16) {
let required = 1u16.saturating_add(question_height);
let mut remaining = available_height.saturating_sub(required);
let mut notes_height = notes_pref_height.min(remaining);
remaining = remaining.saturating_sub(notes_height);
let footer_lines = footer_pref.min(remaining);
remaining = remaining.saturating_sub(footer_lines);
let mut progress_height = 0;
if remaining > 0 {
progress_height = 1;
remaining = remaining.saturating_sub(1);
}
notes_height = notes_height.saturating_add(remaining);
(
question_height,
progress_height,
0,
0,
notes_height,
0,
footer_lines,
)
}
/// Build the final layout areas from computed heights.
fn build_layout_areas(
&self,
area: Rect,
heights: LayoutHeights,
) -> (
Rect, // progress_area
Rect, // header_area
Rect, // question_area
Rect, // answer_title_area
Rect, // options_area
Rect, // notes_title_area
Rect, // notes_area
) {
let mut cursor_y = area.y;
let progress_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.progress_height,
};
cursor_y = cursor_y.saturating_add(heights.progress_height);
let header_height = area.height.saturating_sub(heights.progress_height).min(1);
let header_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: header_height,
};
cursor_y = cursor_y.saturating_add(header_height);
let question_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.question_height,
};
cursor_y = cursor_y.saturating_add(heights.question_height);
let answer_title_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.answer_title_height,
};
cursor_y = cursor_y.saturating_add(heights.answer_title_height);
let options_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.options_height,
};
cursor_y = cursor_y.saturating_add(heights.options_height);
let notes_title_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.notes_title_height,
};
cursor_y = cursor_y.saturating_add(heights.notes_title_height);
let notes_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.notes_height,
};
(
progress_area,
header_area,
question_area,
answer_title_area,
options_area,
notes_title_area,
notes_area,
)
}
}
#[derive(Clone, Copy, Debug)]
struct LayoutHeights {
progress_height: u16,
question_height: u16,
answer_title_height: u16,
options_height: u16,
notes_title_height: u16,
notes_height: u16,
}
@@ -2,13 +2,13 @@
//!
//! Core behaviors:
//! - Each question can be answered by selecting one option and/or providing notes.
//! - When options exist, notes are stored per selected option and appended as extra answers.
//! - Notes are stored per question and appended as extra answers.
//! - Typing while focused on options jumps into notes to keep freeform input fast.
//! - Enter advances to the next question; the last question submits all answers.
//! - Freeform-only questions submit an empty answer list when empty.
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -19,18 +19,25 @@ mod render;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::ChatComposerConfig;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::render::renderable::Renderable;
use codex_core::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::TextElement;
const NOTES_PLACEHOLDER: &str = "Add notes (optional)";
const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)";
// Keep in sync with ChatComposer's minimum composer height.
const MIN_COMPOSER_HEIGHT: u16 = 3;
const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes (optional)";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -39,18 +46,11 @@ enum Focus {
Notes,
}
struct NotesEntry {
text: TextArea,
state: RefCell<TextAreaState>,
}
impl NotesEntry {
fn new() -> Self {
Self {
text: TextArea::new(),
state: RefCell::new(TextAreaState::default()),
}
}
#[derive(Default, Clone)]
struct ComposerDraft {
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
}
struct AnswerState {
@@ -58,10 +58,8 @@ struct AnswerState {
selected: Option<usize>,
// Scrollable cursor state for option navigation/highlight.
option_state: ScrollState,
// Notes for freeform-only questions.
notes: NotesEntry,
// Per-option notes for option questions.
option_notes: Vec<NotesEntry>,
// Per-question notes draft.
draft: ComposerDraft,
}
pub(crate) struct RequestUserInputOverlay {
@@ -69,6 +67,10 @@ pub(crate) struct RequestUserInputOverlay {
request: RequestUserInputEvent,
// Queue of incoming requests to process after the current one.
queue: VecDeque<RequestUserInputEvent>,
// Reuse the shared chat composer so notes/freeform answers match the
// primary input styling and behavior.
composer: ChatComposer,
// One entry per question: selection state plus a stored notes draft.
answers: Vec<AnswerState>,
current_idx: usize,
focus: Focus,
@@ -76,11 +78,30 @@ pub(crate) struct RequestUserInputOverlay {
}
impl RequestUserInputOverlay {
pub(crate) fn new(request: RequestUserInputEvent, app_event_tx: AppEventSender) -> Self {
pub(crate) fn new(
request: RequestUserInputEvent,
app_event_tx: AppEventSender,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
) -> Self {
// Use the same composer widget, but disable popups/slash-commands and
// image-path attachment so it behaves like a focused notes field.
let mut composer = ChatComposer::new_with_config(
has_input_focus,
app_event_tx.clone(),
enhanced_keys_supported,
ANSWER_PLACEHOLDER.to_string(),
disable_paste_burst,
ChatComposerConfig::plain_text(),
);
// The overlay renders its own footer hints, so keep the composer footer empty.
composer.set_footer_hint_override(Some(Vec::new()));
let mut overlay = Self {
app_event_tx,
request,
queue: VecDeque::new(),
composer,
answers: Vec::new(),
current_idx: 0,
focus: Focus::Options,
@@ -88,6 +109,7 @@ impl RequestUserInputOverlay {
};
overlay.reset_for_request();
overlay.ensure_focus_available();
overlay.restore_current_draft();
overlay
}
@@ -144,28 +166,96 @@ impl RequestUserInputOverlay {
.map(|option| option.label.as_str())
}
fn current_notes_entry(&self) -> Option<&NotesEntry> {
let answer = self.current_answer()?;
if !self.has_options() {
return Some(&answer.notes);
}
let idx = self
.selected_option_index()
.or(answer.option_state.selected_idx)?;
answer.option_notes.get(idx)
pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec<String> {
self.current_question()
.map(|q| {
textwrap::wrap(&q.question, width.max(1) as usize)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn current_notes_entry_mut(&mut self) -> Option<&mut NotesEntry> {
let has_options = self.has_options();
let answer = self.current_answer_mut()?;
if !has_options {
return Some(&mut answer.notes);
pub(super) fn option_rows(&self) -> Vec<GenericDisplayRow> {
self.current_question()
.and_then(|question| question.options.as_ref())
.map(|options| {
options
.iter()
.enumerate()
.map(|(idx, opt)| {
let selected = self
.current_answer()
.and_then(|answer| answer.selected)
.is_some_and(|sel| sel == idx);
let prefix = if selected { "(x)" } else { "( )" };
GenericDisplayRow {
name: format!("{prefix} {}", opt.label),
description: Some(opt.description.clone()),
..Default::default()
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub(super) fn options_required_height(&self, width: u16) -> u16 {
if !self.has_options() {
return 0;
}
let idx = answer
.selected
.or(answer.option_state.selected_idx)
.or_else(|| answer.option_notes.is_empty().then_some(0))?;
answer.option_notes.get_mut(idx)
let rows = self.option_rows();
if rows.is_empty() {
return 1;
}
let mut state = self
.current_answer()
.map(|answer| answer.option_state)
.unwrap_or_default();
if state.selected_idx.is_none() {
state.selected_idx = Some(0);
}
measure_rows_height(&rows, &state, rows.len(), width.max(1))
}
fn capture_composer_draft(&self) -> ComposerDraft {
ComposerDraft {
text: self.composer.current_text_with_pending(),
text_elements: self.composer.text_elements(),
local_image_paths: self
.composer
.local_images()
.into_iter()
.map(|img| img.path)
.collect(),
}
}
fn save_current_draft(&mut self) {
let draft = self.capture_composer_draft();
if let Some(answer) = self.current_answer_mut() {
answer.draft = draft;
}
}
fn restore_current_draft(&mut self) {
self.composer
.set_placeholder_text(self.notes_placeholder().to_string());
self.composer.set_footer_hint_override(Some(Vec::new()));
let Some(answer) = self.current_answer() else {
self.composer
.set_text_content(String::new(), Vec::new(), Vec::new());
self.composer.move_cursor_to_end();
return;
};
let draft = answer.draft.clone();
self.composer
.set_text_content(draft.text, draft.text_elements, draft.local_image_paths);
self.composer.move_cursor_to_end();
}
fn notes_placeholder(&self) -> &'static str {
@@ -200,24 +290,23 @@ impl RequestUserInputOverlay {
.iter()
.map(|question| {
let mut option_state = ScrollState::new();
let mut option_notes = Vec::new();
if let Some(options) = question.options.as_ref()
&& !options.is_empty()
{
option_state.selected_idx = Some(0);
option_notes = (0..options.len()).map(|_| NotesEntry::new()).collect();
}
AnswerState {
selected: option_state.selected_idx,
option_state,
notes: NotesEntry::new(),
option_notes,
draft: ComposerDraft::default(),
}
})
.collect();
self.current_idx = 0;
self.focus = Focus::Options;
self.composer
.set_text_content(String::new(), Vec::new(), Vec::new());
}
/// Move to the next/previous question, wrapping in either direction.
@@ -226,9 +315,11 @@ impl RequestUserInputOverlay {
if len == 0 {
return;
}
self.save_current_draft();
let offset = if next { 1 } else { len.saturating_sub(1) };
self.current_idx = (self.current_idx + offset) % len;
self.ensure_focus_available();
self.restore_current_draft();
}
/// Synchronize selection state to the currently focused option.
@@ -266,6 +357,7 @@ impl RequestUserInputOverlay {
/// Build the response payload and dispatch it to the app.
fn submit_answers(&mut self) {
self.save_current_draft();
let mut answers = HashMap::new();
for (idx, question) in self.request.questions.iter().enumerate() {
let answer_state = &self.answers[idx];
@@ -278,15 +370,8 @@ impl RequestUserInputOverlay {
} else {
answer_state.selected
};
// Notes are appended as extra answers. When options exist, notes are per selected option.
let notes = if options.is_some_and(|opts| !opts.is_empty()) {
selected_idx
.and_then(|selected| answer_state.option_notes.get(selected))
.map(|entry| entry.text.text().trim().to_string())
.unwrap_or_default()
} else {
answer_state.notes.text.text().trim().to_string()
};
// Notes are appended as extra answers.
let notes = answer_state.draft.text.trim().to_string();
let selected_label = selected_idx.and_then(|selected_idx| {
question
.options
@@ -314,6 +399,7 @@ impl RequestUserInputOverlay {
self.request = next;
self.reset_for_request();
self.ensure_focus_available();
self.restore_current_draft();
} else {
self.done = true;
}
@@ -321,6 +407,7 @@ impl RequestUserInputOverlay {
/// Count freeform-only questions that have no notes.
fn unanswered_count(&self) -> usize {
let current_text = self.composer.current_text();
self.request
.questions
.iter()
@@ -331,7 +418,12 @@ impl RequestUserInputOverlay {
if options.is_some_and(|opts| !opts.is_empty()) {
false
} else {
answer.notes.text.text().trim().is_empty()
let notes = if *idx == self.current_index() {
current_text.as_str()
} else {
answer.draft.text.as_str()
};
notes.trim().is_empty()
}
})
.count()
@@ -339,12 +431,48 @@ impl RequestUserInputOverlay {
/// Compute the preferred notes input height for the current question.
fn notes_input_height(&self, width: u16) -> u16 {
let Some(entry) = self.current_notes_entry() else {
return 3;
};
let usable_width = width.saturating_sub(2);
let text_height = entry.text.desired_height(usable_width).clamp(1, 6);
text_height.saturating_add(2).clamp(3, 8)
let min_height = MIN_COMPOSER_HEIGHT;
self.composer
.desired_height(width.max(1))
.clamp(min_height, min_height.saturating_add(5))
}
fn apply_submission_to_draft(&mut self, text: String, text_elements: Vec<TextElement>) {
let local_image_paths = self
.composer
.local_images()
.into_iter()
.map(|img| img.path)
.collect::<Vec<_>>();
if let Some(answer) = self.current_answer_mut() {
answer.draft = ComposerDraft {
text: text.clone(),
text_elements: text_elements.clone(),
local_image_paths: local_image_paths.clone(),
};
}
self.composer
.set_text_content(text, text_elements, local_image_paths);
self.composer.move_cursor_to_end();
self.composer.set_footer_hint_override(Some(Vec::new()));
}
fn handle_composer_input_result(&mut self, result: InputResult) -> bool {
match result {
InputResult::Submitted {
text,
text_elements,
}
| InputResult::Queued {
text,
text_elements,
} => {
self.apply_submission_to_draft(text, text_elements);
self.go_next_or_submit();
true
}
_ => false,
}
}
}
@@ -376,18 +504,19 @@ impl BottomPaneView for RequestUserInputOverlay {
match self.focus {
Focus::Options => {
let options_len = self.options_len();
let Some(answer) = self.current_answer_mut() else {
return;
};
// Keep selection synchronized as the user moves.
match key_event.code {
KeyCode::Up => {
answer.option_state.move_up_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
if let Some(answer) = self.current_answer_mut() {
answer.option_state.move_up_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
}
KeyCode::Down => {
answer.option_state.move_down_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
if let Some(answer) = self.current_answer_mut() {
answer.option_state.move_down_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
}
KeyCode::Char(' ') => {
self.select_current_option();
@@ -400,41 +529,43 @@ impl BottomPaneView for RequestUserInputOverlay {
// Any typing while in options switches to notes for fast freeform input.
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.input(key_event);
}
let (result, _) = self.composer.handle_key_event(key_event);
self.handle_composer_input_result(result);
}
_ => {}
}
}
Focus::Notes => {
if matches!(key_event.code, KeyCode::Enter) {
self.go_next_or_submit();
self.ensure_selected_for_notes();
let (result, _) = self.composer.handle_key_event(key_event);
if !self.handle_composer_input_result(result) {
self.go_next_or_submit();
}
return;
}
if self.has_options() && matches!(key_event.code, KeyCode::Up | KeyCode::Down) {
let options_len = self.options_len();
let Some(answer) = self.current_answer_mut() else {
return;
};
match key_event.code {
KeyCode::Up => {
answer.option_state.move_up_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
if let Some(answer) = self.current_answer_mut() {
answer.option_state.move_up_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
}
KeyCode::Down => {
answer.option_state.move_down_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
if let Some(answer) = self.current_answer_mut() {
answer.option_state.move_down_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
}
_ => {}
}
return;
}
// Notes are per option when options exist.
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.input(key_event);
}
let (result, _) = self.composer.handle_key_event(key_event);
self.handle_composer_input_result(result);
}
}
}
@@ -453,25 +584,20 @@ impl BottomPaneView for RequestUserInputOverlay {
if pasted.is_empty() {
return false;
}
if matches!(self.focus, Focus::Notes) {
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.insert_str(&pasted);
return true;
}
return true;
}
if matches!(self.focus, Focus::Options) {
// Treat pastes the same as typing: switch into notes.
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.insert_str(&pasted);
return true;
}
return true;
}
false
self.ensure_selected_for_notes();
self.composer.handle_paste(pasted)
}
fn flush_paste_burst_if_due(&mut self) -> bool {
self.composer.flush_paste_burst_if_due()
}
fn is_in_paste_burst(&self) -> bool {
self.composer.is_in_paste_burst()
}
fn try_consume_user_input_request(
@@ -508,6 +634,7 @@ mod tests {
id: id.to_string(),
header: header.to_string(),
question: "Choose an option.".to_string(),
is_other: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: "Option 1".to_string(),
@@ -525,11 +652,41 @@ mod tests {
}
}
fn question_with_wrapped_options(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Choose the next step for this task.".to_string(),
is_other: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: "Discuss a code change".to_string(),
description:
"Walk through a plan, then implement it together with careful checks."
.to_string(),
},
RequestUserInputQuestionOption {
label: "Run targeted tests".to_string(),
description:
"Pick the most relevant crate and validate the current behavior first."
.to_string(),
},
RequestUserInputQuestionOption {
label: "Review the diff".to_string(),
description:
"Summarize the changes and highlight the most important risks and gaps."
.to_string(),
},
]),
}
}
fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Share details.".to_string(),
is_other: false,
options: None,
}
}
@@ -569,6 +726,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]),
tx,
true,
false,
false,
);
overlay.try_consume_user_input_request(request_event(
"turn-2",
@@ -592,6 +752,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
overlay.submit_answers();
@@ -611,6 +774,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
);
overlay.submit_answers();
@@ -629,6 +795,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
{
@@ -637,10 +806,9 @@ mod tests {
}
overlay.select_current_option();
overlay
.current_notes_entry_mut()
.expect("notes entry missing")
.text
.insert_str("Notes for option 2");
.composer
.set_text_content("Notes for option 2".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.submit_answers();
@@ -658,12 +826,39 @@ mod tests {
);
}
#[test]
fn large_paste_is_preserved_when_switching_questions() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_without_options("q1", "First"),
question_without_options("q2", "Second"),
],
),
tx,
true,
false,
false,
);
let large = "x".repeat(1_500);
overlay.composer.handle_paste(large.clone());
overlay.move_question(true);
assert_eq!(overlay.answers[0].draft.text, large);
}
#[test]
fn request_user_input_options_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 64, 16);
insta::assert_snapshot!(
@@ -678,6 +873,9 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 60, 8);
insta::assert_snapshot!(
@@ -686,6 +884,60 @@ mod tests {
);
}
#[test]
fn layout_allocates_all_wrapped_options_when_space_allows() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_wrapped_options("q1", "Next Step")],
),
tx,
true,
false,
false,
);
let width = 48u16;
let question_height = overlay.wrapped_question_lines(width).len() as u16;
let options_height = overlay.options_required_height(width);
let height = 1u16
.saturating_add(question_height)
.saturating_add(options_height)
.saturating_add(4);
let sections = overlay.layout_sections(Rect::new(0, 0, width, height));
assert_eq!(sections.options_area.height, options_height);
}
#[test]
fn request_user_input_wrapped_options_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_wrapped_options("q1", "Next Step")],
),
tx,
true,
false,
false,
);
let width = 52u16;
let question_height = overlay.wrapped_question_lines(width).len() as u16;
let options_height = overlay.options_required_height(width);
let height = 1u16
.saturating_add(question_height)
.saturating_add(options_height)
.saturating_add(4);
let area = Rect::new(0, 0, width, height);
insta::assert_snapshot!(
"request_user_input_wrapped_options",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_scroll_options_snapshot() {
let (tx, _rx) = test_sender();
@@ -696,6 +948,7 @@ mod tests {
id: "q1".to_string(),
header: "Next Step".to_string(),
question: "What would you like to do next?".to_string(),
is_other: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: "Discuss a code change (Recommended)".to_string(),
@@ -721,6 +974,9 @@ mod tests {
}],
),
tx,
true,
false,
false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
@@ -740,6 +996,9 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 64, 10);
insta::assert_snapshot!(
@@ -754,13 +1013,15 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
overlay.focus = Focus::Notes;
overlay
.current_notes_entry_mut()
.expect("notes entry missing")
.text
.insert_str("Notes");
.composer
.set_text_content("Notes".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.handle_key_event(KeyEvent::from(KeyCode::Down));
@@ -3,12 +3,12 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::key_hint;
use crate::render::renderable::Renderable;
@@ -17,18 +17,28 @@ use super::RequestUserInputOverlay;
impl Renderable for RequestUserInputOverlay {
fn desired_height(&self, width: u16) -> u16 {
let sections = self.layout_sections(Rect::new(0, 0, width, u16::MAX));
let mut height = sections
.question_lines
.len()
.saturating_add(5)
.saturating_add(self.notes_input_height(width) as usize)
.saturating_add(sections.footer_lines as usize);
let outer = Rect::new(0, 0, width, u16::MAX);
let inner = menu_surface_inset(outer);
let inner_width = inner.width.max(1);
let question_height = self.wrapped_question_lines(inner_width).len();
let options_height = self.options_required_height(inner_width) as usize;
let notes_height = self.notes_input_height(inner_width) as usize;
let footer_height = if self.unanswered_count() > 0 { 2 } else { 1 };
// Tight minimum height: progress + header + question + (optional) titles/options
// + notes composer + footer + menu padding.
let mut height = question_height
.saturating_add(options_height)
.saturating_add(notes_height)
.saturating_add(footer_height)
.saturating_add(2); // progress + header
if self.has_options() {
height = height.saturating_add(2);
height = height
.saturating_add(1) // answer title
.saturating_add(1); // notes title
}
height = height.max(8);
height as u16
height = height.saturating_add(menu_surface_padding_height() as usize);
height.max(8) as u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -46,7 +56,13 @@ impl RequestUserInputOverlay {
if area.width == 0 || area.height == 0 {
return;
}
let sections = self.layout_sections(area);
// Paint the same menu surface used by other bottom-pane overlays and
// then render the overlay content inside its inset area.
let content_area = render_menu_surface(area, buf);
if content_area.width == 0 || content_area.height == 0 {
return;
}
let sections = self.layout_sections(content_area);
// Progress header keeps the user oriented across multiple questions.
let progress_line = if self.question_count() > 0 {
@@ -96,28 +112,7 @@ impl RequestUserInputOverlay {
}
// Build rows with selection markers for the shared selection renderer.
let option_rows = self
.current_question()
.and_then(|question| question.options.as_ref())
.map(|options| {
options
.iter()
.enumerate()
.map(|(idx, opt)| {
let selected = self
.current_answer()
.and_then(|answer| answer.selected)
.is_some_and(|sel| sel == idx);
let prefix = if selected { "(x)" } else { "( )" };
GenericDisplayRow {
name: format!("{prefix} {}", opt.label),
description: Some(opt.description.clone()),
..Default::default()
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let option_rows = self.option_rows();
if self.has_options() {
let mut option_state = self
@@ -177,9 +172,9 @@ impl RequestUserInputOverlay {
);
Paragraph::new(Line::from(warning.dim())).render(
Rect {
x: area.x,
x: content_area.x,
y: footer_y,
width: area.width,
width: content_area.width,
height: 1,
},
buf,
@@ -218,9 +213,9 @@ impl RequestUserInputOverlay {
]);
Paragraph::new(Line::from(hint_spans).dim()).render(
Rect {
x: area.x,
x: content_area.x,
y: hint_y,
width: area.width,
width: content_area.width,
height: 1,
},
buf,
@@ -232,129 +227,24 @@ impl RequestUserInputOverlay {
if !self.focus_is_notes() {
return None;
}
let sections = self.layout_sections(area);
let entry = self.current_notes_entry()?;
let input_area = sections.notes_area;
if input_area.width <= 2 || input_area.height == 0 {
let content_area = menu_surface_inset(area);
if content_area.width == 0 || content_area.height == 0 {
return None;
}
if input_area.height < 3 {
// Inline notes layout uses a prefix and a single-line text area.
let prefix = notes_prefix();
let prefix_width = prefix.len() as u16;
if input_area.width <= prefix_width {
return None;
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(prefix_width),
y: input_area.y,
width: input_area.width.saturating_sub(prefix_width),
height: 1,
};
let state = *entry.state.borrow();
return entry.text.cursor_pos_with_state(textarea_rect, state);
let sections = self.layout_sections(content_area);
let input_area = sections.notes_area;
if input_area.width == 0 || input_area.height == 0 {
return None;
}
let text_area_height = input_area.height.saturating_sub(2);
let textarea_rect = Rect {
x: input_area.x.saturating_add(1),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let state = *entry.state.borrow();
entry.text.cursor_pos_with_state(textarea_rect, state)
self.composer.cursor_pos(input_area)
}
/// Render the notes input box or inline notes field.
/// Render the notes composer.
fn render_notes_input(&self, area: Rect, buf: &mut Buffer) {
let Some(entry) = self.current_notes_entry() else {
return;
};
if area.width < 2 || area.height == 0 {
if area.width == 0 || area.height == 0 {
return;
}
if area.height < 3 {
// Inline notes field for tight layouts.
let prefix = notes_prefix();
let prefix_width = prefix.len() as u16;
if area.width <= prefix_width {
Paragraph::new(Line::from(prefix.dim())).render(area, buf);
return;
}
Paragraph::new(Line::from(prefix.dim())).render(
Rect {
x: area.x,
y: area.y,
width: prefix_width,
height: 1,
},
buf,
);
let textarea_rect = Rect {
x: area.x.saturating_add(prefix_width),
y: area.y,
width: area.width.saturating_sub(prefix_width),
height: 1,
};
let mut state = entry.state.borrow_mut();
Clear.render(textarea_rect, buf);
StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state);
if entry.text.text().is_empty() {
Paragraph::new(Line::from(self.notes_placeholder().dim()))
.render(textarea_rect, buf);
}
return;
}
// Draw a light ASCII frame around the notes area.
let top_border = format!("+{}+", "-".repeat(area.width.saturating_sub(2) as usize));
let bottom_border = top_border.clone();
Paragraph::new(Line::from(top_border)).render(
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
buf,
);
Paragraph::new(Line::from(bottom_border)).render(
Rect {
x: area.x,
y: area.y.saturating_add(area.height.saturating_sub(1)),
width: area.width,
height: 1,
},
buf,
);
for row in 1..area.height.saturating_sub(1) {
Line::from(vec![
"|".into(),
" ".repeat(area.width.saturating_sub(2) as usize).into(),
"|".into(),
])
.render(
Rect {
x: area.x,
y: area.y.saturating_add(row),
width: area.width,
height: 1,
},
buf,
);
}
let text_area_height = area.height.saturating_sub(2);
let textarea_rect = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = entry.state.borrow_mut();
Clear.render(textarea_rect, buf);
StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state);
if entry.text.text().is_empty() {
Paragraph::new(Line::from(self.notes_placeholder().dim())).render(textarea_rect, buf);
}
self.composer.render(area, buf);
}
fn focus_is_options(&self) -> bool {
@@ -369,7 +259,3 @@ impl RequestUserInputOverlay {
!self.has_options() && self.focus_is_notes()
}
}
fn notes_prefix() -> &'static str {
"Notes: "
}
@@ -2,11 +2,12 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Goal
Share details.
+--------------------------------------------------------------+
|Type your answer (optional) |
+--------------------------------------------------------------+
Unanswered: 1 | Will submit as skipped
↑/↓ scroll | enter next question | esc interrupt
Question 1/1
Goal
Share details.
› Type your answer (optional)
Unanswered: 1 | Will submit as skipped
↑/↓ scroll | enter next question | esc interrupt
@@ -2,19 +2,18 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Area
Choose an option.
Answer
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Question 1/1
Area
Choose an option.
Answer
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Notes for Option 1 (optional)
› Add notes (optional)
Notes for Option 1 (optional)
+--------------------------------------------------------------+
|Add notes (optional) |
+--------------------------------------------------------------+
Option 1 of 3 | ↑/↓ scroll | enter next question | esc interrupt
Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter
@@ -2,13 +2,13 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Next Step
What would you like to do next?
( ) Discuss a code change (Recommended) Walk through a plan and
edit code together.
( ) Run tests Pick a crate and run its
tests.
( ) Review a diff Summarize or review current
Notes: Add notes (optional)
Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt
Next Step
What would you like to do next?
( ) Discuss a code change (Recommended) Walk through a plan and
edit code together.
( ) Run tests Pick a crate and run
its tests.
( ) Review a diff Summarize or review
current changes.
Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt
@@ -2,11 +2,11 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Area
Choose an option.
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Notes: Add notes (optional)
Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter
Area
Choose an option.
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Option 1 of 3 | ↑/↓ scroll | enter next question | esc i
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Next Step
Choose the next step for this task.
(x) Discuss a code change Walk through a plan,
then implement it
together with careful
checks.
( ) Run targeted tests Pick the most
relevant crate and
validate the current
behavior first.
( ) Review the diff Summarize the changes
and highlight the
most important risks
and gaps.
Option 1 of 3 | ↑/↓ scroll | enter next question
@@ -7,11 +7,15 @@ use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::key_hint::KeyBinding;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::user_message_style;
use super::scroll_state::ScrollState;
@@ -27,6 +31,36 @@ pub(crate) struct GenericDisplayRow {
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
}
const MENU_SURFACE_INSET_V: u16 = 1;
const MENU_SURFACE_INSET_H: u16 = 2;
/// Apply the shared "menu surface" padding used by bottom-pane overlays.
///
/// Rendering code should generally call [`render_menu_surface`] and then lay
/// out content inside the returned inset rect.
pub(crate) fn menu_surface_inset(area: Rect) -> Rect {
area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H))
}
/// Total vertical padding introduced by the menu surface treatment.
pub(crate) const fn menu_surface_padding_height() -> u16 {
MENU_SURFACE_INSET_V * 2
}
/// Paint the shared menu background and return the inset content area.
///
/// This keeps the surface treatment consistent across selection-style overlays
/// (for example `/model`, approvals, and request-user-input).
pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect {
if area.is_empty() {
return area;
}
Block::default()
.style(user_message_style())
.render(area, buf);
menu_surface_inset(area)
}
pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec<Line<'a>> {
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
@@ -0,0 +1,54 @@
//! Shared helpers for filtering and matching built-in slash commands.
//!
//! The same sandbox- and feature-gating rules are used by both the composer
//! and the command popup. Centralizing them here keeps those call sites small
//! and ensures they stay in sync.
use codex_common::fuzzy_match::fuzzy_match;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
/// Whether the Windows degraded-sandbox elevation flow is currently allowed.
pub(crate) fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// Return the built-ins that should be visible/usable for the current input.
pub(crate) fn builtins_for_input(
collaboration_modes_enabled: bool,
personality_command_enabled: bool,
) -> Vec<(&'static str, SlashCommand)> {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality)
.collect()
}
/// Find a single built-in command by exact name, after applying the gating rules.
pub(crate) fn find_builtin_command(
name: &str,
collaboration_modes_enabled: bool,
personality_command_enabled: bool,
) -> Option<SlashCommand> {
builtins_for_input(collaboration_modes_enabled, personality_command_enabled)
.into_iter()
.find(|(command_name, _)| *command_name == name)
.map(|(_, cmd)| cmd)
}
/// Whether any visible built-in fuzzily matches the provided prefix.
pub(crate) fn has_builtin_prefix(
name: &str,
collaboration_modes_enabled: bool,
personality_command_enabled: bool,
) -> bool {
builtins_for_input(collaboration_modes_enabled, personality_command_enabled)
.into_iter()
.any(|(command_name, _)| fuzzy_match(command_name, name).is_some())
}

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