diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 3625c830dd..35b91e25d5 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -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" diff --git a/AGENTS.md b/AGENTS.md index a0dcb98728..4b6f8992e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d0b3a0ec37..75d72d360f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index b39cfd5e15..a8ebb9be89 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -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" diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 75acf96f2a..e32b060902 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 65648bbdb0..e4b3b90589 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -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>, + /// 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>, /// 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, } +#[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>, } @@ -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!( diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a4860ea512..adf8ef0b45 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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: diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0cb6fb1bc6..20e7db762b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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() diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ab2f31fc51..fec9b2abdb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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>>; @@ -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(¶ms.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 = 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, model_providers: Option>, + source_kinds: Option>, sort_key: CoreThreadSortKey, archived: bool, ) -> Result<(Vec, Option), 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::>(); 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, diff --git a/codex-rs/app-server/src/filters.rs b/codex-rs/app-server/src/filters.rs new file mode 100644 index 0000000000..bd784c3dcc --- /dev/null +++ b/codex-rs/app-server/src/filters.rs @@ -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, Option>) { + 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::>(); + (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] + )); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index c6d943ae46..ca588e59d4 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -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 .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 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")); diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 8093121403..12bd4049b4 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -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; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 0be10c5fc6..fa160b4c41 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -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 { + 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, diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index e15319e02f..2a5b5d2159 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -67,6 +67,7 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result, git_info: Option, +) -> Result { + 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, + source: SessionSource, ) -> Result { 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, }; diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index b97acfc40c..d905c3c1b5 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -108,6 +108,10 @@ async fn test_codex_jsonrpc_conversation_flow() -> Result<()> { let AddConversationSubscriptionResponse { subscription_id } = to_response::(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::(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") diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 4d9e105147..09ec866781 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -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; diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 8eb253df65..f310b6c562 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -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, limit: Option, providers: Option>, + source_kinds: Option>, archived: Option, ) -> Result { - 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, limit: Option, providers: Option>, + source_kinds: Option>, sort_key: Option, archived: Option, ) -> Result { @@ -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?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs new file mode 100644 index 0000000000..c56b39e33b --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -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::(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::(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::(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" +"# +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 103cc4b349..faa6cf2fce 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -147,7 +147,7 @@ struct ResumeCommand { session_id: Option, /// 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 { diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 22cd18dde3..7cc42c4c49 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -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, diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index f23975f8dd..ad94cf5ea1 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -291,7 +291,7 @@ pub fn process_responses_event( if let Ok(item) = serde_json::from_value::(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" => { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 68b9ba699c..6b48dcb4a9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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", diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index acb2481fc8..dd05c18470 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -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, /// 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 diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 437380deef..6392b3e70c 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -625,11 +625,13 @@ fn build_api_prompt(prompt: &Prompt, instructions: String, tools_json: Vec 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>>, ) -> 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() diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8a66fca0e1..41e148e113 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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, models_manager: Arc, skills_manager: Arc, @@ -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; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index b99fde92bf..a34e689348 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -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, }, ); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ca3ccc1ffd..8744b756bf 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, + /// 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, @@ -906,6 +909,9 @@ pub struct ConfigToml { #[schemars(schema_with = "crate::config::schema::features_schema")] pub features: Option, + /// Suppress warnings about unstable (under development) features. + pub suppress_unstable_features_warning: Option, + /// Settings for ghost snapshots (used for undo). #[serde(default)] pub ghost_snapshot: Option, @@ -1199,17 +1205,17 @@ fn resolve_web_search_mode( config_toml: &ConfigToml, config_profile: &ConfigProfile, features: &Features, -) -> Option { +) -> 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, diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 1b3f381d3b..96379dfa03 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -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>, + + /// Optional OAuth scopes to request during MCP login. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scopes: Option>, } // Raw MCP config shape used for deserialization and JSON Schema generation. @@ -113,6 +117,8 @@ pub(crate) struct RawMcpServerConfig { pub enabled_tools: Option>, #[serde(default)] pub disabled_tools: Option>, + #[serde(default)] + pub scopes: Option>, } 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(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, }) } } diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 6d85a79360..ae56c384e4 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -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(); diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 55a1037542..32382aad71 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -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 { if UserInstructions::is_user_instructions(message) @@ -127,14 +128,17 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { 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:?}"), } } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 5e2a0d5d50..7fde52687c 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -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, +) { + 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 }), + }); +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 7e1d80ace5..010ba3874a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -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; diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index c3513bb061..1bb08a75be 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -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, } } diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 434db3b2fa..9462f745ba 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -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, }; diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index aff111b1ad..83059b912e 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -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())), } } diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 14b2addc55..9609f366b2 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -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 { 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> { // 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> { + 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> { + 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)) +} diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 5b65bada7c..fbddfecf35 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -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; diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 75bf0cc3f7..34afae2c94 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -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 { .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, diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index b578c4232f..78581186c7 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -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!( diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 09c988a15b..2216390857 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -35,7 +35,7 @@ struct SkillFrontmatterMetadata { } #[derive(Debug, Default, Deserialize)] -struct SkillToml { +struct SkillMetadataFile { #[serde(default)] interface: Option, } @@ -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 Option { - // 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 { + 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"); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index a2f30aa522..bbf9d9c276 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -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 {sub_id}\n interrupted\n {TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" + "{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" ), }], end_turn: None, diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index d156d3e0d5..89dbaf90fe 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -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()); diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 83ee53905b..b1666949f6 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -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::, _>>()?; // 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 { 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); } diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index d2c55b08df..112dcae21e 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -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" ))); diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index c3ff4354a8..64f4053bb5 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -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, - pub web_search_mode: Option, + pub web_search_mode: WebSearchMode, pub collab_tools: bool, pub collaboration_modes_tools: bool, pub experimental_supported_tools: Vec, @@ -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, + 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, + 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, diff --git a/codex-rs/core/src/web_search.rs b/codex-rs/core/src/web_search.rs new file mode 100644 index 0000000000..458758b70e --- /dev/null +++ b/codex-rs/core/src/web_search.rs @@ -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 + } +} diff --git a/codex-rs/core/templates/agents/orchestrator.md b/codex-rs/core/templates/agents/orchestrator.md index 09b6ea0b4e..c303591d48 100644 --- a/codex-rs/core/templates/agents/orchestrator.md +++ b/codex-rs/core/templates/agents/orchestrator.md @@ -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 diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 3d0447c729..4b5f450a50 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -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?”). \ No newline at end of file + +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. diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 3ca45ca735..e36d9592f1 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -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 } }) } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 3d867d592c..a247453775 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -57,6 +57,7 @@ pub struct TestCodexBuilder { config_mutators: Vec>, auth: CodexAuth, pre_build_hooks: Vec>, + home: Option>, } impl TestCodexBuilder { @@ -88,8 +89,16 @@ impl TestCodexBuilder { self } + pub fn with_home(mut self, home: Arc) -> Self { + self.home = Some(home); + self + } + pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result { - 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 { 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 { 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, } } diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 22d9fa8b79..9e268a2c50 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -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 diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 902609afc5..bdc2f311c0 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -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 diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 1ecac3afe0..f4706962a0 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -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 { diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index f6047ac733..d8757fb388 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -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 { .flat_map(ResponseMock::requests) .map(|request| request.body_json()) .collect::>(); - 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 { async fn start_test_conversation( server: &MockServer, model: Option<&str>, -) -> (TempDir, Config, ThreadManager, Arc) { - 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, Config, Arc, Arc) { + 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, text: &str) { @@ -1021,13 +1001,14 @@ async fn resume_conversation( config: &Config, path: std::path::PathBuf, ) -> Arc { - 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 { - let NewThread { thread, .. } = manager + manager .fork_thread(nth_user_message, config.clone(), path) .await - .expect("fork conversation"); - thread + .expect("fork conversation") + .thread } diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 8698991846..55474a68aa 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -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"] { diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 60d0dbc75f..08ac598388 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -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(()) } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e0da93a69c..9bf4ef8ef2 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -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; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index d2190653ea..763e72c371 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -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, diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index b08b5281c8..0c8277e915 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -38,7 +38,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { .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 diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 686428b21a..32969bcf5a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -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) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 74d3fc98f2..3872969610 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -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 { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index df678f9e9d..de62bf462d 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -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( server: &MockServer, - codex_home: &TempDir, + codex_home: Arc, mutator: F, ) -> Arc 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( server: &MockServer, - codex_home: &TempDir, + codex_home: Arc, resume_path: std::path::PathBuf, mutator: F, ) -> Arc 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 } diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4210a5c6ba..7eaeef6590 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -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 diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index a4213729bc..2af6192dbe 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -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 /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)); +} diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index cf03e09920..c24e263f9e 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -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 diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs new file mode 100644 index 0000000000..f148edc497 --- /dev/null +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -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()); +} diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 74cb1c55c8..45c91126d1 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -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(); diff --git a/codex-rs/core/tests/suite/web_search_cached.rs b/codex-rs/core/tests/suite/web_search_cached.rs index 1a69d8b737..261efaf942 100644 --- a/codex-rs/core/tests/suite/web_search_cached.rs +++ b/codex-rs/core/tests/suite/web_search_cached.rs @@ -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) diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index c0a2746ee6..b5bac6fb58 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -173,6 +173,7 @@ impl AppServerClient { limit: None, sort_key: None, model_providers: None, + source_kinds: None, archived: None, }, }; diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 2c8e124c1f..edd693d5ed 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -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: diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index f5579008b2..ee49cac0a6 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -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 diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 3d2001ae61..56ff805fa0 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -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 " 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, + session_id: Option, /// 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, + images: Vec, /// Prompt to send after resuming the session. If `-` is used, read from stdin. #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] + prompt: Option, +} + +#[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, + + /// 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, + + /// Prompt to send after resuming the session. If `-` is used, read from stdin. pub prompt: Option, } +impl From 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 { + 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. diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 76c22caa75..fb7aa54ab4 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -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::>(); + 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::>() + .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::(); + 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); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 3679b57380..0dfce33cb6 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -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, last_total_token_usage: Option, running_mcp_tool_calls: HashMap, + running_collab_tool_calls: HashMap, + running_web_search_calls: HashMap, last_critical_error: Option, } @@ -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) -> 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 { + fn handle_web_search_begin(&mut self, ev: &protocol::WebSearchBeginEvent) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::>(); + 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 { + 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 { + 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, + prompt: Option, + ) -> Vec { + 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, + prompt: Option, + agents_states: HashMap, + status: CollabToolCallStatus, + ) -> Vec { + 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 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 { diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index f3726dad76..47c67a6dca 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -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, +} + +/// 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, + pub prompt: Option, + pub agents_states: HashMap, + 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. diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 43de1f95f8..105824c60d 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -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); diff --git a/codex-rs/lmstudio/Cargo.toml b/codex-rs/lmstudio/Cargo.toml index cbe8d3313b..5f4849638a 100644 --- a/codex-rs/lmstudio/Cargo.toml +++ b/codex-rs/lmstudio/Cargo.toml @@ -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] diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index ba6b12aadf..00b9bc0e80 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -94,8 +94,8 @@ pub enum Personality { #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum WebSearchMode { - #[default] Disabled, + #[default] Cached, Live, } diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 9276a759cc..12fa6a0f51 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -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, } -#[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(), }) } } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 57241bb8b6..b0b8156916 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -157,7 +157,9 @@ pub enum ResponseItem { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] status: Option, - action: WebSearchAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + action: Option, }, // 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(()) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e9cb8147d7..c60b13d72f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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 { 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()), + }, }), }; diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs index 7c0e02c592..0e58348da4 100644 --- a/codex-rs/protocol/src/request_user_input.rs +++ b/codex-rs/protocol/src/request_user_input.rs @@ -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>, } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d3185adf34..5413fb1496 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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(); diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index b3be4fed1f..d143449226 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -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( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c4fba97f65..c283b10ab1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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, @@ -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, 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 { 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 { + 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 { + 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 { - 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) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index d0805707fa..3c54dff0fe 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -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, 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 = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs index dd0e84eb6d..07cfce163f 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -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, + features: Vec, state: ScrollState, complete: bool, app_event_tx: AppEventSender, @@ -46,11 +46,14 @@ pub(crate) struct ExperimentalFeaturesView { } impl ExperimentalFeaturesView { - pub(crate) fn new(features: Vec, app_event_tx: AppEventSender) -> Self { + pub(crate) fn new( + features: Vec, + 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 { diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 7591b99326..095a095500 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -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] = diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a570bfb384..d0236062f6 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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( diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs index fd5066c994..4a1817da89 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -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::>() - }) - .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, + ) -> (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, + ) -> (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, + ) -> (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, + ) -> (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, } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index a01aa2296b..1da955ec3b 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -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, -} - -impl NotesEntry { - fn new() -> Self { - Self { - text: TextArea::new(), - state: RefCell::new(TextAreaState::default()), - } - } +#[derive(Default, Clone)] +struct ComposerDraft { + text: String, + text_elements: Vec, + local_image_paths: Vec, } struct AnswerState { @@ -58,10 +58,8 @@ struct AnswerState { selected: Option, // 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, + // 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, + // 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, 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 { + self.current_question() + .map(|q| { + textwrap::wrap(&q.question, width.max(1) as usize) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + }) + .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 { + 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::>() + }) + .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) { + let local_image_paths = self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect::>(); + 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)); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index 68b7eefdc3..b64f704340 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -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::>() - }) - .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: " -} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap index b698776b13..dd5ced6fca 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap index bc3d7d55b2..37ed036478 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap index 22cb487413..ace8eb4537 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap index e1d01df7af..e8cd2bd22e 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap new file mode 100644 index 0000000000..4ae9dd048f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index c9ca076d60..89ef8b3b3f 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -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, // 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> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs new file mode 100644 index 0000000000..55765a00da --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -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 { + 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()) +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index aa420a3ea9..9dc1bc4bfe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -133,12 +133,12 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; -use crate::bottom_pane::BetaFeatureItem; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::CollaborationModeIndicator; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; +use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; use crate::bottom_pane::LocalImageAttachment; @@ -162,6 +162,7 @@ use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; +use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::markdown::append_markdown; @@ -1490,13 +1491,43 @@ impl ChatWidget { self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); } - fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { + fn on_web_search_begin(&mut self, ev: WebSearchBeginEvent) { self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.active_cell = Some(Box::new(history_cell::new_active_web_search_call( + ev.call_id, + String::new(), + self.config.animations, + ))); + self.bump_active_cell_revision(); + self.request_redraw(); } fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_web_search_call(ev.query)); + let WebSearchEndEvent { + call_id, + query, + action, + } = ev; + let mut handled = false; + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && cell.call_id() == call_id + { + cell.update(action.clone(), query.clone()); + cell.complete(); + self.bump_active_cell_revision(); + self.flush_active_cell(); + handled = true; + } + + if !handled { + self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); + } + self.had_work_activity = true; } fn on_collab_event(&mut self, cell: PlainHistoryCell) { @@ -4029,12 +4060,12 @@ impl ChatWidget { } pub(crate) fn open_experimental_popup(&mut self) { - let features: Vec = FEATURES + let features: Vec = FEATURES .iter() .filter_map(|spec| { - let name = spec.stage.beta_menu_name()?; - let description = spec.stage.beta_menu_description()?; - Some(BetaFeatureItem { + let name = spec.stage.experimental_menu_name()?; + let description = spec.stage.experimental_menu_description()?; + Some(ExperimentalFeatureItem { feature: spec.id, name: name.to_string(), description: description.to_string(), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap index c0de5e4eef..4101717cf9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap @@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs expression: popup --- Experimental features - Toggle beta features. Changes are saved to config.toml. + Toggle experimental features. Changes are saved to config.toml. › [ ] Ghost snapshots Capture undo snapshots each turn. [x] Shell tool Allow the model to run shell commands. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fab175edcf..44aa0340cb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2911,13 +2911,13 @@ async fn experimental_features_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; let features = vec![ - BetaFeatureItem { + ExperimentalFeatureItem { feature: Feature::GhostCommit, name: "Ghost snapshots".to_string(), description: "Capture undo snapshots each turn.".to_string(), enabled: false, }, - BetaFeatureItem { + ExperimentalFeatureItem { feature: Feature::ShellTool, name: "Shell tool".to_string(), description: "Allow the model to run shell commands.".to_string(), @@ -2937,7 +2937,7 @@ async fn experimental_features_toggle_saves_on_exit() { let expected_feature = Feature::GhostCommit; let view = ExperimentalFeaturesView::new( - vec![BetaFeatureItem { + vec![ExperimentalFeatureItem { feature: expected_feature, name: "Ghost snapshots".to_string(), description: "Capture undo snapshots each turn.".to_string(), diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 37c2c9f535..5020f248a0 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -43,6 +43,8 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; +use codex_core::web_search::web_search_detail; +use codex_protocol::models::WebSearchAction; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; @@ -1342,9 +1344,89 @@ pub(crate) fn new_active_mcp_tool_call( McpToolCallCell::new(call_id, invocation, animations_enabled) } -pub(crate) fn new_web_search_call(query: String) -> PrefixedWrappedHistoryCell { - let text: Text<'static> = Line::from(vec!["Searched".bold(), " ".into(), query.into()]).into(); - PrefixedWrappedHistoryCell::new(text, "• ".dim(), " ") +fn web_search_header(completed: bool) -> &'static str { + if completed { + "Searched" + } else { + "Searching the web" + } +} + +#[derive(Debug)] +pub(crate) struct WebSearchCell { + call_id: String, + query: String, + action: Option, + start_time: Instant, + completed: bool, + animations_enabled: bool, +} + +impl WebSearchCell { + pub(crate) fn new( + call_id: String, + query: String, + action: Option, + animations_enabled: bool, + ) -> Self { + Self { + call_id, + query, + action, + start_time: Instant::now(), + completed: false, + animations_enabled, + } + } + + pub(crate) fn call_id(&self) -> &str { + &self.call_id + } + + pub(crate) fn update(&mut self, action: WebSearchAction, query: String) { + self.action = Some(action); + self.query = query; + } + + pub(crate) fn complete(&mut self) { + self.completed = true; + } +} + +impl HistoryCell for WebSearchCell { + fn display_lines(&self, width: u16) -> Vec> { + let bullet = if self.completed { + "•".dim() + } else { + spinner(Some(self.start_time), self.animations_enabled) + }; + let header = web_search_header(self.completed); + let detail = web_search_detail(self.action.as_ref(), &self.query); + let text: Text<'static> = if detail.is_empty() { + Line::from(vec![header.bold()]).into() + } else { + Line::from(vec![header.bold(), " ".into(), detail.into()]).into() + }; + PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width) + } +} + +pub(crate) fn new_active_web_search_call( + call_id: String, + query: String, + animations_enabled: bool, +) -> WebSearchCell { + WebSearchCell::new(call_id, query, None, animations_enabled) +} + +pub(crate) fn new_web_search_call( + call_id: String, + query: String, + action: WebSearchAction, +) -> WebSearchCell { + let mut cell = WebSearchCell::new(call_id, query, Some(action), false); + cell.complete(); + cell } /// If any content is an image, return a new cell with the first decodable image. @@ -1840,6 +1922,7 @@ mod tests { use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::protocol::McpAuthStatus; + use codex_protocol::models::WebSearchAction; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; @@ -1968,6 +2051,7 @@ mod tests { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }; let mut servers = config.mcp_servers.get().clone(); servers.insert("docs".to_string(), stdio_config); @@ -1989,6 +2073,7 @@ mod tests { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }; servers.insert("http".to_string(), http_config); config @@ -2073,8 +2158,12 @@ mod tests { #[test] fn web_search_history_cell_snapshot() { + let query = + "example search query with several generic words to exercise wrapping".to_string(); let cell = new_web_search_call( - "example search query with several generic words to exercise wrapping".to_string(), + "call-1".to_string(), + query.clone(), + WebSearchAction::Search { query: Some(query) }, ); let rendered = render_lines(&cell.display_lines(64)).join("\n"); @@ -2083,8 +2172,12 @@ mod tests { #[test] fn web_search_history_cell_wraps_with_indented_continuation() { + let query = + "example search query with several generic words to exercise wrapping".to_string(); let cell = new_web_search_call( - "example search query with several generic words to exercise wrapping".to_string(), + "call-1".to_string(), + query.clone(), + WebSearchAction::Search { query: Some(query) }, ); let rendered = render_lines(&cell.display_lines(64)); @@ -2099,7 +2192,12 @@ mod tests { #[test] fn web_search_history_cell_short_query_does_not_wrap() { - let cell = new_web_search_call("short query".to_string()); + let query = "short query".to_string(); + let cell = new_web_search_call( + "call-1".to_string(), + query.clone(), + WebSearchAction::Search { query: Some(query) }, + ); let rendered = render_lines(&cell.display_lines(64)); assert_eq!(rendered, vec!["• Searched short query".to_string()]); @@ -2107,8 +2205,12 @@ mod tests { #[test] fn web_search_history_cell_transcript_snapshot() { + let query = + "example search query with several generic words to exercise wrapping".to_string(); let cell = new_web_search_call( - "example search query with several generic words to exercise wrapping".to_string(), + "call-1".to_string(), + query.clone(), + WebSearchAction::Search { query: Some(query) }, ); let rendered = render_lines(&cell.transcript_lines(64)).join("\n"); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 28d27d2301..806cc39e04 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -67,7 +67,7 @@ impl SlashCommand { SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Permissions => "choose what Codex is allowed to do", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", - SlashCommand::Experimental => "toggle beta features", + SlashCommand::Experimental => "toggle experimental features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 8acfcce892..1b59eb4f80 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -15,15 +15,15 @@ lazy_static! { static ref ALL_TOOLTIPS: Vec<&'static str> = { let mut tips = Vec::new(); tips.extend(TOOLTIPS.iter().copied()); - tips.extend(beta_tooltips()); + tips.extend(experimental_tooltips()); tips }; } -fn beta_tooltips() -> Vec<&'static str> { +fn experimental_tooltips() -> Vec<&'static str> { FEATURES .iter() - .filter_map(|spec| spec.stage.beta_announcement()) + .filter_map(|spec| spec.stage.experimental_announcement()) .collect() } diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index b5cf56a6b4..0bb6fa2caa 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -15,7 +15,7 @@ impl UpdateAction { match self { UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), - UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), } } diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 302a2b1ea5..31819c6628 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -10,8 +10,7 @@ Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. You can run any shell command from Codex using `!` (e.g. `!ls`) -Type / to open the command popup; Tab autocompletes slash commands and saved prompts. -You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts +Type / to open the command popup; Tab autocompletes slash commands. When the composer is empty, press Esc to step back and edit your last message; Enter confirms. Press Tab to queue a message instead of sending it immediately; Enter always sends immediately. Paste an image with Ctrl+V to attach it to your next message. diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index aa872035bc..f92c389e4b 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -25,6 +25,7 @@ chrono = { version = "0.4.42", default-features = false, features = [ "std", ] } codex-utils-absolute-path = { workspace = true } +codex-utils-string = { workspace = true } dunce = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index 2e4de1d29a..f575ce3b8d 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -4,6 +4,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; +use codex_utils_string::take_bytes_at_char_boundary; + const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; pub const LOG_FILE_NAME: &str = "sandbox.log"; @@ -22,7 +24,7 @@ fn preview(command: &[String]) -> String { if joined.len() <= LOG_COMMAND_PREVIEW_LIMIT { joined } else { - joined[..LOG_COMMAND_PREVIEW_LIMIT].to_string() + take_bytes_at_char_boundary(&joined, LOG_COMMAND_PREVIEW_LIMIT).to_string() } } @@ -72,3 +74,19 @@ pub fn log_note(msg: &str, base_dir: Option<&Path>) { let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_does_not_panic_on_utf8_boundary() { + // Place a 4-byte emoji such that naive (byte-based) truncation would split it. + let prefix = "x".repeat(LOG_COMMAND_PREVIEW_LIMIT - 1); + let command = vec![format!("{prefix}😀")]; + let result = std::panic::catch_unwind(|| preview(&command)); + assert!(result.is_ok()); + let previewed = result.unwrap(); + assert!(previewed.len() <= LOG_COMMAND_PREVIEW_LIMIT); + } +} diff --git a/docs/contributing.md b/docs/contributing.md index 8e73119910..d410123d2d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,29 +1,47 @@ ## Contributing -This project is under active development and the code will likely change pretty significantly. +**External contributions are by invitation only** -**At the moment, we are generally accepting external contributions only for bugs fixes.** +At this time, the Codex team does not accept unsolicited code contributions. -If you want to add a new feature or change the behavior of an existing one, please open an issue proposing the feature or upvote an existing enhancement request. We will generally prioritize new features based on community feedback. New features must compose well with existing and upcoming features and fit into our roadmap. They must also be implemented consistently across all Codex surfaces (CLI, IDE extension, web, etc.). +If you would like to propose a new feature or a change in behavior, please open an issue describing the proposal or upvote an existing enhancement request. We prioritize new features based on community feedback, alignment with our roadmap, and consistency across all Codex surfaces (CLI, IDE extensions, web, etc.). -If you want to contribute a bug fix, please open a bug report first - or verify that there is an existing bug report that discusses the issue. All bug fix PRs should include a link to a bug report. +If you encounter a bug, please open a bug report or verify that an existing report already covers the issue. If you would like to help, we encourage you to contribute by sharing analysis, reproduction details, root-cause hypotheses, or a high-level outline of a potential fix directly in the issue thread. -**New contributions that don't go through this process may be closed** if they aren't aligned with our current roadmap or conflict with other priorities/upcoming features. +The Codex team may invite an external contributor to submit a pull request when: + +- the problem is well understood, +- the proposed approach aligns with the team’s intended solution, and +- the issue is deemed high-impact and high-priority. + +Pull requests that have not been explicitly invited by a member of the Codex team will be closed without review. + +**Why we do not generally accept external code contributions** + +In the past, the Codex team accepted external pull requests for bug fixes. While we appreciated the effort and engagement from the community, this model did not scale well. + +Many contributions were made without full visibility into the architectural context, system-level constraints, or near-term roadmap considerations that guide Codex development. Others focused on issues that were low priority or affected a very small subset of users. Reviewing and iterating on these PRs often took more time than implementing the fix directly, and diverted attention from higher-priority work. + +The most valuable contributions consistently came from community members who demonstrated deep understanding of a problem domain. That expertise is most helpful when shared early -- through detailed bug reports, analysis, and design discussion in issues. Identifying the right solution is typically the hard part; implementing it is comparatively straightforward with the help of Codex itself. + +For these reasons, we focus external contributions on discussion, analysis, and feedback, and reserve code changes for cases where a targeted invitation makes sense. ### Development workflow +If you are invited by a Codex team member to contribute a PR, here is the recommended development workflow. + - Create a _topic branch_ from `main` - e.g. `feat/interactive-prompt`. - Keep your changes focused. Multiple unrelated fixes should be opened as separate PRs. - Ensure your change is free of lint warnings and test failures. -### Writing high-impact code changes +### Guidance for invited code contributions 1. **Start with an issue.** Open a new one or comment on an existing discussion so we can agree on the solution before code is written. 2. **Add or update tests.** A bug fix should generally come with test coverage that fails before your change and passes afterwards. 100% coverage is not required, but aim for meaningful assertions. 3. **Document behavior.** If your change affects user-facing behavior, update the README, inline help (`codex --help`), or relevant example projects. 4. **Keep commits atomic.** Each commit should compile and the tests should pass. This makes reviews and potential rollbacks easier. -### Opening a pull request +### Opening a pull request (by invitation only) - Fill in the PR template (or include similar information) - **What? Why? How?** - Include a link to a bug report or enhancement request in the issue tracker @@ -34,7 +52,7 @@ If you want to contribute a bug fix, please open a bug report first - or verify ### Review process 1. One maintainer will be assigned as a primary reviewer. -2. If your PR adds a new feature that was not previously discussed and approved, we may close your PR (see [Contributing](#contributing)). +2. If your invited PR introduces scope or behavior that was not previously discussed and approved, we may close the PR. 3. We may ask for changes. Please do not take this personally. We value the work, but we also value consistency and long-term maintainability. 4. When there is consensus that the PR meets the bar, a maintainer will squash-and-merge. @@ -42,7 +60,7 @@ If you want to contribute a bug fix, please open a bug report first - or verify - **Be kind and inclusive.** Treat others with respect; we follow the [Contributor Covenant](https://www.contributor-covenant.org/). - **Assume good intent.** Written communication is hard - err on the side of generosity. -- **Teach & learn.** If you spot something confusing, open an issue or PR with improvements. +- **Teach & learn.** If you spot something confusing, open an issue or discussion with suggestions or clarifications. ### Getting help diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 6436426cbb..baab4ce0ed 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -49,6 +49,31 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl - After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the latest text + cursor. +## Config gating for reuse + +`ChatComposer` now supports feature gating via `ChatComposerConfig` +(`codex-rs/tui/src/bottom_pane/chat_composer.rs`). The default config preserves current chat +behavior. + +Flags: + +- `popups_enabled` +- `slash_commands_enabled` +- `image_paste_enabled` + +Key effects when disabled: + +- When `popups_enabled` is `false`, `sync_popups()` forces `ActivePopup::None`. +- When `slash_commands_enabled` is `false`, the composer does not treat `/...` input as commands. +- When `slash_commands_enabled` is `false`, the composer does not expand custom prompts in + `prepare_submission_text`. +- When `slash_commands_enabled` is `false`, slash-context paste-burst exceptions are disabled. +- When `image_paste_enabled` is `false`, file-path paste image attachment is skipped. + +Built-in slash command availability is centralized in +`codex-rs/tui/src/bottom_pane/slash_commands.rs` and reused by both the composer and the command +popup so gating stays in sync. + ## Submission flow (Enter/Tab) There are multiple submission paths, but they share the same core rules: