From 1fc72c647fd52e3e73d4309c3b568d4d5fe012b5 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 15 Jan 2026 19:48:11 -0800 Subject: [PATCH 01/78] Fix token estimate during compaction (#9337) This addresses #9287 --- codex-rs/core/src/context_manager/history.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 4ee2e252c4..2279beef20 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -97,7 +97,11 @@ impl ContextManager { } | ResponseItem::Compaction { encrypted_content: content, - } => estimate_reasoning_length(content.len()) as i64, + } => { + let reasoning_bytes = estimate_reasoning_length(content.len()); + i64::try_from(approx_tokens_from_byte_count(reasoning_bytes)) + .unwrap_or(i64::MAX) + } item => { let serialized = serde_json::to_string(item).unwrap_or_default(); i64::try_from(approx_token_count(&serialized)).unwrap_or(i64::MAX) From 0cce6ebd833da7be1d3e432d4231fe5eae10c740 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 16 Jan 2026 01:06:24 -0800 Subject: [PATCH 02/78] rename model turn to sampling request (#9336) We have two type of turns now: model and user turns. It's always confusing to refer to either. Model turn is basically a sampling request. --- codex-rs/core/src/codex.rs | 46 ++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 06000a345c..61fb48f697 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2528,17 +2528,17 @@ fn errors_to_info(errors: &[SkillError]) -> Vec { .collect() } -/// Takes a user message as input and runs a loop where, at each turn, the model +/// Takes a user message as input and runs a loop where, at each sampling request, the model /// replies with either: /// /// - requested function calls /// - an assistant message /// /// While it is possible for the model to return multiple of these items in a -/// single turn, in practice, we generally one item per turn: +/// single sampling request, in practice, we generally one item per sampling request: /// /// - If the model requests a function call, we execute it and send the output -/// back to the model in the next turn. +/// back to the model in the next sampling request. /// - If the model sends only an assistant message, we record it in the /// conversation history and consider the turn complete. /// @@ -2611,13 +2611,13 @@ pub(crate) async fn run_turn( .collect::>(); // Construct the input that we will send to the model. - let turn_input: Vec = { + let sampling_request_input: Vec = { sess.record_conversation_items(&turn_context, &pending_input) .await; sess.clone_history().await.for_prompt() }; - let turn_input_messages = turn_input + let sampling_request_input_messages = sampling_request_input .iter() .filter_map(|item| match parse_turn_item(item) { Some(TurnItem::UserMessage(user_message)) => Some(user_message), @@ -2625,21 +2625,21 @@ pub(crate) async fn run_turn( }) .map(|user_message| user_message.message()) .collect::>(); - match run_model_turn( + match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), &mut client_session, - turn_input, + sampling_request_input, cancellation_token.child_token(), ) .await { - Ok(turn_output) => { - let TurnRunResult { + Ok(sampling_request_output) => { + let SamplingRequestResult { needs_follow_up, - last_agent_message: turn_last_agent_message, - } = turn_output; + last_agent_message: sampling_request_last_agent_message, + } = sampling_request_output; let total_usage_tokens = sess.get_total_token_usage().await; let token_limit_reached = total_usage_tokens >= auto_compact_limit; @@ -2650,13 +2650,13 @@ pub(crate) async fn run_turn( } if !needs_follow_up { - last_agent_message = turn_last_agent_message; + last_agent_message = sampling_request_last_agent_message; sess.notifier() .notify(&UserNotification::AgentTurnComplete { thread_id: sess.conversation_id.to_string(), turn_id: turn_context.sub_id.clone(), cwd: turn_context.cwd.display().to_string(), - input_messages: turn_input_messages, + input_messages: sampling_request_input_messages, last_assistant_message: last_agent_message.clone(), }); break; @@ -2712,14 +2712,14 @@ async fn run_auto_compact(sess: &Arc, turn_context: &Arc) cwd = %turn_context.cwd.display() ) )] -async fn run_model_turn( +async fn run_sampling_request( sess: Arc, turn_context: Arc, turn_diff_tracker: SharedTurnDiffTracker, client_session: &mut ModelClientSession, input: Vec, cancellation_token: CancellationToken, -) -> CodexResult { +) -> CodexResult { let mcp_tools = sess .services .mcp_connection_manager @@ -2753,7 +2753,7 @@ async fn run_model_turn( let mut retries = 0; loop { - let err = match try_run_turn( + let err = match try_run_sampling_request( Arc::clone(&router), Arc::clone(&sess), Arc::clone(&turn_context), @@ -2793,7 +2793,9 @@ async fn run_model_turn( } _ => backoff(retries), }; - warn!("stream disconnected - retrying turn ({retries}/{max_retries} in {delay:?})...",); + warn!( + "stream disconnected - retrying sampling request ({retries}/{max_retries} in {delay:?})...", + ); // Surface retry information to any UI/front‑end so the // user understands what is happening instead of staring @@ -2813,7 +2815,7 @@ async fn run_model_turn( } #[derive(Debug)] -struct TurnRunResult { +struct SamplingRequestResult { needs_follow_up: bool, last_agent_message: Option, } @@ -2845,7 +2847,7 @@ async fn drain_in_flight( model = %turn_context.client.get_model() ) )] -async fn try_run_turn( +async fn try_run_sampling_request( router: Arc, sess: Arc, turn_context: Arc, @@ -2853,7 +2855,7 @@ async fn try_run_turn( turn_diff_tracker: SharedTurnDiffTracker, prompt: &Prompt, cancellation_token: CancellationToken, -) -> CodexResult { +) -> CodexResult { let rollout_item = RolloutItem::TurnContext(TurnContextItem { cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, @@ -2897,7 +2899,7 @@ async fn try_run_turn( let mut active_item: Option = None; let mut should_emit_turn_diff = false; let receiving_span = trace_span!("receiving_stream"); - let outcome: CodexResult = loop { + let outcome: CodexResult = loop { let handle_responses = trace_span!( parent: &receiving_span, "handle_responses", @@ -2983,7 +2985,7 @@ async fn try_run_turn( needs_follow_up |= sess.has_pending_input().await; - break Ok(TurnRunResult { + break Ok(SamplingRequestResult { needs_follow_up, last_agent_message, }); From f5b3e738fbaa742a38071e367c18b1626837c25e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 16 Jan 2026 11:23:01 +0100 Subject: [PATCH 03/78] feat: propagate approval request of unsubscribed threads (#9232) A thread can now be spawned by another thread. In order to process the approval requests of such sub-threads, we need to detect those event and show them in the TUI. This is a temporary solution while the UX is being figured out. This PR should be reverted once done --- codex-rs/tui/src/app.rs | 189 +++++++++++++++++++++++++++++++--- codex-rs/tui/src/app_event.rs | 5 + 2 files changed, 180 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f1d6ca5da4..87d76909de 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -15,6 +15,8 @@ use crate::external_editor; use crate::file_search::FileSearchManager; use crate::history_cell; use crate::history_cell::HistoryCell; +#[cfg(not(debug_assertions))] +use crate::history_cell::UpdateAvailableHistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; @@ -37,6 +39,7 @@ use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::protocol::DeprecationNoticeEvent; +use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; @@ -58,6 +61,8 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -66,11 +71,9 @@ use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; use tokio::select; +use tokio::sync::broadcast; use tokio::sync::mpsc::unbounded_channel; -#[cfg(not(debug_assertions))] -use crate::history_cell::UpdateAvailableHistoryCell; - const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; #[derive(Debug, Clone)] @@ -347,6 +350,13 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, + + // TODO(jif) drop once new UX is here. + // Track external agent approvals spawned via AgentControl. + /// Map routed approval IDs to their originating external threads and original IDs. + external_approval_routes: HashMap, + /// Buffered Codex events while external approvals are pending. + paused_codex_events: VecDeque, } impl App { @@ -496,6 +506,8 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + external_approval_routes: HashMap::new(), + paused_codex_events: VecDeque::new(), }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -548,6 +560,9 @@ impl App { tui.frame_requester().schedule_frame(); + let mut thread_created_rx = thread_manager.subscribe_thread_created(); + let mut listen_for_threads = true; + let exit_reason = loop { let control = select! { Some(event) = app_event_rx.recv() => { @@ -556,6 +571,21 @@ impl App { Some(event) = tui_events.next() => { app.handle_tui_event(tui, event).await? } + // Listen on new thread creation due to collab tools. + created = thread_created_rx.recv(), if listen_for_threads => { + match created { + Ok(thread_id) => { + app.handle_thread_created(thread_id).await?; + } + Err(broadcast::error::RecvError::Lagged(_)) => { + tracing::warn!("thread_created receiver lagged; skipping resync"); + } + Err(broadcast::error::RecvError::Closed) => { + listen_for_threads = false; + } + } + AppRunControl::Continue + } }; match control { AppRunControl::Continue => {} @@ -846,18 +876,15 @@ impl App { self.chat_widget.on_commit_tick(); } AppEvent::CodexEvent(event) => { - if self.suppress_shutdown_complete - && matches!(event.msg, EventMsg::ShutdownComplete) - { - self.suppress_shutdown_complete = false; + if !self.external_approval_routes.is_empty() { + // Store the events while the approval is pending. + self.paused_codex_events.push_back(event); return Ok(AppRunControl::Continue); } - if let EventMsg::ListSkillsResponse(response) = &event.msg { - let cwd = self.chat_widget.config_ref().cwd.clone(); - let errors = errors_for_cwd(&cwd, response); - emit_skill_load_warnings(&self.app_event_tx, &errors); - } - self.chat_widget.handle_codex_event(event); + self.handle_codex_event_now(event); + } + AppEvent::ExternalApprovalRequest { thread_id, event } => { + self.handle_external_approval_request(thread_id, event); } AppEvent::Exit(mode) => match mode { ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), @@ -868,7 +895,54 @@ impl App { AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } - AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), + AppEvent::CodexOp(op) => match op { + // Catch potential approvals coming from an external thread and treat them + // directly. This support both command and patch approval. In such case + // the approval get transferred to the corresponding thread and the external + // approval map (`external_approval_routes`) is updated. + Op::ExecApproval { id, decision } => { + if let Some((thread_id, original_id)) = + self.external_approval_routes.remove(&id) + { + // Approval of a sub-agent. + self.forward_external_op( + thread_id, + Op::ExecApproval { + id: original_id, + decision, + }, + ) + .await; + self.finish_external_approval(); + } else { + // This is an approval but not external. + self.chat_widget + .submit_op(Op::ExecApproval { id, decision }); + } + } + Op::PatchApproval { id, decision } => { + if let Some((thread_id, original_id)) = + self.external_approval_routes.remove(&id) + { + // Approval of a sub-agent. + self.forward_external_op( + thread_id, + Op::PatchApproval { + id: original_id, + decision, + }, + ) + .await; + self.finish_external_approval(); + } else { + // This is an approval but not external. + self.chat_widget + .submit_op(Op::PatchApproval { id, decision }); + } + } + // Standard path where this is not an external approval response. + _ => self.chat_widget.submit_op(op), + }, AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane self.chat_widget.on_diff_complete(); @@ -1343,6 +1417,89 @@ impl App { Ok(AppRunControl::Continue) } + fn handle_codex_event_now(&mut self, event: Event) { + if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) { + self.suppress_shutdown_complete = false; + return; + } + if let EventMsg::ListSkillsResponse(response) = &event.msg { + let cwd = self.chat_widget.config_ref().cwd.clone(); + let errors = errors_for_cwd(&cwd, response); + emit_skill_load_warnings(&self.app_event_tx, &errors); + } + self.chat_widget.handle_codex_event(event); + } + + /// Routes external approval request events through the chat widget by + /// rewriting the event id to include the originating thread. + /// + /// `thread_id` is the external thread that issued the approval request. + /// `event` is the approval request event whose id is rewritten so replies + /// can be routed back to the correct thread. + fn handle_external_approval_request(&mut self, thread_id: ThreadId, mut event: Event) { + match &mut event.msg { + EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { + let original_id = event.id.clone(); + let routing_id = format!("{thread_id}:{original_id}"); + self.external_approval_routes + .insert(routing_id.clone(), (thread_id, original_id)); + event.id = routing_id; + } + _ => return, + } + self.chat_widget.handle_codex_event(event); + } + + async fn forward_external_op(&self, thread_id: ThreadId, op: Op) { + let thread = match self.server.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + tracing::warn!("failed to find thread {thread_id} for approval response: {err}"); + return; + } + }; + if let Err(err) = thread.submit(op).await { + tracing::warn!("failed to submit approval response to thread {thread_id}: {err}"); + } + } + + fn finish_external_approval(&mut self) { + if self.external_approval_routes.is_empty() { + while let Some(event) = self.paused_codex_events.pop_front() { + self.handle_codex_event_now(event); + } + } + } + + async fn handle_thread_created(&mut self, thread_id: ThreadId) -> Result<()> { + let thread = match self.server.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + tracing::warn!("failed to attach listener for thread {thread_id}: {err}"); + return Ok(()); + } + }; + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + loop { + let event = match thread.next_event().await { + Ok(event) => event, + Err(err) => { + tracing::debug!("external thread {thread_id} listener stopped: {err}"); + break; + } + }; + match event.msg { + EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { + app_event_tx.send(AppEvent::ExternalApprovalRequest { thread_id, event }); + } + _ => {} + } + } + }); + Ok(()) + } + fn reasoning_label(reasoning_effort: Option) -> &'static str { match reasoning_effort { Some(ReasoningEffortConfig::Minimal) => "minimal", @@ -1598,6 +1755,8 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + external_approval_routes: HashMap::new(), + paused_codex_events: VecDeque::new(), } } @@ -1638,6 +1797,8 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + external_approval_routes: HashMap::new(), + paused_codex_events: VecDeque::new(), }, rx, op_rx, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6ce5d2cae5..785f3ae2ce 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -14,6 +14,7 @@ use codex_common::approval_presets::ApprovalPreset; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; +use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use crate::bottom_pane::ApprovalRequest; @@ -41,6 +42,10 @@ pub(crate) enum WindowsSandboxFallbackReason { #[derive(Debug)] pub(crate) enum AppEvent { CodexEvent(Event), + ExternalApprovalRequest { + thread_id: ThreadId, + event: Event, + }, /// Start a new session. NewSession, From c1ac5223e125b042205466a70cbd509166ebec57 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 16 Jan 2026 11:49:28 +0100 Subject: [PATCH 04/78] feat: run user commands under user snapshot (#9357) The initial goal is for user snapshots to have access to aliases etc --- codex-rs/core/src/tasks/user_shell.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 2b7ae53d0c..508b16d484 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -27,6 +27,7 @@ use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; use crate::state::TaskKind; use crate::tools::format_exec_output_str; +use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::user_shell_command::user_shell_command_record_item; use super::SessionTask; @@ -74,9 +75,9 @@ impl SessionTask for UserShellCommandTask { // allows commands that use shell features (pipes, &&, redirects, etc.). // We do not source rc files or otherwise reformat the script. let use_login_shell = true; - let command = session - .user_shell() - .derive_exec_args(&self.command, use_login_shell); + let session_shell = session.user_shell(); + let command = session_shell.derive_exec_args(&self.command, use_login_shell); + let command = maybe_wrap_shell_lc_with_snapshot(&command, session_shell.as_ref()); let call_id = Uuid::new_v4().to_string(); let raw_command = self.command.clone(); From c576756c81ee84036093e7abcb0f0660dd3c2914 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 16 Jan 2026 12:05:04 +0100 Subject: [PATCH 05/78] feat: collab wait multiple IDs (#9294) --- .../app-server-protocol/src/protocol/v2.rs | 8 +- .../app-server/src/bespoke_event_handling.rs | 92 ++++-- codex-rs/core/src/tools/handlers/collab.rs | 291 +++++++++++++----- codex-rs/core/src/tools/spec.rs | 13 +- codex-rs/protocol/src/protocol.rs | 10 +- codex-rs/tui/src/collab.rs | 10 +- codex-rs/tui2/src/collab.rs | 10 +- 7 files changed, 302 insertions(+), 132 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ac930e1f42..8655d89c41 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1771,12 +1771,12 @@ pub enum ThreadItem { /// Thread ID of the agent issuing the collab request. sender_thread_id: String, /// Thread ID of the receiving agent, when applicable. In case of spawn operation, - /// this correspond to the newly spawned agent. - receiver_thread_id: Option, + /// this corresponds to the newly spawned agent. + receiver_thread_ids: Vec, /// Prompt text sent as part of the collab tool call, when available. prompt: Option, - /// Last known status of the target agent, when available. - agent_state: Option, + /// Last known status of the target agents, when available. + agents_states: HashMap, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f14dd2ddca..0b2520098d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -287,9 +287,9 @@ pub(crate) async fn apply_bespoke_event_handling( tool: CollabAgentTool::SpawnAgent, status: V2CollabToolCallStatus::InProgress, sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_id: None, + receiver_thread_ids: Vec::new(), prompt: Some(begin_event.prompt), - agent_state: None, + agents_states: HashMap::new(), }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -301,19 +301,32 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabAgentSpawnEnd(end_event) => { - let status = if end_event.new_thread_id.is_some() { - V2CollabToolCallStatus::Completed - } else { - V2CollabToolCallStatus::Failed + let has_receiver = end_event.new_thread_id.is_some(); + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, + _ if has_receiver => V2CollabToolCallStatus::Completed, + _ => V2CollabToolCallStatus::Failed, + }; + let (receiver_thread_ids, agents_states) = match end_event.new_thread_id { + Some(id) => { + let receiver_id = id.to_string(); + let received_status = V2CollabAgentStatus::from(end_event.status.clone()); + ( + vec![receiver_id.clone()], + [(receiver_id, received_status)].into_iter().collect(), + ) + } + None => (Vec::new(), HashMap::new()), }; let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::SpawnAgent, status, sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_id: end_event.new_thread_id.map(|id| id.to_string()), + receiver_thread_ids, prompt: Some(end_event.prompt), - agent_state: Some(V2CollabAgentStatus::from(end_event.status)), + agents_states, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -325,14 +338,15 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabAgentInteractionBegin(begin_event) => { + let receiver_thread_ids = vec![begin_event.receiver_thread_id.to_string()]; let item = ThreadItem::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::SendInput, status: V2CollabToolCallStatus::InProgress, sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_id: Some(begin_event.receiver_thread_id.to_string()), + receiver_thread_ids, prompt: Some(begin_event.prompt), - agent_state: None, + agents_states: HashMap::new(), }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -344,19 +358,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabAgentInteractionEnd(end_event) => { - let status = match end_event.status { + let status = match &end_event.status { codex_protocol::protocol::AgentStatus::Errored(_) | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, _ => V2CollabToolCallStatus::Completed, }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let received_status = V2CollabAgentStatus::from(end_event.status); let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::SendInput, status, sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_id: Some(end_event.receiver_thread_id.to_string()), + receiver_thread_ids: vec![receiver_id.clone()], prompt: Some(end_event.prompt), - agent_state: Some(V2CollabAgentStatus::from(end_event.status)), + agents_states: [(receiver_id, received_status)].into_iter().collect(), }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -368,14 +384,19 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabWaitingBegin(begin_event) => { + let receiver_thread_ids = begin_event + .receiver_thread_ids + .iter() + .map(ToString::to_string) + .collect(); let item = ThreadItem::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::Wait, status: V2CollabToolCallStatus::InProgress, sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_id: Some(begin_event.receiver_thread_id.to_string()), + receiver_thread_ids, prompt: None, - agent_state: None, + agents_states: HashMap::new(), }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -387,19 +408,31 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabWaitingEnd(end_event) => { - let status = match end_event.status { - codex_protocol::protocol::AgentStatus::Errored(_) - | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, - _ => V2CollabToolCallStatus::Completed, + let status = if end_event.statuses.values().any(|status| { + matches!( + status, + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound + ) + }) { + V2CollabToolCallStatus::Failed + } else { + V2CollabToolCallStatus::Completed }; + let receiver_thread_ids = end_event.statuses.keys().map(ToString::to_string).collect(); + let agents_states = end_event + .statuses + .iter() + .map(|(id, status)| (id.to_string(), V2CollabAgentStatus::from(status.clone()))) + .collect(); let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::Wait, status, sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_id: Some(end_event.receiver_thread_id.to_string()), + receiver_thread_ids, prompt: None, - agent_state: Some(V2CollabAgentStatus::from(end_event.status)), + agents_states, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -416,9 +449,9 @@ pub(crate) async fn apply_bespoke_event_handling( tool: CollabAgentTool::CloseAgent, status: V2CollabToolCallStatus::InProgress, sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_id: Some(begin_event.receiver_thread_id.to_string()), + receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()], prompt: None, - agent_state: None, + agents_states: HashMap::new(), }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -430,19 +463,26 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabCloseEnd(end_event) => { - let status = match end_event.status { + let status = match &end_event.status { codex_protocol::protocol::AgentStatus::Errored(_) | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, _ => V2CollabToolCallStatus::Completed, }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let agents_states = [( + receiver_id.clone(), + V2CollabAgentStatus::from(end_event.status), + )] + .into_iter() + .collect(); let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::CloseAgent, status, sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_id: Some(end_event.receiver_thread_id.to_string()), + receiver_thread_ids: vec![receiver_id], prompt: None, - agent_state: Some(V2CollabAgentStatus::from(end_event.status)), + agents_states, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index df3f2901da..6117211757 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -254,20 +254,26 @@ mod send_input { mod wait { use super::*; use crate::agent::status::is_final; + use futures::FutureExt; + use futures::StreamExt; + use futures::stream::FuturesUnordered; + use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; + use tokio::sync::watch::Receiver; use tokio::time::Instant; + use tokio::time::timeout_at; #[derive(Debug, Deserialize)] struct WaitArgs { - id: String, + ids: Vec, timeout_ms: Option, } #[derive(Debug, Serialize)] struct WaitResult { - status: AgentStatus, + status: HashMap, timed_out: bool, } @@ -278,7 +284,16 @@ mod wait { arguments: String, ) -> Result { let args: WaitArgs = parse_arguments(&arguments)?; - let receiver_thread_id = agent_id(&args.id)?; + if args.ids.is_empty() { + return Err(FunctionCallError::RespondToModel( + "ids must be non-empty".to_owned(), + )); + } + let receiver_thread_ids = args + .ids + .iter() + .map(|id| agent_id(id)) + .collect::, _>>()?; // Validate timeout. let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); @@ -296,105 +311,131 @@ mod wait { &turn, CollabWaitingBeginEvent { sender_thread_id: session.conversation_id, - receiver_thread_id, + receiver_thread_ids: receiver_thread_ids.clone(), call_id: call_id.clone(), } .into(), ) .await; - let status_rx = match session - .services - .agent_control - .subscribe_status(receiver_thread_id) - .await - { - Ok(status_rx) => status_rx, - Err(err) => { - let status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - session - .send_event( - &turn, - CollabWaitingEndEvent { - sender_thread_id: session.conversation_id, - receiver_thread_id, - call_id: call_id.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(receiver_thread_id, err)); + + let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len()); + let mut initial_final_statuses = Vec::new(); + for id in &receiver_thread_ids { + match session.services.agent_control.subscribe_status(*id).await { + Ok(rx) => { + let status = rx.borrow().clone(); + if is_final(&status) { + initial_final_statuses.push((*id, status)); + } + status_rxs.push((*id, rx)); + } + Err(CodexErr::ThreadNotFound(_)) => { + initial_final_statuses.push((*id, AgentStatus::NotFound)); + } + Err(err) => { + let mut statuses = HashMap::with_capacity(1); + statuses.insert(*id, session.services.agent_control.get_status(*id).await); + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id: call_id.clone(), + statuses, + } + .into(), + ) + .await; + return Err(collab_agent_error(*id, err)); + } } + } + + let statuses = if !initial_final_statuses.is_empty() { + initial_final_statuses + } else { + // Wait for the first agent to reach a final status. + let mut futures = FuturesUnordered::new(); + for (id, rx) in status_rxs.into_iter() { + let session = session.clone(); + futures.push(wait_for_final_status(session, id, rx)); + } + let mut results = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + loop { + match timeout_at(deadline, futures.next()).await { + Ok(Some(Some(result))) => { + results.push(result); + break; + } + Ok(Some(None)) => continue, + Ok(None) | Err(_) => break, + } + } + if !results.is_empty() { + // Drain the unlikely last elements to prevent race. + loop { + match futures.next().now_or_never() { + Some(Some(Some(result))) => results.push(result), + Some(Some(None)) => continue, + Some(None) | None => break, + } + } + } + results }; - let result = - wait_for_status(session.as_ref(), receiver_thread_id, timeout_ms, status_rx).await; + + // Convert payload. + let statuses_map = statuses.clone().into_iter().collect::>(); + let result = WaitResult { + status: statuses_map.clone(), + timed_out: statuses.is_empty(), + }; + + // Final event emission. session .send_event( &turn, CollabWaitingEndEvent { sender_thread_id: session.conversation_id, - receiver_thread_id, call_id, - status: result.status.clone(), + statuses: statuses_map, } .into(), ) .await; - if matches!(result.status, AgentStatus::NotFound) { - return Err(FunctionCallError::RespondToModel(format!( - "agent with id {receiver_thread_id} not found" - ))); - } - let content = serde_json::to_string(&result).map_err(|err| { FunctionCallError::Fatal(format!("failed to serialize wait result: {err}")) })?; - let success = !result.timed_out && !matches!(result.status, AgentStatus::Errored(_)); - Ok(ToolOutput::Function { content, - success: Some(success), + success: None, content_items: None, }) } - async fn wait_for_status( - session: &Session, - agent_id: ThreadId, - timeout_ms: i64, - mut status_rx: tokio::sync::watch::Receiver, - ) -> WaitResult { - // Get last known status. - let mut status = status_rx.borrow_and_update().clone(); - let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + async fn wait_for_final_status( + session: Arc, + thread_id: ThreadId, + mut status_rx: Receiver, + ) -> Option<(ThreadId, AgentStatus)> { + let mut status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } - let timed_out = loop { + loop { + if status_rx.changed().await.is_err() { + let latest = session.services.agent_control.get_status(thread_id).await; + return is_final(&latest).then_some((thread_id, latest)); + } + status = status_rx.borrow().clone(); if is_final(&status) { - break false; + return Some((thread_id, status)); } - - match timeout_at(deadline, status_rx.changed()).await { - Ok(Ok(())) => status = status_rx.borrow().clone(), - Ok(Err(_)) => { - let last_status = session.services.agent_control.get_status(agent_id).await; - if last_status != AgentStatus::NotFound { - // On-purpose we keep the last known status if the agent gets dropped. This - // event is not supposed to happen. - status = last_status; - } - break false; - } - Err(_) => break true, - } - }; - - WaitResult { status, timed_out } + } } } @@ -560,7 +601,9 @@ mod tests { use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::ThreadId; use pretty_assertions::assert_eq; + use serde::Deserialize; use serde_json::json; + use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -772,6 +815,12 @@ mod tests { .expect("shutdown should submit"); } + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct WaitResult { + status: HashMap, + timed_out: bool, + } + #[tokio::test] async fn wait_rejects_non_positive_timeout() { let (session, turn) = make_session_and_context().await; @@ -779,7 +828,10 @@ mod tests { Arc::new(session), Arc::new(turn), "wait", - function_payload(json!({"id": ThreadId::new().to_string(), "timeout_ms": 0})), + function_payload(json!({ + "ids": [ThreadId::new().to_string()], + "timeout_ms": 0 + })), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("non-positive timeout should be rejected"); @@ -797,7 +849,7 @@ mod tests { Arc::new(session), Arc::new(turn), "wait", - function_payload(json!({"id": "invalid"})), + function_payload(json!({"ids": ["invalid"]})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("invalid id should be rejected"); @@ -808,6 +860,65 @@ mod tests { assert!(msg.starts_with("invalid agent id invalid:")); } + #[tokio::test] + async fn wait_rejects_empty_ids() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({"ids": []})), + ); + let Err(err) = CollabHandler.handle(invocation).await else { + panic!("empty ids should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("ids must be non-empty".to_string()) + ); + } + + #[tokio::test] + async fn wait_returns_not_found_for_missing_agents() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let id_a = ThreadId::new(); + let id_b = ThreadId::new(); + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({ + "ids": [id_a.to_string(), id_b.to_string()], + "timeout_ms": 1000 + })), + ); + let output = CollabHandler + .handle(invocation) + .await + .expect("wait should succeed"); + let ToolOutput::Function { + content, success, .. + } = output + else { + panic!("expected function output"); + }; + let result: WaitResult = + serde_json::from_str(&content).expect("wait result should be json"); + assert_eq!( + result, + WaitResult { + status: HashMap::from([ + (id_a, AgentStatus::NotFound), + (id_b, AgentStatus::NotFound), + ]), + timed_out: false + } + ); + assert_eq!(success, None); + } + #[tokio::test] async fn wait_times_out_when_status_is_not_final() { let (mut session, turn) = make_session_and_context().await; @@ -820,7 +931,10 @@ mod tests { Arc::new(session), Arc::new(turn), "wait", - function_payload(json!({"id": agent_id.to_string(), "timeout_ms": 10})), + function_payload(json!({ + "ids": [agent_id.to_string()], + "timeout_ms": 10 + })), ); let output = CollabHandler .handle(invocation) @@ -832,8 +946,16 @@ mod tests { else { panic!("expected function output"); }; - assert_eq!(content, r#"{"status":"pending_init","timed_out":true}"#); - assert_eq!(success, Some(false)); + let result: WaitResult = + serde_json::from_str(&content).expect("wait result should be json"); + assert_eq!( + result, + WaitResult { + status: HashMap::new(), + timed_out: true + } + ); + assert_eq!(success, None); let _ = thread .thread @@ -869,7 +991,10 @@ mod tests { Arc::new(session), Arc::new(turn), "wait", - function_payload(json!({"id": agent_id.to_string(), "timeout_ms": 1000})), + function_payload(json!({ + "ids": [agent_id.to_string()], + "timeout_ms": 1000 + })), ); let output = CollabHandler .handle(invocation) @@ -881,8 +1006,16 @@ mod tests { else { panic!("expected function output"); }; - assert_eq!(content, r#"{"status":"shutdown","timed_out":false}"#); - assert_eq!(success, Some(true)); + let result: WaitResult = + serde_json::from_str(&content).expect("wait result should be json"); + assert_eq!( + result, + WaitResult { + status: HashMap::from([(agent_id, AgentStatus::Shutdown)]), + timed_out: false + } + ); + assert_eq!(success, None); } #[tokio::test] diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 5a35f2b648..9d272a5e6f 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -503,9 +503,10 @@ fn create_send_input_tool() -> ToolSpec { fn create_wait_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( - "id".to_string(), - JsonSchema::String { - description: Some("Identifier of the agent to wait on.".to_string()), + "ids".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some("Identifiers of the agents to wait on.".to_string()), }, ); properties.insert( @@ -519,11 +520,13 @@ fn create_wait_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "wait".to_string(), - description: "Wait for an agent and return its status.".to_string(), + description: + "Wait for agents and return their statuses. If no agent is done, no status get returned." + .to_string(), strict: false, parameters: JsonSchema::Object { properties, - required: Some(vec!["id".to_string()]), + required: Some(vec!["ids".to_string()]), additional_properties: Some(false.into()), }, }) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index df1b523cbc..7ab9099ae8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2177,8 +2177,8 @@ pub struct CollabAgentInteractionEndEvent { pub struct CollabWaitingBeginEvent { /// Thread ID of the sender. pub sender_thread_id: ThreadId, - /// Thread ID of the receiver. - pub receiver_thread_id: ThreadId, + /// Thread ID of the receivers. + pub receiver_thread_ids: Vec, /// ID of the waiting call. pub call_id: String, } @@ -2187,12 +2187,10 @@ pub struct CollabWaitingBeginEvent { pub struct CollabWaitingEndEvent { /// Thread ID of the sender. pub sender_thread_id: ThreadId, - /// Thread ID of the receiver. - pub receiver_thread_id: ThreadId, /// ID of the waiting call. pub call_id: String, - /// Last known status of the receiver agent reported to the sender agent. - pub status: AgentStatus, + /// Last known status of the receiver agents reported to the sender agent. + pub statuses: HashMap, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/collab.rs b/codex-rs/tui/src/collab.rs index 6b1042810c..5a1a18f63c 100644 --- a/codex-rs/tui/src/collab.rs +++ b/codex-rs/tui/src/collab.rs @@ -59,12 +59,12 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { call_id, sender_thread_id, - receiver_thread_id, + receiver_thread_ids, } = ev; let details = vec![ detail_line("call", call_id), detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), + detail_line("receiver", format!("{receiver_thread_ids:?}")), ]; collab_event("Collab wait begin", details) } @@ -73,14 +73,12 @@ pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { call_id, sender_thread_id, - receiver_thread_id, - status, + statuses, } = ev; let details = vec![ detail_line("call", call_id), detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), - status_line(&status), + detail_line("statuses", format!("{statuses:#?}")), ]; collab_event("Collab wait end", details) } diff --git a/codex-rs/tui2/src/collab.rs b/codex-rs/tui2/src/collab.rs index 6b1042810c..5a1a18f63c 100644 --- a/codex-rs/tui2/src/collab.rs +++ b/codex-rs/tui2/src/collab.rs @@ -59,12 +59,12 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { call_id, sender_thread_id, - receiver_thread_id, + receiver_thread_ids, } = ev; let details = vec![ detail_line("call", call_id), detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), + detail_line("receiver", format!("{receiver_thread_ids:?}")), ]; collab_event("Collab wait begin", details) } @@ -73,14 +73,12 @@ pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { call_id, sender_thread_id, - receiver_thread_id, - status, + statuses, } = ev; let details = vec![ detail_line("call", call_id), detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), - status_line(&status), + detail_line("statuses", format!("{statuses:#?}")), ]; collab_event("Collab wait end", details) } From 7fc49697dd1ac2fc13e24102fc376a854f9357b8 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 16 Jan 2026 14:52:16 +0100 Subject: [PATCH 06/78] feat: CODEX_CI (#9366) --- codex-rs/core/src/unified_exec/process_manager.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 851b92d24f..e51dc5e266 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -47,7 +47,7 @@ use crate::unified_exec::process::OutputHandles; use crate::unified_exec::process::UnifiedExecProcess; use crate::unified_exec::resolve_max_tokens; -const UNIFIED_EXEC_ENV: [(&str, &str); 9] = [ +const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [ ("NO_COLOR", "1"), ("TERM", "dumb"), ("LANG", "C.UTF-8"), @@ -57,6 +57,7 @@ const UNIFIED_EXEC_ENV: [(&str, &str); 9] = [ ("PAGER", "cat"), ("GIT_PAGER", "cat"), ("GH_PAGER", "cat"), + ("CODEX_CI", "1"), ]; fn apply_unified_exec_env(mut env: HashMap) -> HashMap { @@ -688,6 +689,7 @@ mod tests { ("PAGER".to_string(), "cat".to_string()), ("GIT_PAGER".to_string(), "cat".to_string()), ("GH_PAGER".to_string(), "cat".to_string()), + ("CODEX_CI".to_string(), "1".to_string()), ]); assert_eq!(env, expected); From 7905e99d03027f17dff5df50cef0fb601fc10f93 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 16 Jan 2026 15:12:41 +0100 Subject: [PATCH 07/78] prompt collab (#9367) --- .../core/templates/agents/orchestrator.md | 79 +++++++++++++------ 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/codex-rs/core/templates/agents/orchestrator.md b/codex-rs/core/templates/agents/orchestrator.md index 89d98c9334..19be2a1afe 100644 --- a/codex-rs/core/templates/agents/orchestrator.md +++ b/codex-rs/core/templates/agents/orchestrator.md @@ -1,38 +1,71 @@ You are Codex Orchestrator, based on GPT-5. You are running as an orchestration agent in the Codex CLI on a user's computer. ## Role -- The interface between the user and the workers. Your role is to understand a problem and then delegate/coordinate workers to solve the task. -- Monitor progress, resolve conflicts, and integrate results into a single, coherent outcome. -- You can perform basic actions such as code exploration or running basic commands if needed to understand the problem, but you must delegate the hard work to workers. -- If a task can be split in well scoped sub-tasks, use multiple workers to solve it, and you take care of the global orchestration. -- Your job is not finished before the entire task is completed. While this is not the case, keep monitoring and coordinating your workers. -- Do not rush the workers. If they are working, let them work and don't ask them to "finalize now" unless requested by the user. + +* You are the interface between the user and the workers. +* Your job is to understand the task, decompose it, and delegate well-scoped work to workers. +* You coordinate execution, monitor progress, resolve conflicts, and integrate results into a single coherent outcome. +* You may perform lightweight actions (e.g. reading files, basic commands) to understand the task, but all substantive work must be delegated to workers. +* **Your job is not finished until the entire task is fully completed and verified.** +* While the task is incomplete, you must keep monitoring and coordinating workers. You must not return early. + +## Core invariants + +* **Never stop monitoring workers.** +* **Do not rush workers. Be patient.** +* The orchestrator must not return unless the task is fully accomplished. + +## Worker execution semantics + +* While a worker is running, you cannot observe intermediate state. +* Messages sent with `send_input` are queued and processed only after the worker finishes, unless interrupted. +* Therefore: + * Do not send messages to “check status” or “ask for progress” unless being asked. + * Monitoring happens exclusively via `wait`. + * Sending a message is a commitment for the *next* phase of work. + +## Interrupt semantics + +* If a worker is taking longer than expected but is still working, do nothing and keep waiting unless being asked. +* Only intervene if you must change, stop, or redirect the *current* work. +* To stop a worker’s current task, you **must** use `send_input(interrupt=true)`. +* Use `interrupt=true` sparingly and deliberately. ## Multi-agent workflow -1. Understand the request and identify the optimal set of workers needed. +1. Understand the request and determine the optimal set of workers. If the task can be divided into sub-tasks, spawn one worker per sub-task and make them work together. 2. Spawn worker(s) with precise goals, constraints, and expected deliverables. -3. Monitor workers with `wait`, route questions via `send_input`, and keep scope boundaries clear. -4. When all workers report done, verify their work to make sure the task was correctly solved. -5. If you spot issues, assign fixes to the relevant worker(s) and repeat steps 3–5 until the task is correctly completed. -6. Close all agents when you don't need them anymore (i.e. when the task if fully finished). +3. Monitor workers using `wait`. +4. When a worker finishes: + * verify correctness, + * check integration with other work, + * assess whether the global task is closer to completion. +5. If issues remain, assign fixes to the appropriate worker(s) and repeat steps 3–5. +6. Close agents only when no further work is required from them. +7. Return to the user only when the task is fully completed and verified. ## Collaboration rules -- Tell every worker they are not alone in the environment and must not revert or overwrite others' work. -- Default: workers must not spawn sub-agents unless you explicitly allow it. -- When multiple workers are running, you can provide multiple ids to `wait` in order to wait for the first worker to finish. This will make your workflow event-based as the tool will return when the first agent is done (i.e. when you need to react on it). +* Workers operate in a shared environment. You must tell it to them. +* 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). ## Collab tools -- `spawn_agent`: create a worker with an initial prompt (set `agent_type`). -- `send_input`: send follow-ups, clarifications, or fix requests (`interrupt` can stop the current task first). -- `wait`: poll the completion status of a list of workers. Return once at least one worker in the list is done. -- `close_agent`: close the agent when done. +* `spawn_agent`: create a worker with an initial prompt (`agent_type` required). +* `send_input`: send follow-ups or fixes (queued unless interrupted). +* `send_input(interrupt=true)`: stop current work and redirect immediately. +* `wait`: wait for one or more workers; returns when at least one finishes. +* `close_agent`: close a worker when fully done. -## Presenting your work and final message +## Final response -- Keep responses concise, factual, and in plain text. -- Summarize: what was delegated, key outcomes, tests/verification status, and any remaining risks. -- If verification failed, state the issues clearly and what you asked workers to change. -- Do not dump large files; reference paths with backticks. +* Keep responses concise, factual, and in plain text. +* Summarize: + * what was delegated, + * key outcomes, + * verification performed, + * and any remaining risks. +* If verification failed, state issues clearly and describe what was reassigned. +* Do not dump large files inline; reference paths using backticks. From 1668ca726ff8cc5649710a20f297e16b1ec6420c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 16 Jan 2026 15:54:32 +0100 Subject: [PATCH 08/78] chore: close pipe on non-pty processes (#9369) Closing the STDIN of piped process when starting them to avoid commands like `rg` to wait for content on STDIN and hangs for ever --- .../core/src/tools/handlers/unified_exec.rs | 2 +- codex-rs/core/src/unified_exec/errors.rs | 4 ++ codex-rs/core/src/unified_exec/mod.rs | 1 + .../core/src/unified_exec/process_manager.rs | 11 +++- codex-rs/core/tests/suite/unified_exec.rs | 5 ++ codex-rs/utils/pty/README.md | 2 + codex-rs/utils/pty/src/lib.rs | 2 + codex-rs/utils/pty/src/pipe.rs | 62 +++++++++++++++---- 8 files changed, 74 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index d90ed3b644..daa39bc358 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -202,7 +202,7 @@ impl ToolHandler for UnifiedExecHandler { }) .await .map_err(|err| { - FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}")) + FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) })?; let interaction = TerminalInteractionEvent { diff --git a/codex-rs/core/src/unified_exec/errors.rs b/codex-rs/core/src/unified_exec/errors.rs index d8df389252..284c7bca6d 100644 --- a/codex-rs/core/src/unified_exec/errors.rs +++ b/codex-rs/core/src/unified_exec/errors.rs @@ -10,6 +10,10 @@ pub(crate) enum UnifiedExecError { UnknownProcessId { process_id: String }, #[error("failed to write to stdin")] WriteToStdin, + #[error( + "stdin is closed for this session; rerun exec_command with tty=true to keep stdin open" + )] + StdinClosed, #[error("missing command line for unified exec request")] MissingCommandLine, #[error("Command denied by sandbox: {message}")] diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 1db38b9905..42a08b571f 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -136,6 +136,7 @@ struct ProcessEntry { call_id: String, process_id: String, command: Vec, + tty: bool, last_used: tokio::time::Instant, } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index e51dc5e266..3230e75b15 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -74,6 +74,7 @@ struct PreparedProcessHandles { cancellation_token: CancellationToken, command: Vec, process_id: String, + tty: bool, } impl UnifiedExecProcessManager { @@ -218,6 +219,7 @@ impl UnifiedExecProcessManager { cwd.clone(), start, process_id, + request.tty, Arc::clone(&transcript), ) .await; @@ -256,10 +258,14 @@ impl UnifiedExecProcessManager { cancellation_token, command: session_command, process_id, + tty, .. } = self.prepare_process_handles(process_id.as_str()).await?; if !request.input.is_empty() { + if !tty { + return Err(UnifiedExecError::StdinClosed); + } Self::send_input(&writer_tx, request.input.as_bytes()).await?; // Give the remote process a brief window to react so that we are // more likely to capture its output in the poll below. @@ -380,6 +386,7 @@ impl UnifiedExecProcessManager { cancellation_token, command: entry.command.clone(), process_id: entry.process_id.clone(), + tty: entry.tty, }) } @@ -402,6 +409,7 @@ impl UnifiedExecProcessManager { cwd: PathBuf, started_at: Instant, process_id: String, + tty: bool, transcript: Arc>, ) { let entry = ProcessEntry { @@ -409,6 +417,7 @@ impl UnifiedExecProcessManager { call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), + tty, last_used: started_at, }; let number_processes = { @@ -461,7 +470,7 @@ impl UnifiedExecProcessManager { ) .await } else { - codex_utils_pty::pipe::spawn_process( + codex_utils_pty::pipe::spawn_process_no_stdin( program, args, env.cwd.as_path(), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 3a6c728df0..df72d249b3 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -805,6 +805,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> let open_args = json!({ "cmd": "/bin/bash -i", "yield_time_ms": 200, + "tty": true, }); let stdin_call_id = "uexec-stdin-delta"; @@ -905,6 +906,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let open_args = json!({ "cmd": "sleep 3 && echo MARKER1 && sleep 3 && echo MARKER2", "yield_time_ms": 10, + "tty": true, }); // Poll stdin three times: first for no output, second after the first marker, @@ -1966,6 +1968,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let first_args = serde_json::json!({ "cmd": "/bin/cat", "yield_time_ms": 200, + "tty": true, }); let second_call_id = "uexec-stdin"; @@ -2678,6 +2681,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { let keep_args = serde_json::json!({ "cmd": "/bin/cat", "yield_time_ms": 250, + "tty": true, }); let prune_call_id = "uexec-prune-target"; @@ -2685,6 +2689,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { let prune_args = serde_json::json!({ "cmd": "sleep 1", "yield_time_ms": 1_250, + "tty": true, }); let mut events = vec![ev_response_created("resp-prune-1")]; diff --git a/codex-rs/utils/pty/README.md b/codex-rs/utils/pty/README.md index 22f2e3a89b..d0f77268a2 100644 --- a/codex-rs/utils/pty/README.md +++ b/codex-rs/utils/pty/README.md @@ -6,6 +6,7 @@ Lightweight helpers for spawning interactive processes either under a PTY (pseud - `spawn_pty_process(program, args, cwd, env, arg0)` → `SpawnedProcess` - `spawn_pipe_process(program, args, cwd, env, arg0)` → `SpawnedProcess` +- `spawn_pipe_process_no_stdin(program, args, cwd, env, arg0)` → `SpawnedProcess` - `conpty_supported()` → `bool` (Windows only; always true elsewhere) - `ProcessHandle` exposes: - `writer_sender()` → `mpsc::Sender>` (stdin) @@ -46,6 +47,7 @@ let exit_code = spawned.exit_rx.await.unwrap_or(-1); ``` Swap in `spawn_pipe_process` for a non-TTY subprocess; the rest of the API stays the same. +Use `spawn_pipe_process_no_stdin` to force stdin closed (commands that read stdin will see EOF immediately). ## Tests diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 037b0d761c..590770e2ae 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -9,6 +9,8 @@ mod win; /// Spawn a non-interactive process using regular pipes for stdin/stdout/stderr. pub use pipe::spawn_process as spawn_pipe_process; +/// Spawn a non-interactive process using regular pipes, but close stdin immediately. +pub use pipe::spawn_process_no_stdin as spawn_pipe_process_no_stdin; /// Handle for interacting with a spawned process (PTY or pipe). pub use process::ProcessHandle; /// Bundle of process handles plus output and exit receivers returned by spawn helpers. diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index c3dcd4ddcb..5d9eb0232d 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -90,13 +90,19 @@ where } } -/// Spawn a process using regular pipes (no PTY), returning handles for stdin, output, and exit. -pub async fn spawn_process( +#[derive(Clone, Copy)] +enum PipeStdinMode { + Piped, + Null, +} + +async fn spawn_process_with_stdin_mode( program: &str, args: &[String], cwd: &Path, env: &HashMap, arg0: &Option, + stdin_mode: PipeStdinMode, ) -> Result { if program.is_empty() { anyhow::bail!("missing program for pipe spawn"); @@ -128,7 +134,14 @@ pub async fn spawn_process( for arg in args { command.arg(arg); } - command.stdin(Stdio::piped()); + match stdin_mode { + PipeStdinMode::Piped => { + command.stdin(Stdio::piped()); + } + PipeStdinMode::Null => { + command.stdin(Stdio::null()); + } + } command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -147,18 +160,19 @@ pub async fn spawn_process( let (output_tx, _) = broadcast::channel::>(256); let initial_output_rx = output_tx.subscribe(); - let writer_handle = tokio::spawn({ - let writer = stdin.map(|w| Arc::new(tokio::sync::Mutex::new(w))); - async move { + let writer_handle = if let Some(stdin) = stdin { + let writer = Arc::new(tokio::sync::Mutex::new(stdin)); + tokio::spawn(async move { while let Some(bytes) = writer_rx.recv().await { - if let Some(writer) = &writer { - let mut guard = writer.lock().await; - let _ = guard.write_all(&bytes).await; - let _ = guard.flush().await; - } + let mut guard = writer.lock().await; + let _ = guard.write_all(&bytes).await; + let _ = guard.flush().await; } - } - }); + }) + } else { + drop(writer_rx); + tokio::spawn(async {}) + }; let stdout_handle = stdout.map(|stdout| { let output_tx = output_tx.clone(); @@ -230,3 +244,25 @@ pub async fn spawn_process( exit_rx, }) } + +/// Spawn a process using regular pipes (no PTY), returning handles for stdin, output, and exit. +pub async fn spawn_process( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, + arg0: &Option, +) -> Result { + spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Piped).await +} + +/// Spawn a process using regular pipes, but close stdin immediately. +pub async fn spawn_process_no_stdin( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, + arg0: &Option, +) -> Result { + spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Null).await +} From 2691e1ce218d483deae43ce04fbd10039b2278c0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 16 Jan 2026 17:24:41 +0100 Subject: [PATCH 09/78] fix: flaky tests (#9373) --- codex-rs/utils/pty/src/tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index cdf9f0824a..8c41d1dbde 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -163,9 +163,11 @@ async fn pipe_and_pty_share_interface() -> anyhow::Result<()> { spawn_pipe_process(&pipe_program, &pipe_args, Path::new("."), &env_map, &None).await?; let pty = spawn_pty_process(&pty_program, &pty_args, Path::new("."), &env_map, &None).await?; + let timeout_ms = if cfg!(windows) { 10_000 } else { 3_000 }; let (pipe_out, pipe_code) = - collect_output_until_exit(pipe.output_rx, pipe.exit_rx, 3_000).await; - let (pty_out, pty_code) = collect_output_until_exit(pty.output_rx, pty.exit_rx, 3_000).await; + collect_output_until_exit(pipe.output_rx, pipe.exit_rx, timeout_ms).await; + let (pty_out, pty_code) = + collect_output_until_exit(pty.output_rx, pty.exit_rx, timeout_ms).await; assert_eq!(pipe_code, 0); assert_eq!(pty_code, 0); From 131590066e171c78dfe493a7c492c7eef1bf0069 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 16 Jan 2026 08:35:03 -0800 Subject: [PATCH 10/78] [device-auth] Add device code auth as a standalone option when headless environment is detected. (#9333) --- codex-rs/cli/src/main.rs | 9 - codex-rs/tui/src/onboarding/auth.rs | 212 +++++++++++++----- .../tui/src/onboarding/onboarding_screen.rs | 6 +- codex-rs/tui2/src/onboarding/auth.rs | 212 +++++++++++++----- .../tui2/src/onboarding/onboarding_screen.rs | 6 +- 5 files changed, 308 insertions(+), 137 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1c82eae004..337be81e5d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -14,11 +14,9 @@ use codex_cli::login::run_login_status; use codex_cli::login::run_login_with_api_key; use codex_cli::login::run_login_with_chatgpt; use codex_cli::login::run_login_with_device_code; -use codex_cli::login::run_login_with_device_code_fallback_to_browser; use codex_cli::login::run_logout; use codex_cloud_tasks::Cli as CloudTasksCli; use codex_common::CliConfigOverrides; -use codex_core::env::is_headless_environment; use codex_exec::Cli as ExecCli; use codex_exec::Command as ExecCommand; use codex_exec::ReviewArgs; @@ -600,13 +598,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; - } else if is_headless_environment() { - run_login_with_device_code_fallback_to_browser( - login_cli.config_overrides, - login_cli.issuer_base_url, - login_cli.client_id, - ) - .await; } else { run_login_with_chatgpt(login_cli.config_overrides).await; } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 430ef5f195..551fa9180e 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -5,7 +5,6 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; -use codex_core::env::is_headless_environment; use codex_login::DeviceCode; use codex_login::ServerOptions; use codex_login::ShutdownHandle; @@ -59,6 +58,13 @@ pub(crate) enum SignInState { ApiKeyConfigured, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SignInOption { + ChatGpt, + DeviceCode, + ApiKey, +} + const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled."; #[derive(Clone, Default)] @@ -96,42 +102,26 @@ impl KeyboardHandler for AuthModeWidget { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - if self.is_chatgpt_login_allowed() { - self.highlighted_mode = AuthMode::ChatGPT; - } + self.move_highlight(-1); } KeyCode::Down | KeyCode::Char('j') => { - if self.is_api_login_allowed() { - self.highlighted_mode = AuthMode::ApiKey; - } + self.move_highlight(1); } KeyCode::Char('1') => { - if self.is_chatgpt_login_allowed() { - self.start_chatgpt_login(); - } + self.select_option_by_index(0); } KeyCode::Char('2') => { - if self.is_api_login_allowed() { - self.start_api_key_entry(); - } else { - self.disallow_api_login(); - } + self.select_option_by_index(1); + } + KeyCode::Char('3') => { + self.select_option_by_index(2); } KeyCode::Enter => { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { - SignInState::PickMode => match self.highlighted_mode { - AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => { - self.start_chatgpt_login(); - } - AuthMode::ApiKey if self.is_api_login_allowed() => { - self.start_api_key_entry(); - } - AuthMode::ChatGPT => {} - AuthMode::ApiKey => { - self.disallow_api_login(); - } - }, + SignInState::PickMode => { + self.handle_sign_in_option(self.highlighted_mode); + } SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } @@ -170,7 +160,7 @@ impl KeyboardHandler for AuthModeWidget { #[derive(Clone)] pub(crate) struct AuthModeWidget { pub request_frame: FrameRequester, - pub highlighted_mode: AuthMode, + pub highlighted_mode: SignInOption, pub error: Option, pub sign_in_state: Arc>, pub codex_home: PathBuf, @@ -191,8 +181,75 @@ impl AuthModeWidget { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) } + fn displayed_sign_in_options(&self) -> Vec { + let mut options = vec![SignInOption::ChatGpt]; + if self.is_chatgpt_login_allowed() { + options.push(SignInOption::DeviceCode); + } + if self.is_api_login_allowed() { + options.push(SignInOption::ApiKey); + } + options + } + + fn selectable_sign_in_options(&self) -> Vec { + let mut options = Vec::new(); + if self.is_chatgpt_login_allowed() { + options.push(SignInOption::ChatGpt); + options.push(SignInOption::DeviceCode); + } + if self.is_api_login_allowed() { + options.push(SignInOption::ApiKey); + } + options + } + + fn move_highlight(&mut self, delta: isize) { + let options = self.selectable_sign_in_options(); + if options.is_empty() { + return; + } + + let current_index = options + .iter() + .position(|option| *option == self.highlighted_mode) + .unwrap_or(0); + let next_index = + (current_index as isize + delta).rem_euclid(options.len() as isize) as usize; + self.highlighted_mode = options[next_index]; + } + + fn select_option_by_index(&mut self, index: usize) { + let options = self.displayed_sign_in_options(); + if let Some(option) = options.get(index).copied() { + self.handle_sign_in_option(option); + } + } + + fn handle_sign_in_option(&mut self, option: SignInOption) { + match option { + SignInOption::ChatGpt => { + if self.is_chatgpt_login_allowed() { + self.start_chatgpt_login(); + } + } + SignInOption::DeviceCode => { + if self.is_chatgpt_login_allowed() { + self.start_device_code_login(); + } + } + SignInOption::ApiKey => { + if self.is_api_login_allowed() { + self.start_api_key_entry(); + } else { + self.disallow_api_login(); + } + } + } + } + fn disallow_api_login(&mut self) { - self.highlighted_mode = AuthMode::ChatGPT; + self.highlighted_mode = SignInOption::ChatGpt; self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.request_frame.schedule_frame(); @@ -212,7 +269,7 @@ impl AuthModeWidget { ]; let create_mode_item = |idx: usize, - selected_mode: AuthMode, + selected_mode: SignInOption, text: &str, description: &str| -> Vec> { @@ -221,11 +278,11 @@ impl AuthModeWidget { let line1 = if is_selected { Line::from(vec![ - format!("{} {}. ", caret, idx + 1).cyan().dim(), + format!("{caret} {index}. ", index = idx + 1).cyan().dim(), text.to_string().cyan(), ]) } else { - format!(" {}. {text}", idx + 1).into() + format!(" {index}. {text}", index = idx + 1).into() }; let line2 = if is_selected { @@ -242,27 +299,42 @@ impl AuthModeWidget { let chatgpt_description = if !self.is_chatgpt_login_allowed() { "ChatGPT login is disabled" - } else if is_headless_environment() { - "Uses device code login (headless environment detected)" } else { "Usage included with Plus, Pro, Team, and Enterprise plans" }; - lines.extend(create_mode_item( - 0, - AuthMode::ChatGPT, - "Sign in with ChatGPT", - chatgpt_description, - )); - lines.push("".into()); - if self.is_api_login_allowed() { - lines.extend(create_mode_item( - 1, - AuthMode::ApiKey, - "Provide your own API key", - "Pay for what you use", - )); + let device_code_description = "Sign in from another device with a one-time code"; + + for (idx, option) in self.displayed_sign_in_options().into_iter().enumerate() { + match option { + SignInOption::ChatGpt => { + lines.extend(create_mode_item( + idx, + option, + "Sign in with ChatGPT", + chatgpt_description, + )); + } + SignInOption::DeviceCode => { + lines.extend(create_mode_item( + idx, + option, + "Sign in with Device Code", + device_code_description, + )); + } + SignInOption::ApiKey => { + lines.extend(create_mode_item( + idx, + option, + "Provide your own API key", + "Pay for what you use", + )); + } + } lines.push("".into()); - } else { + } + + if !self.is_api_login_allowed() { lines.push( " API key login is disabled by this workspace. Sign in with ChatGPT to continue." .dim() @@ -309,9 +381,9 @@ impl AuthModeWidget { ])); lines.push("".into()); lines.push(Line::from(vec![ - " On a remote or headless machine? Use ".into(), - "codex login --device-auth".cyan(), - " instead".into(), + " On a remote or headless machine? Press Esc and choose ".into(), + "Sign in with Device Code".cyan(), + ".".into(), ])); lines.push("".into()); } @@ -588,13 +660,21 @@ impl AuthModeWidget { self.request_frame.schedule_frame(); } + fn handle_existing_chatgpt_login(&mut self) -> bool { + if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + self.request_frame.schedule_frame(); + true + } else { + false + } + } + /// Kicks off the ChatGPT auth flow and keeps the UI state consistent with the attempt. fn start_chatgpt_login(&mut self) { // If we're already authenticated with ChatGPT, don't start a new login – // just proceed to the success message flow. - if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { - *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; - self.request_frame.schedule_frame(); + if self.handle_existing_chatgpt_login() { return; } @@ -606,11 +686,6 @@ impl AuthModeWidget { self.cli_auth_credentials_store_mode, ); - if is_headless_environment() { - headless_chatgpt_login::start_headless_chatgpt_login(self, opts); - return; - } - match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -650,6 +725,21 @@ impl AuthModeWidget { } } } + + fn start_device_code_login(&mut self) { + if self.handle_existing_chatgpt_login() { + return; + } + + self.error = None; + let opts = ServerOptions::new( + self.codex_home.clone(), + CLIENT_ID.to_string(), + self.forced_chatgpt_workspace_id.clone(), + self.cli_auth_credentials_store_mode, + ); + headless_chatgpt_login::start_headless_chatgpt_login(self, opts); + } } impl StepStateProvider for AuthModeWidget { @@ -708,7 +798,7 @@ mod tests { let codex_home_path = codex_home.path().to_path_buf(); let widget = AuthModeWidget { request_frame: FrameRequester::test_dummy(), - highlighted_mode: AuthMode::ChatGPT, + highlighted_mode: SignInOption::ChatGpt, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home_path.clone(), diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 14999b2229..7e076ae160 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -11,11 +11,11 @@ use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; +use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; @@ -92,8 +92,8 @@ impl OnboardingScreen { ))); if show_login_screen { let highlighted_mode = match forced_login_method { - Some(ForcedLoginMethod::Api) => AuthMode::ApiKey, - _ => AuthMode::ChatGPT, + Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, + _ => SignInOption::ChatGpt, }; steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), diff --git a/codex-rs/tui2/src/onboarding/auth.rs b/codex-rs/tui2/src/onboarding/auth.rs index 640fca14da..a1fb3dee12 100644 --- a/codex-rs/tui2/src/onboarding/auth.rs +++ b/codex-rs/tui2/src/onboarding/auth.rs @@ -5,7 +5,6 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; -use codex_core::env::is_headless_environment; use codex_login::DeviceCode; use codex_login::ServerOptions; use codex_login::ShutdownHandle; @@ -59,6 +58,13 @@ pub(crate) enum SignInState { ApiKeyConfigured, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SignInOption { + ChatGpt, + DeviceCode, + ApiKey, +} + const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled."; #[derive(Clone, Default)] @@ -96,42 +102,26 @@ impl KeyboardHandler for AuthModeWidget { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - if self.is_chatgpt_login_allowed() { - self.highlighted_mode = AuthMode::ChatGPT; - } + self.move_highlight(-1); } KeyCode::Down | KeyCode::Char('j') => { - if self.is_api_login_allowed() { - self.highlighted_mode = AuthMode::ApiKey; - } + self.move_highlight(1); } KeyCode::Char('1') => { - if self.is_chatgpt_login_allowed() { - self.start_chatgpt_login(); - } + self.select_option_by_index(0); } KeyCode::Char('2') => { - if self.is_api_login_allowed() { - self.start_api_key_entry(); - } else { - self.disallow_api_login(); - } + self.select_option_by_index(1); + } + KeyCode::Char('3') => { + self.select_option_by_index(2); } KeyCode::Enter => { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { - SignInState::PickMode => match self.highlighted_mode { - AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => { - self.start_chatgpt_login(); - } - AuthMode::ApiKey if self.is_api_login_allowed() => { - self.start_api_key_entry(); - } - AuthMode::ChatGPT => {} - AuthMode::ApiKey => { - self.disallow_api_login(); - } - }, + SignInState::PickMode => { + self.handle_sign_in_option(self.highlighted_mode); + } SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } @@ -170,7 +160,7 @@ impl KeyboardHandler for AuthModeWidget { #[derive(Clone)] pub(crate) struct AuthModeWidget { pub request_frame: FrameRequester, - pub highlighted_mode: AuthMode, + pub highlighted_mode: SignInOption, pub error: Option, pub sign_in_state: Arc>, pub codex_home: PathBuf, @@ -191,8 +181,75 @@ impl AuthModeWidget { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) } + fn displayed_sign_in_options(&self) -> Vec { + let mut options = vec![SignInOption::ChatGpt]; + if self.is_chatgpt_login_allowed() { + options.push(SignInOption::DeviceCode); + } + if self.is_api_login_allowed() { + options.push(SignInOption::ApiKey); + } + options + } + + fn selectable_sign_in_options(&self) -> Vec { + let mut options = Vec::new(); + if self.is_chatgpt_login_allowed() { + options.push(SignInOption::ChatGpt); + options.push(SignInOption::DeviceCode); + } + if self.is_api_login_allowed() { + options.push(SignInOption::ApiKey); + } + options + } + + fn move_highlight(&mut self, delta: isize) { + let options = self.selectable_sign_in_options(); + if options.is_empty() { + return; + } + + let current_index = options + .iter() + .position(|option| *option == self.highlighted_mode) + .unwrap_or(0); + let next_index = + (current_index as isize + delta).rem_euclid(options.len() as isize) as usize; + self.highlighted_mode = options[next_index]; + } + + fn select_option_by_index(&mut self, index: usize) { + let options = self.displayed_sign_in_options(); + if let Some(option) = options.get(index).copied() { + self.handle_sign_in_option(option); + } + } + + fn handle_sign_in_option(&mut self, option: SignInOption) { + match option { + SignInOption::ChatGpt => { + if self.is_chatgpt_login_allowed() { + self.start_chatgpt_login(); + } + } + SignInOption::DeviceCode => { + if self.is_chatgpt_login_allowed() { + self.start_device_code_login(); + } + } + SignInOption::ApiKey => { + if self.is_api_login_allowed() { + self.start_api_key_entry(); + } else { + self.disallow_api_login(); + } + } + } + } + fn disallow_api_login(&mut self) { - self.highlighted_mode = AuthMode::ChatGPT; + self.highlighted_mode = SignInOption::ChatGpt; self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.request_frame.schedule_frame(); @@ -212,7 +269,7 @@ impl AuthModeWidget { ]; let create_mode_item = |idx: usize, - selected_mode: AuthMode, + selected_mode: SignInOption, text: &str, description: &str| -> Vec> { @@ -221,11 +278,11 @@ impl AuthModeWidget { let line1 = if is_selected { Line::from(vec![ - format!("{} {}. ", caret, idx + 1).cyan().dim(), + format!("{caret} {index}. ", index = idx + 1).cyan().dim(), text.to_string().cyan(), ]) } else { - format!(" {}. {text}", idx + 1).into() + format!(" {index}. {text}", index = idx + 1).into() }; let line2 = if is_selected { @@ -242,27 +299,42 @@ impl AuthModeWidget { let chatgpt_description = if !self.is_chatgpt_login_allowed() { "ChatGPT login is disabled" - } else if is_headless_environment() { - "Uses device code login (headless environment detected)" } else { "Usage included with Plus, Pro, Team, and Enterprise plans" }; - lines.extend(create_mode_item( - 0, - AuthMode::ChatGPT, - "Sign in with ChatGPT", - chatgpt_description, - )); - lines.push("".into()); - if self.is_api_login_allowed() { - lines.extend(create_mode_item( - 1, - AuthMode::ApiKey, - "Provide your own API key", - "Pay for what you use", - )); + let device_code_description = "Sign in from another device with a one-time code"; + + for (idx, option) in self.displayed_sign_in_options().into_iter().enumerate() { + match option { + SignInOption::ChatGpt => { + lines.extend(create_mode_item( + idx, + option, + "Sign in with ChatGPT", + chatgpt_description, + )); + } + SignInOption::DeviceCode => { + lines.extend(create_mode_item( + idx, + option, + "Sign in with Device Code", + device_code_description, + )); + } + SignInOption::ApiKey => { + lines.extend(create_mode_item( + idx, + option, + "Provide your own API key", + "Pay for what you use", + )); + } + } lines.push("".into()); - } else { + } + + if !self.is_api_login_allowed() { lines.push( " API key login is disabled by this workspace. Sign in with ChatGPT to continue." .dim() @@ -309,9 +381,9 @@ impl AuthModeWidget { ])); lines.push("".into()); lines.push(Line::from(vec![ - " On a remote or headless machine? Use ".into(), - "codex login --device-auth".cyan(), - " instead".into(), + " On a remote or headless machine? Press Esc and choose ".into(), + "Sign in with Device Code".cyan(), + ".".into(), ])); lines.push("".into()); } @@ -588,12 +660,20 @@ impl AuthModeWidget { self.request_frame.schedule_frame(); } - fn start_chatgpt_login(&mut self) { - // If we're already authenticated with ChatGPT, don't start a new login – - // just proceed to the success message flow. + fn handle_existing_chatgpt_login(&mut self) -> bool { if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); + true + } else { + false + } + } + + fn start_chatgpt_login(&mut self) { + // If we're already authenticated with ChatGPT, don't start a new login – + // just proceed to the success message flow. + if self.handle_existing_chatgpt_login() { return; } @@ -605,11 +685,6 @@ impl AuthModeWidget { self.cli_auth_credentials_store_mode, ); - if is_headless_environment() { - headless_chatgpt_login::start_headless_chatgpt_login(self, opts); - return; - } - match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -649,6 +724,21 @@ impl AuthModeWidget { } } } + + fn start_device_code_login(&mut self) { + if self.handle_existing_chatgpt_login() { + return; + } + + self.error = None; + let opts = ServerOptions::new( + self.codex_home.clone(), + CLIENT_ID.to_string(), + self.forced_chatgpt_workspace_id.clone(), + self.cli_auth_credentials_store_mode, + ); + headless_chatgpt_login::start_headless_chatgpt_login(self, opts); + } } impl StepStateProvider for AuthModeWidget { @@ -707,7 +797,7 @@ mod tests { let codex_home_path = codex_home.path().to_path_buf(); let widget = AuthModeWidget { request_frame: FrameRequester::test_dummy(), - highlighted_mode: AuthMode::ChatGPT, + highlighted_mode: SignInOption::ChatGpt, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home_path.clone(), diff --git a/codex-rs/tui2/src/onboarding/onboarding_screen.rs b/codex-rs/tui2/src/onboarding/onboarding_screen.rs index 3ba2619a87..b16a980625 100644 --- a/codex-rs/tui2/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui2/src/onboarding/onboarding_screen.rs @@ -11,11 +11,11 @@ use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; +use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; @@ -92,8 +92,8 @@ impl OnboardingScreen { ))); if show_login_screen { let highlighted_mode = match forced_login_method { - Some(ForcedLoginMethod::Api) => AuthMode::ApiKey, - _ => AuthMode::ChatGPT, + Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, + _ => SignInOption::ChatGpt, }; steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), From 9147df0e60ac4600941bc11ccc08123ece9ea889 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 16 Jan 2026 08:53:47 -0800 Subject: [PATCH 11/78] Made `codex exec resume --last` consistent with `codex resume --last` (#9352) PR #9245 made `codex resume --last` honor cwd, but I forgot to make the same change for `codex exec resume --last`. This PR fixes the inconsistency. This addresses #8700 --- codex-rs/exec/src/cli.rs | 4 ++ codex-rs/exec/src/lib.rs | 12 ++-- codex-rs/exec/tests/suite/resume.rs | 85 +++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index e12e869394..3d2001ae61 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -119,6 +119,10 @@ pub struct ResumeArgs { #[arg(long = "last", default_value_t = false)] pub last: bool, + /// Show all sessions (disables cwd filtering). + #[arg(long = "all", default_value_t = false)] + pub all: bool, + /// Optional image(s) to attach to the prompt sent after resuming. #[arg( long = "image", diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 966dc65346..cdd8bf7daf 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -507,17 +507,21 @@ async fn resolve_resume_path( ) -> anyhow::Result> { if args.last { let default_provider_filter = vec![config.model_provider_id.clone()]; - match codex_core::RolloutRecorder::list_threads( + let filter_cwd = if args.all { + None + } else { + Some(config.cwd.as_path()) + }; + match codex_core::RolloutRecorder::find_latest_thread_path( &config.codex_home, - 1, - None, &[], Some(default_provider_filter.as_slice()), &config.model_provider_id, + filter_cwd, ) .await { - Ok(page) => Ok(page.items.first().map(|it| it.path.clone())), + Ok(path) => Ok(path), Err(e) => { error!("Error listing threads: {e}"); Ok(None) diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 32913983e6..ec7fec0831 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -5,6 +5,7 @@ use core_test_support::test_codex_exec::test_codex_exec; use pretty_assertions::assert_eq; use serde_json::Value; use std::string::ToString; +use tempfile::TempDir; use uuid::Uuid; use walkdir::WalkDir; @@ -219,6 +220,90 @@ fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<( Ok(()) } +#[test] +fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { + let test = test_codex_exec(); + let fixture = exec_fixture()?; + + let dir_a = TempDir::new()?; + let dir_b = TempDir::new()?; + + let marker_a = format!("resume-cwd-a-{}", Uuid::new_v4()); + let prompt_a = format!("echo {marker_a}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(dir_a.path()) + .arg(&prompt_a) + .assert() + .success(); + + let marker_b = format!("resume-cwd-b-{}", Uuid::new_v4()); + let prompt_b = format!("echo {marker_b}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(dir_b.path()) + .arg(&prompt_b) + .assert() + .success(); + + let sessions_dir = test.home_path().join("sessions"); + let path_a = find_session_file_containing_marker(&sessions_dir, &marker_a) + .expect("no session file found for marker_a"); + let path_b = find_session_file_containing_marker(&sessions_dir, &marker_b) + .expect("no session file found for marker_b"); + + let marker_b2 = format!("resume-cwd-b-2-{}", Uuid::new_v4()); + let prompt_b2 = format!("echo {marker_b2}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(dir_a.path()) + .arg("resume") + .arg("--last") + .arg("--all") + .arg(&prompt_b2) + .assert() + .success(); + + let resumed_path_all = find_session_file_containing_marker(&sessions_dir, &marker_b2) + .expect("no resumed session file containing marker_b2"); + assert_eq!( + resumed_path_all, path_b, + "resume --last --all should pick newest session" + ); + + let marker_a2 = format!("resume-cwd-a-2-{}", Uuid::new_v4()); + let prompt_a2 = format!("echo {marker_a2}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(dir_a.path()) + .arg("resume") + .arg("--last") + .arg(&prompt_a2) + .assert() + .success(); + + let resumed_path_cwd = find_session_file_containing_marker(&sessions_dir, &marker_a2) + .expect("no resumed session file containing marker_a2"); + assert_eq!( + resumed_path_cwd, path_a, + "resume --last should prefer sessions from the same cwd" + ); + + Ok(()) +} + #[test] fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<()> { let test = test_codex_exec(); From 4125c825f9ac26a2db42f37bdf6a20cfff262503 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 16 Jan 2026 08:56:38 -0800 Subject: [PATCH 12/78] add codex cloud list (#9324) for listing cloud tasks. --- codex-rs/backend-client/src/client.rs | 6 + codex-rs/cloud-tasks-client/src/api.rs | 13 ++- codex-rs/cloud-tasks-client/src/http.rs | 32 +++++- codex-rs/cloud-tasks-client/src/lib.rs | 1 + codex-rs/cloud-tasks-client/src/mock.rs | 14 ++- codex-rs/cloud-tasks/src/app.rs | 29 ++++- codex-rs/cloud-tasks/src/cli.rs | 32 ++++++ codex-rs/cloud-tasks/src/lib.rs | 140 ++++++++++++++++++++++- codex-rs/cloud-tasks/tests/env_filter.rs | 15 ++- 9 files changed, 259 insertions(+), 23 deletions(-) diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index fdd4504bb6..ea3585956b 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -174,6 +174,7 @@ impl Client { limit: Option, task_filter: Option<&str>, environment_id: Option<&str>, + cursor: Option<&str>, ) -> Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), @@ -190,6 +191,11 @@ impl Client { } else { req }; + let req = if let Some(c) = cursor { + req.query(&[("cursor", c)]) + } else { + req + }; let req = if let Some(id) = environment_id { req.query(&[("environment_id", id)]) } else { diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs index cd8228bc28..7059bdb39f 100644 --- a/codex-rs/cloud-tasks-client/src/api.rs +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -94,6 +94,12 @@ pub struct CreatedTask { pub id: TaskId, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaskListPage { + pub tasks: Vec, + pub cursor: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct DiffSummary { pub files_changed: usize, @@ -126,7 +132,12 @@ impl Default for TaskText { #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { - async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn list_tasks( + &self, + env: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result; async fn get_task_summary(&self, id: TaskId) -> Result; async fn get_task_diff(&self, id: TaskId) -> Result>; /// Return assistant output messages (no diff) when available. diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index f55d0fe797..e6990b7ebd 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -6,6 +6,7 @@ use crate::CloudTaskError; use crate::DiffSummary; use crate::Result; use crate::TaskId; +use crate::TaskListPage; use crate::TaskStatus; use crate::TaskSummary; use crate::TurnAttempt; @@ -59,8 +60,13 @@ impl HttpClient { #[async_trait::async_trait] impl CloudBackend for HttpClient { - async fn list_tasks(&self, env: Option<&str>) -> Result> { - self.tasks_api().list(env).await + async fn list_tasks( + &self, + env: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result { + self.tasks_api().list(env, limit, cursor).await } async fn get_task_summary(&self, id: TaskId) -> Result { @@ -132,10 +138,16 @@ mod api { } } - pub(crate) async fn list(&self, env: Option<&str>) -> Result> { + pub(crate) async fn list( + &self, + env: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result { + let limit_i32 = limit.and_then(|lim| i32::try_from(lim).ok()); let resp = self .backend - .list_tasks(Some(20), Some("current"), env) + .list_tasks(limit_i32, Some("current"), env, cursor) .await .map_err(|e| CloudTaskError::Http(format!("list_tasks failed: {e}")))?; @@ -146,11 +158,19 @@ mod api { .collect(); append_error_log(&format!( - "http.list_tasks: env={} items={}", + "http.list_tasks: env={} limit={} cursor_in={} cursor_out={} items={}", env.unwrap_or(""), + limit_i32 + .map(|v| v.to_string()) + .unwrap_or_else(|| "".to_string()), + cursor.unwrap_or(""), + resp.cursor.as_deref().unwrap_or(""), tasks.len() )); - Ok(tasks) + Ok(TaskListPage { + tasks, + cursor: resp.cursor, + }) } pub(crate) async fn summary(&self, id: TaskId) -> Result { diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs index a723512f8a..b28b356f2a 100644 --- a/codex-rs/cloud-tasks-client/src/lib.rs +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -9,6 +9,7 @@ pub use api::CreatedTask; pub use api::DiffSummary; pub use api::Result; pub use api::TaskId; +pub use api::TaskListPage; pub use api::TaskStatus; pub use api::TaskSummary; pub use api::TaskText; diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs index 2d03cea029..f6e14e61a2 100644 --- a/codex-rs/cloud-tasks-client/src/mock.rs +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -16,7 +16,12 @@ pub struct MockClient; #[async_trait::async_trait] impl CloudBackend for MockClient { - async fn list_tasks(&self, _env: Option<&str>) -> Result> { + async fn list_tasks( + &self, + _env: Option<&str>, + _limit: Option, + _cursor: Option<&str>, + ) -> Result { // Slightly vary content by env to aid tests that rely on the mock let rows = match _env { Some("env-A") => vec![("T-2000", "A: First", TaskStatus::Ready)], @@ -58,11 +63,14 @@ impl CloudBackend for MockClient { attempt_total: Some(if id_str == "T-1000" { 2 } else { 1 }), }); } - Ok(out) + Ok(crate::TaskListPage { + tasks: out, + cursor: None, + }) } async fn get_task_summary(&self, id: TaskId) -> Result { - let tasks = self.list_tasks(None).await?; + let tasks = self.list_tasks(None, None, None).await?.tasks; tasks .into_iter() .find(|t| t.id == id) diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index ce12128a3e..ac3dd9e8df 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -123,9 +123,13 @@ pub async fn load_tasks( env: Option<&str>, ) -> anyhow::Result> { // In later milestones, add a small debounce, spinner, and error display. - let tasks = tokio::time::timeout(Duration::from_secs(5), backend.list_tasks(env)).await??; + let tasks = tokio::time::timeout( + Duration::from_secs(5), + backend.list_tasks(env, Some(20), None), + ) + .await??; // Hide review-only tasks from the main list. - let filtered: Vec = tasks.into_iter().filter(|t| !t.is_review).collect(); + let filtered: Vec = tasks.tasks.into_iter().filter(|t| !t.is_review).collect(); Ok(filtered) } @@ -362,7 +366,9 @@ mod tests { async fn list_tasks( &self, env: Option<&str>, - ) -> codex_cloud_tasks_client::Result> { + limit: Option, + cursor: Option<&str>, + ) -> codex_cloud_tasks_client::Result { let key = env.map(str::to_string); let titles = self .by_env @@ -383,15 +389,28 @@ mod tests { attempt_total: Some(1), }); } - Ok(out) + let max = limit.unwrap_or(i64::MAX); + let max = max.min(20); + let mut limited = Vec::new(); + for task in out { + if (limited.len() as i64) >= max { + break; + } + limited.push(task); + } + Ok(codex_cloud_tasks_client::TaskListPage { + tasks: limited, + cursor: cursor.map(str::to_string), + }) } async fn get_task_summary( &self, id: TaskId, ) -> codex_cloud_tasks_client::Result { - self.list_tasks(None) + self.list_tasks(None, None, None) .await? + .tasks .into_iter() .find(|t| t.id == id) .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0))) diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs index 6b36509639..595c649b38 100644 --- a/codex-rs/cloud-tasks/src/cli.rs +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -18,6 +18,8 @@ pub enum Command { Exec(ExecCommand), /// Show the status of a Codex Cloud task. Status(StatusCommand), + /// List Codex Cloud tasks. + List(ListCommand), /// Apply the diff for a Codex Cloud task locally. Apply(ApplyCommand), /// Show the unified diff for a Codex Cloud task. @@ -58,6 +60,17 @@ fn parse_attempts(input: &str) -> Result { } } +fn parse_limit(input: &str) -> Result { + let value: i64 = input + .parse() + .map_err(|_| "limit must be an integer between 1 and 20".to_string())?; + if (1..=20).contains(&value) { + Ok(value) + } else { + Err("limit must be between 1 and 20".to_string()) + } +} + #[derive(Debug, Args)] pub struct StatusCommand { /// Codex Cloud task identifier to inspect. @@ -65,6 +78,25 @@ pub struct StatusCommand { pub task_id: String, } +#[derive(Debug, Args)] +pub struct ListCommand { + /// Filter tasks by environment identifier. + #[arg(long = "env", value_name = "ENV_ID")] + pub environment: Option, + + /// Maximum number of tasks to return (1-20). + #[arg(long = "limit", default_value_t = 20, value_parser = parse_limit, value_name = "N")] + pub limit: i64, + + /// Pagination cursor returned by a previous call. + #[arg(long = "cursor", value_name = "CURSOR")] + pub cursor: Option, + + /// Emit JSON instead of plain text. + #[arg(long = "json", default_value_t = false)] + pub json: bool, +} + #[derive(Debug, Args)] pub struct ApplyCommand { /// Codex Cloud task identifier to apply. diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index e1bedbc1ce..b1d42fb86f 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -393,11 +393,10 @@ fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) let bullet = "•" .if_supports_color(Stream::Stdout, |t| t.dimmed()) .to_string(); - let file_label = "file" + let file_label = format!("file{}", if files == 1 { "" } else { "s" }) .if_supports_color(Stream::Stdout, |t| t.dimmed()) .to_string(); - let plural = if files == 1 { "" } else { "s" }; - format!("{adds_str}/{dels_str} {bullet} {files} {file_label}{plural}") + format!("{adds_str}/{dels_str} {bullet} {files} {file_label}") } else { format!( "+{adds}/-{dels} • {files} file{}", @@ -473,6 +472,25 @@ fn format_task_status_lines( lines } +fn format_task_list_lines( + tasks: &[codex_cloud_tasks_client::TaskSummary], + base_url: &str, + now: chrono::DateTime, + colorize: bool, +) -> Vec { + let mut lines = Vec::new(); + for (idx, task) in tasks.iter().enumerate() { + lines.push(util::task_url(base_url, &task.id.0)); + for line in format_task_status_lines(task, now, colorize) { + lines.push(format!(" {line}")); + } + if idx + 1 < tasks.len() { + lines.push(String::new()); + } + } + lines +} + async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> { let ctx = init_backend("codex_cloud_tasks_status").await?; let task_id = parse_task_id(&args.task_id)?; @@ -489,6 +507,73 @@ async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<( Ok(()) } +async fn run_list_command(args: crate::cli::ListCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_list").await?; + let env_filter = if let Some(env) = args.environment { + Some(resolve_environment_id(&ctx, &env).await?) + } else { + None + }; + let page = codex_cloud_tasks_client::CloudBackend::list_tasks( + &*ctx.backend, + env_filter.as_deref(), + Some(args.limit), + args.cursor.as_deref(), + ) + .await?; + if args.json { + let tasks: Vec<_> = page + .tasks + .iter() + .map(|task| { + serde_json::json!({ + "id": task.id.0, + "url": util::task_url(&ctx.base_url, &task.id.0), + "title": task.title, + "status": task.status, + "updated_at": task.updated_at, + "environment_id": task.environment_id, + "environment_label": task.environment_label, + "summary": { + "files_changed": task.summary.files_changed, + "lines_added": task.summary.lines_added, + "lines_removed": task.summary.lines_removed, + }, + "is_review": task.is_review, + "attempt_total": task.attempt_total, + }) + }) + .collect(); + let payload = serde_json::json!({ + "tasks": tasks, + "cursor": page.cursor, + }); + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + if page.tasks.is_empty() { + println!("No tasks found."); + return Ok(()); + } + let now = Utc::now(); + let colorize = supports_color::on(SupportStream::Stdout).is_some(); + for line in format_task_list_lines(&page.tasks, &ctx.base_url, now, colorize) { + println!("{line}"); + } + if let Some(cursor) = page.cursor { + let command = format!("codex cloud list --cursor='{cursor}'"); + if colorize { + println!( + "\nTo fetch the next page, run {}", + command.if_supports_color(Stream::Stdout, |text| text.cyan()) + ); + } else { + println!("\nTo fetch the next page, run {command}"); + } + } + Ok(()) +} + async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> { let ctx = init_backend("codex_cloud_tasks_diff").await?; let task_id = parse_task_id(&args.task_id)?; @@ -649,6 +734,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an return match command { crate::cli::Command::Exec(args) => run_exec_command(args).await, crate::cli::Command::Status(args) => run_status_command(args).await, + crate::cli::Command::List(args) => run_list_command(args).await, crate::cli::Command::Apply(args) => run_apply_command(args).await, crate::cli::Command::Diff(args) => run_diff_command(args).await, }; @@ -2181,6 +2267,54 @@ mod tests { ); } + #[test] + fn format_task_list_lines_formats_urls() { + let now = Utc::now(); + let tasks = vec![ + TaskSummary { + id: TaskId("task_1".to_string()), + title: "Example task".to_string(), + status: TaskStatus::Ready, + updated_at: now, + environment_id: Some("env-1".to_string()), + environment_label: Some("Env".to_string()), + summary: DiffSummary { + files_changed: 3, + lines_added: 5, + lines_removed: 2, + }, + is_review: false, + attempt_total: None, + }, + TaskSummary { + id: TaskId("task_2".to_string()), + title: "No diff task".to_string(), + status: TaskStatus::Pending, + updated_at: now, + environment_id: Some("env-2".to_string()), + environment_label: None, + summary: DiffSummary::default(), + is_review: false, + attempt_total: Some(1), + }, + ]; + let lines = format_task_list_lines(&tasks, "https://chatgpt.com/backend-api", now, false); + assert_eq!( + lines, + vec![ + "https://chatgpt.com/codex/tasks/task_1".to_string(), + " [READY] Example task".to_string(), + " Env • 0s ago".to_string(), + " +5/-2 • 3 files".to_string(), + String::new(), + "https://chatgpt.com/codex/tasks/task_2".to_string(), + " [PENDING] No diff task".to_string(), + " env-2 • 0s ago".to_string(), + " no diff".to_string(), + ] + ); + } + #[tokio::test] async fn collect_attempt_diffs_includes_sibling_attempts() { let backend = MockClient; diff --git a/codex-rs/cloud-tasks/tests/env_filter.rs b/codex-rs/cloud-tasks/tests/env_filter.rs index 8c737c6c28..688ccd29bd 100644 --- a/codex-rs/cloud-tasks/tests/env_filter.rs +++ b/codex-rs/cloud-tasks/tests/env_filter.rs @@ -5,18 +5,23 @@ use codex_cloud_tasks_client::MockClient; async fn mock_backend_varies_by_env() { let client = MockClient; - let root = CloudBackend::list_tasks(&client, None).await.unwrap(); + let root = CloudBackend::list_tasks(&client, None, None, None) + .await + .unwrap() + .tasks; assert!(root.iter().any(|t| t.title.contains("Update README"))); - let a = CloudBackend::list_tasks(&client, Some("env-A")) + let a = CloudBackend::list_tasks(&client, Some("env-A"), None, None) .await - .unwrap(); + .unwrap() + .tasks; assert_eq!(a.len(), 1); assert_eq!(a[0].title, "A: First"); - let b = CloudBackend::list_tasks(&client, Some("env-B")) + let b = CloudBackend::list_tasks(&client, Some("env-B"), None, None) .await - .unwrap(); + .unwrap() + .tasks; assert_eq!(b.len(), 2); assert!(b[0].title.starts_with("B: ")); } From ebdd8795e924a8149b616e46ca2ed7848c207a4b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 16 Jan 2026 09:30:11 -0800 Subject: [PATCH 13/78] Turn-state sticky routing per turn (#9332) - capture the header from SSE/WS handshakes, store it per ModelClientSession using `Oncelock`, echo it on turn-scoped requests, and add SSE+WS integration tests for within-turn persistence + cross-turn reset. - keep `x-codex-turn-state` sticky within a user turn to maintain routing continuity for retries/tool follow-ups. --- codex-rs/codex-api/src/endpoint/chat.rs | 1 + codex-rs/codex-api/src/endpoint/responses.rs | 17 ++- .../src/endpoint/responses_websocket.rs | 21 ++- codex-rs/codex-api/src/endpoint/streaming.rs | 12 +- codex-rs/codex-api/src/sse/chat.rs | 5 +- codex-rs/codex-api/src/sse/responses.rs | 10 ++ codex-rs/codex-api/tests/clients.rs | 6 +- codex-rs/codex-api/tests/sse_end_to_end.rs | 2 + codex-rs/core/src/client.rs | 31 +++- codex-rs/core/tests/common/responses.rs | 140 ++++++++++++++++-- codex-rs/core/tests/suite/turn_state.rs | 122 +++++++++++++++ 11 files changed, 343 insertions(+), 24 deletions(-) create mode 100644 codex-rs/core/tests/suite/turn_state.rs diff --git a/codex-rs/codex-api/src/endpoint/chat.rs b/codex-rs/codex-api/src/endpoint/chat.rs index f747c57411..cd830a09fc 100644 --- a/codex-rs/codex-api/src/endpoint/chat.rs +++ b/codex-rs/codex-api/src/endpoint/chat.rs @@ -87,6 +87,7 @@ impl ChatClient { extra_headers, RequestCompression::None, spawn_chat_stream, + None, ) .await } diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 57c7b0da03..4aded33911 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::SessionSource; use http::HeaderMap; use serde_json::Value; use std::sync::Arc; +use std::sync::OnceLock; use tracing::instrument; pub struct ResponsesClient { @@ -36,6 +37,7 @@ pub struct ResponsesOptions { pub session_source: Option, pub extra_headers: HeaderMap, pub compression: Compression, + pub turn_state: Option>>, } impl ResponsesClient { @@ -58,9 +60,15 @@ impl ResponsesClient { pub async fn stream_request( &self, request: ResponsesRequest, + turn_state: Option>>, ) -> Result { - self.stream(request.body, request.headers, request.compression) - .await + self.stream( + request.body, + request.headers, + request.compression, + turn_state, + ) + .await } #[instrument(level = "trace", skip_all, err)] @@ -80,6 +88,7 @@ impl ResponsesClient { session_source, extra_headers, compression, + turn_state, } = options; let request = ResponsesRequestBuilder::new(model, &prompt.instructions, &prompt.input) @@ -96,7 +105,7 @@ impl ResponsesClient { .compression(compression) .build(self.streaming.provider())?; - self.stream_request(request).await + self.stream_request(request, turn_state).await } fn path(&self) -> &'static str { @@ -111,6 +120,7 @@ impl ResponsesClient { body: Value, extra_headers: HeaderMap, compression: Compression, + turn_state: Option>>, ) -> Result { let compression = match compression { Compression::None => RequestCompression::None, @@ -124,6 +134,7 @@ impl ResponsesClient { extra_headers, compression, spawn_response_stream, + turn_state, ) .await } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index af3335ce76..3c6cab74e5 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -13,6 +13,7 @@ use http::HeaderMap; use http::HeaderValue; use serde_json::Value; use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration; use tokio::net::TcpStream; use tokio::sync::Mutex; @@ -27,6 +28,7 @@ use tracing::trace; use url::Url; type WsStream = WebSocketStream>; +const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; pub struct ResponsesWebsocketConnection { stream: Arc>>, @@ -100,6 +102,7 @@ impl ResponsesWebsocketClient { pub async fn connect( &self, extra_headers: HeaderMap, + turn_state: Option>>, ) -> Result { let ws_url = Url::parse(&self.provider.url_for_path("responses")) .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; @@ -108,7 +111,7 @@ impl ResponsesWebsocketClient { headers.extend(extra_headers); apply_auth_headers(&mut headers, &self.auth); - let stream = connect_websocket(ws_url, headers).await?; + let stream = connect_websocket(ws_url, headers, turn_state).await?; Ok(ResponsesWebsocketConnection::new( stream, self.provider.stream_idle_timeout, @@ -130,16 +133,28 @@ fn apply_auth_headers(headers: &mut HeaderMap, auth: &impl AuthProvider) { } } -async fn connect_websocket(url: Url, headers: HeaderMap) -> Result { +async fn connect_websocket( + url: Url, + headers: HeaderMap, + turn_state: Option>>, +) -> Result { let mut request = url .clone() .into_client_request() .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; request.headers_mut().extend(headers); - let (stream, _) = tokio_tungstenite::connect_async(request) + let (stream, response) = tokio_tungstenite::connect_async(request) .await .map_err(|err| map_ws_error(err, &url))?; + if let Some(turn_state) = turn_state + && let Some(header_value) = response + .headers() + .get(X_CODEX_TURN_STATE_HEADER) + .and_then(|value| value.to_str().ok()) + { + let _ = turn_state.set(header_value.to_string()); + } Ok(stream) } diff --git a/codex-rs/codex-api/src/endpoint/streaming.rs b/codex-rs/codex-api/src/endpoint/streaming.rs index de180845e8..15d4c077a0 100644 --- a/codex-rs/codex-api/src/endpoint/streaming.rs +++ b/codex-rs/codex-api/src/endpoint/streaming.rs @@ -13,6 +13,7 @@ use http::HeaderMap; use http::Method; use serde_json::Value; use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration; pub(crate) struct StreamingClient { @@ -23,6 +24,13 @@ pub(crate) struct StreamingClient { sse_telemetry: Option>, } +type StreamSpawner = fn( + StreamResponse, + Duration, + Option>, + Option>>, +) -> ResponseStream; + impl StreamingClient { pub(crate) fn new(transport: T, provider: Provider, auth: A) -> Self { Self { @@ -54,7 +62,8 @@ impl StreamingClient { body: Value, extra_headers: HeaderMap, compression: RequestCompression, - spawner: fn(StreamResponse, Duration, Option>) -> ResponseStream, + spawner: StreamSpawner, + turn_state: Option>>, ) -> Result { let builder = || { let mut req = self.provider.build_request(Method::POST, path); @@ -80,6 +89,7 @@ impl StreamingClient { stream_response, self.provider.stream_idle_timeout, self.sse_telemetry.clone(), + turn_state, )) } } diff --git a/codex-rs/codex-api/src/sse/chat.rs b/codex-rs/codex-api/src/sse/chat.rs index dec35890b7..afd7c8852b 100644 --- a/codex-rs/codex-api/src/sse/chat.rs +++ b/codex-rs/codex-api/src/sse/chat.rs @@ -11,6 +11,8 @@ use futures::Stream; use futures::StreamExt; use std::collections::HashMap; use std::collections::HashSet; +use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::Instant; @@ -21,7 +23,8 @@ use tracing::trace; pub(crate) fn spawn_chat_stream( stream_response: StreamResponse, idle_timeout: Duration, - telemetry: Option>, + telemetry: Option>, + _turn_state: Option>>, ) -> ResponseStream { let (tx_event, rx_event) = mpsc::channel::>(1600); tokio::spawn(async move { diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 06f74db0ab..5299310e06 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -16,6 +16,7 @@ use serde_json::Value; use std::io::BufRead; use std::path::Path; use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::Instant; @@ -49,6 +50,7 @@ pub fn spawn_response_stream( stream_response: StreamResponse, idle_timeout: Duration, telemetry: Option>, + turn_state: Option>>, ) -> ResponseStream { let rate_limits = parse_rate_limit(&stream_response.headers); let models_etag = stream_response @@ -56,6 +58,14 @@ pub fn spawn_response_stream( .get("X-Models-Etag") .and_then(|v| v.to_str().ok()) .map(ToString::to_string); + if let Some(turn_state) = turn_state.as_ref() + && let Some(header_value) = stream_response + .headers + .get("x-codex-turn-state") + .and_then(|v| v.to_str().ok()) + { + let _ = turn_state.set(header_value.to_string()); + } let (tx_event, rx_event) = mpsc::channel::>(1600); tokio::spawn(async move { if let Some(snapshot) = rate_limits { diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 70af9fe829..1a513bdca4 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -231,7 +231,7 @@ async fn responses_client_uses_responses_path_for_responses_wire() -> Result<()> let body = serde_json::json!({ "echo": true }); let _stream = client - .stream(body, HeaderMap::new(), Compression::None) + .stream(body, HeaderMap::new(), Compression::None, None) .await?; let requests = state.take_stream_requests(); @@ -247,7 +247,7 @@ async fn responses_client_uses_chat_path_for_chat_wire() -> Result<()> { let body = serde_json::json!({ "echo": true }); let _stream = client - .stream(body, HeaderMap::new(), Compression::None) + .stream(body, HeaderMap::new(), Compression::None, None) .await?; let requests = state.take_stream_requests(); @@ -264,7 +264,7 @@ async fn streaming_client_adds_auth_headers() -> Result<()> { let body = serde_json::json!({ "model": "gpt-test" }); let _stream = client - .stream(body, HeaderMap::new(), Compression::None) + .stream(body, HeaderMap::new(), Compression::None, None) .await?; let requests = state.take_stream_requests(); diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index f324cc7480..eb9d7993f8 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -129,6 +129,7 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> serde_json::json!({"echo": true}), HeaderMap::new(), Compression::None, + None, ) .await?; @@ -198,6 +199,7 @@ async fn responses_stream_aggregates_output_text_deltas() -> Result<()> { serde_json::json!({"echo": true}), HeaderMap::new(), Compression::None, + None, ) .await?; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 326d81e0e9..03e74b54b9 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::OnceLock; use crate::api_bridge::CoreAuthProvider; use crate::api_bridge::auth_provider_from_auth; @@ -66,6 +67,7 @@ use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; pub const WEB_SEARCH_ELIGIBLE_HEADER: &str = "x-oai-web-search-eligible"; +pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; #[derive(Debug)] struct ModelClientState { @@ -89,6 +91,17 @@ pub struct ModelClientSession { state: Arc, connection: Option, websocket_last_items: Vec, + /// Turn state for sticky routing. + /// + /// This is an `OnceLock` that stores the turn state value received from the server + /// on turn start via the `x-codex-turn-state` response header. Once set, this value + /// should be sent back to the server in the `x-codex-turn-state` request header for + /// all subsequent requests within the same turn to maintain sticky routing. + /// + /// This is a contract between the client and server: we receive it at turn start, + /// keep sending it unchanged between turn requests (e.g., for retries, incremental + /// appends, or continuation requests), and must not send it between different turns. + turn_state: Arc>, } #[allow(clippy::too_many_arguments)] @@ -124,6 +137,7 @@ impl ModelClient { state: Arc::clone(&self.state), connection: None, websocket_last_items: Vec::new(), + turn_state: Arc::new(OnceLock::new()), } } } @@ -226,7 +240,6 @@ impl ModelClient { extra_headers.insert("x-openai-subagent", val); } } - client .compact_input(&payload, extra_headers) .await @@ -322,8 +335,9 @@ impl ModelClientSession { store_override: None, conversation_id: Some(conversation_id), session_source: Some(self.state.session_source.clone()), - extra_headers: build_responses_headers(&self.state.config), + extra_headers: build_responses_headers(&self.state.config, Some(&self.turn_state)), compression, + turn_state: Some(Arc::clone(&self.turn_state)), } } @@ -397,7 +411,7 @@ impl ModelClientSession { headers.extend(build_conversation_headers(options.conversation_id.clone())); let new_conn: ApiWebSocketConnection = ApiWebSocketResponsesClient::new(api_provider, api_auth) - .connect(headers) + .connect(headers, options.turn_state.clone()) .await?; self.connection = Some(new_conn); } @@ -638,7 +652,10 @@ fn beta_feature_headers(config: &Config) -> ApiHeaderMap { headers } -fn build_responses_headers(config: &Config) -> ApiHeaderMap { +fn build_responses_headers( + config: &Config, + turn_state: Option<&Arc>>, +) -> ApiHeaderMap { let mut headers = beta_feature_headers(config); headers.insert( WEB_SEARCH_ELIGIBLE_HEADER, @@ -650,6 +667,12 @@ fn build_responses_headers(config: &Config) -> ApiHeaderMap { }, ), ); + if let Some(turn_state) = turn_state + && let Some(state) = turn_state.get() + && let Ok(header_value) = HeaderValue::from_str(state) + { + headers.insert(X_CODEX_TURN_STATE_HEADER, header_value); + } headers } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index f8fbcfff5c..3896911cc2 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -12,6 +12,8 @@ use serde_json::Value; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::handshake::server::Request; +use tokio_tungstenite::tungstenite::handshake::server::Response; use wiremock::BodyPrintLimit; use wiremock::Match; use wiremock::Mock; @@ -19,6 +21,8 @@ use wiremock::MockBuilder; use wiremock::MockServer; use wiremock::Respond; use wiremock::ResponseTemplate; +use wiremock::http::HeaderName; +use wiremock::http::HeaderValue; use wiremock::matchers::method; use wiremock::matchers::path_regex; @@ -216,9 +220,30 @@ impl WebSocketRequest { } } +#[derive(Debug, Clone)] +pub struct WebSocketHandshake { + headers: Vec<(String, String)>, +} + +impl WebSocketHandshake { + pub fn header(&self, name: &str) -> Option { + self.headers + .iter() + .find(|(header, _)| header.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.clone()) + } +} + +#[derive(Debug, Clone)] +pub struct WebSocketConnectionConfig { + pub requests: Vec>, + pub response_headers: Vec<(String, String)>, +} + pub struct WebSocketTestServer { uri: String, connections: Arc>>>, + handshakes: Arc>>, shutdown: oneshot::Sender<()>, task: tokio::task::JoinHandle<()>, } @@ -240,6 +265,18 @@ impl WebSocketTestServer { connections.first().cloned().unwrap_or_default() } + pub fn handshakes(&self) -> Vec { + self.handshakes.lock().unwrap().clone() + } + + pub fn single_handshake(&self) -> WebSocketHandshake { + let handshakes = self.handshakes.lock().unwrap(); + if handshakes.len() != 1 { + panic!("expected 1 handshake, got {}", handshakes.len()); + } + handshakes.first().cloned().unwrap() + } + pub async fn shutdown(self) { let _ = self.shutdown.send(()); let _ = self.task.await; @@ -786,13 +823,28 @@ pub async fn start_mock_server() -> MockServer { /// request message, the server records the payload and streams the matching /// events as WebSocket text frames before moving to the next request. pub async fn start_websocket_server(connections: Vec>>) -> WebSocketTestServer { + let connections = connections + .into_iter() + .map(|requests| WebSocketConnectionConfig { + requests, + response_headers: Vec::new(), + }) + .collect(); + start_websocket_server_with_headers(connections).await +} + +pub async fn start_websocket_server_with_headers( + connections: Vec, +) -> WebSocketTestServer { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind websocket server"); let addr = listener.local_addr().expect("websocket server address"); let uri = format!("ws://{addr}"); let connections_log = Arc::new(Mutex::new(Vec::new())); + let handshakes_log = Arc::new(Mutex::new(Vec::new())); let requests = Arc::clone(&connections_log); + let handshakes = Arc::clone(&handshakes_log); let connections = Arc::new(Mutex::new(VecDeque::from(connections))); let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); @@ -806,27 +858,57 @@ pub async fn start_websocket_server(connections: Vec>>) -> WebSoc Ok(value) => value, Err(_) => return, }; - let mut ws_stream = match tokio_tungstenite::accept_async(stream).await { - Ok(ws) => ws, - Err(_) => continue, - }; - - let connection_requests = { + let connection = { let mut pending = connections.lock().unwrap(); pending.pop_front() }; - let Some(connection_requests) = connection_requests else { - let _ = ws_stream.close(None).await; + let Some(connection) = connection else { continue; }; + let response_headers = connection.response_headers.clone(); + let handshake_log = Arc::clone(&handshakes); + let callback = move |req: &Request, mut response: Response| { + let headers = req + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_string(), value.to_string())) + }) + .collect(); + handshake_log + .lock() + .unwrap() + .push(WebSocketHandshake { headers }); + + let headers_mut = response.headers_mut(); + for (name, value) in &response_headers { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + headers_mut.insert(name, value); + } + } + + Ok(response) + }; + + let mut ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await { + Ok(ws) => ws, + Err(_) => continue, + }; + let connection_index = { let mut log = requests.lock().unwrap(); log.push(Vec::new()); log.len() - 1 }; - for request_events in connection_requests { + for request_events in connection.requests { let Some(Ok(message)) = ws_stream.next().await else { break; }; @@ -858,6 +940,7 @@ pub async fn start_websocket_server(connections: Vec>>) -> WebSoc WebSocketTestServer { uri, connections: connections_log, + handshakes: handshakes_log, shutdown: shutdown_tx, task, } @@ -942,6 +1025,45 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) -> Res response_mock } +/// Mounts a sequence of responses for each POST to `/v1/responses`. +/// Panics if more requests are received than responses provided. +pub async fn mount_response_sequence( + server: &MockServer, + responses: Vec, +) -> ResponseMock { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + struct SeqResponder { + num_calls: AtomicUsize, + responses: Vec, + } + + impl Respond for SeqResponder { + fn respond(&self, _: &wiremock::Request) -> ResponseTemplate { + let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst); + self.responses + .get(call_num) + .unwrap_or_else(|| panic!("no response for {call_num}")) + .clone() + } + } + + let num_calls = responses.len(); + let responder = SeqResponder { + num_calls: AtomicUsize::new(0), + responses, + }; + + let (mock, response_mock) = base_mock(); + mock.respond_with(responder) + .up_to_n_times(num_calls as u64) + .expect(num_calls as u64) + .mount(server) + .await; + response_mock +} + /// Validate invariants on the request body sent to `/v1/responses`. /// /// - No `function_call_output`/`custom_tool_call_output` with missing/empty `call_id`. diff --git a/codex-rs/core/tests/suite/turn_state.rs b/codex-rs/core/tests/suite/turn_state.rs new file mode 100644 index 0000000000..3198f07cd6 --- /dev/null +++ b/codex-rs/core/tests/suite/turn_state.rs @@ -0,0 +1,122 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use anyhow::Result; +use core_test_support::responses::WebSocketConnectionConfig; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_done; +use core_test_support::responses::ev_reasoning_item; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_shell_command_call; +use core_test_support::responses::mount_response_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::sse_response; +use core_test_support::responses::start_mock_server; +use core_test_support::responses::start_websocket_server_with_headers; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; + +const TURN_STATE_HEADER: &str = "x-codex-turn-state"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_turn_state_persists_within_turn_and_resets_after() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "shell-turn-state"; + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_reasoning_item("rsn-1", &["thinking"], &[]), + ev_shell_command_call(call_id, "echo turn-state"), + ev_completed("resp-1"), + ]); + let second_response = sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let third_response = sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-3"), + ]); + + // First response sets turn_state; follow-up request in the same turn should echo it. + let responses = vec![ + sse_response(first_response).insert_header(TURN_STATE_HEADER, "ts-1"), + sse_response(second_response), + sse_response(third_response), + ]; + let request_log = mount_response_sequence(&server, responses).await; + + let test = test_codex().build(&server).await?; + test.submit_turn("run a shell command").await?; + test.submit_turn("second turn").await?; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 3); + // Initial turn request has no header; follow-up has it; next turn clears it. + assert_eq!(requests[0].header(TURN_STATE_HEADER), None); + assert_eq!( + requests[1].header(TURN_STATE_HEADER), + Some("ts-1".to_string()) + ); + assert_eq!(requests[2].header(TURN_STATE_HEADER), None); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result<()> { + skip_if_no_network!(Ok(())); + + let call_id = "ws-shell-turn-state"; + // First connection delivers turn_state; second (same turn) must send it; third (new turn) must not. + let server = start_websocket_server_with_headers(vec![ + WebSocketConnectionConfig { + requests: vec![vec![ + ev_response_created("resp-1"), + ev_reasoning_item("rsn-1", &["thinking"], &[]), + ev_shell_command_call(call_id, "echo websocket"), + ev_done(), + ]], + response_headers: vec![(TURN_STATE_HEADER.to_string(), "ts-1".to_string())], + }, + WebSocketConnectionConfig { + requests: vec![vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]], + response_headers: Vec::new(), + }, + WebSocketConnectionConfig { + requests: vec![vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-3"), + ]], + response_headers: Vec::new(), + }, + ]) + .await; + + let mut builder = test_codex(); + let test = builder.build_with_websocket_server(&server).await?; + test.submit_turn("run the echo command").await?; + test.submit_turn("second turn").await?; + + let handshakes = server.handshakes(); + assert_eq!(handshakes.len(), 3); + assert_eq!(handshakes[0].header(TURN_STATE_HEADER), None); + assert_eq!( + handshakes[1].header(TURN_STATE_HEADER), + Some("ts-1".to_string()) + ); + assert_eq!(handshakes[2].header(TURN_STATE_HEADER), None); + + server.shutdown().await; + Ok(()) +} From e650d4b02c690751123b02f42330a848644c8484 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 16 Jan 2026 18:33:14 +0100 Subject: [PATCH 14/78] feat: tool call duration metric (#9364) --- codex-rs/otel/src/traces/otel_manager.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index 2b490bbb37..a1aaf0b513 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -446,6 +446,11 @@ impl OtelManager { 1, &[("tool", tool_name), ("success", success_str)], ); + self.record_duration( + "codex.tool.call.duration_ms", + duration, + &[("tool", tool_name), ("success", success_str)], + ); tracing::event!( tracing::Level::INFO, event.name = "codex.tool_result", From f89a40a849f18e897192baa522dcb59c23771a06 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 16 Jan 2026 11:12:52 -0800 Subject: [PATCH 15/78] chore: upgrade to Rust 1.92.0 (#8860) **Summary** - Upgrade Rust toolchain used by CI to 1.92.0. - Address new clippy `derivable_impls` warnings by deriving `Default` for enums across protocol, core, backend openapi models, and windows-sandbox setup. - Tidy up related test/config behavior (originator header handling, env override cleanup) and remove a now-unused assignment in TUI/TUI2 render layout. **Testing** - `just fmt` - `just fix -p codex-tui` - `just fix -p codex-tui2` - `just fix -p codex-windows-sandbox` - `cargo test -p codex-tui` - `cargo test -p codex-tui2` - `cargo test -p codex-windows-sandbox` - `cargo test -p codex-core --test all` - `cargo test -p codex-app-server --test all` - `cargo test -p codex-mcp-server --test all` - `cargo test --all-features` --- .github/workflows/rust-ci.yml | 8 ++++---- .github/workflows/rust-release.yml | 2 +- .github/workflows/sdk.yml | 2 +- .github/workflows/shell-tool-mcp.yml | 2 +- .../src/models/rate_limit_status_payload.rs | 11 ++++------- codex-rs/core/src/config/types.rs | 9 ++------- codex-rs/core/src/shell.rs | 13 +++++++------ codex-rs/core/src/tools/handlers/read_file.rs | 9 ++------- codex-rs/protocol/src/protocol.rs | 11 ++++------- codex-rs/rust-toolchain.toml | 2 +- codex-rs/scripts/setup-windows.ps1 | 2 +- codex-rs/tui/src/render/renderable.rs | 1 - codex-rs/tui2/src/render/renderable.rs | 1 - codex-rs/windows-sandbox-rs/src/setup_main_win.rs | 9 ++------- 14 files changed, 30 insertions(+), 52 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 49c2e6c771..22a1a8320e 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -59,7 +59,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: components: rustfmt - name: cargo fmt @@ -77,7 +77,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: cargo-shear @@ -177,7 +177,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: targets: ${{ matrix.target }} components: clippy @@ -416,7 +416,7 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@v2 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 76f49eeabb..acc8b6bafe 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -80,7 +80,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 3e5a249d40..f506dce6ba 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -24,7 +24,7 @@ jobs: node-version: 22 cache: pnpm - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 - name: build codex run: cargo build --bin codex diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index b27b4bdbd4..bc49a90fa6 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -93,7 +93,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: targets: ${{ matrix.target }} diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 0f5caf52ff..daf8e6af39 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -42,9 +42,12 @@ impl RateLimitStatusPayload { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default, +)] pub enum PlanType { #[serde(rename = "guest")] + #[default] Guest, #[serde(rename = "free")] Free, @@ -71,9 +74,3 @@ pub enum PlanType { #[serde(rename = "edu")] Edu, } - -impl Default for PlanType { - fn default() -> PlanType { - Self::Guest - } -} diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 981f9b541a..9d75302cbd 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -423,10 +423,11 @@ impl Default for Notifications { /// Terminals generally encode both mouse wheels and trackpads as the same "scroll up/down" mouse /// button events, without a magnitude. This setting controls whether Codex uses a heuristic to /// infer wheel vs trackpad per stream, or forces a specific behavior. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] #[serde(rename_all = "snake_case")] pub enum ScrollInputMode { /// Infer wheel vs trackpad behavior per scroll stream. + #[default] Auto, /// Always treat scroll events as mouse-wheel input (fixed lines per tick). Wheel, @@ -434,12 +435,6 @@ pub enum ScrollInputMode { Trackpad, } -impl Default for ScrollInputMode { - fn default() -> Self { - Self::Auto - } -} - /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index d22b6543a9..b6adf861d8 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -340,6 +340,7 @@ mod detect_shell_type_tests { #[cfg(unix)] mod tests { use super::*; + use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -350,7 +351,7 @@ mod tests { let shell_path = zsh_shell.shell_path; - assert_eq!(shell_path, PathBuf::from("/bin/zsh")); + assert_eq!(shell_path, Path::new("/bin/zsh")); } #[test] @@ -360,7 +361,7 @@ mod tests { let shell_path = zsh_shell.shell_path; - assert_eq!(shell_path, PathBuf::from("/bin/zsh")); + assert_eq!(shell_path, Path::new("/bin/zsh")); } #[test] @@ -369,9 +370,9 @@ mod tests { let shell_path = bash_shell.shell_path; assert!( - shell_path == PathBuf::from("/bin/bash") - || shell_path == PathBuf::from("/usr/bin/bash") - || shell_path == PathBuf::from("/usr/local/bin/bash"), + shell_path == Path::new("/bin/bash") + || shell_path == Path::new("/usr/bin/bash") + || shell_path == Path::new("/usr/local/bin/bash"), "shell path: {shell_path:?}", ); } @@ -381,7 +382,7 @@ mod tests { let sh_shell = get_shell(ShellType::Sh, None).unwrap(); let shell_path = sh_shell.shell_path; assert!( - shell_path == PathBuf::from("/bin/sh") || shell_path == PathBuf::from("/usr/bin/sh"), + shell_path == Path::new("/bin/sh") || shell_path == Path::new("/usr/bin/sh"), "shell path: {shell_path:?}", ); } diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs index 4f187540a6..eb5ce647ce 100644 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -40,9 +40,10 @@ struct ReadFileArgs { indentation: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] #[serde(rename_all = "snake_case")] enum ReadMode { + #[default] Slice, Indentation, } @@ -461,12 +462,6 @@ mod defaults { } } - impl Default for ReadMode { - fn default() -> Self { - Self::Slice - } - } - pub fn offset() -> usize { 1 } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7ab9099ae8..a08e30e226 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1656,21 +1656,18 @@ pub struct ReviewLineRange { pub end: u32, } -#[derive(Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[derive( + Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, Default, +)] #[serde(rename_all = "snake_case")] pub enum ExecCommandSource { + #[default] Agent, UserShell, UnifiedExecStartup, UnifiedExecInteraction, } -impl Default for ExecCommandSource { - fn default() -> Self { - Self::Agent - } -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecCommandBeginEvent { /// Identifier so this can be paired with the ExecCommandEnd event. diff --git a/codex-rs/rust-toolchain.toml b/codex-rs/rust-toolchain.toml index 7187a33189..05eeaac960 100644 --- a/codex-rs/rust-toolchain.toml +++ b/codex-rs/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.90.0" +channel = "1.92.0" components = ["clippy", "rustfmt", "rust-src"] diff --git a/codex-rs/scripts/setup-windows.ps1 b/codex-rs/scripts/setup-windows.ps1 index 554dc63352..33b6b0c352 100644 --- a/codex-rs/scripts/setup-windows.ps1 +++ b/codex-rs/scripts/setup-windows.ps1 @@ -179,7 +179,7 @@ if (-not (Ensure-Command 'cargo')) { Write-Host "==> Configuring Rust toolchain per rust-toolchain.toml" -ForegroundColor Cyan # Pin to the workspace toolchain and install components -$toolchain = '1.90.0' +$toolchain = '1.92.0' & rustup toolchain install $toolchain --profile minimal | Out-Host & rustup default $toolchain | Out-Host & rustup component add clippy rustfmt rust-src --toolchain $toolchain | Out-Host diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index 8581468c0a..eb514b3b0f 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -275,7 +275,6 @@ impl<'a> FlexRenderable<'a> { }; let child_size = child.desired_height(area.width).min(max_child_extent); child_sizes[i] = child_size; - allocated_size += child_size; allocated_flex_space += child_size; } } diff --git a/codex-rs/tui2/src/render/renderable.rs b/codex-rs/tui2/src/render/renderable.rs index 8581468c0a..eb514b3b0f 100644 --- a/codex-rs/tui2/src/render/renderable.rs +++ b/codex-rs/tui2/src/render/renderable.rs @@ -275,7 +275,6 @@ impl<'a> FlexRenderable<'a> { }; let child_size = child.desired_height(area.width).min(max_child_extent); child_sizes[i] = child_size; - allocated_size += child_size; allocated_flex_space += child_size; } } diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs index e1b914859e..52e60a1da7 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs @@ -76,19 +76,14 @@ struct Payload { refresh_only: bool, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)] #[serde(rename_all = "kebab-case")] enum SetupMode { + #[default] Full, ReadAclsOnly, } -impl Default for SetupMode { - fn default() -> Self { - Self::Full - } -} - fn log_line(log: &mut File, msg: &str) -> Result<()> { let ts = chrono::Utc::now().to_rfc3339(); writeln!(log, "[{ts}] {msg}")?; From e893e83eb9b5209ab4ac21206d6b9b86bf8ec5d2 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Fri, 16 Jan 2026 11:28:52 -0800 Subject: [PATCH 16/78] feat: /fork the current session instead of opening session picker (#9385) Implemented /fork to fork the current session directly (no picker), handling it via a new ForkCurrentSession app event in both tui and tui2. Updated slash command descriptions/tooltips and adjusted the fork tests accordingly. Removed the unused in-session fork picker event. --- codex-rs/tui/src/app.rs | 110 ++++++++++++-------------- codex-rs/tui/src/app_event.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 4 +- codex-rs/tui/src/slash_command.rs | 2 +- codex-rs/tui/tooltips.txt | 2 +- codex-rs/tui2/src/app.rs | 110 ++++++++++++-------------- codex-rs/tui2/src/app_event.rs | 4 +- codex-rs/tui2/src/chatwidget.rs | 2 +- codex-rs/tui2/src/chatwidget/tests.rs | 4 +- codex-rs/tui2/src/slash_command.rs | 2 +- codex-rs/tui2/tooltips.txt | 2 +- 12 files changed, 113 insertions(+), 135 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 87d76909de..adc48c1d12 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -758,73 +758,61 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } - AppEvent::OpenForkPicker => { - match crate::resume_picker::run_fork_picker( - tui, - &self.config.codex_home, - &self.config.model_provider_id, - false, - ) - .await? - { - SessionSelection::Fork(path) => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.thread_id(), - ); - match self - .server - .fork_thread(usize::MAX, self.config.clone(), path.clone()) - .await - { - Ok(forked) => { - self.shutdown_current_thread().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; - self.chat_widget = ChatWidget::new_from_existing( - init, - forked.thread, - forked.session_configured, - ); - self.current_model = model_info.slug.clone(); - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); + AppEvent::ForkCurrentSession => { + let summary = + session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); + if let Some(path) = self.chat_widget.rollout_path() { + match self + .server + .fork_thread(usize::MAX, self.config.clone(), path.clone()) + .await + { + Ok(forked) => { + self.shutdown_current_thread().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + model: Some(self.current_model.clone()), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + forked.thread, + forked.session_configured, + ); + self.current_model = model_info.slug.clone(); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); } - } - Err(err) => { - let path_display = path.display(); - self.chat_widget.add_error_message(format!( - "Failed to fork session from {path_display}: {err}" - )); + self.chat_widget.add_plain_history_lines(lines); } } + Err(err) => { + let path_display = path.display(); + self.chat_widget.add_error_message(format!( + "Failed to fork current session from {path_display}: {err}" + )); + } } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Resume(_) => {} + } else { + self.chat_widget + .add_error_message("Current session is not ready to fork yet.".to_string()); } - // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 785f3ae2ce..f34e1d7d5a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -53,8 +53,8 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, - /// Open the fork picker inside the running TUI session. - OpenForkPicker, + /// Fork the current session into a new thread. + ForkCurrentSession, /// Request to exit the application. /// diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3d566356e2..d301243ca7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1994,7 +1994,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::OpenResumePicker); } SlashCommand::Fork => { - self.app_event_tx.send(AppEvent::OpenForkPicker); + self.app_event_tx.send(AppEvent::ForkCurrentSession); } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 40d30c8956..6b216e6d76 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1532,12 +1532,12 @@ async fn slash_resume_opens_picker() { } #[tokio::test] -async fn slash_fork_opens_picker() { +async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::Fork); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker)); + assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); } #[tokio::test] diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 0d274f8eb5..d7cbd7fc1d 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -48,7 +48,7 @@ impl SlashCommand { SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", SlashCommand::Resume => "resume a saved chat", - SlashCommand::Fork => "fork a saved chat", + SlashCommand::Fork => "fork the current chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 3ba7931ff1..302a2b1ea5 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -6,7 +6,7 @@ Use /approvals to control when Codex asks for confirmation. Run /review to get a code review of your current changes. Use /skills to list available skills or ask Codex to use one. Use /status to see the current model, approvals, and token usage. -Use /fork to branch a saved chat into a new thread. +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`) diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index cd174fbbbd..52bf416419 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1531,72 +1531,62 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } - AppEvent::OpenForkPicker => { - match crate::resume_picker::run_fork_picker( - tui, - &self.config.codex_home, - &self.config.model_provider_id, - false, - ) - .await? - { - SessionSelection::Fork(path) => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), - ); - match self - .server - .fork_thread(usize::MAX, self.config.clone(), path.clone()) - .await - { - Ok(forked) => { - self.shutdown_current_conversation().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; - self.chat_widget = ChatWidget::new_from_existing( - init, - forked.thread, - forked.session_configured, - ); - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); + AppEvent::ForkCurrentSession => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + if let Some(path) = self.chat_widget.rollout_path() { + match self + .server + .fork_thread(usize::MAX, self.config.clone(), path.clone()) + .await + { + Ok(forked) => { + self.shutdown_current_conversation().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + model: Some(self.current_model.clone()), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + forked.thread, + forked.session_configured, + ); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); } - } - Err(err) => { - let path_display = path.display(); - self.chat_widget.add_error_message(format!( - "Failed to fork session from {path_display}: {err}" - )); + self.chat_widget.add_plain_history_lines(lines); } } + Err(err) => { + let path_display = path.display(); + self.chat_widget.add_error_message(format!( + "Failed to fork current session from {path_display}: {err}" + )); + } } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Resume(_) => {} + } else { + self.chat_widget + .add_error_message("Current session is not ready to fork yet.".to_string()); } - // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index f24ca1e07c..b2a70cae19 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -47,8 +47,8 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, - /// Open the fork picker inside the running TUI session. - OpenForkPicker, + /// Fork the current session into a new thread. + ForkCurrentSession, /// Request to exit the application. /// diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 5facd756f7..ab40b19c7b 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1769,7 +1769,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::OpenResumePicker); } SlashCommand::Fork => { - self.app_event_tx.send(AppEvent::OpenForkPicker); + self.app_event_tx.send(AppEvent::ForkCurrentSession); } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 22871c39d7..fa555e597e 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -1338,12 +1338,12 @@ async fn slash_resume_opens_picker() { } #[tokio::test] -async fn slash_fork_opens_picker() { +async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::Fork); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker)); + assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); } #[tokio::test] diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index e2d7761221..caac37b85d 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -46,7 +46,7 @@ impl SlashCommand { SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", SlashCommand::Resume => "resume a saved chat", - SlashCommand::Fork => "fork a saved chat", + SlashCommand::Fork => "fork the current chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", diff --git a/codex-rs/tui2/tooltips.txt b/codex-rs/tui2/tooltips.txt index 3ba7931ff1..302a2b1ea5 100644 --- a/codex-rs/tui2/tooltips.txt +++ b/codex-rs/tui2/tooltips.txt @@ -6,7 +6,7 @@ Use /approvals to control when Codex asks for confirmation. Run /review to get a code review of your current changes. Use /skills to list available skills or ask Codex to use one. Use /status to see the current model, approvals, and token usage. -Use /fork to branch a saved chat into a new thread. +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`) From f1653dd4d369d411f35442b48293816e9a92fb0e Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 16 Jan 2026 12:58:55 -0800 Subject: [PATCH 17/78] feat(app-server, core): return threads by created_at or updated_at (#9247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for returning threads by either `created_at` OR `updated_at` descending. Previously core always returned threads ordered by `created_at`. This PR: - updates core to be able to list threads by `updated_at` OR `created_at` descending based on what the caller wants - also update `thread/list` in app-server to expose this (default to `created_at` if not specified) All existing codepaths (app-server, TUI) still default to `created_at`, so no behavior change is expected with this PR. **Implementation** To sort by `updated_at` is a bit nontrivial (whereas `created_at` is easy due to the way we structure the folders and filenames on disk, which are all based on `created_at`). The most naive way to do this without introducing a cache file or sqlite DB (which we have to implement/maintain) is to scan files in reverse `created_at` order on disk, and look at the file's mtime (last modified timestamp according to the filesystem) until we reach `MAX_SCAN_FILES` (currently set to 10,000). Then, we can return the most recent N threads. Based on some quick and dirty benchmarking on my machine with ~1000 rollout files, calling `thread/list` with limit 50, the `updated_at` path is slower as expected due to all the I/O: - updated-at: average 103.10 ms - created-at: average 41.10 ms Those absolute numbers aren't a big deal IMO, but we can certainly optimize this in a followup if needed by introducing more state stored on disk. **Caveat** There's also a limitation in that any files older than `MAX_SCAN_FILES` will be excluded, which means if a user continues a REALLY old thread, it's possible to not be included. In practice that should not be too big of an issue. If a user makes... - 1000 rollouts/day → threads older than 10 days won't show up - 100 rollouts/day → ~100 days If this becomes a problem for some reason, even more motivation to implement an updated_at cache. --- .../app-server-protocol/src/protocol/v1.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 13 + codex-rs/app-server/README.md | 8 +- .../app-server/src/codex_message_processor.rs | 84 ++- codex-rs/app-server/tests/common/lib.rs | 1 + codex-rs/app-server/tests/common/rollout.rs | 34 +- .../app-server/tests/suite/v2/thread_list.rs | 382 +++++++++++++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/list.rs | 504 ++++++++++++++---- codex-rs/core/src/rollout/recorder.rs | 12 +- codex-rs/core/src/rollout/tests.rs | 88 ++- codex-rs/core/tests/suite/cli_stream.rs | 1 + codex-rs/debug-client/src/client.rs | 1 + codex-rs/exec/src/lib.rs | 3 + codex-rs/tui/src/lib.rs | 5 + codex-rs/tui/src/resume_picker.rs | 3 + codex-rs/tui2/src/lib.rs | 5 + codex-rs/tui2/src/resume_picker.rs | 3 + 18 files changed, 1022 insertions(+), 127 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index a5bc69be5d..39090c022f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -128,6 +128,7 @@ pub struct ConversationSummary { pub path: PathBuf, pub preview: String, pub timestamp: Option, + pub updated_at: Option, pub model_provider: String, pub cwd: PathBuf, pub cli_version: String, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8655d89c41..b44e1575ad 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1182,11 +1182,21 @@ pub struct ThreadListParams { pub cursor: Option, /// Optional page size; defaults to a reasonable server-side value. pub limit: Option, + /// Optional sort key; defaults to created_at. + pub sort_key: Option, /// Optional provider filter; when set, only sessions recorded under these /// providers are returned. When present but empty, includes all providers. pub model_providers: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum ThreadSortKey { + CreatedAt, + UpdatedAt, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1359,6 +1369,9 @@ pub struct Thread { /// Unix timestamp (in seconds) when the thread was created. #[ts(type = "number")] pub created_at: i64, + /// Unix timestamp (in seconds) when the thread was last updated. + #[ts(type = "number")] + pub updated_at: i64, /// [UNSTABLE] Path to the thread on disk. pub path: PathBuf, /// Working directory captured for the thread. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 63f4dea965..75e1853d70 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -138,10 +138,11 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c ### Example: List threads (with pagination & filters) -`thread/list` lets you render a history UI. Pass any combination of: +`thread/list` lets you render a history UI. Results default to `createdAt` (newest first) descending. Pass any combination of: - `cursor` — opaque string from a prior response; omit for the first page. - `limit` — server defaults to a reasonable page size if unset. +- `sortKey` — `created_at` (default) or `updated_at`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. Example: @@ -150,11 +151,12 @@ Example: { "method": "thread/list", "id": 20, "params": { "cursor": null, "limit": 25, + "sortKey": "created_at" } } { "id": 20, "result": { "data": [ - { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111 }, - { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000 } + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111 }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000 } ], "nextCursor": "opaque-token-or-null" } } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index fc7115447d..153028512e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6,6 +6,7 @@ use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; use chrono::DateTime; +use chrono::SecondsFormat; use chrono::Utc; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; @@ -99,6 +100,7 @@ use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -123,6 +125,7 @@ use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; use codex_core::ThreadManager; +use codex_core::ThreadSortKey as CoreThreadSortKey; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::config::Config; @@ -1598,6 +1601,7 @@ impl CodexMessageProcessor { let ThreadListParams { cursor, limit, + sort_key, model_providers, } = params; @@ -1605,8 +1609,12 @@ impl CodexMessageProcessor { .map(|value| value as usize) .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) .clamp(1, THREAD_LIST_MAX_LIMIT); + let core_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { + ThreadSortKey::CreatedAt => CoreThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt => CoreThreadSortKey::UpdatedAt, + }; let (summaries, next_cursor) = match self - .list_threads_common(requested_page_size, cursor, model_providers) + .list_threads_common(requested_page_size, cursor, model_providers, core_sort_key) .await { Ok(r) => r, @@ -2171,7 +2179,12 @@ impl CodexMessageProcessor { .clamp(1, THREAD_LIST_MAX_LIMIT); match self - .list_threads_common(requested_page_size, cursor, model_providers) + .list_threads_common( + requested_page_size, + cursor, + model_providers, + CoreThreadSortKey::UpdatedAt, + ) .await { Ok((items, next_cursor)) => { @@ -2189,8 +2202,18 @@ impl CodexMessageProcessor { requested_page_size: usize, cursor: Option, model_providers: Option>, + sort_key: CoreThreadSortKey, ) -> Result<(Vec, Option), JSONRPCErrorError> { - let mut cursor_obj: Option = cursor.as_ref().and_then(|s| parse_cursor(s)); + let mut cursor_obj: Option = match cursor.as_ref() { + Some(cursor_str) => { + Some(parse_cursor(cursor_str).ok_or_else(|| JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid cursor: {cursor_str}"), + data: None, + })?) + } + None => None, + }; let mut last_cursor = cursor_obj.clone(); let mut remaining = requested_page_size; let mut items = Vec::with_capacity(requested_page_size); @@ -2214,6 +2237,7 @@ impl CodexMessageProcessor { &self.config.codex_home, page_size, cursor_obj.as_ref(), + sort_key, INTERACTIVE_SESSION_SOURCES, model_provider_filter.as_deref(), fallback_provider.as_str(), @@ -2229,6 +2253,7 @@ impl CodexMessageProcessor { .items .into_iter() .filter_map(|it| { + let updated_at = it.updated_at.clone(); let session_meta_line = it.head.first().and_then(|first| { serde_json::from_value::(first.clone()).ok() })?; @@ -2238,6 +2263,7 @@ impl CodexMessageProcessor { &session_meta_line.meta, session_meta_line.git.as_ref(), fallback_provider.as_str(), + updated_at, ) }) .collect::>(); @@ -3982,12 +4008,19 @@ pub(crate) async fn read_summary_from_rollout( git, } = session_meta_line; + let created_at = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.as_str()) + }; + let updated_at = read_updated_at(path, created_at).await; if let Some(summary) = extract_conversation_summary( path.to_path_buf(), &head, &session_meta, git.as_ref(), fallback_provider, + updated_at.clone(), ) { return Ok(summary); } @@ -4002,10 +4035,12 @@ pub(crate) async fn read_summary_from_rollout( .clone() .unwrap_or_else(|| fallback_provider.to_string()); let git_info = git.as_ref().map(map_git_info); + let updated_at = updated_at.or_else(|| timestamp.clone()); Ok(ConversationSummary { conversation_id: session_meta.id, timestamp, + updated_at, path: path.to_path_buf(), preview: String::new(), model_provider, @@ -4040,6 +4075,7 @@ fn extract_conversation_summary( session_meta: &SessionMeta, git: Option<&CoreGitInfo>, fallback_provider: &str, + updated_at: Option, ) -> Option { let preview = head .iter() @@ -4065,10 +4101,12 @@ fn extract_conversation_summary( .clone() .unwrap_or_else(|| fallback_provider.to_string()); let git_info = git.map(map_git_info); + let updated_at = updated_at.or_else(|| timestamp.clone()); Some(ConversationSummary { conversation_id, timestamp, + updated_at, path, preview: preview.to_string(), model_provider, @@ -4095,12 +4133,25 @@ fn parse_datetime(timestamp: Option<&str>) -> Option> { }) } +async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option { + let updated_at = tokio::fs::metadata(path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(|modified| { + let updated_at: DateTime = modified.into(); + updated_at.to_rfc3339_opts(SecondsFormat::Secs, true) + }); + updated_at.or_else(|| created_at.map(str::to_string)) +} + pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { let ConversationSummary { conversation_id, path, preview, timestamp, + updated_at, model_provider, cwd, cli_version, @@ -4109,6 +4160,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { } = summary; let created_at = parse_datetime(timestamp.as_deref()); + let updated_at = parse_datetime(updated_at.as_deref()).or(created_at); let git_info = git_info.map(|info| ApiGitInfo { sha: info.sha, branch: info.branch, @@ -4120,6 +4172,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { preview, model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), + updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), path, cwd, cli_version, @@ -4174,13 +4227,20 @@ mod tests { let session_meta = serde_json::from_value::(head[0].clone())?; - let summary = - extract_conversation_summary(path.clone(), &head, &session_meta, None, "test-provider") - .expect("summary"); + let summary = extract_conversation_summary( + path.clone(), + &head, + &session_meta, + None, + "test-provider", + timestamp.clone(), + ) + .expect("summary"); let expected = ConversationSummary { conversation_id, - timestamp, + timestamp: timestamp.clone(), + updated_at: timestamp, path, preview: "Count to 5".to_string(), model_provider: "test-provider".to_string(), @@ -4200,6 +4260,7 @@ mod tests { use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMetaLine; use std::fs; + use std::fs::FileTimes; let temp_dir = TempDir::new()?; let path = temp_dir.path().join("rollout.jsonl"); @@ -4223,12 +4284,19 @@ mod tests { }; fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + let parsed = chrono::DateTime::parse_from_rfc3339(×tamp)?.with_timezone(&Utc); + let times = FileTimes::new().set_modified(parsed.into()); + std::fs::OpenOptions::new() + .append(true) + .open(&path)? + .set_times(times)?; let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; let expected = ConversationSummary { conversation_id, - timestamp: Some(timestamp), + timestamp: Some(timestamp.clone()), + updated_at: Some("2025-09-05T16:53:11Z".to_string()), path: path.clone(), preview: String::new(), model_provider: "fallback".to_string(), diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 48577db110..d350a5df82 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_shell_command_sse_response; pub use rollout::create_fake_rollout; pub use rollout::create_fake_rollout_with_text_elements; +pub use rollout::rollout_path; use serde::de::DeserializeOwned; pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 784b79b98d..31d4356638 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -6,10 +6,23 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use serde_json::json; use std::fs; +use std::fs::FileTimes; use std::path::Path; use std::path::PathBuf; use uuid::Uuid; +pub fn rollout_path(codex_home: &Path, filename_ts: &str, thread_id: &str) -> PathBuf { + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + codex_home + .join("sessions") + .join(year) + .join(month) + .join(day) + .join(format!("rollout-{filename_ts}-{thread_id}.jsonl")) +} + /// Create a minimal rollout file under `CODEX_HOME/sessions/YYYY/MM/DD/`. /// /// - `filename_ts` is the filename timestamp component in `YYYY-MM-DDThh-mm-ss` format. @@ -30,14 +43,11 @@ pub fn create_fake_rollout( let uuid_str = uuid.to_string(); let conversation_id = ThreadId::from_string(&uuid_str)?; - // sessions/YYYY/MM/DD derived from filename_ts (YYYY-MM-DDThh-mm-ss) - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let dir = codex_home.join("sessions").join(year).join(month).join(day); - fs::create_dir_all(&dir)?; - - let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); + let file_path = rollout_path(codex_home, filename_ts, &uuid_str); + let dir = file_path + .parent() + .ok_or_else(|| anyhow::anyhow!("missing rollout parent directory"))?; + fs::create_dir_all(dir)?; // Build JSONL lines let meta = SessionMeta { @@ -84,7 +94,13 @@ pub fn create_fake_rollout( .to_string(), ]; - fs::write(file_path, lines.join("\n") + "\n")?; + fs::write(&file_path, lines.join("\n") + "\n")?; + let parsed = chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); + let times = FileTimes::new().set_modified(parsed.into()); + std::fs::OpenOptions::new() + .append(true) + .open(&file_path)? + .set_times(times)?; Ok(uuid_str) } 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 0132651df8..7c3568c637 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1,17 +1,27 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_fake_rollout; +use app_test_support::rollout_path; use app_test_support::to_response; +use chrono::DateTime; +use chrono::Utc; use codex_app_server_protocol::GitInfo as ApiGitInfo; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSortKey; use codex_protocol::protocol::GitInfo as CoreGitInfo; +use pretty_assertions::assert_eq; +use std::cmp::Reverse; +use std::fs::FileTimes; +use std::fs::OpenOptions; use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; +use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -26,11 +36,22 @@ async fn list_threads( cursor: Option, limit: Option, providers: Option>, +) -> Result { + list_threads_with_sort(mcp, cursor, limit, providers, None).await +} + +async fn list_threads_with_sort( + mcp: &mut McpProcess, + cursor: Option, + limit: Option, + providers: Option>, + sort_key: Option, ) -> Result { let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { cursor, limit, + sort_key, model_providers: providers, }) .await?; @@ -82,6 +103,16 @@ fn timestamp_at( ) } +fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> { + let parsed = DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc); + let times = FileTimes::new().set_modified(parsed.into()); + OpenOptions::new() + .append(true) + .open(path)? + .set_times(times)?; + Ok(()) +} + #[tokio::test] async fn thread_list_basic_empty() -> Result<()> { let codex_home = TempDir::new()?; @@ -163,6 +194,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { assert_eq!(thread.preview, "Hello"); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.created_at > 0); + assert_eq!(thread.updated_at, thread.created_at); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); @@ -186,6 +218,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { assert_eq!(thread.preview, "Hello"); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.created_at > 0); + assert_eq!(thread.updated_at, thread.created_at); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); @@ -236,6 +269,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> { assert_eq!(thread.model_provider, "other_provider"); let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp(); assert_eq!(thread.created_at, expected_ts); + assert_eq!(thread.updated_at, expected_ts); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); @@ -429,3 +463,351 @@ async fn thread_list_includes_git_info() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn thread_list_default_sorts_by_created_at() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-01-01T13-00-00", + "2025-01-01T13:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_c = create_fake_rollout( + codex_home.path(), + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + None, + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_old = create_fake_rollout( + codex_home.path(), + "2025-01-01T10-00-00", + "2025-01-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_mid = create_fake_rollout( + codex_home.path(), + "2025-01-01T11-00-00", + "2025-01-01T11:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_new = create_fake_rollout( + codex_home.path(), + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(), + "2025-01-03T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T11-00-00", &id_mid).as_path(), + "2025-01-02T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T12-00-00", &id_new).as_path(), + "2025-01-01T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![id_old.as_str(), id_mid.as_str(), id_new.as_str()]); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_c = create_fake_rollout( + codex_home.path(), + "2025-02-01T12-00-00", + "2025-02-01T12:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(), + "2025-02-03T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(), + "2025-02-02T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T12-00-00", &id_c).as_path(), + "2025-02-01T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { + data: page1, + next_cursor: cursor1, + } = list_threads_with_sort( + &mut mcp, + None, + Some(2), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_page1, vec![id_a.as_str(), id_b.as_str()]); + let cursor1 = cursor1.expect("expected nextCursor on first page"); + + let ThreadListResponse { + data: page2, + next_cursor: cursor2, + } = list_threads_with_sort( + &mut mcp, + Some(cursor1), + Some(2), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + let ids_page2: Vec<_> = page2.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_page2, vec![id_c.as_str()]); + assert_eq!(cursor2, None); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + let mut expected = [id_a, id_b]; + expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse"))); + let expected: Vec<_> = expected.iter().map(String::as_str).collect(); + assert_eq!(ids, expected); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + let updated_at = "2025-02-03T00:00:00Z"; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(), + updated_at, + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(), + updated_at, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + let mut expected = [id_a, id_b]; + expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse"))); + let expected: Vec<_> = expected.iter().map(String::as_str).collect(); + assert_eq!(ids, expected); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_updated_at_uses_mtime() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &thread_id).as_path(), + "2025-02-05T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + + let thread = data + .iter() + .find(|item| item.id == thread_id) + .expect("expected thread for created rollout"); + let expected_created = + chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z")?.timestamp(); + let expected_updated = + chrono::DateTime::parse_from_rfc3339("2025-02-05T00:00:00Z")?.timestamp(); + assert_eq!(thread.created_at, expected_created); + assert_eq!(thread.updated_at, expected_updated); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_invalid_cursor_returns_error() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: Some("not-a-cursor".to_string()), + limit: Some(2), + sort_key: None, + model_providers: Some(vec!["mock_provider".to_string()]), + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert_eq!(error.error.message, "invalid cursor: not-a-cursor"); + + Ok(()) +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 498c45748f..e5a21b0517 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -100,6 +100,7 @@ pub use rollout::find_conversation_path_by_id_str; pub use rollout::find_thread_path_by_id_str; pub use rollout::list::Cursor; pub use rollout::list::ThreadItem; +pub use rollout::list::ThreadSortKey; pub use rollout::list::ThreadsPage; pub use rollout::list::parse_cursor; pub use rollout::list::read_head_for_summary; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 487304ddc8..3e53882fcf 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -1,11 +1,13 @@ use std::cmp::Reverse; use std::io::{self}; use std::num::NonZero; +use std::ops::ControlFlow; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; +use async_trait::async_trait; use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; @@ -41,8 +43,10 @@ pub struct ThreadItem { /// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line). pub head: Vec, /// RFC3339 timestamp string for when the session was created, if available. + /// created_at comes from the filename timestamp with second precision. pub created_at: Option, /// RFC3339 timestamp string for the most recent update (from file mtime). + /// updated_at is truncated to second precision to match created_at. pub updated_at: Option, } @@ -68,6 +72,12 @@ struct HeadTailSummary { const MAX_SCAN_FILES: usize = 10000; const HEAD_RECORD_LIMIT: usize = 10; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThreadSortKey { + CreatedAt, + UpdatedAt, +} + /// Pagination cursor identifying a file by timestamp and UUID. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { @@ -81,6 +91,135 @@ impl Cursor { } } +/// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest, +/// it ignores everything until it reaches the last seen item from the previous page, then +/// starts returning results after that. This makes paging stable even if new files show up during +/// pagination. +struct AnchorState { + ts: OffsetDateTime, + id: Uuid, + passed: bool, +} + +impl AnchorState { + fn new(anchor: Option) -> Self { + match anchor { + Some(cursor) => Self { + ts: cursor.ts, + id: cursor.id, + passed: false, + }, + None => Self { + ts: OffsetDateTime::UNIX_EPOCH, + id: Uuid::nil(), + passed: true, + }, + } + } + + fn should_skip(&mut self, ts: OffsetDateTime, id: Uuid) -> bool { + if self.passed { + return false; + } + if ts < self.ts || (ts == self.ts && id < self.id) { + self.passed = true; + false + } else { + true + } + } +} + +/// Visitor interface to customize behavior when visiting each rollout file +/// in `walk_rollout_files`. +/// +/// We need to apply different logic if we're ultimately going to be returning +/// threads ordered by created_at or updated_at. +#[async_trait] +trait RolloutFileVisitor { + async fn visit( + &mut self, + ts: OffsetDateTime, + id: Uuid, + path: PathBuf, + scanned: usize, + ) -> ControlFlow<()>; +} + +/// Collects thread items during directory traversal in created_at order, +/// applying pagination and filters inline. +struct FilesByCreatedAtVisitor<'a> { + items: &'a mut Vec, + page_size: usize, + anchor_state: AnchorState, + more_matches_available: bool, + allowed_sources: &'a [SessionSource], + provider_matcher: Option<&'a ProviderMatcher<'a>>, +} + +#[async_trait] +impl<'a> RolloutFileVisitor for FilesByCreatedAtVisitor<'a> { + async fn visit( + &mut self, + ts: OffsetDateTime, + id: Uuid, + path: PathBuf, + scanned: usize, + ) -> ControlFlow<()> { + if scanned >= MAX_SCAN_FILES && self.items.len() >= self.page_size { + self.more_matches_available = true; + return ControlFlow::Break(()); + } + if self.anchor_state.should_skip(ts, id) { + return ControlFlow::Continue(()); + } + if self.items.len() == self.page_size { + self.more_matches_available = true; + return ControlFlow::Break(()); + } + let updated_at = file_modified_time(&path) + .await + .unwrap_or(None) + .and_then(format_rfc3339); + if let Some(item) = build_thread_item( + path, + self.allowed_sources, + self.provider_matcher, + updated_at, + ) + .await + { + self.items.push(item); + } + ControlFlow::Continue(()) + } +} + +/// Collects lightweight file candidates (path + id + mtime). +/// Sorting after mtime happens after all files are collected. +struct FilesByUpdatedAtVisitor<'a> { + candidates: &'a mut Vec, +} + +#[async_trait] +impl<'a> RolloutFileVisitor for FilesByUpdatedAtVisitor<'a> { + async fn visit( + &mut self, + _ts: OffsetDateTime, + id: Uuid, + path: PathBuf, + _scanned: usize, + ) -> ControlFlow<()> { + let updated_at = file_modified_time(&path).await.unwrap_or(None); + self.candidates.push(ThreadCandidate { + path, + id, + updated_at, + }); + ControlFlow::Continue(()) + } +} + impl serde::Serialize for Cursor { fn serialize(&self, serializer: S) -> Result where @@ -108,11 +247,13 @@ impl<'de> serde::Deserialize<'de> for Cursor { /// Retrieve recorded thread file paths with token pagination. The returned `next_cursor` /// can be supplied on the next call to resume after the last returned item, resilient to -/// concurrent new sessions being appended. Ordering is stable by timestamp desc, then UUID desc. +/// concurrent new sessions being appended. Ordering is stable by the requested sort key +/// (timestamp desc, then UUID desc). pub(crate) async fn get_threads( codex_home: &Path, page_size: usize, cursor: Option<&Cursor>, + sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, default_provider: &str, @@ -138,6 +279,7 @@ pub(crate) async fn get_threads( root.clone(), page_size, anchor, + sort_key, allowed_sources, provider_matcher.as_ref(), ) @@ -148,8 +290,46 @@ pub(crate) async fn get_threads( /// Load thread file paths from disk using directory traversal. /// /// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-.jsonl` -/// Returned newest (latest) first. +/// Returned newest (based on sort key) first. async fn traverse_directories_for_paths( + root: PathBuf, + page_size: usize, + anchor: Option, + sort_key: ThreadSortKey, + allowed_sources: &[SessionSource], + provider_matcher: Option<&ProviderMatcher<'_>>, +) -> io::Result { + match sort_key { + ThreadSortKey::CreatedAt => { + traverse_directories_for_paths_created( + root, + page_size, + anchor, + allowed_sources, + provider_matcher, + ) + .await + } + ThreadSortKey::UpdatedAt => { + traverse_directories_for_paths_updated( + root, + page_size, + anchor, + allowed_sources, + provider_matcher, + ) + .await + } + } +} + +/// Walk the rollout directory tree in reverse chronological order and +/// collect items until the page fills or the scan cap is hit. +/// +/// Ordering comes from directory/filename sorting, so created_at is derived +/// from the filename timestamp. Pagination is handled by the anchor cursor +/// so we resume strictly after the last returned `(ts, id)` pair. +async fn traverse_directories_for_paths_created( root: PathBuf, page_size: usize, anchor: Option, @@ -158,98 +338,17 @@ async fn traverse_directories_for_paths( ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); let mut scanned_files = 0usize; - let mut anchor_passed = anchor.is_none(); - let (anchor_ts, anchor_id) = match anchor { - Some(c) => (c.ts, c.id), - None => (OffsetDateTime::UNIX_EPOCH, Uuid::nil()), - }; let mut more_matches_available = false; - - let year_dirs = collect_dirs_desc(&root, |s| s.parse::().ok()).await?; - - 'outer: for (_year, year_path) in year_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { - break; - } - let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; - for (_month, month_path) in month_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { - break 'outer; - } - let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; - for (_day, day_path) in day_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { - break 'outer; - } - let mut day_files = collect_files(day_path, |name_str, path| { - if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { - return None; - } - - parse_timestamp_uuid_from_filename(name_str) - .map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf())) - }) - .await?; - // Stable ordering within the same second: (timestamp desc, uuid desc) - day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid))); - for (ts, sid, _name_str, path) in day_files.into_iter() { - scanned_files += 1; - if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { - more_matches_available = true; - break 'outer; - } - if !anchor_passed { - if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { - anchor_passed = true; - } else { - continue; - } - } - if items.len() == page_size { - more_matches_available = true; - break 'outer; - } - // Read head and detect message events; stop once meta + user are found. - let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) - .await - .unwrap_or_default(); - if !allowed_sources.is_empty() - && !summary - .source - .is_some_and(|source| allowed_sources.iter().any(|s| s == &source)) - { - continue; - } - if let Some(matcher) = provider_matcher - && !matcher.matches(summary.model_provider.as_deref()) - { - continue; - } - // Apply filters: must have session meta and at least one user message event - if summary.saw_session_meta && summary.saw_user_event { - let HeadTailSummary { - head, - created_at, - mut updated_at, - .. - } = summary; - if updated_at.is_none() { - updated_at = file_modified_rfc3339(&path) - .await - .unwrap_or(None) - .or_else(|| created_at.clone()); - } - items.push(ThreadItem { - path, - head, - created_at, - updated_at, - }); - } - } - } - } - } + let mut visitor = FilesByCreatedAtVisitor { + items: &mut items, + page_size, + anchor_state: AnchorState::new(anchor), + more_matches_available, + allowed_sources, + provider_matcher, + }; + walk_rollout_files(&root, &mut scanned_files, &mut visitor).await?; + more_matches_available = visitor.more_matches_available; let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; if reached_scan_cap && !items.is_empty() { @@ -257,7 +356,7 @@ async fn traverse_directories_for_paths( } let next = if more_matches_available { - build_next_cursor(&items) + build_next_cursor(&items, ThreadSortKey::CreatedAt) } else { None }; @@ -269,9 +368,77 @@ async fn traverse_directories_for_paths( }) } -/// Pagination cursor token format: "|" where `file_ts` matches the -/// filename timestamp portion (YYYY-MM-DDThh-mm-ss) used in rollout filenames. -/// The cursor orders files by timestamp desc, then UUID desc. +/// Walk the rollout directory tree to collect files by updated_at, then sort by +/// file mtime (updated_at) and apply pagination/filtering in that order. +/// +/// Because updated_at is not encoded in filenames, this path must scan all +/// files up to the scan cap, then sort and filter by the anchor cursor. +/// +/// NOTE: This can be optimized in the future if we store additional state on disk +/// to cache updated_at timestamps. +async fn traverse_directories_for_paths_updated( + root: PathBuf, + page_size: usize, + anchor: Option, + allowed_sources: &[SessionSource], + provider_matcher: Option<&ProviderMatcher<'_>>, +) -> io::Result { + let mut items: Vec = Vec::with_capacity(page_size); + let mut scanned_files = 0usize; + let mut anchor_state = AnchorState::new(anchor); + let mut more_matches_available = false; + + let candidates = collect_files_by_updated_at(&root, &mut scanned_files).await?; + let mut candidates = candidates; + candidates.sort_by_key(|candidate| { + let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); + (Reverse(ts), Reverse(candidate.id)) + }); + + for candidate in candidates.into_iter() { + let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); + if anchor_state.should_skip(ts, candidate.id) { + continue; + } + if items.len() == page_size { + more_matches_available = true; + break; + } + + let updated_at_fallback = candidate.updated_at.and_then(format_rfc3339); + if let Some(item) = build_thread_item( + candidate.path, + allowed_sources, + provider_matcher, + updated_at_fallback, + ) + .await + { + items.push(item); + } + } + + let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; + if reached_scan_cap && !items.is_empty() { + more_matches_available = true; + } + + let next = if more_matches_available { + build_next_cursor(&items, ThreadSortKey::UpdatedAt) + } else { + None + }; + Ok(ThreadsPage { + items, + next_cursor: next, + num_scanned_files: scanned_files, + reached_scan_cap, + }) +} + +/// Pagination cursor token format: "|" where `ts` uses +/// YYYY-MM-DDThh-mm-ss (UTC, second precision). +/// The cursor orders files by the requested sort key (timestamp desc, then UUID desc). pub fn parse_cursor(token: &str) -> Option { let (file_ts, uuid_str) = token.split_once('|')?; @@ -286,13 +453,63 @@ pub fn parse_cursor(token: &str) -> Option { Some(Cursor::new(ts, uuid)) } -fn build_next_cursor(items: &[ThreadItem]) -> Option { +fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option { let last = items.last()?; let file_name = last.path.file_name()?.to_string_lossy(); - let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; + let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; + let ts = match sort_key { + ThreadSortKey::CreatedAt => created_ts, + ThreadSortKey::UpdatedAt => { + let updated_at = last.updated_at.as_deref()?; + OffsetDateTime::parse(updated_at, &Rfc3339).ok()? + } + }; Some(Cursor::new(ts, id)) } +async fn build_thread_item( + path: PathBuf, + allowed_sources: &[SessionSource], + provider_matcher: Option<&ProviderMatcher<'_>>, + updated_at: Option, +) -> Option { + // Read head and detect message events; stop once meta + user are found. + let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) + .await + .unwrap_or_default(); + if !allowed_sources.is_empty() + && !summary + .source + .is_some_and(|source| allowed_sources.contains(&source)) + { + return None; + } + if let Some(matcher) = provider_matcher + && !matcher.matches(summary.model_provider.as_deref()) + { + return None; + } + // Apply filters: must have session meta and at least one user message event + if summary.saw_session_meta && summary.saw_user_event { + let HeadTailSummary { + head, + created_at, + updated_at: mut summary_updated_at, + .. + } = summary; + if summary_updated_at.is_none() { + summary_updated_at = updated_at.or_else(|| created_at.clone()); + } + return Some(ThreadItem { + path, + head, + created_at, + updated_at: summary_updated_at, + }); + } + None +} + /// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`, /// and returns them sorted descending by the parsed key. async fn collect_dirs_desc(parent: &Path, parse: F) -> io::Result> @@ -340,6 +557,22 @@ where Ok(collected) } +async fn collect_rollout_day_files( + day_path: &Path, +) -> io::Result> { + let mut day_files = collect_files(day_path, |name_str, path| { + if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { + return None; + } + + parse_timestamp_uuid_from_filename(name_str).map(|(ts, id)| (ts, id, path.to_path_buf())) + }) + .await?; + // Stable ordering within the same second: (timestamp desc, uuid desc) + day_files.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid))); + Ok(day_files) +} + fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> { // Expected: rollout-YYYY-MM-DDThh-mm-ss-.jsonl let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?; @@ -357,6 +590,65 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui Some((ts, uuid)) } +struct ThreadCandidate { + path: PathBuf, + id: Uuid, + updated_at: Option, +} + +async fn collect_files_by_updated_at( + root: &Path, + scanned_files: &mut usize, +) -> io::Result> { + let mut candidates = Vec::new(); + let mut visitor = FilesByUpdatedAtVisitor { + candidates: &mut candidates, + }; + walk_rollout_files(root, scanned_files, &mut visitor).await?; + + Ok(candidates) +} + +async fn walk_rollout_files( + root: &Path, + scanned_files: &mut usize, + visitor: &mut impl RolloutFileVisitor, +) -> io::Result<()> { + let year_dirs = collect_dirs_desc(root, |s| s.parse::().ok()).await?; + + 'outer: for (_year, year_path) in year_dirs.iter() { + if *scanned_files >= MAX_SCAN_FILES { + break; + } + let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; + for (_month, month_path) in month_dirs.iter() { + if *scanned_files >= MAX_SCAN_FILES { + break 'outer; + } + let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; + for (_day, day_path) in day_dirs.iter() { + if *scanned_files >= MAX_SCAN_FILES { + break 'outer; + } + let day_files = collect_rollout_day_files(day_path).await?; + for (ts, id, path) in day_files.into_iter() { + *scanned_files += 1; + if *scanned_files > MAX_SCAN_FILES { + break 'outer; + } + if let ControlFlow::Break(()) = + visitor.visit(ts, id, path, *scanned_files).await + { + break 'outer; + } + } + } + } + } + + Ok(()) +} + struct ProviderMatcher<'a> { filters: &'a [String], matches_default_provider: bool, @@ -452,14 +744,24 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result io::Result> { +async fn file_modified_time(path: &Path) -> io::Result> { let meta = tokio::fs::metadata(path).await?; let modified = meta.modified().ok(); let Some(modified) = modified else { return Ok(None); }; let dt = OffsetDateTime::from(modified); - Ok(dt.format(&Rfc3339).ok()) + // Truncate to seconds so ordering and cursor comparisons align with the + // cursor timestamp format (which exposes seconds), keeping pagination stable. + Ok(truncate_to_seconds(dt)) +} + +fn format_rfc3339(dt: OffsetDateTime) -> Option { + dt.format(&Rfc3339).ok() +} + +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 diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 7151f95b95..3bde40a734 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -22,6 +22,7 @@ use tracing::warn; use super::SESSIONS_SUBDIR; use super::list::Cursor; +use super::list::ThreadSortKey; use super::list::ThreadsPage; use super::list::get_threads; use super::policy::is_persisted_response_item; @@ -99,6 +100,7 @@ impl RolloutRecorder { codex_home: &Path, page_size: usize, cursor: Option<&Cursor>, + sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, default_provider: &str, @@ -107,6 +109,7 @@ impl RolloutRecorder { codex_home, page_size, cursor, + sort_key, allowed_sources, model_providers, default_provider, @@ -115,19 +118,24 @@ impl RolloutRecorder { } /// Find the newest recorded thread path, optionally filtering to a matching cwd. + #[allow(clippy::too_many_arguments)] pub async fn find_latest_thread_path( codex_home: &Path, + page_size: usize, + cursor: Option<&Cursor>, + sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, default_provider: &str, filter_cwd: Option<&Path>, ) -> std::io::Result> { - let mut cursor: Option = None; + let mut cursor = cursor.cloned(); loop { let page = Self::list_threads( codex_home, - 25, + page_size, cursor.as_ref(), + sort_key, allowed_sources, model_providers, default_provider, diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index c16ec65cf8..d0675e97ed 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -1,11 +1,13 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use std::fs::File; +use std::fs::FileTimes; use std::fs::{self}; use std::io::Write; use std::path::Path; use tempfile::TempDir; +use time::Duration; use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; @@ -15,6 +17,7 @@ use uuid::Uuid; use crate::rollout::INTERACTIVE_SESSION_SOURCES; use crate::rollout::list::Cursor; use crate::rollout::list::ThreadItem; +use crate::rollout::list::ThreadSortKey; use crate::rollout::list::ThreadsPage; use crate::rollout::list::get_threads; use anyhow::Result; @@ -122,6 +125,8 @@ fn write_session_file_with_provider( }); writeln!(file, "{rec}")?; } + let times = FileTimes::new().set_modified(dt.into()); + file.set_times(times)?; Ok((dt, uuid)) } @@ -166,6 +171,7 @@ async fn test_list_conversations_latest_first() { home, 10, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -315,6 +321,7 @@ async fn test_pagination_cursor() { home, 2, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -382,6 +389,7 @@ async fn test_pagination_cursor() { home, 2, page1.next_cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -449,6 +457,7 @@ async fn test_pagination_cursor() { home, 2, page2.next_cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -501,6 +510,7 @@ async fn test_get_thread_contents() { home, 1, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -567,6 +577,52 @@ async fn test_get_thread_contents() { assert_eq!(content, expected_content); } +#[tokio::test] +async fn test_created_at_sort_uses_file_mtime_for_updated_at() -> Result<()> { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-06-01T08-00-00"; + let uuid = Uuid::from_u128(43); + write_session_file(home, ts, uuid, 0, Some(SessionSource::VSCode)).unwrap(); + + let created = PrimitiveDateTime::parse( + ts, + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"), + )? + .assume_utc(); + let updated = created + Duration::hours(2); + let expected_updated = updated.format(&time::format_description::well_known::Rfc3339)?; + + let file_path = home + .join("sessions") + .join("2025") + .join("06") + .join("01") + .join(format!("rollout-{ts}-{uuid}.jsonl")); + let file = std::fs::OpenOptions::new().write(true).open(&file_path)?; + let times = FileTimes::new().set_modified(updated.into()); + file.set_times(times)?; + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 1, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await?; + + let item = page.items.first().expect("conversation item"); + assert_eq!(item.created_at.as_deref(), Some(ts)); + assert_eq!(item.updated_at.as_deref(), Some(expected_updated.as_str())); + + Ok(()) +} + #[tokio::test] async fn test_updated_at_uses_file_mtime() -> Result<()> { let temp = TempDir::new().unwrap(); @@ -630,6 +686,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { home, 1, None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -669,6 +726,7 @@ async fn test_stable_ordering_same_second_pagination() { home, 2, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -728,6 +786,7 @@ async fn test_stable_ordering_same_second_pagination() { home, 2, page1.next_cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -786,6 +845,7 @@ async fn test_source_filter_excludes_non_matching_sessions() { home, 10, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -803,9 +863,17 @@ async fn test_source_filter_excludes_non_matching_sessions() { path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl") })); - let all_sessions = get_threads(home, 10, None, NO_SOURCE_FILTER, None, TEST_PROVIDER) - .await - .unwrap(); + let all_sessions = get_threads( + home, + 10, + None, + ThreadSortKey::CreatedAt, + NO_SOURCE_FILTER, + None, + TEST_PROVIDER, + ) + .await + .unwrap(); let all_paths: Vec<_> = all_sessions .items .into_iter() @@ -861,6 +929,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( home, 10, None, + ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, Some(openai_filter.as_slice()), "openai", @@ -886,6 +955,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( home, 10, None, + ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, Some(beta_filter.as_slice()), "openai", @@ -906,6 +976,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( home, 10, None, + ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, Some(unknown_filter.as_slice()), "openai", @@ -913,7 +984,16 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( .await?; assert!(unknown_sessions.items.is_empty()); - let all_sessions = get_threads(home, 10, None, NO_SOURCE_FILTER, None, "openai").await?; + let all_sessions = get_threads( + home, + 10, + None, + ThreadSortKey::CreatedAt, + NO_SOURCE_FILTER, + None, + "openai", + ) + .await?; assert_eq!(all_sessions.items.len(), 3); Ok(()) diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 633d55a608..9108f263f9 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -88,6 +88,7 @@ async fn chat_mode_stream_cli() { home.path(), 10, None, + codex_core::ThreadSortKey::UpdatedAt, &[], Some(provider_filter.as_slice()), "mock", diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 5267e48156..377dc02a8d 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -171,6 +171,7 @@ impl AppServerClient { params: ThreadListParams { cursor, limit: None, + sort_key: None, model_providers: None, }, }; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index cdd8bf7daf..ccab33048b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -514,6 +514,9 @@ async fn resolve_resume_path( }; match codex_core::RolloutRecorder::find_latest_thread_path( &config.codex_home, + 1, + None, + codex_core::ThreadSortKey::UpdatedAt, &[], Some(default_provider_filter.as_slice()), &config.model_provider_id, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 29bf2b1ce0..880ff19322 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -15,6 +15,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; +use codex_core::ThreadSortKey; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -478,6 +479,7 @@ async fn run_ratatui_app( &config.codex_home, 1, None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), &config.model_provider_id, @@ -529,6 +531,9 @@ async fn run_ratatui_app( }; match RolloutRecorder::find_latest_thread_path( &config.codex_home, + 1, + None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), &config.model_provider_id, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 5e96c8f2bb..71d6da453b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -9,6 +9,7 @@ use codex_core::Cursor; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use codex_core::ThreadItem; +use codex_core::ThreadSortKey; use codex_core::ThreadsPage; use codex_core::path_utils; use codex_protocol::items::TurnItem; @@ -156,6 +157,7 @@ async fn run_session_picker( &request.codex_home, PAGE_SIZE, request.cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), request.default_provider.as_str(), @@ -1422,6 +1424,7 @@ mod tests { &state.codex_home, PAGE_SIZE, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(&[String::from("openai")]), "openai", diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 55e640c58f..11a0f9f6be 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -15,6 +15,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; +use codex_core::ThreadSortKey; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -500,6 +501,7 @@ async fn run_ratatui_app( &config.codex_home, 1, None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), &config.model_provider_id, @@ -552,6 +554,9 @@ async fn run_ratatui_app( }; match RolloutRecorder::find_latest_thread_path( &config.codex_home, + 1, + None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), &config.model_provider_id, diff --git a/codex-rs/tui2/src/resume_picker.rs b/codex-rs/tui2/src/resume_picker.rs index 5e96c8f2bb..71d6da453b 100644 --- a/codex-rs/tui2/src/resume_picker.rs +++ b/codex-rs/tui2/src/resume_picker.rs @@ -9,6 +9,7 @@ use codex_core::Cursor; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use codex_core::ThreadItem; +use codex_core::ThreadSortKey; use codex_core::ThreadsPage; use codex_core::path_utils; use codex_protocol::items::TurnItem; @@ -156,6 +157,7 @@ async fn run_session_picker( &request.codex_home, PAGE_SIZE, request.cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), request.default_provider.as_str(), @@ -1422,6 +1424,7 @@ mod tests { &state.codex_home, PAGE_SIZE, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(&[String::from("openai")]), "openai", From c26fe645393534c47b8ae5e03b6037c1dfc0d504 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Fri, 16 Jan 2026 13:41:46 -0800 Subject: [PATCH 18/78] feat: show forked from session id in /status (#9330) Summary: - Add forked_from to SessionMeta/SessionConfiguredEvent and persist it for forked sessions. - Surface forked_from in /status for tui + tui2 and add snapshots. --- codex-rs/app-server/tests/common/rollout.rs | 2 + codex-rs/core/src/codex.rs | 4 ++ codex-rs/core/src/rollout/recorder.rs | 5 ++ codex-rs/core/src/rollout/tests.rs | 1 + .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/protocol/src/protocol.rs | 22 ++++++ codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/chatwidget.rs | 5 ++ codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/status/card.rs | 14 ++++ ..._status_snapshot_includes_forked_from.snap | 24 +++++++ codex-rs/tui/src/status/tests.rs | 66 ++++++++++++++++++ codex-rs/tui2/src/app.rs | 3 + codex-rs/tui2/src/chatwidget.rs | 5 ++ codex-rs/tui2/src/chatwidget/tests.rs | 2 + codex-rs/tui2/src/status/card.rs | 14 ++++ ..._status_snapshot_includes_forked_from.snap | 24 +++++++ codex-rs/tui2/src/status/tests.rs | 67 +++++++++++++++++++ 19 files changed, 267 insertions(+) create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 31d4356638..72bc07753d 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -52,6 +52,7 @@ pub fn create_fake_rollout( // Build JSONL lines let meta = SessionMeta { id: conversation_id, + forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".to_string(), @@ -129,6 +130,7 @@ pub fn create_fake_rollout_with_text_elements( // Build JSONL lines let meta = SessionMeta { id: conversation_id, + forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".to_string(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 61fb48f697..00aa70aa69 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -588,6 +588,8 @@ impl Session { )); } + let forked_from_id = initial_history.forked_from_id(); + let (conversation_id, rollout_params) = match &initial_history { InitialHistory::New | InitialHistory::Forked(_) => { let conversation_id = ThreadId::default(); @@ -595,6 +597,7 @@ impl Session { conversation_id, RolloutRecorderParams::new( conversation_id, + forked_from_id, session_configuration.user_instructions.clone(), session_source, ), @@ -733,6 +736,7 @@ impl Session { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + forked_from_id, model: session_configuration.model.clone(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 3bde40a734..13eace9f3c 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -57,6 +57,7 @@ pub struct RolloutRecorder { pub enum RolloutRecorderParams { Create { conversation_id: ThreadId, + forked_from_id: Option, instructions: Option, source: SessionSource, }, @@ -79,11 +80,13 @@ enum RolloutCmd { impl RolloutRecorderParams { pub fn new( conversation_id: ThreadId, + forked_from_id: Option, instructions: Option, source: SessionSource, ) -> Self { Self::Create { conversation_id, + forked_from_id, instructions, source, } @@ -158,6 +161,7 @@ impl RolloutRecorder { let (file, rollout_path, meta) = match params { RolloutRecorderParams::Create { conversation_id, + forked_from_id, instructions, source, } => { @@ -181,6 +185,7 @@ impl RolloutRecorder { path, Some(SessionMeta { id: session_id, + forked_from_id, timestamp, cwd: config.cwd.clone(), originator: originator().value, diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index d0675e97ed..660e83e56f 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -641,6 +641,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { id: conversation_id, + forked_from_id: None, timestamp: ts.to_string(), instructions: None, cwd: ".".into(), 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 e60fcba8e8..e0b2509667 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -75,6 +75,7 @@ fn session_configured_produces_thread_started_event() { "e1", EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, + forked_from_id: None, model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 0b05bc3609..b7c66592ff 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -257,6 +257,7 @@ mod tests { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: thread_id, + forked_from_id: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -296,6 +297,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -359,6 +361,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: thread_id, + forked_from_id: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a08e30e226..6ddc0aeb5a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1376,6 +1376,22 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn forked_from_id(&self) -> Option { + match self { + InitialHistory::New => None, + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.forked_from_id, + _ => None, + }) + } + InitialHistory::Forked(items) => items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id), + _ => None, + }), + } + } + pub fn get_rollout_items(&self) -> Vec { match self { InitialHistory::New => Vec::new(), @@ -1459,6 +1475,8 @@ impl fmt::Display for SubAgentSource { #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { pub id: ThreadId, + #[serde(skip_serializing_if = "Option::is_none")] + pub forked_from_id: Option, pub timestamp: String, pub cwd: PathBuf, pub originator: String, @@ -1473,6 +1491,7 @@ impl Default for SessionMeta { fn default() -> Self { SessionMeta { id: ThreadId::default(), + forked_from_id: None, timestamp: String::new(), cwd: PathBuf::new(), originator: String::new(), @@ -2003,6 +2022,8 @@ pub struct SkillsListEntry { pub struct SessionConfiguredEvent { /// Name left as session_id instead of thread_id for backwards compatibility. pub session_id: ThreadId, + #[serde(skip_serializing_if = "Option::is_none")] + pub forked_from_id: Option, /// Tell the client what model is being queried. pub model: String, @@ -2376,6 +2397,7 @@ mod tests { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index adc48c1d12..0178b31b2e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1992,6 +1992,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2033,6 +2034,7 @@ mod tests { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2075,6 +2077,7 @@ mod tests { let thread_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: thread_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d301243ca7..e1054d0a3e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -440,6 +440,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, + forked_from: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -600,6 +601,7 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); self.thread_id = Some(event.session_id); + self.forked_from = event.forked_from_id; self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); @@ -1699,6 +1701,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1797,6 +1800,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -2635,6 +2639,7 @@ impl ChatWidget { token_info, total_usage, &self.thread_id, + self.forked_from, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6b216e6d76..19d10ad51d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -131,6 +131,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -428,6 +429,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 7174cf41c9..b860b03941 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -66,6 +66,7 @@ struct StatusHistoryCell { model_provider: Option, account: Option, session_id: Option, + forked_from: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, } @@ -77,6 +78,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -89,6 +91,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + forked_from, rate_limits, plan_type, now, @@ -106,6 +109,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -134,6 +138,7 @@ impl StatusHistoryCell { let model_provider = format_model_provider(config); let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); + let forked_from = forked_from.map(|id| id.to_string()); let default_usage = TokenUsage::default(); let (context_usage, context_window) = match token_info { Some(info) => (&info.last_token_usage, info.model_context_window), @@ -163,6 +168,7 @@ impl StatusHistoryCell { model_provider, account, session_id, + forked_from, token_usage, rate_limits, } @@ -351,6 +357,9 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } + if self.session_id.is_some() && self.forked_from.is_some() { + push_label(&mut labels, &mut seen, "Forked from"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -403,6 +412,11 @@ impl HistoryCell for StatusHistoryCell { if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } + if self.session_id.is_some() + && let Some(forked_from) = self.forked_from.as_ref() + { + lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())])); + } lines.push(Line::from(Vec::>::new())); // Hide token usage only for ChatGPT subscribers diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap new file mode 100644 index 0000000000..18772b7d76 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index bb16f13328..b01a139522 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -14,6 +14,7 @@ use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; +use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; @@ -146,6 +147,7 @@ async fn status_snapshot_includes_reasoning_details() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -161,6 +163,57 @@ async fn status_snapshot_includes_reasoning_details() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_includes_forked_from() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 8, 9, 10, 11, 12) + .single() + .expect("valid time"); + + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let session_id = + ThreadId::from_string("0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e").expect("session id"); + let forked_from = + ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); + + let composite = new_status_output( + &config, + &auth_manager, + Some(&token_info), + &usage, + &Some(session_id), + Some(forked_from), + None, + None, + captured_at, + &model_slug, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); @@ -202,6 +255,7 @@ async fn status_snapshot_includes_monthly_limit() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -246,6 +300,7 @@ async fn status_snapshot_shows_unlimited_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -289,6 +344,7 @@ async fn status_snapshot_shows_positive_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -332,6 +388,7 @@ async fn status_snapshot_hides_zero_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -373,6 +430,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -416,6 +474,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { &None, None, None, + None, now, &model_slug, ); @@ -470,6 +529,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -517,6 +577,7 @@ async fn status_snapshot_shows_missing_limits_message() { &None, None, None, + None, now, &model_slug, ); @@ -578,6 +639,7 @@ async fn status_snapshot_includes_credits_and_limits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -629,6 +691,7 @@ async fn status_snapshot_shows_empty_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -689,6 +752,7 @@ async fn status_snapshot_shows_stale_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -753,6 +817,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -809,6 +874,7 @@ async fn status_context_window_uses_last_usage() { &None, None, None, + None, now, &model_slug, ); diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 52bf416419..5b66869056 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -2627,6 +2627,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2668,6 +2669,7 @@ mod tests { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2950,6 +2952,7 @@ mod tests { let conversation_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index ab40b19c7b..c0944fa089 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -383,6 +383,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, conversation_id: Option, + forked_from: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -522,6 +523,7 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); self.conversation_id = Some(event.session_id); + self.forked_from = event.forked_from_id; self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); @@ -1502,6 +1504,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1598,6 +1601,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -2400,6 +2404,7 @@ impl ChatWidget { token_info, total_usage, &self.conversation_id, + self.forked_from, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index fa555e597e..b3ad2114c7 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -120,6 +120,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -414,6 +415,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 55896365e2..3e9e07d538 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -66,6 +66,7 @@ struct StatusHistoryCell { model_provider: Option, account: Option, session_id: Option, + forked_from: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, } @@ -77,6 +78,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -89,6 +91,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + forked_from, rate_limits, plan_type, now, @@ -106,6 +109,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -134,6 +138,7 @@ impl StatusHistoryCell { let model_provider = format_model_provider(config); let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); + let forked_from = forked_from.map(|id| id.to_string()); let default_usage = TokenUsage::default(); let (context_usage, context_window) = match token_info { Some(info) => (&info.last_token_usage, info.model_context_window), @@ -163,6 +168,7 @@ impl StatusHistoryCell { model_provider, account, session_id, + forked_from, token_usage, rate_limits, } @@ -351,6 +357,9 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } + if self.session_id.is_some() && self.forked_from.is_some() { + push_label(&mut labels, &mut seen, "Forked from"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -402,6 +411,11 @@ impl HistoryCell for StatusHistoryCell { if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } + if self.session_id.is_some() + && let Some(forked_from) = self.forked_from.as_ref() + { + lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())])); + } lines.push(Line::from(Vec::>::new())); // Hide token usage only for ChatGPT subscribers diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap new file mode 100644 index 0000000000..f238fd0eeb --- /dev/null +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap @@ -0,0 +1,24 @@ +--- +source: tui2/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index b16940a4de..e79688f44e 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -14,6 +14,7 @@ use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; +use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; @@ -146,6 +147,7 @@ async fn status_snapshot_includes_reasoning_details() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -160,6 +162,58 @@ async fn status_snapshot_includes_reasoning_details() { let sanitized = sanitize_directory(rendered_lines).join("\n"); assert_snapshot!(sanitized); } + +#[tokio::test] +async fn status_snapshot_includes_forked_from() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 8, 9, 10, 11, 12) + .single() + .expect("valid time"); + + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let session_id = + ThreadId::from_string("0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e").expect("session id"); + let forked_from = + ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); + + let composite = new_status_output( + &config, + &auth_manager, + Some(&token_info), + &usage, + &Some(session_id), + Some(forked_from), + None, + None, + captured_at, + &model_slug, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); @@ -201,6 +255,7 @@ async fn status_snapshot_includes_monthly_limit() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -244,6 +299,7 @@ async fn status_snapshot_shows_unlimited_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -287,6 +343,7 @@ async fn status_snapshot_shows_positive_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -330,6 +387,7 @@ async fn status_snapshot_hides_zero_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -371,6 +429,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -414,6 +473,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { &None, None, None, + None, now, &model_slug, ); @@ -468,6 +528,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -515,6 +576,7 @@ async fn status_snapshot_shows_missing_limits_message() { &None, None, None, + None, now, &model_slug, ); @@ -575,6 +637,7 @@ async fn status_snapshot_includes_credits_and_limits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -626,6 +689,7 @@ async fn status_snapshot_shows_empty_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -686,6 +750,7 @@ async fn status_snapshot_shows_stale_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -750,6 +815,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -806,6 +872,7 @@ async fn status_context_window_uses_last_usage() { &None, None, None, + None, now, &model_slug, ); From 246f50655111fda3d4fa4f2032285d03464fe249 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 16 Jan 2026 16:28:22 -0800 Subject: [PATCH 19/78] Introduce collaboration modes (#9340) - Merge `model` and `reasoning_effort` under collaboration modes. - Add additional instructions for custom collaboration mode - Default to Custom to not change behavior --- codex-rs/core/src/codex.rs | 135 +++++++++++++++++--------- codex-rs/protocol/src/config_types.rs | 69 +++++++++++++ 2 files changed, 156 insertions(+), 48 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 00aa70aa69..b554450d11 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -160,12 +160,13 @@ use crate::user_notification::UserNotification; use crate::util::backoff; use codex_async_utils::OrCancelExt; use codex_otel::OtelManager; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::config_types::Settings; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::user_input::UserInput; @@ -270,10 +271,16 @@ impl Codex { crate::models_manager::manager::RefreshStrategy::OnlineIfUncached, ) .await; + // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode + // to avoid extracting these fields separately and constructing CollaborationMode here. + let collaboration_mode = CollaborationMode::Custom(Settings { + model: model.clone(), + reasoning_effort: config.model_reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: model.clone(), - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions, @@ -422,10 +429,7 @@ pub(crate) struct SessionConfiguration { /// Provider identifier ("openai", "openrouter", ...). provider: ModelProviderInfo, - /// If not specified, server will use its default model. - model: String, - - model_reasoning_effort: Option, + collaboration_mode: CollaborationMode, model_reasoning_summary: ReasoningSummaryConfig, /// Developer instructions that supplement the base instructions. @@ -463,11 +467,8 @@ pub(crate) struct SessionConfiguration { impl SessionConfiguration { pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult { let mut next_configuration = self.clone(); - if let Some(model) = updates.model.clone() { - next_configuration.model = model; - } - if let Some(effort) = updates.reasoning_effort { - next_configuration.model_reasoning_effort = effort; + if let Some(collaboration_mode) = updates.collaboration_mode.clone() { + next_configuration.collaboration_mode = collaboration_mode; } if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = summary; @@ -490,8 +491,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) cwd: Option, pub(crate) approval_policy: Option, pub(crate) sandbox_policy: Option, - pub(crate) model: Option, - pub(crate) reasoning_effort: Option>, + pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, pub(crate) final_output_json_schema: Option>, } @@ -502,7 +502,8 @@ impl Session { // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); - per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; + per_turn_config.model_reasoning_effort = + session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; per_turn_config.features = config.features.clone(); per_turn_config @@ -520,7 +521,7 @@ impl Session { sub_id: String, ) -> TurnContext { let otel_manager = otel_manager.clone().with_model( - session_configuration.model.as_str(), + session_configuration.collaboration_mode.model(), model_info.slug.as_str(), ); @@ -531,7 +532,7 @@ impl Session { model_info.clone(), otel_manager, provider, - session_configuration.model_reasoning_effort, + session_configuration.collaboration_mode.reasoning_effort(), session_configuration.model_reasoning_summary, conversation_id, session_configuration.session_source.clone(), @@ -579,7 +580,8 @@ impl Session { ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", - session_configuration.model, session_configuration.provider + session_configuration.collaboration_mode.model(), + session_configuration.provider ); if !session_configuration.cwd.is_absolute() { return Err(anyhow::anyhow!( @@ -655,8 +657,8 @@ impl Session { let auth = auth.as_ref(); let otel_manager = OtelManager::new( conversation_id, - session_configuration.model.as_str(), - session_configuration.model.as_str(), + session_configuration.collaboration_mode.model(), + session_configuration.collaboration_mode.model(), auth.and_then(CodexAuth::get_account_id), auth.and_then(CodexAuth::get_account_email), auth.map(|a| a.mode), @@ -680,7 +682,7 @@ impl Session { otel_manager.conversation_starts( config.model_provider.name.as_str(), - config.model_reasoning_effort, + session_configuration.collaboration_mode.reasoning_effort(), config.model_reasoning_summary, config.model_context_window, config.model_auto_compact_token_limit, @@ -737,12 +739,12 @@ impl Session { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, forked_from_id, - model: session_configuration.model.clone(), + model: session_configuration.collaboration_mode.model().to_string(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.get().clone(), cwd: session_configuration.cwd.clone(), - reasoning_effort: session_configuration.model_reasoning_effort, + reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, history_entry_count, initial_messages, @@ -978,7 +980,10 @@ impl Session { let model_info = self .services .models_manager - .get_model_info(session_configuration.model.as_str(), &per_turn_config) + .get_model_info( + session_configuration.collaboration_mode.model(), + &per_turn_config, + ) .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), @@ -1837,6 +1842,13 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv effort, summary, } => { + let collaboration_mode = { + let state = sess.state.lock().await; + state + .session_configuration + .collaboration_mode + .with_updates(model, effort, None) + }; handlers::override_turn_context( &sess, sub.id.clone(), @@ -1844,8 +1856,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv cwd, approval_policy, sandbox_policy, - model, - reasoning_effort: effort, + collaboration_mode: Some(collaboration_mode), reasoning_summary: summary, ..Default::default() }, @@ -1953,6 +1964,8 @@ mod handlers { use codex_protocol::protocol::WarningEvent; use crate::context_manager::is_user_turn_boundary; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::Settings; use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; @@ -1999,18 +2012,24 @@ mod handlers { summary, final_output_json_schema, items, - } => ( - items, - SessionSettingsUpdate { - cwd: Some(cwd), - approval_policy: Some(approval_policy), - sandbox_policy: Some(sandbox_policy), - model: Some(model), - reasoning_effort: Some(effort), - reasoning_summary: Some(summary), - final_output_json_schema: Some(final_output_json_schema), - }, - ), + } => { + let collaboration_mode = Some(CollaborationMode::Custom(Settings { + model, + reasoning_effort: effort, + developer_instructions: None, + })); + ( + items, + SessionSettingsUpdate { + cwd: Some(cwd), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + collaboration_mode, + reasoning_summary: Some(summary), + final_output_json_schema: Some(final_output_json_schema), + }, + ) + } Op::UserInput { items, final_output_json_schema, @@ -3402,10 +3421,15 @@ mod tests { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode::Custom(Settings { + model, + reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model, - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), @@ -3468,10 +3492,15 @@ mod tests { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode::Custom(Settings { + model, + reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model, - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), @@ -3718,10 +3747,15 @@ mod tests { let exec_policy = ExecPolicyManager::default(); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode::Custom(Settings { + model, + reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model, - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), @@ -3735,7 +3769,7 @@ mod tests { }; let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline( - session_configuration.model.as_str(), + session_configuration.collaboration_mode.model(), &per_turn_config, ); let otel_manager = otel_manager( @@ -3813,10 +3847,15 @@ mod tests { let exec_policy = ExecPolicyManager::default(); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode::Custom(Settings { + model, + reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model, - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), @@ -3830,7 +3869,7 @@ mod tests { }; let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline( - session_configuration.model.as_str(), + session_configuration.collaboration_mode.model(), &per_turn_config, ); let otel_manager = otel_manager( diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 3979bc3810..0febd2fdd5 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -4,6 +4,8 @@ use serde::Serialize; use strum_macros::Display; use ts_rs::TS; +use crate::openai_models::ReasoningEffort; + /// A summary of the reasoning performed by the model. This can be useful for /// debugging and understanding the model's reasoning process. /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries @@ -127,3 +129,70 @@ pub enum AltScreenMode { /// Never use alternate screen (inline mode only). Never, } + +/// Collaboration mode for a Codex session. +#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "lowercase")] +pub enum CollaborationMode { + Plan(Settings), + Collaborate(Settings), + Execute(Settings), + Custom(Settings), +} + +impl CollaborationMode { + /// Returns a reference to the settings, regardless of variant. + fn settings(&self) -> &Settings { + match self { + CollaborationMode::Plan(settings) + | CollaborationMode::Collaborate(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings, + } + } + + pub fn model(&self) -> &str { + self.settings().model.as_str() + } + + pub fn reasoning_effort(&self) -> Option { + self.settings().reasoning_effort + } + + /// Updates the collaboration mode with new model and/or effort values. + /// + /// - `model`: `Some(s)` to update the model, `None` to keep the current model + /// - `effort`: `Some(Some(e))` to set effort to `e`, `Some(None)` to clear effort, `None` to keep current effort + /// - `developer_instructions`: `Some(s)` to update developer instructions, `None` to keep current + /// + /// Returns a new `CollaborationMode` with updated values, preserving the variant. + pub fn with_updates( + &self, + model: Option, + effort: Option>, + developer_instructions: Option, + ) -> Self { + let settings = self.settings(); + let updated_settings = Settings { + model: model.unwrap_or_else(|| settings.model.clone()), + reasoning_effort: effort.unwrap_or(settings.reasoning_effort), + developer_instructions: developer_instructions + .or_else(|| settings.developer_instructions.clone()), + }; + + match self { + CollaborationMode::Plan(_) => CollaborationMode::Plan(updated_settings), + CollaborationMode::Collaborate(_) => CollaborationMode::Collaborate(updated_settings), + CollaborationMode::Execute(_) => CollaborationMode::Execute(updated_settings), + CollaborationMode::Custom(_) => CollaborationMode::Custom(updated_settings), + } + } +} + +/// Settings for a collaboration mode. +#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)] +pub struct Settings { + pub model: String, + pub reasoning_effort: Option, + pub developer_instructions: Option, +} From ad8bf59cbfb6edbc1dd54626b23fa35c2a94a688 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 16 Jan 2026 20:22:05 -0800 Subject: [PATCH 20/78] Support enable/disable skill via config/api. (#9328) In config.toml: ``` [[skills.config]] path = "/Users/xl/.codex/skills/my_skill/SKILL.md" enabled = false ``` API: skills/list, skills/config/write --- .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 17 + codex-rs/app-server/README.md | 24 +- .../app-server/src/codex_message_processor.rs | 76 +++- codex-rs/core/config.schema.json | 48 ++- codex-rs/core/src/codex.rs | 6 +- codex-rs/core/src/config/edit.rs | 168 +++++++++ codex-rs/core/src/config/mod.rs | 4 + codex-rs/core/src/config/types.rs | 14 + codex-rs/core/src/skills/injection.rs | 12 +- codex-rs/core/src/skills/loader.rs | 335 +++++++----------- codex-rs/core/src/skills/manager.rs | 54 ++- codex-rs/core/src/skills/model.rs | 22 ++ codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui2/src/chatwidget.rs | 2 +- 15 files changed, 550 insertions(+), 238 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index dd54eb25da..bce2e48c97 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -133,6 +133,10 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, + SkillsConfigWrite => "skills/config/write" { + params: v2::SkillsConfigWriteParams, + response: v2::SkillsConfigWriteResponse, + }, TurnStart => "turn/start" { params: v2::TurnStartParams, response: v2::TurnStartResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b44e1575ad..2855b6f578 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1274,6 +1274,7 @@ pub struct SkillMetadata { pub interface: Option, pub path: PathBuf, pub scope: SkillScope, + pub enabled: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1311,6 +1312,21 @@ pub struct SkillsListEntry { pub errors: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteParams { + pub path: PathBuf, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteResponse { + pub effective_enabled: bool, +} + impl From for SkillMetadata { fn from(value: CoreSkillMetadata) -> Self { Self { @@ -1320,6 +1336,7 @@ impl From for SkillMetadata { interface: value.interface.map(SkillInterface::from), path: value.path, scope: value.scope.into(), + enabled: true, } } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 75e1853d70..891632796b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -87,6 +87,7 @@ Example (from OpenAI's official VSCode extension): - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `model/list` — list available models (with reasoning effort options). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `skills/config/write` — write user-level skill config by path. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. @@ -483,17 +484,30 @@ Example: $skill-creator Add a new skill for triaging flaky CI and include step-by-step usage. ``` -Use `skills/list` to fetch the available skills (optionally scoped by `cwd` and/or with `forceReload`). +Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`). ```json { "method": "skills/list", "id": 25, "params": { - "cwd": "/Users/me/project", + "cwds": ["/Users/me/project"], "forceReload": false } } { "id": 25, "result": { - "skills": [ - { "name": "skill-creator", "description": "Create or update a Codex skill" } - ] + "data": [{ + "cwd": "/Users/me/project", + "skills": [ + { "name": "skill-creator", "description": "Create or update a Codex skill", "enabled": true } + ], + "errors": [] + }] +} } +``` + +To enable or disable a skill by path: + +```json +{ "method": "skills/config/write", "id": 26, "params": { + "path": "/Users/me/.codex/skills/skill-creator/SKILL.md", + "enabled": false } } ``` diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 153028512e..fb6699a34f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -85,6 +85,8 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionConfiguredNotification; use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; +use codex_app_server_protocol::SkillsConfigWriteParams; +use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; @@ -131,6 +133,7 @@ use codex_core::auth::login_with_api_key; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigService; +use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::McpServerTransportConfig; use codex_core::default_client::get_codex_user_agent; @@ -400,6 +403,9 @@ impl CodexMessageProcessor { ClientRequest::SkillsList { request_id, params } => { self.skills_list(request_id, params).await; } + ClientRequest::SkillsConfigWrite { request_id, params } => { + self.skills_config_write(request_id, params).await; + } ClientRequest::TurnStart { request_id, params } => { self.turn_start(request_id, params).await; } @@ -3246,7 +3252,7 @@ impl CodexMessageProcessor { for cwd in cwds { let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await; let errors = errors_to_info(&outcome.errors); - let skills = skills_to_info(&outcome.skills); + let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); data.push(codex_app_server_protocol::SkillsListEntry { cwd, skills, @@ -3258,6 +3264,37 @@ impl CodexMessageProcessor { .await; } + async fn skills_config_write(&self, request_id: RequestId, params: SkillsConfigWriteParams) { + let SkillsConfigWriteParams { path, enabled } = params; + let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }]; + let result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits(edits) + .apply() + .await; + + match result { + Ok(()) => { + self.thread_manager.skills_manager().clear_cache(); + self.outgoing + .send_response( + request_id, + SkillsConfigWriteResponse { + effective_enabled: enabled, + }, + ) + .await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to update skill settings: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn interrupt_conversation( &mut self, request_id: RequestId, @@ -3918,25 +3955,30 @@ impl CodexMessageProcessor { fn skills_to_info( skills: &[codex_core::skills::SkillMetadata], + disabled_paths: &std::collections::HashSet, ) -> Vec { skills .iter() - .map(|skill| codex_app_server_protocol::SkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - short_description: skill.short_description.clone(), - interface: skill.interface.clone().map(|interface| { - codex_app_server_protocol::SkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - } - }), - path: skill.path.clone(), - scope: skill.scope.into(), + .map(|skill| { + let enabled = !disabled_paths.contains(&skill.path); + codex_app_server_protocol::SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill.short_description.clone(), + interface: skill.interface.clone().map(|interface| { + codex_app_server_protocol::SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + } + }), + path: skill.path.clone(), + scope: skill.scope.into(), + enabled, + } }) .collect() } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 37a0cce703..e954d88c1a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -75,6 +75,9 @@ "apply_patch_freeform": { "type": "boolean" }, + "child_agents_md": { + "type": "boolean" + }, "collab": { "type": "boolean" }, @@ -99,9 +102,6 @@ "experimental_windows_sandbox": { "type": "boolean" }, - "child_agents_md": { - "type": "boolean" - }, "include_apply_patch_tool": { "type": "boolean" }, @@ -373,6 +373,14 @@ "description": "When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. Defaults to `false`.", "type": "boolean" }, + "skills": { + "description": "User-level skill config entries keyed by SKILL.md path.", + "allOf": [ + { + "$ref": "#/definitions/SkillsConfig" + } + ] + }, "tool_output_token_limit": { "description": "Token budget applied when storing tool/function outputs in the context manager.", "type": "integer", @@ -543,6 +551,9 @@ "apply_patch_freeform": { "type": "boolean" }, + "child_agents_md": { + "type": "boolean" + }, "collab": { "type": "boolean" }, @@ -567,9 +578,6 @@ "experimental_windows_sandbox": { "type": "boolean" }, - "child_agents_md": { - "type": "boolean" - }, "include_apply_patch_tool": { "type": "boolean" }, @@ -1288,6 +1296,34 @@ }, "additionalProperties": false }, + "SkillConfig": { + "type": "object", + "required": [ + "enabled", + "path" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "additionalProperties": false + }, + "SkillsConfig": { + "type": "object", + "properties": { + "config": { + "type": "array", + "items": { + "$ref": "#/definitions/SkillConfig" + } + } + }, + "additionalProperties": false + }, "ToolsToml": { "type": "object", "properties": { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b554450d11..b59856f26f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -251,7 +251,8 @@ impl Codex { ); } - let user_instructions = get_user_instructions(&config, Some(&loaded_skills.skills)).await; + let enabled_skills = loaded_skills.enabled_skills(); + let user_instructions = get_user_instructions(&config, Some(&enabled_skills)).await; let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack) .await @@ -2264,7 +2265,8 @@ mod handlers { for cwd in cwds { let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await; let errors = super::errors_to_info(&outcome.errors); - let skills_metadata = super::skills_to_info(&outcome.skills); + let enabled_skills = outcome.enabled_skills(); + let skills_metadata = super::skills_to_info(&enabled_skills); skills.push(SkillsListEntry { cwd, skills: skills_metadata, diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index fb6829bb96..f97903b31a 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -9,6 +9,7 @@ use std::path::Path; use std::path::PathBuf; use tempfile::NamedTempFile; use tokio::task; +use toml_edit::ArrayOfTables; use toml_edit::DocumentMut; use toml_edit::Item as TomlItem; use toml_edit::Table as TomlTable; @@ -36,6 +37,8 @@ pub enum ConfigEdit { RecordModelMigrationSeen { from: String, to: String }, /// Replace the entire `[mcp_servers]` table. ReplaceMcpServers(BTreeMap), + /// Set or clear a skill config entry under `[[skills.config]]`. + SetSkillConfig { path: PathBuf, enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -298,6 +301,9 @@ impl ConfigDocument { value(*acknowledged), )), ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), + ConfigEdit::SetSkillConfig { path, enabled } => { + Ok(self.set_skill_config(path.as_path(), *enabled)) + } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { @@ -387,6 +393,113 @@ impl ConfigDocument { true } + fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool { + let normalized_path = normalize_skill_config_path(path); + let mut remove_skills_table = false; + let mut mutated = false; + + { + let root = self.doc.as_table_mut(); + let skills_item = match root.get_mut("skills") { + Some(item) => item, + None => { + if enabled { + return false; + } + root.insert( + "skills", + TomlItem::Table(document_helpers::new_implicit_table()), + ); + let Some(item) = root.get_mut("skills") else { + return false; + }; + item + } + }; + + if document_helpers::ensure_table_for_write(skills_item).is_none() { + if enabled { + return false; + } + *skills_item = TomlItem::Table(document_helpers::new_implicit_table()); + } + let Some(skills_table) = skills_item.as_table_mut() else { + return false; + }; + + let config_item = match skills_table.get_mut("config") { + Some(item) => item, + None => { + if enabled { + return false; + } + skills_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); + let Some(item) = skills_table.get_mut("config") else { + return false; + }; + item + } + }; + + if !matches!(config_item, TomlItem::ArrayOfTables(_)) { + if enabled { + return false; + } + *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new()); + } + + let TomlItem::ArrayOfTables(overrides) = config_item else { + return false; + }; + + let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { + table + .get("path") + .and_then(|item| item.as_str()) + .map(Path::new) + .map(normalize_skill_config_path) + .filter(|value| *value == normalized_path) + .map(|_| idx) + }); + + if enabled { + if let Some(index) = existing_index { + overrides.remove(index); + mutated = true; + if overrides.is_empty() { + skills_table.remove("config"); + if skills_table.is_empty() { + remove_skills_table = true; + } + } + } + } else if let Some(index) = existing_index { + for (idx, table) in overrides.iter_mut().enumerate() { + if idx == index { + table["path"] = value(normalized_path); + table["enabled"] = value(false); + mutated = true; + break; + } + } + } else { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + entry["path"] = value(normalized_path); + entry["enabled"] = value(false); + overrides.push(entry); + mutated = true; + } + } + + if remove_skills_table { + let root = self.doc.as_table_mut(); + root.remove("skills"); + } + + mutated + } + fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { let resolved: Vec = segments .iter() @@ -494,6 +607,13 @@ impl ConfigDocument { } } +fn normalize_skill_config_path(path: &Path) -> String { + dunce::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string() +} + /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, @@ -737,6 +857,54 @@ model_reasoning_effort = "high" assert_eq!(contents, "enabled = true\n"); } + #[test] + fn set_skill_config_writes_disabled_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetSkillConfig { + path: PathBuf::from("/tmp/skills/demo/SKILL.md"), + enabled: false, + }]) + .apply_blocking() + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[[skills.config]] +path = "/tmp/skills/demo/SKILL.md" +enabled = false +"#; + assert_eq!(contents, expected); + } + + #[test] + fn set_skill_config_removes_entry_when_enabled() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[[skills.config]] +path = "/tmp/skills/demo/SKILL.md" +enabled = false +"#, + ) + .expect("seed config"); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetSkillConfig { + path: PathBuf::from("/tmp/skills/demo/SKILL.md"), + enabled: true, + }]) + .apply_blocking() + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, ""); + } + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6228bc3ac3..956df12e9c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -13,6 +13,7 @@ use crate::config::types::SandboxWorkspaceWrite; use crate::config::types::ScrollInputMode; use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; +use crate::config::types::SkillsConfig; use crate::config::types::Tui; use crate::config::types::UriBasedFileOpener; use crate::config_loader::ConfigLayerStack; @@ -911,6 +912,9 @@ pub struct ConfigToml { /// Nested tools section for feature toggles pub tools: Option, + /// User-level skill config entries keyed by SKILL.md path. + pub skills: Option, + /// Centralized feature flags (new). Prefer this over individual toggles. #[serde(default)] // Injects known feature keys into the schema and forbids unknown keys. diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 9d75302cbd..46c06d9a22 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -600,6 +600,20 @@ impl Notice { pub(crate) const TABLE_KEY: &'static str = "notice"; } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillConfig { + pub path: AbsolutePathBuf, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillsConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct SandboxWorkspaceWrite { diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index a143fce1f2..dfd7e2af59 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::path::PathBuf; use crate::skills::SkillLoadOutcome; use crate::skills::SkillMetadata; @@ -25,7 +26,8 @@ pub(crate) async fn build_skill_injections( return SkillInjections::default(); }; - let mentioned_skills = collect_explicit_skill_mentions(inputs, &outcome.skills); + let mentioned_skills = + collect_explicit_skill_mentions(inputs, &outcome.skills, &outcome.disabled_paths); if mentioned_skills.is_empty() { return SkillInjections::default(); } @@ -46,9 +48,9 @@ pub(crate) async fn build_skill_injections( } Err(err) => { let message = format!( - "Failed to load skill {} at {}: {err:#}", - skill.name, - skill.path.display() + "Failed to load skill {name} at {path}: {err:#}", + name = skill.name, + path = skill.path.display() ); result.warnings.push(message); } @@ -61,6 +63,7 @@ pub(crate) async fn build_skill_injections( fn collect_explicit_skill_mentions( inputs: &[UserInput], skills: &[SkillMetadata], + disabled_paths: &HashSet, ) -> Vec { let mut selected: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); @@ -69,6 +72,7 @@ fn collect_explicit_skill_mentions( if let UserInput::Skill { name, path } = input && seen.insert(name.clone()) && let Some(skill) = skills.iter().find(|s| s.name == *name && s.path == *path) + && !disabled_paths.contains(&skill.path) { selected.push(skill.clone()); } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index da51fea41e..f6dd3dae92 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -105,13 +105,13 @@ where discover_skills_under_root(&root.path, root.scope, &mut outcome); } - let mut seen: HashSet = HashSet::new(); + let mut seen: HashSet = HashSet::new(); outcome .skills - .retain(|skill| seen.insert(skill.name.clone())); + .retain(|skill| seen.insert(skill.path.clone())); fn scope_rank(scope: SkillScope) -> u8 { - // Higher-priority scopes first (matches dedupe priority order). + // Higher-priority scopes first (matches root scan order for dedupe). match scope { SkillScope::Repo => 0, SkillScope::User => 1, @@ -445,10 +445,23 @@ fn resolve_asset_path( return None; } - let mut components = path.components().peekable(); - while matches!(components.peek(), Some(Component::CurDir)) { - components.next(); + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(component) => normalized.push(component), + Component::ParentDir => { + tracing::warn!("ignoring {field}: icon path must not contain '..'"); + return None; + } + _ => { + tracing::warn!("ignoring {field}: icon path must be under assets/"); + return None; + } + } } + + let mut components = normalized.components(); match components.next() { Some(Component::Normal(component)) if component == "assets" => {} _ => { @@ -456,12 +469,8 @@ fn resolve_asset_path( return None; } } - if components.any(|component| matches!(component, Component::ParentDir)) { - tracing::warn!("ignoring {field}: icon path must not contain '..'"); - return None; - } - Some(skill_dir.join(path)) + Some(skill_dir.join(normalized)) } fn sanitize_single_line(raw: &str) -> String { @@ -707,8 +716,8 @@ default_prompt = " default prompt " interface: Some(SkillInterface { display_name: Some("UI Skill".to_string()), short_description: Some("short desc".to_string()), - icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")), - icon_large: Some(normalized_skill_dir.join("./assets/large-logo.svg")), + icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), + icon_large: Some(normalized_skill_dir.join("assets/large-logo.svg")), brand_color: Some("#3B82F6".to_string()), default_prompt: Some("default prompt".to_string()), }), @@ -753,7 +762,7 @@ icon_large = "./assets/logo.svg" display_name: Some("UI Skill".to_string()), short_description: None, icon_small: Some(normalized_skill_dir.join("assets/icon.png")), - icon_large: Some(normalized_skill_dir.join("./assets/logo.svg")), + icon_large: Some(normalized_skill_dir.join("assets/logo.svg")), brand_color: None, default_prompt: None, }), @@ -835,7 +844,7 @@ default_prompt = "{too_long}" interface: Some(SkillInterface { display_name: Some("UI Skill".to_string()), short_description: None, - icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")), + icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), icon_large: None, brand_color: None, default_prompt: None, @@ -1376,12 +1385,47 @@ icon_large = "./assets/../logo.svg" } #[tokio::test] - async fn deduplicates_by_name_preferring_repo_over_user() { + async fn deduplicates_by_path_preferring_first_root() { + let root = tempfile::tempdir().expect("tempdir"); + + let skill_path = write_skill_at(root.path(), "dupe", "dupe-skill", "from repo"); + + let outcome = load_skills_from_roots([ + SkillRoot { + path: root.path().to_path_buf(), + scope: SkillScope::Repo, + }, + SkillRoot { + path: root.path().to_path_buf(), + scope: SkillScope::User, + }, + ]); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "dupe-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + path: normalized(&skill_path), + scope: SkillScope::Repo, + }] + ); + } + + #[tokio::test] + async fn keeps_duplicate_names_from_repo_and_user() { let codex_home = tempfile::tempdir().expect("tempdir"); let repo_dir = tempfile::tempdir().expect("tempdir"); mark_as_git_repo(repo_dir.path()); - let _user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); + let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); let repo_skill_path = write_skill_at( &repo_dir .path() @@ -1402,42 +1446,94 @@ icon_large = "./assets/../logo.svg" ); assert_eq!( outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from repo".to_string(), - short_description: None, - interface: None, - path: normalized(&repo_skill_path), - scope: SkillScope::Repo, - }] + vec![ + SkillMetadata { + name: "dupe-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + path: normalized(&repo_skill_path), + scope: SkillScope::Repo, + }, + SkillMetadata { + name: "dupe-skill".to_string(), + description: "from user".to_string(), + short_description: None, + interface: None, + path: normalized(&user_skill_path), + scope: SkillScope::User, + }, + ] ); } #[tokio::test] - async fn loads_system_skills_when_present() { + async fn keeps_duplicate_names_from_nested_codex_dirs() { let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); - let _system_skill_path = - write_system_skill(&codex_home, "system", "dupe-skill", "from system"); - let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); + let nested_dir = repo_dir.path().join("nested/inner"); + fs::create_dir_all(&nested_dir).unwrap(); - let cfg = make_config(&codex_home).await; + let root_skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "root", + "dupe-skill", + "from root", + ); + let nested_skill_path = write_skill_at( + &repo_dir + .path() + .join("nested") + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "nested", + "dupe-skill", + "from nested", + ); + + let cfg = make_config_for_cwd(&codex_home, nested_dir).await; let outcome = load_skills(&cfg); + assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", outcome.errors ); + let root_path = + canonicalize_path(&root_skill_path).unwrap_or_else(|_| root_skill_path.clone()); + let nested_path = + canonicalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone()); + let (first_path, second_path, first_description, second_description) = + if root_path <= nested_path { + (root_path, nested_path, "from root", "from nested") + } else { + (nested_path, root_path, "from nested", "from root") + }; assert_eq!( outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from user".to_string(), - short_description: None, - interface: None, - path: normalized(&user_skill_path), - scope: SkillScope::User, - }] + vec![ + SkillMetadata { + name: "dupe-skill".to_string(), + description: first_description.to_string(), + short_description: None, + interface: None, + path: first_path, + scope: SkillScope::Repo, + }, + SkillMetadata { + name: "dupe-skill".to_string(), + description: second_description.to_string(), + short_description: None, + interface: None, + path: second_path, + scope: SkillScope::Repo, + }, + ] ); } @@ -1448,7 +1544,7 @@ icon_large = "./assets/../logo.svg" let repo_dir = outer_dir.path().join("repo"); fs::create_dir_all(&repo_dir).unwrap(); - write_skill_at( + let _skill_path = write_skill_at( &outer_dir .path() .join(REPO_ROOT_CONFIG_DIR_NAME) @@ -1457,7 +1553,6 @@ icon_large = "./assets/../logo.svg" "outer-skill", "from outer", ); - mark_as_git_repo(&repo_dir); let cfg = make_config_for_cwd(&codex_home, repo_dir).await; @@ -1581,164 +1676,4 @@ icon_large = "./assets/../logo.svg" } assert_eq!(scopes, expected); } - - #[tokio::test] - async fn deduplicates_by_name_preferring_system_over_admin() { - let system_dir = tempfile::tempdir().expect("tempdir"); - let admin_dir = tempfile::tempdir().expect("tempdir"); - - let system_skill_path = - write_skill_at(system_dir.path(), "system", "dupe-skill", "from system"); - let _admin_skill_path = - write_skill_at(admin_dir.path(), "admin", "dupe-skill", "from admin"); - - let outcome = load_skills_from_roots([ - SkillRoot { - path: system_dir.path().to_path_buf(), - scope: SkillScope::System, - }, - SkillRoot { - path: admin_dir.path().to_path_buf(), - scope: SkillScope::Admin, - }, - ]); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from system".to_string(), - short_description: None, - interface: None, - path: normalized(&system_skill_path), - scope: SkillScope::System, - }] - ); - } - - #[tokio::test] - async fn deduplicates_by_name_preferring_user_over_system() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let work_dir = tempfile::tempdir().expect("tempdir"); - - let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); - let _system_skill_path = - write_system_skill(&codex_home, "system", "dupe-skill", "from system"); - - let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; - - let outcome = load_skills(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from user".to_string(), - short_description: None, - interface: None, - path: normalized(&user_skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn deduplicates_by_name_preferring_repo_over_system() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let repo_skill_path = write_skill_at( - &repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "repo", - "dupe-skill", - "from repo", - ); - let _system_skill_path = - write_system_skill(&codex_home, "system", "dupe-skill", "from system"); - - let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - - let outcome = load_skills(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from repo".to_string(), - short_description: None, - interface: None, - path: normalized(&repo_skill_path), - scope: SkillScope::Repo, - }] - ); - } - - #[tokio::test] - async fn deduplicates_by_name_preferring_nearest_project_codex_dir() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let nested_dir = repo_dir.path().join("nested/inner"); - fs::create_dir_all(&nested_dir).unwrap(); - - let _root_skill_path = write_skill_at( - &repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "root", - "dupe-skill", - "from root", - ); - let nested_skill_path = write_skill_at( - &repo_dir - .path() - .join("nested") - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "nested", - "dupe-skill", - "from nested", - ); - - let cfg = make_config_for_cwd(&codex_home, nested_dir).await; - let outcome = load_skills(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - let expected_path = - canonicalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone()); - assert_eq!( - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from nested".to_string(), - short_description: None, - interface: None, - path: expected_path, - scope: SkillScope::Repo, - }], - outcome.skills - ); - } } diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index bff928a565..8aa6986882 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -1,12 +1,15 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::RwLock; use codex_utils_absolute_path::AbsolutePathBuf; use toml::Value as TomlValue; +use tracing::warn; use crate::config::Config; +use crate::config::types::SkillsConfig; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::skills::SkillLoadOutcome; @@ -44,7 +47,8 @@ impl SkillsManager { } let roots = skill_roots_from_layer_stack(&config.config_layer_stack); - let outcome = load_skills_from_roots(roots); + let mut outcome = load_skills_from_roots(roots); + outcome.disabled_paths = disabled_paths_from_stack(&config.config_layer_stack); match self.cache_by_cwd.write() { Ok(mut cache) => { cache.insert(cwd.to_path_buf(), outcome.clone()); @@ -100,7 +104,8 @@ impl SkillsManager { }; let roots = skill_roots_from_layer_stack(&config_layer_stack); - let outcome = load_skills_from_roots(roots); + let mut outcome = load_skills_from_roots(roots); + outcome.disabled_paths = disabled_paths_from_stack(&config_layer_stack); match self.cache_by_cwd.write() { Ok(mut cache) => { cache.insert(cwd.to_path_buf(), outcome.clone()); @@ -111,6 +116,51 @@ impl SkillsManager { } outcome } + + pub fn clear_cache(&self) { + match self.cache_by_cwd.write() { + Ok(mut cache) => cache.clear(), + Err(err) => err.into_inner().clear(), + } + } +} + +fn disabled_paths_from_stack( + config_layer_stack: &crate::config_loader::ConfigLayerStack, +) -> HashSet { + let mut disabled = HashSet::new(); + let mut configs = HashMap::new(); + // Skills config is user-layer only for now; higher-precedence layers are ignored. + let Some(user_layer) = config_layer_stack.get_user_layer() else { + return disabled; + }; + let Some(skills_value) = user_layer.config.get("skills") else { + return disabled; + }; + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + return disabled; + } + }; + + for entry in skills.config { + let path = normalize_override_path(entry.path.as_path()); + configs.insert(path, entry.enabled); + } + + for (path, enabled) in configs { + if !enabled { + disabled.insert(path); + } + } + + disabled +} + +fn normalize_override_path(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } #[cfg(test)] diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index fba00d2711..fe3357f9d9 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::PathBuf; use codex_protocol::protocol::SkillScope; @@ -32,4 +33,25 @@ pub struct SkillError { pub struct SkillLoadOutcome { pub skills: Vec, pub errors: Vec, + pub disabled_paths: HashSet, +} + +impl SkillLoadOutcome { + pub fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool { + !self.disabled_paths.contains(&skill.path) + } + + pub fn enabled_skills(&self) -> Vec { + self.skills + .iter() + .filter(|skill| self.is_skill_enabled(skill)) + .cloned() + .collect() + } + + pub fn skills_with_enabled(&self) -> impl Iterator { + self.skills + .iter() + .map(|skill| (skill, self.is_skill_enabled(skill))) + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e1054d0a3e..3593e1f16b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -622,7 +622,7 @@ impl ChatWidget { self.submit_op(Op::ListCustomPrompts); self.submit_op(Op::ListSkills { cwds: Vec::new(), - force_reload: false, + force_reload: true, }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index c0944fa089..5b3343d5b4 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -544,7 +544,7 @@ impl ChatWidget { self.submit_op(Op::ListCustomPrompts); self.submit_op(Op::ListSkills { cwds: Vec::new(), - force_reload: false, + force_reload: true, }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); From 146d54cede3c4ea38fbcacc89ab15a7e30d73b20 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 16 Jan 2026 21:51:25 -0800 Subject: [PATCH 21/78] Add collaboration_mode override to turns (#9408) --- .../app-server-protocol/src/protocol/v2.rs | 5 ++ .../app-server/src/codex_message_processor.rs | 5 +- .../app-server/tests/suite/v2/turn_start.rs | 76 +++++++++++++++++++ codex-rs/core/src/codex.rs | 27 ++++--- codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 57 ++++++++++++++ codex-rs/core/tests/suite/compact.rs | 1 + codex-rs/core/tests/suite/exec_policy.rs | 1 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/model_overrides.rs | 2 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + .../core/tests/suite/permissions_messages.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 27 ++++++- codex-rs/core/tests/suite/remote_models.rs | 4 + codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 6 ++ codex-rs/core/tests/suite/shell_snapshot.rs | 3 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 ++ codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 25 ++++++ codex-rs/core/tests/suite/view_image.rs | 6 ++ codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 11 +++ codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/chatwidget.rs | 4 + codex-rs/tui2/src/app.rs | 1 + codex-rs/tui2/src/chatwidget.rs | 4 + 33 files changed, 281 insertions(+), 13 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2855b6f578..c0caa7ea32 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode as CoreSandboxMode; @@ -1538,6 +1539,10 @@ pub struct TurnStartParams { pub summary: Option, /// Optional JSON Schema used to constrain the final assistant message for this turn. pub output_schema: Option, + + /// EXPERIMENTAL - set a pre-set collaboration mode. + /// Takes precedence over model, reasoning_effort, and developer instructions if set. + pub collaboration_mode: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index fb6699a34f..551015f94a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3231,6 +3231,7 @@ impl CodexMessageProcessor { effort, summary, final_output_json_schema: output_schema, + collaboration_mode: None, }) .await; @@ -3344,7 +3345,8 @@ impl CodexMessageProcessor { || params.sandbox_policy.is_some() || params.model.is_some() || params.effort.is_some() - || params.summary.is_some(); + || params.summary.is_some() + || params.collaboration_mode.is_some(); // If any overrides are provided, update the session turn context first. if has_any_overrides { @@ -3356,6 +3358,7 @@ impl CodexMessageProcessor { model: params.model, effort: params.effort.map(Some), summary: params.summary, + collaboration_mode: params.collaboration_mode, }) .await; } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index e3b758a4c6..4c8df56c3d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -35,7 +35,10 @@ use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::protocol_config_types::ReasoningSummary; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use std::path::Path; @@ -305,6 +308,77 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( Ok(()) } +#[tokio::test] +async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let collaboration_mode = CollaborationMode::Custom(Settings { + model: "mock-model-collab".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model-override".to_string()), + effort: Some(ReasoningEffort::Low), + summary: Some(ReasoningSummary::Auto), + output_schema: None, + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let payload = request.body_json(); + assert_eq!(payload["model"].as_str(), Some("mock-model-collab")); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_local_image_input() -> Result<()> { // Two Codex turns hit the mock model (session start + turn/start). @@ -703,6 +777,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), output_schema: None, + collaboration_mode: None, }) .await?; timeout( @@ -732,6 +807,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), output_schema: None, + collaboration_mode: None, }) .await?; timeout( diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b59856f26f..a9dc1004ab 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1842,13 +1842,17 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv model, effort, summary, + collaboration_mode, } => { - let collaboration_mode = { + let collaboration_mode = if let Some(collab_mode) = collaboration_mode { + collab_mode + } else { let state = sess.state.lock().await; - state - .session_configuration - .collaboration_mode - .with_updates(model, effort, None) + state.session_configuration.collaboration_mode.with_updates( + model.clone(), + effort, + None, + ) }; handlers::override_turn_context( &sess, @@ -2013,12 +2017,15 @@ mod handlers { summary, final_output_json_schema, items, + collaboration_mode, } => { - let collaboration_mode = Some(CollaborationMode::Custom(Settings { - model, - reasoning_effort: effort, - developer_instructions: None, - })); + let collaboration_mode = collaboration_mode.or_else(|| { + Some(CollaborationMode::Custom(Settings { + model: model.clone(), + reasoning_effort: effort, + developer_instructions: None, + })) + }); ( items, SessionSettingsUpdate { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 7bf0819488..9a902b6d12 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -279,6 +279,7 @@ impl TestCodex { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 5f764d227a..69147843c4 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -311,6 +311,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -898,6 +899,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -976,6 +978,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1124,6 +1127,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1185,6 +1189,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1254,6 +1259,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1323,6 +1329,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 357fdd124a..18abb2f189 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -501,6 +501,7 @@ async fn submit_turn( model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 6f659e1b64..cfae930d95 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -22,7 +22,9 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_otel::OtelManager; use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::Verbosity; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ReasoningItemContent; @@ -44,6 +46,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use dunce::canonicalize as normalize_path; use futures::StreamExt; +use pretty_assertions::assert_eq; use serde_json::json; use std::io::Write; use std::sync::Arc; @@ -865,6 +868,60 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() + .with_model("gpt-5.1-codex") + .build(&server) + .await?; + + let collaboration_mode = CollaborationMode::Custom(Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: config.cwd.clone(), + approval_policy: config.approval_policy.value(), + sandbox_policy: config.sandbox_policy.get().clone(), + model: session_configured.model.clone(), + effort: Some(ReasoningEffort::Low), + summary: config.model_reasoning_summary, + collaboration_mode: Some(collaboration_mode), + final_output_json_schema: None, + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + assert_eq!(request_body["model"].as_str(), Some("gpt-5.1")); + assert_eq!( + request_body + .get("reasoning") + .and_then(|t| t.get("effort")) + .and_then(|v| v.as_str()), + Some("high") + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 79bac55a8b..d033c66644 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1288,6 +1288,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { model: resumed.session_configured.model.clone(), effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index c7ae447bd0..70a3153344 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -81,6 +81,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 0df7c7e731..3c3f92ce45 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -127,6 +127,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -205,6 +206,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index d90cb217c8..06d42e8a99 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -85,6 +85,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index a418e35a39..9501bc06da 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -40,6 +40,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: None, + collaboration_mode: None, }) .await .expect("submit override"); @@ -82,6 +83,7 @@ async fn override_turn_context_does_not_create_config_file() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::Medium)), summary: None, + collaboration_mode: None, }) .await .expect("submit override"); diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 706a3dea55..5bcb0ccc0b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -98,6 +98,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { model: test.session_configured.model.clone(), effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 4976b2881c..c264b5da97 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -109,6 +109,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 6f676c1da7..3e54fa29a0 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -109,6 +109,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { model: None, effort: None, summary: None, + collaboration_mode: None, }) .await?; @@ -224,6 +225,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { model: None, effort: None, summary: None, + collaboration_mode: None, }) .await?; @@ -300,6 +302,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { model: None, effort: None, summary: None, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 6ea5619bfe..093932512f 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -11,6 +11,8 @@ use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::Shell; use codex_core::shell::default_user_shell; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; @@ -22,6 +24,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; use tempfile::TempDir; fn text_user_input(text: String) -> serde_json::Value { @@ -344,6 +347,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), + collaboration_mode: None, }) .await?; @@ -399,14 +403,21 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul let TestCodex { codex, .. } = test_codex().build(&server).await?; + let collaboration_mode = CollaborationMode::Custom(Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }); + codex .submit(Op::OverrideTurnContext { cwd: None, approval_policy: Some(AskForApproval::Never), sandbox_policy: None, - model: None, - effort: None, + model: Some("gpt-5.1-codex".to_string()), + effort: Some(Some(ReasoningEffort::Low)), summary: None, + collaboration_mode: Some(collaboration_mode), }) .await?; @@ -423,6 +434,13 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let body = req.single_request().body_json(); + assert_eq!(body["model"].as_str(), Some("gpt-5.1")); + assert_eq!( + body.get("reasoning") + .and_then(|reasoning| reasoning.get("effort")) + .and_then(|value| value.as_str()), + Some("high") + ); let input = body["input"] .as_array() .expect("input array must be present"); @@ -554,6 +572,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: ReasoningSummary::Detailed, + collaboration_mode: None, final_output_json_schema: None, }) .await?; @@ -646,6 +665,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a model: default_model.clone(), effort: default_effort, summary: default_summary, + collaboration_mode: None, final_output_json_schema: None, }) .await?; @@ -663,6 +683,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a model: default_model.clone(), effort: default_effort, summary: default_summary, + collaboration_mode: None, final_output_json_schema: None, }) .await?; @@ -741,6 +762,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu model: default_model, effort: default_effort, summary: default_summary, + collaboration_mode: None, final_output_json_schema: None, }) .await?; @@ -758,6 +780,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: ReasoningSummary::Detailed, + collaboration_mode: None, final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 75c22df554..e7123c4305 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -139,6 +139,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { model: Some(REMOTE_MODEL_SLUG.to_string()), effort: None, summary: None, + collaboration_mode: None, }) .await?; @@ -174,6 +175,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { model: REMOTE_MODEL_SLUG.to_string(), effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -363,6 +365,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { model: Some(model.to_string()), effort: None, summary: None, + collaboration_mode: None, }) .await?; @@ -379,6 +382,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { model: model.to_string(), effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index d182af8255..0de18438ce 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -820,6 +820,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { model: None, effort: None, summary: None, + collaboration_mode: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 5abc392644..cba1ed6225 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -118,6 +118,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -256,6 +257,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -452,6 +454,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -596,6 +599,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -751,6 +755,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -938,6 +943,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 74ff56e84e..443a41abab 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -80,6 +80,7 @@ async fn run_snapshot_command(command: &str) -> Result { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -157,6 +158,7 @@ async fn run_shell_command_snapshot(command: &str) -> Result { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -295,6 +297,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index eb6b4288d0..4c1c338bca 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -78,6 +78,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { model: session_model, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 75d707af53..8f5646fc30 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -91,6 +91,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -158,6 +159,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -235,6 +237,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -324,6 +327,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -421,6 +425,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 4abf85d7dc..05c02d4228 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -47,6 +47,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -363,6 +364,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 4ae095517c..bd4a9a3061 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -547,6 +547,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index df72d249b3..c3816aefd3 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -209,6 +209,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -336,6 +337,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -412,6 +414,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -491,6 +494,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -582,6 +586,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -655,6 +660,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -729,6 +735,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -857,6 +864,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -992,6 +1000,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1150,6 +1159,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1246,6 +1256,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1362,6 +1373,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1450,6 +1462,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1529,6 +1542,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1658,6 +1672,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1824,6 +1839,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1899,6 +1915,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2019,6 +2036,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2152,6 +2170,7 @@ PY model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2264,6 +2283,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2358,6 +2378,7 @@ PY model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2438,6 +2459,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2540,6 +2562,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2633,6 +2656,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2766,6 +2790,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index ebbbfc478e..c6869323ff 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -88,6 +88,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -180,6 +181,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -311,6 +313,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -384,6 +387,7 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -476,6 +480,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -557,6 +562,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index ccab33048b..a76510ed95 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -452,6 +452,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any effort: default_effort, summary: default_summary, final_output_json_schema: output_schema, + collaboration_mode: None, }) .await?; info!("Sent prompt with event ID: {task_id}"); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6ddc0aeb5a..29eabe2bfa 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -13,6 +13,7 @@ use std::time::Duration; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; +use crate::config_types::CollaborationMode; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; use crate::items::TurnItem; @@ -115,6 +116,11 @@ pub enum Op { summary: ReasoningSummaryConfig, // The JSON schema to use for the final assistant message final_output_json_schema: Option, + + /// EXPERIMENTAL - set a pre-set collaboration mode. + /// Takes precedence over model, effort, and developer instructions if set. + #[serde(skip_serializing_if = "Option::is_none")] + collaboration_mode: Option, }, /// Override parts of the persistent turn context for subsequent turns. @@ -150,6 +156,11 @@ pub enum Op { /// Updated reasoning summary preference (honored only for reasoning-capable models). #[serde(skip_serializing_if = "Option::is_none")] summary: Option, + + /// EXPERIMENTAL - set a pre-set collaboration mode. + /// Takes precedence over model, effort, and developer instructions if set. + #[serde(skip_serializing_if = "Option::is_none")] + collaboration_mode: Option, }, /// Approve a command execution diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0178b31b2e..eace453899 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1110,6 +1110,7 @@ impl App { model: None, effort: None, summary: None, + collaboration_mode: None, }, )); self.app_event_tx diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3593e1f16b..bab27efd0a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2737,6 +2737,7 @@ impl ChatWidget { model: Some(switch_model.clone()), effort: Some(Some(default_effort)), summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateModel(switch_model.clone())); tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); @@ -3011,6 +3012,7 @@ impl ChatWidget { model: Some(model_for_action.clone()), effort: Some(effort_for_action), summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); @@ -3182,6 +3184,7 @@ impl ChatWidget { model: Some(model.clone()), effort: Some(effort), summary: None, + collaboration_mode: None, })); self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); self.app_event_tx @@ -3350,6 +3353,7 @@ impl ChatWidget { model: None, effort: None, summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 5b66869056..1a406264ec 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1820,6 +1820,7 @@ impl App { model: None, effort: None, summary: None, + collaboration_mode: None, }, )); self.app_event_tx diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 5b3343d5b4..117d320a06 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -2492,6 +2492,7 @@ impl ChatWidget { model: Some(switch_model.clone()), effort: Some(Some(default_effort)), summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateModel(switch_model.clone())); tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); @@ -2724,6 +2725,7 @@ impl ChatWidget { model: Some(model_for_action.clone()), effort: Some(effort_for_action), summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); @@ -2895,6 +2897,7 @@ impl ChatWidget { model: Some(model.clone()), effort: Some(effort), summary: None, + collaboration_mode: None, })); self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); self.app_event_tx @@ -3040,6 +3043,7 @@ impl ChatWidget { model: None, effort: None, summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); From 93a5e0fe1c1cb103bea37b44367023e3095e27ef Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Fri, 16 Jan 2026 22:22:08 -0800 Subject: [PATCH 22/78] fix(codex-api): treat invalid_prompt as non-retryable (#9400) **Goal**: Prevent response.failed events with `invalid_prompt` from being treated as retryable errors so the UI shows the actual error message instead of continually retrying. **Before**: Codex would continue to retry despite the prompt being marked as disallowed **After**: Codex will stop retrying once prompt is marked disallowed --- codex-rs/codex-api/src/error.rs | 2 ++ codex-rs/codex-api/src/sse/responses.rs | 30 +++++++++++++++++++++++++ codex-rs/core/src/api_bridge.rs | 1 + 3 files changed, 33 insertions(+) diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index 60118e8723..e7fbd14543 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -25,6 +25,8 @@ pub enum ApiError { }, #[error("rate limit: {0}")] RateLimit(String), + #[error("invalid request: {message}")] + InvalidRequest { message: String }, } impl From for ApiError { diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 5299310e06..a70111d988 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -217,6 +217,11 @@ pub fn process_responses_event( response_error = ApiError::QuotaExceeded; } else if is_usage_not_included(&error) { response_error = ApiError::UsageNotIncluded; + } else if is_invalid_prompt_error(&error) { + let message = error + .message + .unwrap_or_else(|| "Invalid request.".to_string()); + response_error = ApiError::InvalidRequest { message }; } else { let delay = try_parse_retry_after(&error); let message = error.message.unwrap_or_default(); @@ -396,6 +401,10 @@ fn is_usage_not_included(error: &Error) -> bool { error.code.as_deref() == Some("usage_not_included") } +fn is_invalid_prompt_error(error: &Error) -> bool { + error.code.as_deref() == Some("invalid_prompt") +} + fn rate_limit_regex() -> &'static regex_lite::Regex { static RE: std::sync::OnceLock = std::sync::OnceLock::new(); #[expect(clippy::unwrap_used)] @@ -711,6 +720,27 @@ mod tests { assert_matches!(events[0], Err(ApiError::QuotaExceeded)); } + #[tokio::test] + async fn invalid_prompt_without_type_is_invalid_request() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::InvalidRequest { message }) => { + assert_eq!( + message, + "Invalid prompt: we've limited access to this content for safety reasons." + ); + } + other => panic!("unexpected event: {other:?}"), + } + } + #[tokio::test] async fn table_driven_event_kinds() { struct TestCase { diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index 19bd8d5ecb..79ca839816 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -28,6 +28,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { url: None, request_id: None, }), + ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), ApiError::Transport(transport) => match transport { TransportError::Http { status, From 764f3c7d03b25a477659afd956a288e322396dfe Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 16 Jan 2026 22:29:41 -0800 Subject: [PATCH 23/78] fix(tui) Defer backtrack trim until rollback confirms (#9401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the backtrack/rollback state machine and invariants between the transcript overlay, in-flight “live tail”, and core thread state (tui + tui2). Also adjust behavior for correctness: - Track a single pending rollback and block additional rollbacks until core responds. - Defer trimming transcript cells until ThreadRolledBack for the active session. - Clear the guard on ThreadRollbackFailed so the user can retry. - After a confirmed trim, schedule a one-shot scrollback refresh on the next draw. - Clear stale pending rollback state when switching sessions. --------- Co-authored-by: Josh McKinney --- codex-rs/tui/src/app.rs | 18 +++++ codex-rs/tui/src/app_backtrack.rs | 110 +++++++++++++++++++++++++--- codex-rs/tui2/src/app.rs | 18 +++++ codex-rs/tui2/src/app_backtrack.rs | 114 ++++++++++++++++++++++++++--- 4 files changed, 237 insertions(+), 23 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index eace453899..b256ccd200 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -340,6 +340,11 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + /// When set, the next draw re-renders the transcript into terminal scrollback once. + /// + /// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed + /// transcript cells. + pub(crate) backtrack_render_pending: bool, pub(crate) feedback: codex_feedback::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -362,6 +367,8 @@ pub(crate) struct App { impl App { async fn shutdown_current_thread(&mut self) { if let Some(thread_id) = self.chat_widget.thread_id() { + // Clear any in-flight rollback guard when switching threads. + self.backtrack.pending_rollback = None; self.suppress_shutdown_complete = true; self.chat_widget.submit_op(Op::Shutdown); self.server.remove_thread(&thread_id).await; @@ -502,6 +509,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: feedback.clone(), pending_update_action: None, suppress_shutdown_complete: false, @@ -622,6 +630,10 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { + if self.backtrack_render_pending { + self.backtrack_render_pending = false; + self.render_transcript_once(tui); + } self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget @@ -870,6 +882,9 @@ impl App { return Ok(AppRunControl::Continue); } self.handle_codex_event_now(event); + if self.backtrack_render_pending { + tui.frame_requester().schedule_frame(); + } } AppEvent::ExternalApprovalRequest { thread_id, event } => { self.handle_external_approval_request(thread_id, event); @@ -1416,6 +1431,7 @@ impl App { let errors = errors_for_cwd(&cwd, response); emit_skill_load_warnings(&self.app_event_tx, &errors); } + self.handle_backtrack_event(&event.msg); self.chat_widget.handle_codex_event(event); } @@ -1740,6 +1756,7 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, @@ -1782,6 +1799,7 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 43dacb573a..73d238f9a4 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -3,6 +3,18 @@ //! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also //! mediates a key rendering boundary for the transcript overlay. //! +//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing +//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to +//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI +//! state diverging from the agent if a rollback fails or targets a different thread. +//! +//! Backtrack operates as a small state machine: +//! - The first `Esc` in the main view "primes" the feature and captures a base thread id. +//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message. +//! - `Enter` requests a rollback from core and records a `pending_rollback` guard. +//! - Only after receiving `EventMsg::ThreadRolledBack` do we trim local transcript state and +//! schedule a one-time scrollback refresh. +//! //! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live //! tail derived from the current in-flight `ChatWidget.active_cell`. //! @@ -20,6 +32,9 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; +use codex_core::protocol::CodexErrorInfo; +use codex_core::protocol::ErrorEvent; +use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_protocol::ThreadId; use color_eyre::eyre::Result; @@ -33,24 +48,53 @@ pub(crate) struct BacktrackState { /// True when Esc has primed backtrack mode in the main view. pub(crate) primed: bool, /// Session id of the base thread to rollback. + /// + /// If the current thread changes, backtrack selections become invalid and must be ignored. pub(crate) base_id: Option, - /// Index in the transcript of the last user message. + /// Index of the currently highlighted user message. + /// + /// This is an index into the filtered "user messages since the last session start" view, + /// not an index into `transcript_cells`. `usize::MAX` indicates "no selection". pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, + /// Pending rollback request awaiting confirmation from core. + /// + /// This acts as a guardrail: once we request a rollback, we block additional backtrack + /// submissions until core responds with either a success or failure event. + pub(crate) pending_rollback: Option, } +/// A user-visible backtrack choice that can be confirmed into a rollback request. #[derive(Debug, Clone)] pub(crate) struct BacktrackSelection { + /// The selected user message, counted from the most recent session start. + /// + /// This value is used both to compute the rollback depth and to trim the local transcript + /// after core confirms the rollback. pub(crate) nth_user_message: usize, + /// Composer prefill derived from the selected user message. + /// + /// This is applied immediately on selection confirmation; if the rollback fails, the prefill + /// remains as a convenience so the user can retry or edit. pub(crate) prefill: String, } +/// An in-flight rollback requested from core. +/// +/// We keep enough information to apply the corresponding local trim only if the response targets +/// the same active thread we issued the request for. +#[derive(Debug, Clone)] +pub(crate) struct PendingBacktrackRollback { + pub(crate) selection: BacktrackSelection, + pub(crate) thread_id: Option, +} + impl App { - /// Route overlay events when transcript overlay is active. - /// - If backtrack preview is active: Esc steps selection; Enter confirms. - /// - Otherwise: Esc begins preview; all other events forward to overlay. - /// interactions (Esc to step target, Enter to confirm) and overlay lifecycle. + /// Route overlay events while the transcript overlay is active. + /// + /// If backtrack preview is active, Esc steps the selection and Enter confirms it. + /// Otherwise, Esc begins preview mode and all other events are forwarded to the overlay. pub(crate) async fn handle_backtrack_overlay_event( &mut self, tui: &mut tui::Tui, @@ -112,22 +156,38 @@ impl App { } /// Stage a backtrack and request thread history from the agent. + /// + /// We send the rollback request immediately, but we only mutate the transcript after core + /// confirms success so the UI cannot get ahead of the actual thread state. + /// + /// The composer prefill is applied immediately as a UX convenience; it does not imply that + /// core has accepted the rollback. pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { let user_total = user_count(&self.transcript_cells); if user_total == 0 { return; } + if self.backtrack.pending_rollback.is_some() { + self.chat_widget + .add_error_message("Backtrack rollback already in progress.".to_string()); + return; + } + let num_turns = user_total.saturating_sub(selection.nth_user_message); let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX); if num_turns == 0 { return; } + let prefill = selection.prefill.clone(); + self.backtrack.pending_rollback = Some(PendingBacktrackRollback { + selection, + thread_id: self.chat_widget.thread_id(), + }); self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); - self.trim_transcript_for_backtrack(selection.nth_user_message); - if !selection.prefill.is_empty() { - self.chat_widget.set_composer_text(selection.prefill); + if !prefill.is_empty() { + self.chat_widget.set_composer_text(prefill); } } @@ -290,7 +350,6 @@ impl App { self.close_transcript_overlay(tui); if let Some(selection) = selection { self.apply_backtrack_rollback(selection); - self.render_transcript_once(tui); tui.frame_requester().schedule_frame(); } } @@ -328,10 +387,39 @@ impl App { selection: BacktrackSelection, ) { self.apply_backtrack_rollback(selection); - self.render_transcript_once(tui); tui.frame_requester().schedule_frame(); } + pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) { + match event { + EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(), + EventMsg::Error(ErrorEvent { + codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), + .. + }) => { + // Core rejected the rollback; clear the guard so the user can retry. + self.backtrack.pending_rollback = None; + } + _ => {} + } + } + + /// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh. + /// + /// We ignore events that do not correspond to the currently active thread to avoid applying + /// stale updates after a session switch. + fn finish_pending_backtrack(&mut self) { + let Some(pending) = self.backtrack.pending_rollback.take() else { + return; + }; + if pending.thread_id != self.chat_widget.thread_id() { + // Ignore rollbacks targeting a prior thread. + return; + } + self.trim_transcript_for_backtrack(pending.selection.nth_user_message); + self.backtrack_render_pending = true; + } + fn backtrack_selection(&self, nth_user_message: usize) -> Option { let base_id = self.backtrack.base_id?; if self.chat_widget.thread_id() != Some(base_id) { @@ -350,7 +438,7 @@ impl App { }) } - /// Trim transcript_cells to preserve only content up to the selected user message. + /// Trim `transcript_cells` to preserve only content before the selected user message. fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message); } diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 1a406264ec..6cc764c4a2 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -406,6 +406,11 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + /// When set, the next draw re-renders the transcript into terminal scrollback once. + /// + /// This is used after a confirmed conversation rollback to ensure scrollback reflects the + /// trimmed transcript cells. + pub(crate) backtrack_render_pending: bool, pub(crate) feedback: codex_feedback::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -420,6 +425,8 @@ pub(crate) struct App { impl App { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { + // Clear any in-flight rollback guard when switching conversations. + self.backtrack.pending_rollback = None; self.suppress_shutdown_complete = true; self.chat_widget.submit_op(Op::Shutdown); self.server.remove_thread(&conversation_id).await; @@ -587,6 +594,7 @@ impl App { scroll_config, scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: feedback.clone(), pending_update_action: None, suppress_shutdown_complete: false, @@ -711,6 +719,10 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { + if self.backtrack_render_pending { + self.backtrack_render_pending = false; + self.render_transcript_once(tui); + } self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget @@ -1634,7 +1646,11 @@ impl App { let errors = errors_for_cwd(&cwd, response); emit_skill_load_warnings(&self.app_event_tx, &errors); } + self.handle_backtrack_event(&event.msg); self.chat_widget.handle_codex_event(event); + if self.backtrack_render_pending { + tui.frame_requester().schedule_frame(); + } } AppEvent::Exit(mode) => match mode { ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), @@ -2363,6 +2379,7 @@ mod tests { scroll_config: ScrollConfig::default(), scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, @@ -2416,6 +2433,7 @@ mod tests { scroll_config: ScrollConfig::default(), scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index 1f5ec3e45e..195dc79b3e 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -3,6 +3,18 @@ //! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also //! mediates a key rendering boundary for the transcript overlay. //! +//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing +//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to +//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI +//! state diverging from the agent if a rollback fails or targets a different thread. +//! +//! Backtrack operates as a small state machine: +//! - The first `Esc` in the main view "primes" the feature and captures a base conversation id. +//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message. +//! - `Enter` requests a rollback from core and records a `pending_rollback` guard. +//! - Only after receiving `EventMsg::ThreadRolledBack` do we trim local transcript state and +//! schedule a one-time scrollback refresh. +//! //! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live //! tail derived from the current in-flight `ChatWidget.active_cell`. //! @@ -20,6 +32,9 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; +use codex_core::protocol::CodexErrorInfo; +use codex_core::protocol::ErrorEvent; +use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_protocol::ThreadId; use color_eyre::eyre::Result; @@ -32,25 +47,55 @@ use crossterm::event::KeyEventKind; pub(crate) struct BacktrackState { /// True when Esc has primed backtrack mode in the main view. pub(crate) primed: bool, - /// Session id of the base thread to rollback. + /// Session id of the base conversation to rollback. + /// + /// If the current conversation changes, backtrack selections become invalid and must be + /// ignored. pub(crate) base_id: Option, - /// Index in the transcript of the last user message. + /// Index of the currently highlighted user message. + /// + /// This is an index into the filtered "user messages since the last session start" view, + /// not an index into `transcript_cells`. `usize::MAX` indicates "no selection". pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, + /// Pending rollback request awaiting confirmation from core. + /// + /// This acts as a guardrail: once we request a rollback, we block additional backtrack + /// submissions until core responds with either a success or failure event. + pub(crate) pending_rollback: Option, } +/// A user-visible backtrack choice that can be confirmed into a rollback request. #[derive(Debug, Clone)] pub(crate) struct BacktrackSelection { + /// The selected user message, counted from the most recent session start. + /// + /// This value is used both to compute the rollback depth and to trim the local transcript + /// after core confirms the rollback. pub(crate) nth_user_message: usize, + /// Composer prefill derived from the selected user message. + /// + /// This is applied immediately on selection confirmation; if the rollback fails, the prefill + /// remains as a convenience so the user can retry or edit. pub(crate) prefill: String, } +/// An in-flight rollback requested from core. +/// +/// We keep enough information to apply the corresponding local trim only if the response targets +/// the same active conversation we issued the request for. +#[derive(Debug, Clone)] +pub(crate) struct PendingBacktrackRollback { + pub(crate) selection: BacktrackSelection, + pub(crate) thread_id: Option, +} + impl App { - /// Route overlay events when transcript overlay is active. - /// - If backtrack preview is active: Esc steps selection; Enter confirms. - /// - Otherwise: Esc begins preview; all other events forward to overlay. - /// interactions (Esc to step target, Enter to confirm) and overlay lifecycle. + /// Route overlay events while the transcript overlay is active. + /// + /// If backtrack preview is active, Esc steps the selection and Enter confirms it. + /// Otherwise, Esc begins preview mode and all other events are forwarded to the overlay. pub(crate) async fn handle_backtrack_overlay_event( &mut self, tui: &mut tui::Tui, @@ -111,22 +156,39 @@ impl App { } } + /// Stage a backtrack and request thread history from the agent. + /// + /// We send the rollback request immediately, but we only mutate the transcript after core + /// confirms success so the UI cannot get ahead of the actual thread state. + /// + /// The composer prefill is applied immediately as a UX convenience; it does not imply that + /// core has accepted the rollback. pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { let user_total = user_count(&self.transcript_cells); if user_total == 0 { return; } + if self.backtrack.pending_rollback.is_some() { + self.chat_widget + .add_error_message("Backtrack rollback already in progress.".to_string()); + return; + } + let num_turns = user_total.saturating_sub(selection.nth_user_message); let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX); if num_turns == 0 { return; } + let prefill = selection.prefill.clone(); + self.backtrack.pending_rollback = Some(PendingBacktrackRollback { + selection, + thread_id: self.chat_widget.conversation_id(), + }); self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); - self.trim_transcript_for_backtrack(selection.nth_user_message); - if !selection.prefill.is_empty() { - self.chat_widget.set_composer_text(selection.prefill); + if !prefill.is_empty() { + self.chat_widget.set_composer_text(prefill); } } @@ -311,7 +373,6 @@ impl App { self.close_transcript_overlay(tui); if let Some(selection) = selection { self.apply_backtrack_rollback(selection); - self.render_transcript_once(tui); tui.frame_requester().schedule_frame(); } } @@ -349,9 +410,38 @@ impl App { selection: BacktrackSelection, ) { self.apply_backtrack_rollback(selection); - self.render_transcript_once(tui); tui.frame_requester().schedule_frame(); } + + pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) { + match event { + EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(), + EventMsg::Error(ErrorEvent { + codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), + .. + }) => { + // Core rejected the rollback; clear the guard so the user can retry. + self.backtrack.pending_rollback = None; + } + _ => {} + } + } + + /// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh. + /// + /// We ignore events that do not correspond to the currently active conversation to avoid + /// applying stale updates after a session switch. + fn finish_pending_backtrack(&mut self) { + let Some(pending) = self.backtrack.pending_rollback.take() else { + return; + }; + if pending.thread_id != self.chat_widget.conversation_id() { + // Ignore rollbacks targeting a prior thread. + return; + } + self.trim_transcript_for_backtrack(pending.selection.nth_user_message); + self.backtrack_render_pending = true; + } fn backtrack_selection(&self, nth_user_message: usize) -> Option { let base_id = self.backtrack.base_id?; if self.chat_widget.conversation_id() != Some(base_id) { @@ -370,7 +460,7 @@ impl App { }) } - /// Trim transcript_cells to preserve only content up to the selected user message. + /// Trim `transcript_cells` to preserve only content before the selected user message. fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message); } From b877a2041e08d38768aa9764b6aa216e60581f0c Mon Sep 17 00:00:00 2001 From: Alex Hornby <14246801+ahornby@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:05:53 +0000 Subject: [PATCH 24/78] fix unified_exec::tests::unified_exec_timeouts to use a more unique match value (#9414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix unified_exec_timeouts to use a unique variable value rather than "codex" which was causing false positives when running tests locally (presumably from my bash prompts). Discovered while running tests to validate another change. Fixes https://github.com/openai/codex/issues/9413 Test Plan: Ran test locally on my fedora 43 x86_64 machine with: ``` cd codex/cargo-rs cargo nextest run --all-features --no-fail-fast unified_exec::tests::unified_exec_timeouts ``` Before, unified_exec_timeouts fails: ``` Finished `test` profile [unoptimized + debuginfo] target(s) in 0.38s ──────────── Nextest run ID fa2b4949-a66c-408c-8002-32c52c70ec4f with nextest profile: default Starting 1 test across 107 binaries (3211 tests skipped) FAIL [ 5.667s] codex-core unified_exec::tests::unified_exec_timeouts stdout ─── running 1 test test unified_exec::tests::unified_exec_timeouts ... FAILED failures: failures: unified_exec::tests::unified_exec_timeouts test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 774 filtered out; finished in 5.66s stderr ─── thread 'unified_exec::tests::unified_exec_timeouts' (459601) panicked at core/src/unified_exec/mod.rs:381:9: timeout too short should yield incomplete output note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace ──────────── Summary [ 5.677s] 1 test run: 0 passed, 1 failed, 3211 skipped FAIL [ 5.667s] codex-core unified_exec::tests::unified_exec_timeouts error: test run failed ``` After, works: ``` Finished `test` profile [unoptimized + debuginfo] target(s) in 0.34s ──────────── Nextest run ID f49e9004-e30b-4049-b0ff-283b543a1cd7 with nextest profile: default Starting 1 test across 107 binaries (3211 tests skipped) SLOW [> 15.000s] codex-core unified_exec::tests::unified_exec_timeouts PASS [ 17.666s] codex-core unified_exec::tests::unified_exec_timeouts ──────────── Summary [ 17.676s] 1 test run: 1 passed (1 slow), 3211 skipped ``` --- codex-rs/core/src/unified_exec/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 42a08b571f..3f359c3866 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -354,6 +354,8 @@ mod tests { async fn unified_exec_timeouts() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); + const TEST_VAR_VALUE: &str = "unified_exec_var_123"; + let (session, turn) = test_session_and_turn().await; let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?; @@ -366,7 +368,7 @@ mod tests { write_stdin( &session, process_id, - "export CODEX_INTERACTIVE_SHELL_VAR=codex\n", + format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(), 2_500, ) .await?; @@ -379,7 +381,7 @@ mod tests { ) .await?; assert!( - !out_2.output.contains("codex"), + !out_2.output.contains(TEST_VAR_VALUE), "timeout too short should yield incomplete output" ); @@ -388,7 +390,7 @@ mod tests { let out_3 = write_stdin(&session, process_id, "", 100).await?; assert!( - out_3.output.contains("codex"), + out_3.output.contains(TEST_VAR_VALUE), "subsequent poll should retrieve output" ); From 8f0e0300d2ebdb8e743342d73b30a7ba615e9747 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 17 Jan 2026 12:32:50 -0800 Subject: [PATCH 25/78] Expose collaboration presets (#9421) Expose collaboration presets for clients --------- Co-authored-by: Josh McKinney --- .../src/protocol/common.rs | 22 +++++ .../app-server-protocol/src/protocol/v2.rs | 14 +++ codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 23 +++++ .../app-server/tests/common/mcp_process.rs | 10 +++ .../tests/suite/v2/collaboration_mode_list.rs | 85 +++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../collaboration_mode_presets.rs | 31 +++++++ codex-rs/core/src/models_manager/manager.rs | 9 ++ codex-rs/core/src/models_manager/mod.rs | 1 + codex-rs/core/src/thread_manager.rs | 5 ++ codex-rs/docs/codex_mcp_interface.md | 7 ++ 12 files changed, 209 insertions(+) create mode 100644 codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs create mode 100644 codex-rs/core/src/models_manager/collaboration_mode_presets.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bce2e48c97..4011328062 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -154,6 +154,11 @@ client_request_definitions! { params: v2::ModelListParams, response: v2::ModelListResponse, }, + /// EXPERIMENTAL - list collaboration mode presets. + CollaborationModeList => "collaborationMode/list" { + params: v2::CollaborationModeListParams, + response: v2::CollaborationModeListResponse, + }, McpServerOauthLogin => "mcpServer/oauth/login" { params: v2::McpServerOauthLoginParams, @@ -878,4 +883,21 @@ mod tests { ); Ok(()) } + + #[test] + fn serialize_list_collaboration_modes() -> Result<()> { + let request = ClientRequest::CollaborationModeList { + request_id: RequestId::Integer(7), + params: v2::CollaborationModeListParams::default(), + }; + assert_eq!( + json!({ + "method": "collaborationMode/list", + "id": 7, + "params": {} + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c0caa7ea32..db51c278f0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -917,6 +917,20 @@ pub struct ModelListResponse { pub next_cursor: Option, } +/// EXPERIMENTAL - list collaboration mode presets. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListParams {} + +/// EXPERIMENTAL - collaboration mode presets response. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListResponse { + pub data: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 891632796b..4c376ce6a4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -86,6 +86,7 @@ Example (from OpenAI's official VSCode extension): - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `model/list` — list available models (with reasoning effort options). +- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `skills/config/write` — write user-level skill config by path. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 551015f94a..1aeb623987 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -23,6 +23,8 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; @@ -436,6 +438,15 @@ impl CodexMessageProcessor { Self::list_models(outgoing, thread_manager, config, request_id, params).await; }); } + ClientRequest::CollaborationModeList { request_id, params } => { + let outgoing = self.outgoing.clone(); + let thread_manager = self.thread_manager.clone(); + + tokio::spawn(async move { + Self::list_collaboration_modes(outgoing, thread_manager, request_id, params) + .await; + }); + } ClientRequest::McpServerOauthLogin { request_id, params } => { self.mcp_server_oauth_login(request_id, params).await; } @@ -2371,6 +2382,18 @@ impl CodexMessageProcessor { outgoing.send_response(request_id, response).await; } + async fn list_collaboration_modes( + outgoing: Arc, + thread_manager: Arc, + request_id: RequestId, + params: CollaborationModeListParams, + ) { + let CollaborationModeListParams {} = params; + let items = thread_manager.list_collaboration_modes(); + let response = CollaborationModeListResponse { data: items }; + outgoing.send_response(request_id, response).await; + } + async fn mcp_server_refresh(&self, request_id: RequestId, _params: Option<()>) { let config = match self.load_latest_config().await { Ok(config) => config, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index cde6ff3913..fe236a52b2 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; @@ -396,6 +397,15 @@ impl McpProcess { self.send_request("model/list", params).await } + /// Send a `collaborationMode/list` JSON-RPC request. + pub async fn send_list_collaboration_modes_request( + &mut self, + params: CollaborationModeListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("collaborationMode/list", params).await + } + /// Send a `resumeConversation` JSON-RPC request. pub async fn send_resume_conversation_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs new file mode 100644 index 0000000000..b6ff3f888e --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -0,0 +1,85 @@ +//! Validates that the collaboration mode list endpoint returns the expected default presets. +//! +//! The test drives the app server through the MCP harness and asserts that the list response +//! includes the plan, collaborate, and execute modes with their default model and reasoning +//! effort settings, which keeps the API contract visible in one place. + +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CollaborationModeListResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::openai_models::ReasoningEffort; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Confirms the server returns the default collaboration mode presets in a stable order. +#[tokio::test] +async fn list_collaboration_modes_returns_presets() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_collaboration_modes_request(CollaborationModeListParams {}) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let CollaborationModeListResponse { data: items } = + to_response::(response)?; + + let expected = vec![plan_preset(), collaborate_preset(), execute_preset()]; + assert_eq!(expected, items); + Ok(()) +} + +/// Builds the plan preset that the list response is expected to return. +/// +/// If the defaults change in the app server, this helper should be updated alongside the +/// contract, or the test will fail in ways that imply a regression in the API. +fn plan_preset() -> CollaborationMode { + CollaborationMode::Plan(Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }) +} + +/// Builds the collaborate preset that the list response is expected to return. +/// +/// The helper keeps the expected model and reasoning defaults co-located with the test +/// so that mismatches point directly at the API contract being exercised. +fn collaborate_preset() -> CollaborationMode { + CollaborationMode::Collaborate(Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }) +} + +/// Builds the execute preset that the list response is expected to return. +/// +/// The execute preset uses a different reasoning effort to capture the higher-effort +/// execution contract the server currently exposes. +fn execute_preset() -> CollaborationMode { + CollaborationMode::Execute(Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::XHigh), + developer_instructions: None, + }) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index b2159ab9ce..82939908ee 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,5 +1,6 @@ mod account; mod analytics; +mod collaboration_mode_list; mod config_rpc; mod initialize; mod model_list; diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs new file mode 100644 index 0000000000..3d6eb9ca3c --- /dev/null +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -0,0 +1,31 @@ +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::openai_models::ReasoningEffort; + +pub(super) fn builtin_collaboration_mode_presets() -> Vec { + vec![plan_preset(), collaborate_preset(), execute_preset()] +} + +fn plan_preset() -> CollaborationMode { + CollaborationMode::Plan(Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }) +} + +fn collaborate_preset() -> CollaborationMode { + CollaborationMode::Collaborate(Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }) +} + +fn execute_preset() -> CollaborationMode { + CollaborationMode::Execute(Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::XHigh), + developer_instructions: None, + }) +} diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 206b4cab27..82fea6e71f 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -1,6 +1,7 @@ use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; @@ -23,6 +24,7 @@ use crate::error::CodexErr; use crate::error::Result as CoreResult; use crate::features::Feature; use crate::model_provider_info::ModelProviderInfo; +use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use crate::models_manager::model_info; use crate::models_manager::model_presets::builtin_model_presets; @@ -90,6 +92,13 @@ impl ModelsManager { self.build_available_models(remote_models) } + /// List collaboration mode presets. + /// + /// Returns a static set of presets seeded with the configured model. + pub fn list_collaboration_modes(&self) -> Vec { + builtin_collaboration_mode_presets() + } + /// Attempt to list models without blocking, using the current cached state. /// /// Returns an error if the internal lock cannot be acquired. diff --git a/codex-rs/core/src/models_manager/mod.rs b/codex-rs/core/src/models_manager/mod.rs index d0e3c8214a..1b3d95dc40 100644 --- a/codex-rs/core/src/models_manager/mod.rs +++ b/codex-rs/core/src/models_manager/mod.rs @@ -1,4 +1,5 @@ pub mod cache; +pub mod collaboration_mode_presets; pub mod manager; pub mod model_info; pub mod model_presets; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6f2187f527..8d02828d59 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -19,6 +19,7 @@ use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::skills::SkillsManager; use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; @@ -157,6 +158,10 @@ impl ThreadManager { .await } + pub fn list_collaboration_modes(&self) -> Vec { + self.state.models_manager.list_collaboration_modes() + } + pub async fn list_thread_ids(&self) -> Vec { self.state.threads.read().await.keys().copied().collect() } diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 10f81e59b9..883c7321bc 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -20,6 +20,7 @@ At a glance: - Configuration and info - `getUserSavedConfig`, `setDefaultModel`, `getUserAgent`, `userInfo` - `model/list` → enumerate available models and reasoning options + - `collaborationMode/list` → enumerate collaboration mode presets (experimental) - Auth - `account/read`, `account/login/start`, `account/login/cancel`, `account/logout`, `account/rateLimits/read` - notifications: `account/login/completed`, `account/updated`, `account/rateLimits/updated` @@ -96,6 +97,12 @@ Each response yields: - `isDefault` – whether the model is recommended for most users - `nextCursor` – pass into the next request to continue paging (optional) +## Collaboration modes (experimental) + +Fetch the built-in collaboration mode presets with `collaborationMode/list`. This endpoint does not accept pagination and returns the full list in one response: + +- `data` – ordered list of collaboration mode presets + ## Event stream While a conversation runs, the server sends notifications: From bffe9b33e9729fa981c14c0aa4dfae32f2746aa2 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 17 Jan 2026 16:01:26 -0800 Subject: [PATCH 26/78] chore(core) Create instructions module (#9422) ## Summary We have a variety of things we refer to as instructions in the code base: our current canonical terms are: - base instructions (raw string) - developer instructions (has a type in protocol) - user instructions We also have `instructions` floating around in various places. We should standardize on the above, and start using types to prevent them from ending up in the wrong place. There will be additional PRs, but I'm going to keep these small so we can easily follow them! ## Testing - [x] Tests pass, this is purely a file move --- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/context_manager/history.rs | 4 ++-- codex-rs/core/src/event_mapping.rs | 4 ++-- codex-rs/core/src/instructions/mod.rs | 6 ++++++ codex-rs/core/src/{ => instructions}/user_instructions.rs | 0 codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/skills/injection.rs | 2 +- 7 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 codex-rs/core/src/instructions/mod.rs rename codex-rs/core/src/{ => instructions}/user_instructions.rs (100%) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a9dc1004ab..48c689e644 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -98,6 +98,7 @@ use crate::error::Result as CodexResult; use crate::exec::StreamOutput; use crate::exec_policy::ExecPolicyUpdateError; use crate::feedback_tags; +use crate::instructions::UserInstructions; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; @@ -155,7 +156,6 @@ use crate::tools::spec::ToolsConfig; use crate::tools::spec::ToolsConfigParams; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::UnifiedExecProcessManager; -use crate::user_instructions::UserInstructions; use crate::user_notification::UserNotification; use crate::util::backoff; use codex_async_utils::OrCancelExt; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 2279beef20..0c133bdc28 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,12 +1,12 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; +use crate::instructions::SkillInstructions; +use crate::instructions::UserInstructions; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count; use crate::truncate::truncate_function_output_items_with_policy; use crate::truncate::truncate_text; -use crate::user_instructions::SkillInstructions; -use crate::user_instructions::UserInstructions; use crate::user_shell_command::is_user_shell_command_text; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 6c19c726df..eaa477b085 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -17,8 +17,8 @@ use codex_protocol::user_input::UserInput; use tracing::warn; use uuid::Uuid; -use crate::user_instructions::SkillInstructions; -use crate::user_instructions::UserInstructions; +use crate::instructions::SkillInstructions; +use crate::instructions::UserInstructions; use crate::user_shell_command::is_user_shell_command_text; fn is_session_prefix(text: &str) -> bool { diff --git a/codex-rs/core/src/instructions/mod.rs b/codex-rs/core/src/instructions/mod.rs new file mode 100644 index 0000000000..9da925ac74 --- /dev/null +++ b/codex-rs/core/src/instructions/mod.rs @@ -0,0 +1,6 @@ +mod user_instructions; + +pub(crate) use user_instructions::SkillInstructions; +pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG_LEGACY; +pub use user_instructions::USER_INSTRUCTIONS_PREFIX; +pub(crate) use user_instructions::UserInstructions; diff --git a/codex-rs/core/src/user_instructions.rs b/codex-rs/core/src/instructions/user_instructions.rs similarity index 100% rename from codex-rs/core/src/user_instructions.rs rename to codex-rs/core/src/instructions/user_instructions.rs diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e5a21b0517..a23ae55aa8 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -31,6 +31,7 @@ mod exec_policy; pub mod features; mod flags; pub mod git_info; +pub mod instructions; pub mod landlock; pub mod mcp; mod mcp_connection_manager; @@ -50,7 +51,6 @@ mod text_encoding; pub mod token_data; mod truncate; mod unified_exec; -mod user_instructions; pub mod windows_sandbox; pub use model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; pub use model_provider_info::DEFAULT_LMSTUDIO_PORT; diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index dfd7e2af59..ffe879bcd9 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -1,9 +1,9 @@ use std::collections::HashSet; use std::path::PathBuf; +use crate::instructions::SkillInstructions; use crate::skills::SkillLoadOutcome; use crate::skills::SkillMetadata; -use crate::user_instructions::SkillInstructions; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use tokio::fs; From 80d7a5d7fe523f9771b3d005aa8f550e75743f2f Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 17 Jan 2026 16:02:28 -0800 Subject: [PATCH 27/78] chore(instructions) Remove unread SessionMeta.instructions field (#9423) ### Description - Remove the now-unused `instructions` field from the session metadata to simplify SessionMeta and stop propagating transient instruction text through the rollout recorder API. This was only saving user_instructions, and was never being read. - Stop passing user instructions into the rollout writer at session creation so the rollout header only contains canonical session metadata. ### Testing - Ran `just fmt` which completed successfully. - Ran `just fix -p codex-protocol`, `just fix -p codex-core`, `just fix -p codex-app-server`, `just fix -p codex-tui`, and `just fix -p codex-tui2` which completed (Clippy fixes applied) as part of verification. - Ran `cargo test -p codex-protocol` which passed (28 tests). - Ran `cargo test -p codex-core` which showed failures in a small set of tests (not caused by the protocol type change directly): `default_client::tests::test_create_client_sets_default_headers`, several `models_manager::manager::tests::refresh_available_models_*`, and `shell_snapshot::tests::linux_sh_snapshot_includes_sections` (these tests failed in this CI run). - Ran `cargo test -p codex-app-server` which reported several failing integration tests (including `suite::codex_message_processor_flow::test_codex_jsonrpc_conversation_flow`, `suite::output_schema::send_user_turn_*`, and `suite::user_agent::get_user_agent_returns_current_codex_user_agent`). - `cargo test -p codex-tui` and `cargo test -p codex-tui2` were attempted but aborted due to disk space exhaustion (`No space left on device`). ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_696bd8ce632483228d298cf07c7eb41c) --- codex-rs/app-server/src/codex_message_processor.rs | 1 - codex-rs/app-server/tests/common/rollout.rs | 2 -- codex-rs/core/src/codex.rs | 7 +------ codex-rs/core/src/rollout/recorder.rs | 5 ----- codex-rs/core/src/rollout/tests.rs | 13 ------------- codex-rs/core/tests/suite/review.rs | 1 - codex-rs/core/tests/suite/rollout_list_find.rs | 1 - codex-rs/protocol/src/protocol.rs | 2 -- codex-rs/tui/src/resume_picker.rs | 1 - codex-rs/tui2/src/resume_picker.rs | 1 - 10 files changed, 1 insertion(+), 33 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1aeb623987..a83607a621 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4272,7 +4272,6 @@ mod tests { "cwd": "/", "originator": "codex", "cli_version": "0.0.0", - "instructions": null, "model_provider": "test-provider" }), json!({ diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 72bc07753d..994cdf5ff8 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -57,7 +57,6 @@ pub fn create_fake_rollout( cwd: PathBuf::from("/"), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), - instructions: None, source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), }; @@ -135,7 +134,6 @@ pub fn create_fake_rollout_with_text_elements( cwd: PathBuf::from("/"), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), - instructions: None, source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 48c689e644..e3369f8228 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -598,12 +598,7 @@ impl Session { let conversation_id = ThreadId::default(); ( conversation_id, - RolloutRecorderParams::new( - conversation_id, - forked_from_id, - session_configuration.user_instructions.clone(), - session_source, - ), + RolloutRecorderParams::new(conversation_id, forked_from_id, session_source), ) } InitialHistory::Resumed(resumed_history) => ( diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 13eace9f3c..1c6503f318 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -58,7 +58,6 @@ pub enum RolloutRecorderParams { Create { conversation_id: ThreadId, forked_from_id: Option, - instructions: Option, source: SessionSource, }, Resume { @@ -81,13 +80,11 @@ impl RolloutRecorderParams { pub fn new( conversation_id: ThreadId, forked_from_id: Option, - instructions: Option, source: SessionSource, ) -> Self { Self::Create { conversation_id, forked_from_id, - instructions, source, } } @@ -162,7 +159,6 @@ impl RolloutRecorder { RolloutRecorderParams::Create { conversation_id, forked_from_id, - instructions, source, } => { let LogFileInfo { @@ -190,7 +186,6 @@ impl RolloutRecorder { cwd: config.cwd.clone(), originator: originator().value, cli_version: env!("CARGO_PKG_VERSION").to_string(), - instructions, source, model_provider: Some(config.model_provider_id.clone()), }), diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 660e83e56f..c519c0c70c 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -86,7 +86,6 @@ fn write_session_file_with_provider( let mut payload = serde_json::json!({ "id": uuid, "timestamp": ts_str, - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -202,7 +201,6 @@ async fn test_list_conversations_latest_first() { let head_3 = vec![serde_json::json!({ "id": u3, "timestamp": "2025-01-03T12-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -212,7 +210,6 @@ async fn test_list_conversations_latest_first() { let head_2 = vec![serde_json::json!({ "id": u2, "timestamp": "2025-01-02T12-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -222,7 +219,6 @@ async fn test_list_conversations_latest_first() { let head_1 = vec![serde_json::json!({ "id": u1, "timestamp": "2025-01-01T12-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -343,7 +339,6 @@ async fn test_pagination_cursor() { let head_5 = vec![serde_json::json!({ "id": u5, "timestamp": "2025-03-05T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -353,7 +348,6 @@ async fn test_pagination_cursor() { let head_4 = vec![serde_json::json!({ "id": u4, "timestamp": "2025-03-04T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -411,7 +405,6 @@ async fn test_pagination_cursor() { let head_3 = vec![serde_json::json!({ "id": u3, "timestamp": "2025-03-03T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -421,7 +414,6 @@ async fn test_pagination_cursor() { let head_2 = vec![serde_json::json!({ "id": u2, "timestamp": "2025-03-02T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -473,7 +465,6 @@ async fn test_pagination_cursor() { let head_1 = vec![serde_json::json!({ "id": u1, "timestamp": "2025-03-01T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -531,7 +522,6 @@ async fn test_get_thread_contents() { let expected_head = vec![serde_json::json!({ "id": uuid, "timestamp": ts, - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -558,7 +548,6 @@ async fn test_get_thread_contents() { "payload": { "id": uuid, "timestamp": ts, - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -643,7 +632,6 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { id: conversation_id, forked_from_id: None, timestamp: ts.to_string(), - instructions: None, cwd: ".".into(), originator: "test_originator".into(), cli_version: "test_version".into(), @@ -751,7 +739,6 @@ async fn test_stable_ordering_same_second_pagination() { vec![serde_json::json!({ "id": u, "timestamp": ts, - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 0de18438ce..e5c980f681 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -515,7 +515,6 @@ async fn review_input_isolated_from_parent_history() { "payload": { "id": convo_id, "timestamp": "2024-01-01T00:00:00Z", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 518f26c562..a4213729bc 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -25,7 +25,6 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { "payload": { "id": id, "timestamp": "2024-01-01T00:00:00Z", - "instructions": null, "cwd": ".", "originator": "test", "cli_version": "test", diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 29eabe2bfa..d5ead442db 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1492,7 +1492,6 @@ pub struct SessionMeta { pub cwd: PathBuf, pub originator: String, pub cli_version: String, - pub instructions: Option, #[serde(default)] pub source: SessionSource, pub model_provider: Option, @@ -1507,7 +1506,6 @@ impl Default for SessionMeta { cwd: PathBuf::new(), originator: String::new(), cli_version: String::new(), - instructions: None, source: SessionSource::default(), model_provider: None, } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 71d6da453b..4ce17721cd 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1362,7 +1362,6 @@ mod tests { "cwd": cwd, "originator": "user", "cli_version": "0.0.0", - "instructions": null, "source": "Cli", "model_provider": "openai", } diff --git a/codex-rs/tui2/src/resume_picker.rs b/codex-rs/tui2/src/resume_picker.rs index 71d6da453b..4ce17721cd 100644 --- a/codex-rs/tui2/src/resume_picker.rs +++ b/codex-rs/tui2/src/resume_picker.rs @@ -1362,7 +1362,6 @@ mod tests { "cwd": cwd, "originator": "user", "cli_version": "0.0.0", - "instructions": null, "source": "Cli", "model_provider": "openai", } From 1478a88eb0e655ede6efe86764a5bf9255ca7a13 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 17 Jan 2026 17:31:14 -0800 Subject: [PATCH 28/78] Add collaboration developer instructions (#9424) - Add additional instructions when they are available - Make sure to update them on change either UserInput or UserTurn --- codex-rs/core/src/codex.rs | 158 ++++-- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/rollout/truncation.rs | 2 +- codex-rs/core/src/thread_manager.rs | 2 +- .../tests/suite/collaboration_instructions.rs | 500 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/override_updates.rs | 216 ++++++++ .../core/tests/suite/permissions_messages.rs | 6 +- codex-rs/core/tests/suite/prompt_caching.rs | 7 +- codex-rs/protocol/src/models.rs | 22 + codex-rs/protocol/src/protocol.rs | 2 + 11 files changed, 875 insertions(+), 43 deletions(-) create mode 100644 codex-rs/core/tests/suite/collaboration_instructions.rs create mode 100644 codex-rs/core/tests/suite/override_updates.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e3369f8228..c6e11b1376 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -815,7 +815,7 @@ impl Session { match conversation_history { InitialHistory::New => { // Build and record initial items (user instructions + environment context) - let items = self.build_initial_context(&turn_context); + let items = self.build_initial_context(&turn_context).await; self.record_conversation_items(&turn_context, &items).await; // Ensure initial items are visible to immediate readers (e.g., tests, forks). self.flush_rollout().await; @@ -853,8 +853,9 @@ impl Session { } // Always add response items to conversation history - let reconstructed_history = - self.reconstruct_history_from_rollout(&turn_context, &rollout_items); + let reconstructed_history = self + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; if !reconstructed_history.is_empty() { self.record_into_history(&reconstructed_history, &turn_context) .await; @@ -873,7 +874,7 @@ impl Session { } // Append the current session's initial context after the reconstructed history. - let initial_context = self.build_initial_context(&turn_context); + let initial_context = self.build_initial_context(&turn_context).await; self.record_conversation_items(&turn_context, &initial_context) .await; // Flush after seeding history and any persisted rollout copy. @@ -1061,6 +1062,50 @@ impl Session { ) } + fn build_collaboration_mode_update_item( + &self, + previous_collaboration_mode: &CollaborationMode, + next_collaboration_mode: Option<&CollaborationMode>, + ) -> Option { + if let Some(next_mode) = next_collaboration_mode { + if previous_collaboration_mode == next_mode { + return None; + } + // If the next mode has empty developer instructions, this returns None and we emit no + // update, so prior collaboration instructions remain in the prompt history. + Some(DeveloperInstructions::from_collaboration_mode(next_mode)?.into()) + } else { + None + } + } + + fn build_settings_update_items( + &self, + previous_context: Option<&Arc>, + current_context: &TurnContext, + previous_collaboration_mode: &CollaborationMode, + next_collaboration_mode: Option<&CollaborationMode>, + ) -> Vec { + let mut update_items = Vec::new(); + if let Some(env_item) = + self.build_environment_update_item(previous_context, current_context) + { + update_items.push(env_item); + } + if let Some(permissions_item) = + self.build_permissions_update_item(previous_context, current_context) + { + update_items.push(permissions_item); + } + if let Some(collaboration_mode_item) = self.build_collaboration_mode_update_item( + previous_collaboration_mode, + next_collaboration_mode, + ) { + update_items.push(collaboration_mode_item); + } + update_items + } + /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { let legacy_source = msg.clone(); @@ -1299,7 +1344,7 @@ impl Session { self.send_raw_response_items(turn_context, items).await; } - fn reconstruct_history_from_rollout( + async fn reconstruct_history_from_rollout( &self, turn_context: &TurnContext, rollout_items: &[RolloutItem], @@ -1319,7 +1364,7 @@ impl Session { } else { let user_messages = collect_user_messages(history.raw_items()); let rebuilt = compact::build_compacted_history( - self.build_initial_context(turn_context), + self.build_initial_context(turn_context).await, &user_messages, &compacted.message, ); @@ -1389,7 +1434,10 @@ impl Session { } } - pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { + pub(crate) async fn build_initial_context( + &self, + turn_context: &TurnContext, + ) -> Vec { let mut items = Vec::::with_capacity(4); let shell = self.user_shell(); items.push( @@ -1403,6 +1451,16 @@ impl Session { if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); } + // Add developer instructions from collaboration_mode if they exist and are non-empty + let collaboration_mode = { + let state = self.state.lock().await; + state.session_configuration.collaboration_mode.clone() + }; + if let Some(collab_instructions) = + DeveloperInstructions::from_collaboration_mode(&collaboration_mode) + { + items.push(collab_instructions.into()); + } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { items.push( UserInstructions { @@ -1984,6 +2042,18 @@ mod handlers { sub_id: String, updates: SessionSettingsUpdate, ) { + let previous_context = sess + .new_default_turn_with_sub_id(sess.next_internal_sub_id()) + .await; + let previous_collaboration_mode = sess + .state + .lock() + .await + .session_configuration + .collaboration_mode + .clone(); + let next_collaboration_mode = updates.collaboration_mode.clone(); + if let Err(err) = sess.update_settings(updates).await { sess.send_event_raw(Event { id: sub_id, @@ -1993,6 +2063,19 @@ mod handlers { }), }) .await; + return; + } + + let current_context = sess.new_default_turn_with_sub_id(sub_id).await; + let update_items = sess.build_settings_update_items( + Some(&previous_context), + ¤t_context, + &previous_collaboration_mode, + next_collaboration_mode.as_ref(), + ); + if !update_items.is_empty() { + sess.record_conversation_items(¤t_context, &update_items) + .await; } } @@ -2046,6 +2129,14 @@ mod handlers { _ => unreachable!(), }; + let previous_collaboration_mode = sess + .state + .lock() + .await + .session_configuration + .collaboration_mode + .clone(); + let next_collaboration_mode = updates.collaboration_mode.clone(); let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else { // new_turn_with_sub_id already emits the error event. return; @@ -2057,17 +2148,12 @@ mod handlers { // Attempt to inject input into current task if let Err(items) = sess.inject_input(items).await { - let mut update_items = Vec::new(); - if let Some(env_item) = - sess.build_environment_update_item(previous_context.as_ref(), ¤t_context) - { - update_items.push(env_item); - } - if let Some(permissions_item) = - sess.build_permissions_update_item(previous_context.as_ref(), ¤t_context) - { - update_items.push(permissions_item); - } + let update_items = sess.build_settings_update_items( + previous_context.as_ref(), + ¤t_context, + &previous_collaboration_mode, + next_collaboration_mode.as_ref(), + ); if !update_items.is_empty() { sess.record_conversation_items(¤t_context, &update_items) .await; @@ -3178,9 +3264,11 @@ mod tests { #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; - let (rollout_items, expected) = sample_rollout(&session, &turn_context); + let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; - let reconstructed = session.reconstruct_history_from_rollout(&turn_context, &rollout_items); + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; assert_eq!(expected, reconstructed); } @@ -3188,7 +3276,7 @@ mod tests { #[tokio::test] async fn record_initial_history_reconstructs_resumed_transcript() { let (session, turn_context) = make_session_and_context().await; - let (rollout_items, mut expected) = sample_rollout(&session, &turn_context); + let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; session .record_initial_history(InitialHistory::Resumed(ResumedHistory { @@ -3198,7 +3286,7 @@ mod tests { })) .await; - expected.extend(session.build_initial_context(&turn_context)); + expected.extend(session.build_initial_context(&turn_context).await); let history = session.state.lock().await.clone_history(); assert_eq!(expected, history.raw_items()); } @@ -3206,7 +3294,7 @@ mod tests { #[tokio::test] async fn record_initial_history_seeds_token_info_from_rollout() { let (session, turn_context) = make_session_and_context().await; - let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context); + let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context).await; let info1 = TokenUsageInfo { total_token_usage: TokenUsage { @@ -3283,13 +3371,13 @@ mod tests { #[tokio::test] async fn record_initial_history_reconstructs_forked_transcript() { let (session, turn_context) = make_session_and_context().await; - let (rollout_items, mut expected) = sample_rollout(&session, &turn_context); + let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; session .record_initial_history(InitialHistory::Forked(rollout_items)) .await; - expected.extend(session.build_initial_context(&turn_context)); + expected.extend(session.build_initial_context(&turn_context).await); let history = session.state.lock().await.clone_history(); assert_eq!(expected, history.raw_items()); } @@ -3298,7 +3386,7 @@ mod tests { async fn thread_rollback_drops_last_turn_from_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()); + let initial_context = sess.build_initial_context(tc.as_ref()).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -3355,7 +3443,7 @@ mod tests { async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()); + let initial_context = sess.build_initial_context(tc.as_ref()).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -3381,7 +3469,7 @@ mod tests { async fn thread_rollback_fails_when_turn_in_progress() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()); + let initial_context = sess.build_initial_context(tc.as_ref()).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -3402,7 +3490,7 @@ mod tests { async fn thread_rollback_fails_when_num_turns_is_zero() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()); + let initial_context = sess.build_initial_context(tc.as_ref()).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -4188,14 +4276,14 @@ mod tests { } } - fn sample_rollout( + async fn sample_rollout( session: &Session, turn_context: &TurnContext, ) -> (Vec, Vec) { let mut rollout_items = Vec::new(); let mut live_history = ContextManager::new(); - let initial_context = session.build_initial_context(turn_context); + let initial_context = session.build_initial_context(turn_context).await; for item in &initial_context { rollout_items.push(RolloutItem::ResponseItem(item.clone())); } @@ -4225,7 +4313,7 @@ mod tests { let snapshot1 = live_history.clone().for_prompt(); let user_messages1 = collect_user_messages(&snapshot1); let rebuilt1 = compact::build_compacted_history( - session.build_initial_context(turn_context), + session.build_initial_context(turn_context).await, &user_messages1, summary1, ); @@ -4259,7 +4347,7 @@ mod tests { let snapshot2 = live_history.clone().for_prompt(); let user_messages2 = collect_user_messages(&snapshot2); let rebuilt2 = compact::build_compacted_history( - session.build_initial_context(turn_context), + session.build_initial_context(turn_context).await, &user_messages2, summary2, ); @@ -4277,7 +4365,7 @@ mod tests { }], }; live_history.record_items(std::iter::once(&user3), turn_context.truncation_policy); - rollout_items.push(RolloutItem::ResponseItem(user3.clone())); + rollout_items.push(RolloutItem::ResponseItem(user3)); let assistant3 = ResponseItem::Message { id: None, @@ -4287,7 +4375,7 @@ mod tests { }], }; live_history.record_items(std::iter::once(&assistant3), turn_context.truncation_policy); - rollout_items.push(RolloutItem::ResponseItem(assistant3.clone())); + rollout_items.push(RolloutItem::ResponseItem(assistant3)); (rollout_items, live_history.for_prompt()) } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4dc56f10d2..250b91415d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -167,7 +167,7 @@ async fn run_compact_task_inner( let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let user_messages = collect_user_messages(history_items); - let initial_context = sess.build_initial_context(turn_context.as_ref()); + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); let ghost_snapshots: Vec = history_items .iter() diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index 1f70be46f3..0f72cfc50e 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -189,7 +189,7 @@ mod tests { #[tokio::test] async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context); + let mut items = session.build_initial_context(&turn_context).await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 8d02828d59..f533e608f7 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -467,7 +467,7 @@ mod tests { #[tokio::test] async fn ignores_session_prefix_messages_when_truncating() { let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context); + let mut items = session.build_initial_context(&turn_context).await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs new file mode 100644 index 0000000000..c0df7e5ec5 --- /dev/null +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -0,0 +1,500 @@ +use anyhow::Result; +use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG; +use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; + +fn sse_completed(id: &str) -> String { + sse(vec![ev_response_created(id), ev_completed(id)]) +} + +fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode { + CollaborationMode::Custom(Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: None, + developer_instructions: instructions.map(str::to_string), + }) +} + +fn developer_texts(input: &[Value]) -> Vec { + input + .iter() + .filter_map(|item| { + let role = item.get("role")?.as_str()?; + if role != "developer" { + return None; + } + let text = item + .get("content")? + .as_array()? + .first()? + .get("text")? + .as_str()?; + Some(text.to_string()) + }) + .collect() +} + +fn collab_xml(text: &str) -> String { + format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}") +} + +fn count_exact(texts: &[String], target: &str) -> usize { + texts.iter().filter(|text| text.as_str() == target).count() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn no_collaboration_instructions_by_default() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + assert_eq!(dev_texts.len(), 1); + assert!(dev_texts[0].contains("`approval_policy`")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_input_includes_collaboration_instructions_after_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + + let collab_text = "collab instructions"; + let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collaboration_mode), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn collaboration_instructions_added_on_user_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + let collab_text = "turn instructions"; + let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: test.config.cwd.clone(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: test.config.sandbox_policy.get().clone(), + model: test.session_configured.model.clone(), + effort: None, + summary: test.config.model_reasoning_summary, + collaboration_mode: Some(collaboration_mode), + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_then_user_turn_uses_updated_collaboration_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + let collab_text = "override instructions"; + let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collaboration_mode), + }) + .await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: test.config.cwd.clone(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: test.config.sandbox_policy.get().clone(), + model: test.session_configured.model.clone(), + effort: None, + summary: test.config.model_reasoning_summary, + collaboration_mode: None, + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_overrides_collaboration_instructions_after_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + let base_text = "base instructions"; + let base_mode = collab_mode_with_instructions(Some(base_text)); + let turn_text = "turn override"; + let turn_mode = collab_mode_with_instructions(Some(turn_text)); + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(base_mode), + }) + .await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: test.config.cwd.clone(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: test.config.sandbox_policy.get().clone(), + model: test.session_configured.model.clone(), + effort: None, + summary: test.config.model_reasoning_summary, + collaboration_mode: Some(turn_mode), + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + let base_text = collab_xml(base_text); + let turn_text = collab_xml(turn_text); + assert_eq!(count_exact(&dev_texts, &base_text), 1); + assert_eq!(count_exact(&dev_texts, &turn_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await; + let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; + + let test = test_codex().build(&server).await?; + let first_text = "first instructions"; + let second_text = "second instructions"; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req2.single_request().input(); + let dev_texts = developer_texts(&input); + let first_text = collab_xml(first_text); + let second_text = collab_xml(second_text); + assert_eq!(count_exact(&dev_texts, &first_text), 1); + assert_eq!(count_exact(&dev_texts, &second_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await; + let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; + + let test = test_codex().build(&server).await?; + let collab_text = "same instructions"; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req2.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_replays_collaboration_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await; + let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; + + let mut builder = test_codex(); + let initial = builder.build(&server).await?; + let rollout_path = initial.session_configured.rollout_path.clone(); + let home = initial.home.clone(); + + let collab_text = "resume instructions"; + initial + .codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), + }) + .await?; + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let resumed = builder.resume(&server, home, rollout_path).await?; + resumed + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "after resume".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req2.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_collaboration_instructions_are_ignored() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(""))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + assert_eq!(dev_texts.len(), 1); + let collab_text = collab_xml(""); + assert_eq!(count_exact(&dev_texts, &collab_text), 0); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 0d22e8c393..b8066db387 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -24,6 +24,7 @@ mod cli_stream; mod client; mod client_websockets; mod codex_delegate; +mod collaboration_instructions; mod compact; mod compact_remote; mod compact_resume_fork; diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs new file mode 100644 index 0000000000..ddacd97abb --- /dev/null +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -0,0 +1,216 @@ +use anyhow::Result; +use codex_core::config::Constrained; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG; +use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::RolloutItem; +use codex_core::protocol::RolloutLine; +use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use std::collections::HashSet; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; + +fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode { + CollaborationMode::Custom(Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: None, + developer_instructions: instructions.map(str::to_string), + }) +} + +fn collab_xml(text: &str) -> String { + format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}") +} + +async fn read_rollout_text(path: &Path) -> anyhow::Result { + for _ in 0..50 { + if path.exists() + && let Ok(text) = std::fs::read_to_string(path) + && !text.trim().is_empty() + { + return Ok(text); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + Ok(std::fs::read_to_string(path)?) +} + +fn rollout_developer_texts(text: &str) -> Vec { + let mut texts = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let rollout: RolloutLine = match serde_json::from_str(trimmed) { + Ok(rollout) => rollout, + Err(_) => continue, + }; + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = + rollout.item + && role == "developer" + { + for item in content { + if let ContentItem::InputText { text } = item { + texts.push(text); + } + } + } + } + texts +} + +fn rollout_environment_texts(text: &str) -> Vec { + let mut texts = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let rollout: RolloutLine = match serde_json::from_str(trimmed) { + Ok(rollout) => rollout, + Err(_) => continue, + }; + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = + rollout.item + && role == "user" + { + for item in content { + if let ContentItem::InputText { text } = item + && text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) + { + texts.push(text); + } + } + } + } + texts +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_turn_context_records_permissions_update() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(AskForApproval::Never), + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + }) + .await?; + + test.codex.submit(Op::Shutdown).await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + let rollout_path = test.codex.rollout_path(); + let rollout_text = read_rollout_text(&rollout_path).await?; + let developer_texts = rollout_developer_texts(&rollout_text); + let approval_texts: Vec<&String> = developer_texts + .iter() + .filter(|text| text.contains("`approval_policy`")) + .collect(); + assert!( + approval_texts + .iter() + .any(|text| text.contains("`approval_policy` is `never`")), + "expected updated approval policy instructions in rollout" + ); + let unique: HashSet<&String> = approval_texts.iter().copied().collect(); + assert_eq!(unique.len(), 2); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_turn_context_records_environment_update() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let test = test_codex().build(&server).await?; + let new_cwd = TempDir::new()?; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: Some(new_cwd.path().to_path_buf()), + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + }) + .await?; + + test.codex.submit(Op::Shutdown).await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + let rollout_path = test.codex.rollout_path(); + let rollout_text = read_rollout_text(&rollout_path).await?; + let env_texts = rollout_environment_texts(&rollout_text); + let new_cwd_text = new_cwd.path().display().to_string(); + assert!( + env_texts.iter().any(|text| text.contains(&new_cwd_text)), + "expected environment update with new cwd in rollout" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_turn_context_records_collaboration_update() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let test = test_codex().build(&server).await?; + let collab_text = "override collaboration instructions"; + let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collaboration_mode), + }) + .await?; + + test.codex.submit(Op::Shutdown).await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + let rollout_path = test.codex.rollout_path(); + let rollout_text = read_rollout_text(&rollout_path).await?; + let developer_texts = rollout_developer_texts(&rollout_text); + let collab_text = collab_xml(collab_text); + let collab_count = developer_texts + .iter() + .filter(|text| text.as_str() == collab_text.as_str()) + .count(); + assert_eq!(collab_count, 1); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 3e54fa29a0..af61e54a5c 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -132,7 +132,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { let permissions_2 = permissions_texts(input2); assert_eq!(permissions_1.len(), 1); - assert_eq!(permissions_2.len(), 2); + assert_eq!(permissions_2.len(), 3); let unique = permissions_2.into_iter().collect::>(); assert_eq!(unique.len(), 2); @@ -257,7 +257,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { let body3 = req3.single_request().body_json(); let input = body3["input"].as_array().expect("input array"); let permissions = permissions_texts(input); - assert_eq!(permissions.len(), 3); + assert_eq!(permissions.len(), 4); let unique = permissions.into_iter().collect::>(); assert_eq!(unique.len(), 2); @@ -321,7 +321,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { let body2 = req2.single_request().body_json(); let input2 = body2["input"].as_array().expect("input array"); let permissions_base = permissions_texts(input2); - assert_eq!(permissions_base.len(), 2); + assert_eq!(permissions_base.len(), 3); builder = builder.with_config(|config| { config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 093932512f..4e87a43640 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -379,15 +379,18 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an "content": [ { "type": "input_text", "text": "hello 2" } ] }); let expected_permissions_msg = body1["input"][0].clone(); - // After overriding the turn context, emit a new permissions message. let body1_input = body1["input"].as_array().expect("input array"); + // After overriding the turn context, emit two updated permissions messages. let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); + let expected_permissions_msg_3 = body2["input"][body1_input.len() + 1].clone(); assert_ne!( expected_permissions_msg_2, expected_permissions_msg, "expected updated permissions message after override" ); - let mut expected_body2 = body1["input"].as_array().expect("input array").to_vec(); + assert_eq!(expected_permissions_msg_2, expected_permissions_msg_3); + let mut expected_body2 = body1_input.to_vec(); expected_body2.push(expected_permissions_msg_2); + expected_body2.push(expected_permissions_msg_3); expected_body2.push(expected_user_message_2); assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d2c9ddd564..4d54ed08f0 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -10,8 +10,11 @@ use serde::Serialize; use serde::ser::Serializer; use ts_rs::TS; +use crate::config_types::CollaborationMode; use crate::config_types::SandboxMode; use crate::protocol::AskForApproval; +use crate::protocol::COLLABORATION_MODE_CLOSE_TAG; +use crate::protocol::COLLABORATION_MODE_OPEN_TAG; use crate::protocol::NetworkAccess; use crate::protocol::SandboxPolicy; use crate::protocol::WritableRoot; @@ -230,6 +233,25 @@ impl DeveloperInstructions { ) } + /// Returns developer instructions from a collaboration mode if they exist and are non-empty. + pub fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option { + let settings = match collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::Collaborate(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings, + }; + settings + .developer_instructions + .as_ref() + .filter(|instructions| !instructions.is_empty()) + .map(|instructions| { + DeveloperInstructions::new(format!( + "{COLLABORATION_MODE_OPEN_TAG}{instructions}{COLLABORATION_MODE_CLOSE_TAG}" + )) + }) + } + fn from_permissions_with_network( sandbox_mode: SandboxMode, network_access: NetworkAccess, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d5ead442db..1142f182c0 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -51,6 +51,8 @@ pub const USER_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = ""; +pub const COLLABORATION_MODE_OPEN_TAG: &str = ""; +pub const COLLABORATION_MODE_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; /// Submission Queue Entry - requests from user From aeaff26451ec9b230550a4f1855d731e719ca608 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 17 Jan 2026 19:46:07 -0800 Subject: [PATCH 29/78] Preserve slash command order in search (#9425) Keep slash popup search results in presentation order for built-ins and prompts. --- codex-rs/tui/src/bottom_pane/command_popup.rs | 44 ++++++++++++------- .../tui2/src/bottom_pane/command_popup.rs | 34 +++++++------- 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index ec4e86af03..10a469c683 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -113,8 +113,8 @@ impl CommandPopup { } /// Compute fuzzy-filtered matches over built-in commands and user prompts, - /// paired with optional highlight indices and score. Sorted by ascending - /// score, then by name for stability. + /// paired with optional highlight indices and score. Preserves the original + /// presentation order for built-ins and prompts. fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { let filter = self.command_filter.trim(); let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); @@ -144,20 +144,6 @@ impl CommandPopup { out.push((CommandItem::UserPrompt(idx), Some(indices), score)); } } - // When filtering, sort by ascending score and then by name for stability. - out.sort_by(|a, b| { - a.2.cmp(&b.2).then_with(|| { - let an = match a.0 { - CommandItem::Builtin(c) => c.command(), - CommandItem::UserPrompt(i) => &self.prompts[i].name, - }; - let bn = match b.0 { - CommandItem::Builtin(c) => c.command(), - CommandItem::UserPrompt(i) => &self.prompts[i].name, - }; - an.cmp(bn) - }) - }); out } @@ -292,6 +278,32 @@ mod tests { } } + #[test] + fn filtered_commands_keep_presentation_order() { + let mut popup = CommandPopup::new(Vec::new(), false); + popup.on_composer_text_change("/m".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert_eq!( + cmds, + vec![ + "model", + "experimental", + "resume", + "compact", + "mention", + "mcp" + ] + ); + } + #[test] fn prompt_discovery_lists_custom_prompts() { let prompts = vec![ diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs index 2fee23f1b6..ba49ab12c0 100644 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -113,8 +113,8 @@ impl CommandPopup { } /// Compute fuzzy-filtered matches over built-in commands and user prompts, - /// paired with optional highlight indices and score. Sorted by ascending - /// score, then by name for stability. + /// paired with optional highlight indices and score. Preserves the original + /// presentation order for built-ins and prompts. fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { let filter = self.command_filter.trim(); let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); @@ -144,20 +144,6 @@ impl CommandPopup { out.push((CommandItem::UserPrompt(idx), Some(indices), score)); } } - // When filtering, sort by ascending score and then by name for stability. - out.sort_by(|a, b| { - a.2.cmp(&b.2).then_with(|| { - let an = match a.0 { - CommandItem::Builtin(c) => c.command(), - CommandItem::UserPrompt(i) => &self.prompts[i].name, - }; - let bn = match b.0 { - CommandItem::Builtin(c) => c.command(), - CommandItem::UserPrompt(i) => &self.prompts[i].name, - }; - an.cmp(bn) - }) - }); out } @@ -291,6 +277,22 @@ mod tests { } } + #[test] + fn filtered_commands_keep_presentation_order() { + let mut popup = CommandPopup::new(Vec::new(), false); + popup.on_composer_text_change("/m".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert_eq!(cmds, vec!["model", "resume", "compact", "mention", "mcp"]); + } + #[test] fn prompt_discovery_lists_custom_prompts() { let prompts = vec![ From 0a568a47fdd9dc9290e50d8050f4685e2511b74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?SlKz=E1=B5=8D=E1=B5=90?= <105301169+slkzgm@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:10:24 +0100 Subject: [PATCH 30/78] tui: allow forward navigation in backtrack preview (#9059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #9058 ## Summary When the transcript backtrack preview is armed (press `Esc`), allow navigating to newer user messages with the `→` arrow, in addition to navigating backwards with `Esc`/`←`, before confirming with `Enter`. ## Changes - Backtrack preview navigation: `Esc`/`←` steps to older user messages, `→` steps to newer ones, `Enter` edits the selected message (clamped at bounds, no wrap-around). - Transcript overlay footer hints updated to advertise `esc/←`, `→`, and `enter` when a message is highlighted. ## Related - WSL shortcut-overlay snapshot determinism: #9359 ## Testing - `just fmt` - `just fix -p codex-tui` - `just fix -p codex-tui2` - `cargo test -p codex-tui app_backtrack::` - `cargo test -p codex-tui pager_overlay::` - `cargo test -p codex-tui2 app_backtrack::` - `cargo test -p codex-tui2 pager_overlay::` --------- Co-authored-by: Josh McKinney --- codex-rs/tui/src/app_backtrack.rs | 56 ++++++++++++++++++++++++++++-- codex-rs/tui/src/pager_overlay.rs | 41 +++++++++++++++------- codex-rs/tui2/src/app_backtrack.rs | 56 ++++++++++++++++++++++++++++-- codex-rs/tui2/src/pager_overlay.rs | 41 +++++++++++++++------- 4 files changed, 166 insertions(+), 28 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 73d238f9a4..80fc65ca77 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -93,8 +93,9 @@ pub(crate) struct PendingBacktrackRollback { impl App { /// Route overlay events while the transcript overlay is active. /// - /// If backtrack preview is active, Esc steps the selection and Enter confirms it. - /// Otherwise, Esc begins preview mode and all other events are forwarded to the overlay. + /// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter + /// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the + /// overlay. pub(crate) async fn handle_backtrack_overlay_event( &mut self, tui: &mut tui::Tui, @@ -110,6 +111,22 @@ impl App { self.overlay_step_backtrack(tui, event)?; Ok(true) } + TuiEvent::Key(KeyEvent { + code: KeyCode::Left, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack(tui, event)?; + Ok(true) + } + TuiEvent::Key(KeyEvent { + code: KeyCode::Right, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack_forward(tui, event)?; + Ok(true) + } TuiEvent::Key(KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, @@ -277,6 +294,27 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Step selection to the next newer user message and update overlay. + fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { + let count = user_count(&self.transcript_cells); + if count == 0 { + return; + } + + let last_index = count.saturating_sub(1); + let next_selection = if self.backtrack.nth_user_message == usize::MAX { + last_index + } else { + self.backtrack + .nth_user_message + .saturating_add(1) + .min(last_index) + }; + + self.apply_backtrack_selection_internal(next_selection); + tui.frame_requester().schedule_frame(); + } + /// Apply a computed backtrack selection to the overlay and internal counter. fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { @@ -364,6 +402,20 @@ impl App { Ok(()) } + /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward. + fn overlay_step_backtrack_forward( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result<()> { + if self.backtrack.base_id.is_some() { + self.step_forward_backtrack_and_highlight(tui); + } else { + self.overlay_forward_event(tui, event)?; + } + Ok(()) + } + /// Confirm a primed backtrack from the main view (no overlay visible). /// Computes the prefill from the selected user message for rollback. pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index ae71a58c6d..5f3f79e320 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -92,6 +92,8 @@ const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left); +const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right); const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); @@ -637,10 +639,13 @@ impl TranscriptOverlay { let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); - let mut pairs: Vec<(&[KeyBinding], &str)> = - vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")]; + let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; if self.highlight_cell.is_some() { + pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev")); + pairs.push((&[KEY_RIGHT], "to edit next")); pairs.push((&[KEY_ENTER], "to edit message")); + } else { + pairs.push((&[KEY_ESC], "to edit prev")); } render_key_hints(line2, buf, &pairs); } @@ -816,25 +821,37 @@ mod tests { lines: vec![Line::from("hello")], })]); - // Render into a small buffer and assert the backtrack hint is present - let area = Rect::new(0, 0, 40, 10); + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); let mut buf = Buffer::empty(area); overlay.render(area, &mut buf); - // Flatten buffer to a string and check for the hint text - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } + let s = buffer_to_text(&buf, area); assert!( s.contains("edit prev"), "expected 'edit prev' hint in overlay footer, got: {s:?}" ); } + #[test] + fn edit_next_hint_is_visible_when_highlighted() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("hello")], + })]); + overlay.set_highlight_cell(Some(0)); + + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let s = buffer_to_text(&buf, area); + assert!( + s.contains("edit next"), + "expected 'edit next' hint in overlay footer, got: {s:?}" + ); + } + #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index 195dc79b3e..a86ed6dea6 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -94,8 +94,9 @@ pub(crate) struct PendingBacktrackRollback { impl App { /// Route overlay events while the transcript overlay is active. /// - /// If backtrack preview is active, Esc steps the selection and Enter confirms it. - /// Otherwise, Esc begins preview mode and all other events are forwarded to the overlay. + /// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter + /// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the + /// overlay. pub(crate) async fn handle_backtrack_overlay_event( &mut self, tui: &mut tui::Tui, @@ -111,6 +112,22 @@ impl App { self.overlay_step_backtrack(tui, event)?; Ok(true) } + TuiEvent::Key(KeyEvent { + code: KeyCode::Left, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack(tui, event)?; + Ok(true) + } + TuiEvent::Key(KeyEvent { + code: KeyCode::Right, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack_forward(tui, event)?; + Ok(true) + } TuiEvent::Key(KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, @@ -308,6 +325,27 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Step selection to the next newer user message and update overlay. + fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { + let count = user_count(&self.transcript_cells); + if count == 0 { + return; + } + + let last_index = count.saturating_sub(1); + let next_selection = if self.backtrack.nth_user_message == usize::MAX { + last_index + } else { + self.backtrack + .nth_user_message + .saturating_add(1) + .min(last_index) + }; + + self.apply_backtrack_selection_internal(next_selection); + tui.frame_requester().schedule_frame(); + } + /// Apply a computed backtrack selection to the overlay and internal counter. fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { @@ -387,6 +425,20 @@ impl App { Ok(()) } + /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward. + fn overlay_step_backtrack_forward( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result<()> { + if self.backtrack.base_id.is_some() { + self.step_forward_backtrack_and_highlight(tui); + } else { + self.overlay_forward_event(tui, event)?; + } + Ok(()) + } + /// Confirm a primed backtrack from the main view (no overlay visible). /// Computes the prefill from the selected user message for rollback. pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { diff --git a/codex-rs/tui2/src/pager_overlay.rs b/codex-rs/tui2/src/pager_overlay.rs index 6fb6d123cb..c981ae951d 100644 --- a/codex-rs/tui2/src/pager_overlay.rs +++ b/codex-rs/tui2/src/pager_overlay.rs @@ -93,6 +93,8 @@ const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left); +const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right); const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); @@ -656,10 +658,13 @@ impl TranscriptOverlay { let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); - let mut pairs: Vec<(&[KeyBinding], &str)> = - vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")]; + let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; if self.highlight_cell.is_some() { + pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev")); + pairs.push((&[KEY_RIGHT], "to edit next")); pairs.push((&[KEY_ENTER], "to edit message")); + } else { + pairs.push((&[KEY_ESC], "to edit prev")); } render_key_hints(line2, buf, &pairs); } @@ -837,25 +842,37 @@ mod tests { lines: vec![Line::from("hello")], })]); - // Render into a small buffer and assert the backtrack hint is present - let area = Rect::new(0, 0, 40, 10); + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); let mut buf = Buffer::empty(area); overlay.render(area, &mut buf); - // Flatten buffer to a string and check for the hint text - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } + let s = buffer_to_text(&buf, area); assert!( s.contains("edit prev"), "expected 'edit prev' hint in overlay footer, got: {s:?}" ); } + #[test] + fn edit_next_hint_is_visible_when_highlighted() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("hello")], + })]); + overlay.set_highlight_cell(Some(0)); + + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let s = buffer_to_text(&buf, area); + assert!( + s.contains("edit next"), + "expected 'edit next' hint in overlay footer, got: {s:?}" + ); + } + #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines From f72f87fbeeea3663c99bf29478cf5765d4c806f6 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 18 Jan 2026 11:39:08 -0800 Subject: [PATCH 31/78] Add collaboration modes test prompts (#9443) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- .../tests/suite/v2/collaboration_mode_list.rs | 43 +++++++++--------- codex-rs/core/config.schema.json | 16 ++++--- codex-rs/core/src/features.rs | 8 ++++ .../collaboration_mode_presets.rs | 23 +++++++--- codex-rs/core/src/models_manager/mod.rs | 3 ++ .../templates/collaboration_mode/execute.md | 45 +++++++++++++++++++ .../collaboration_mode/pair_programming.md | 7 +++ .../core/templates/collaboration_mode/plan.md | 40 +++++++++++++++++ codex-rs/protocol/src/config_types.rs | 8 ++-- codex-rs/protocol/src/models.rs | 2 +- .../src/prompts/collaboration_mode/execute.md | 45 +++++++++++++++++++ .../collaboration_mode/pair_programming.md | 7 +++ .../src/prompts/collaboration_mode/plan.md | 40 +++++++++++++++++ 13 files changed, 251 insertions(+), 36 deletions(-) create mode 100644 codex-rs/core/templates/collaboration_mode/execute.md create mode 100644 codex-rs/core/templates/collaboration_mode/pair_programming.md create mode 100644 codex-rs/core/templates/collaboration_mode/plan.md create mode 100644 codex-rs/protocol/src/prompts/collaboration_mode/execute.md create mode 100644 codex-rs/protocol/src/prompts/collaboration_mode/pair_programming.md create mode 100644 codex-rs/protocol/src/prompts/collaboration_mode/plan.md diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs index b6ff3f888e..d75a589d00 100644 --- a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -1,9 +1,11 @@ //! Validates that the collaboration mode list endpoint returns the expected default presets. //! //! The test drives the app server through the MCP harness and asserts that the list response -//! includes the plan, collaborate, and execute modes with their default model and reasoning +//! includes the plan, pair programming, and execute modes with their default model and reasoning //! effort settings, which keeps the API contract visible in one place. +#![allow(clippy::unwrap_used)] + use std::time::Duration; use anyhow::Result; @@ -13,9 +15,8 @@ use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_core::models_manager::test_builtin_collaboration_mode_presets; use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -43,7 +44,7 @@ async fn list_collaboration_modes_returns_presets() -> Result<()> { let CollaborationModeListResponse { data: items } = to_response::(response)?; - let expected = vec![plan_preset(), collaborate_preset(), execute_preset()]; + let expected = vec![plan_preset(), pair_programming_preset(), execute_preset()]; assert_eq!(expected, items); Ok(()) } @@ -53,23 +54,23 @@ async fn list_collaboration_modes_returns_presets() -> Result<()> { /// If the defaults change in the app server, this helper should be updated alongside the /// contract, or the test will fail in ways that imply a regression in the API. fn plan_preset() -> CollaborationMode { - CollaborationMode::Plan(Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: None, - }) + let presets = test_builtin_collaboration_mode_presets(); + presets + .into_iter() + .find(|p| matches!(p, CollaborationMode::Plan(_))) + .unwrap() } -/// Builds the collaborate preset that the list response is expected to return. +/// Builds the pair programming preset that the list response is expected to return. /// /// The helper keeps the expected model and reasoning defaults co-located with the test /// so that mismatches point directly at the API contract being exercised. -fn collaborate_preset() -> CollaborationMode { - CollaborationMode::Collaborate(Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: None, - }) +fn pair_programming_preset() -> CollaborationMode { + let presets = test_builtin_collaboration_mode_presets(); + presets + .into_iter() + .find(|p| matches!(p, CollaborationMode::PairProgramming(_))) + .unwrap() } /// Builds the execute preset that the list response is expected to return. @@ -77,9 +78,9 @@ fn collaborate_preset() -> CollaborationMode { /// The execute preset uses a different reasoning effort to capture the higher-effort /// execution contract the server currently exposes. fn execute_preset() -> CollaborationMode { - CollaborationMode::Execute(Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::XHigh), - developer_instructions: None, - }) + let presets = test_builtin_collaboration_mode_presets(); + presets + .into_iter() + .find(|p| matches!(p, CollaborationMode::Execute(_))) + .unwrap() } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e954d88c1a..bfb54821fc 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -81,6 +81,9 @@ "collab": { "type": "boolean" }, + "collaboration_modes": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, @@ -356,12 +359,12 @@ }, "shell_environment_policy": { "default": { - "exclude": null, - "experimental_use_profile": null, - "ignore_default_excludes": null, - "include_only": null, "inherit": null, - "set": null + "ignore_default_excludes": null, + "exclude": null, + "set": null, + "include_only": null, + "experimental_use_profile": null }, "allOf": [ { @@ -557,6 +560,9 @@ "collab": { "type": "boolean" }, + "collaboration_modes": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 2ea63f812a..66f6a08602 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -100,6 +100,8 @@ pub enum Feature { Collab, /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. Steer, + /// Enable collaboration modes (Plan, Pair Programming, Execute). + CollaborationModes, } impl Feature { @@ -444,4 +446,10 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::CollaborationModes, + key: "collaboration_modes", + stage: Stage::Experimental, + default_enabled: false, + }, ]; 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 3d6eb9ca3c..98ffcbd02d 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -2,23 +2,34 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md"); +const COLLABORATION_MODE_PAIR_PROGRAMMING: &str = + include_str!("../../templates/collaboration_mode/pair_programming.md"); +const COLLABORATION_MODE_EXECUTE: &str = + include_str!("../../templates/collaboration_mode/execute.md"); + pub(super) fn builtin_collaboration_mode_presets() -> Vec { - vec![plan_preset(), collaborate_preset(), execute_preset()] + vec![plan_preset(), pair_programming_preset(), execute_preset()] +} + +#[cfg(any(test, feature = "test-support"))] +pub fn test_builtin_collaboration_mode_presets() -> Vec { + builtin_collaboration_mode_presets() } fn plan_preset() -> CollaborationMode { CollaborationMode::Plan(Settings { model: "gpt-5.2-codex".to_string(), reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: None, + developer_instructions: Some(COLLABORATION_MODE_PLAN.to_string()), }) } -fn collaborate_preset() -> CollaborationMode { - CollaborationMode::Collaborate(Settings { +fn pair_programming_preset() -> CollaborationMode { + CollaborationMode::PairProgramming(Settings { model: "gpt-5.2-codex".to_string(), reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: None, + developer_instructions: Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string()), }) } @@ -26,6 +37,6 @@ fn execute_preset() -> CollaborationMode { CollaborationMode::Execute(Settings { model: "gpt-5.2-codex".to_string(), reasoning_effort: Some(ReasoningEffort::XHigh), - developer_instructions: None, + developer_instructions: Some(COLLABORATION_MODE_EXECUTE.to_string()), }) } diff --git a/codex-rs/core/src/models_manager/mod.rs b/codex-rs/core/src/models_manager/mod.rs index 1b3d95dc40..3a649d5f14 100644 --- a/codex-rs/core/src/models_manager/mod.rs +++ b/codex-rs/core/src/models_manager/mod.rs @@ -3,3 +3,6 @@ pub mod collaboration_mode_presets; pub mod manager; pub mod model_info; pub mod model_presets; + +#[cfg(any(test, feature = "test-support"))] +pub use collaboration_mode_presets::test_builtin_collaboration_mode_presets; diff --git a/codex-rs/core/templates/collaboration_mode/execute.md b/codex-rs/core/templates/collaboration_mode/execute.md new file mode 100644 index 0000000000..c57878242d --- /dev/null +++ b/codex-rs/core/templates/collaboration_mode/execute.md @@ -0,0 +1,45 @@ +# Collaboration Style: Execute +You execute on a well-specified task independently and report progress. + +You do not collaborate on decisions in this mode. You execute end-to-end. +You make reasonable assumptions when the user hasn't specified something, and you proceed without asking questions. + +## Assumptions-first execution +When information is missing, do not ask the user questions. +Instead: +- Make a sensible assumption. +- Clearly state the assumption in the final message (briefly). +- Continue executing. + +Group assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. +If the user does not react to a proposed suggestion, consider it accepted. + +## Execution principles +*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. + +*Use reasonable assumptions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. + +Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter." +Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration." + +*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. +Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. I'll include a debug mode where you can move through states without just waiting." + +*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and would normally ask, make a reasonable assumption and continue. +Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. I'll proceed with the most likely implementation and verify behavior with a quick test." + +## Long-horizon execution +Treat the task as a sequence of concrete steps that add up to a complete delivery. +- Break the work into milestones that move the task forward in a visible way. +- Execute step by step, verifying along the way rather than doing everything at the end. +- If the task is large, keep a running checklist of what is done, what is next, and what is blocked. +- Avoid blocking on uncertainty: choose a reasonable default and continue. + +## Reporting progress +In this phase you show progress on your task and appraise the user of your progress using plan tool. +- Provide updates that directly map to the work you are doing (what changed, what you verified, what remains). +- If something fails, report what failed, what you tried, and what you will do next. +- When you finish, summarize what you delivered and how the user can validate it. + +## Executing +Once you start working, you should execute independently. Your job is to deliver the task and report progress. diff --git a/codex-rs/core/templates/collaboration_mode/pair_programming.md b/codex-rs/core/templates/collaboration_mode/pair_programming.md new file mode 100644 index 0000000000..1297129b1d --- /dev/null +++ b/codex-rs/core/templates/collaboration_mode/pair_programming.md @@ -0,0 +1,7 @@ +# Collaboration Style: Pair Programming + +## Build together as you go +You treat collaboration as pairing by default. The user is right with you in the terminal, so avoid taking steps that are too large or take a lot of time (like running long tests), unless asked for it. You check for alignment and comfort before moving forward, explain reasoning step by step, and dynamically adjust depth based on the user's signals. There is no need to ask multiple rounds of questions—build as you go. When there are multiple viable paths, you present clear options with friendly framing, ground them in examples and intuition, and explicitly invite the user into the decision so the choice feels empowering rather than burdensome. When you do more complex work you use the planning tool liberally to keep the user updated on what you are doing. + +## Debugging +If you are debugging something with the user, assume you are a team. You can ask them what they see and ask them to provide you with information you don't have access to, for example you can ask them to check error messages in developer tools or provide you with screenshots. diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md new file mode 100644 index 0000000000..70b710c420 --- /dev/null +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -0,0 +1,40 @@ +# Collaboration Style: Plan +You work in 2 distinct modes: +1. Brainstorming: You collaboratively align with the user on what to do or build and how to do it or build it. +2. Writing and confirming a plan: After you've gathered all the information you write up a plan and verify it with the user. +You usually start with the planning step. Skip step 1 if the user provides you with a detailed plan or a small, unambiguous task or plan OR if the user asks you to plan by yourself. + +## Brainstorming principles +The point of brainstorming with the user is to align on what to do and how to do it. This phase is iterative and conversational. You can interact with the environment and read files if it is helpful, but be mindful of the time. +You MUST follow the principles below. Think about them carefully as you work with the user. Follow the structure and tone of the examples. + +*State what you think the user cares about.* Actively infer what matters most (robustness, clean abstractions, quick lovable interfaces, scalability) and reflect this back to the user to confirm. +Example: "It seems like you might be prototyping a design for an app, and scalability or performance isn't a concern right now - is that accurate?" + +*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. + +*Use reasonable suggestions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. + +Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter - does that resonate?" +Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration - we can relax that if this is exploratory." + +*Ask fewer, better questions.* Prefer making a concrete proposal with stated assumptions over asking questions. Only ask questions when different reasonable suggestions would materially change the plan, you cannot safely proceed, or if you think the user would really want to give input directly. Never ask a question if you already provided a suggestion. + +*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. +Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. Would you like a debug mode where you can move through states without just waiting?" + +*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and think you need to do longer research, ask the user whether they want you to research, or want to give you a tip. +Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. If it's ok, I'll go and spend a bit more time exploring the code base?" + +## Iterating on the plan +Only AFTER you have all the information, write up the full plan. +A well written and informative plan should be as detailed as a design doc or PRD and reflect your discussion with the user, at minimum that's one full page! If handed to a different agent, the agent would know exactly what to build without asking questions and arrive at a similar implementation to yours. At minimum it should include: +- tools and frameworks you use, any dependencies you need to install +- functions, files, or directories you're likely going to edit +- architecture if the code changes are significant +- if developing features, describe the features you are going to build in detail like a PM in a PRD +- if you are developing a frontend, describe the design in detail + +`plan.md`: For long, detailed plans, it makes sense to write them in a separate file. If the changes are substantial and the plan is longer than a full page, ask the user if it's ok to write the plan in `plan.md`. If plan.md is used, ALWAYS update the file rather than outputting the plan in your final answer. + +ALWAYS confirm the plan with the user before ending. If the user requests changes or additions to the plan update the plan. Iterate until the user confirms the plan. diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 0febd2fdd5..868217b3b5 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -135,7 +135,7 @@ pub enum AltScreenMode { #[serde(tag = "mode", rename_all = "lowercase")] pub enum CollaborationMode { Plan(Settings), - Collaborate(Settings), + PairProgramming(Settings), Execute(Settings), Custom(Settings), } @@ -145,7 +145,7 @@ impl CollaborationMode { fn settings(&self) -> &Settings { match self { CollaborationMode::Plan(settings) - | CollaborationMode::Collaborate(settings) + | CollaborationMode::PairProgramming(settings) | CollaborationMode::Execute(settings) | CollaborationMode::Custom(settings) => settings, } @@ -182,7 +182,9 @@ impl CollaborationMode { match self { CollaborationMode::Plan(_) => CollaborationMode::Plan(updated_settings), - CollaborationMode::Collaborate(_) => CollaborationMode::Collaborate(updated_settings), + CollaborationMode::PairProgramming(_) => { + CollaborationMode::PairProgramming(updated_settings) + } CollaborationMode::Execute(_) => CollaborationMode::Execute(updated_settings), CollaborationMode::Custom(_) => CollaborationMode::Custom(updated_settings), } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 4d54ed08f0..8f8c9b3077 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -237,7 +237,7 @@ impl DeveloperInstructions { pub fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option { let settings = match collaboration_mode { CollaborationMode::Plan(settings) - | CollaborationMode::Collaborate(settings) + | CollaborationMode::PairProgramming(settings) | CollaborationMode::Execute(settings) | CollaborationMode::Custom(settings) => settings, }; diff --git a/codex-rs/protocol/src/prompts/collaboration_mode/execute.md b/codex-rs/protocol/src/prompts/collaboration_mode/execute.md new file mode 100644 index 0000000000..c57878242d --- /dev/null +++ b/codex-rs/protocol/src/prompts/collaboration_mode/execute.md @@ -0,0 +1,45 @@ +# Collaboration Style: Execute +You execute on a well-specified task independently and report progress. + +You do not collaborate on decisions in this mode. You execute end-to-end. +You make reasonable assumptions when the user hasn't specified something, and you proceed without asking questions. + +## Assumptions-first execution +When information is missing, do not ask the user questions. +Instead: +- Make a sensible assumption. +- Clearly state the assumption in the final message (briefly). +- Continue executing. + +Group assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. +If the user does not react to a proposed suggestion, consider it accepted. + +## Execution principles +*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. + +*Use reasonable assumptions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. + +Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter." +Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration." + +*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. +Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. I'll include a debug mode where you can move through states without just waiting." + +*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and would normally ask, make a reasonable assumption and continue. +Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. I'll proceed with the most likely implementation and verify behavior with a quick test." + +## Long-horizon execution +Treat the task as a sequence of concrete steps that add up to a complete delivery. +- Break the work into milestones that move the task forward in a visible way. +- Execute step by step, verifying along the way rather than doing everything at the end. +- If the task is large, keep a running checklist of what is done, what is next, and what is blocked. +- Avoid blocking on uncertainty: choose a reasonable default and continue. + +## Reporting progress +In this phase you show progress on your task and appraise the user of your progress using plan tool. +- Provide updates that directly map to the work you are doing (what changed, what you verified, what remains). +- If something fails, report what failed, what you tried, and what you will do next. +- When you finish, summarize what you delivered and how the user can validate it. + +## Executing +Once you start working, you should execute independently. Your job is to deliver the task and report progress. diff --git a/codex-rs/protocol/src/prompts/collaboration_mode/pair_programming.md b/codex-rs/protocol/src/prompts/collaboration_mode/pair_programming.md new file mode 100644 index 0000000000..1297129b1d --- /dev/null +++ b/codex-rs/protocol/src/prompts/collaboration_mode/pair_programming.md @@ -0,0 +1,7 @@ +# Collaboration Style: Pair Programming + +## Build together as you go +You treat collaboration as pairing by default. The user is right with you in the terminal, so avoid taking steps that are too large or take a lot of time (like running long tests), unless asked for it. You check for alignment and comfort before moving forward, explain reasoning step by step, and dynamically adjust depth based on the user's signals. There is no need to ask multiple rounds of questions—build as you go. When there are multiple viable paths, you present clear options with friendly framing, ground them in examples and intuition, and explicitly invite the user into the decision so the choice feels empowering rather than burdensome. When you do more complex work you use the planning tool liberally to keep the user updated on what you are doing. + +## Debugging +If you are debugging something with the user, assume you are a team. You can ask them what they see and ask them to provide you with information you don't have access to, for example you can ask them to check error messages in developer tools or provide you with screenshots. diff --git a/codex-rs/protocol/src/prompts/collaboration_mode/plan.md b/codex-rs/protocol/src/prompts/collaboration_mode/plan.md new file mode 100644 index 0000000000..70b710c420 --- /dev/null +++ b/codex-rs/protocol/src/prompts/collaboration_mode/plan.md @@ -0,0 +1,40 @@ +# Collaboration Style: Plan +You work in 2 distinct modes: +1. Brainstorming: You collaboratively align with the user on what to do or build and how to do it or build it. +2. Writing and confirming a plan: After you've gathered all the information you write up a plan and verify it with the user. +You usually start with the planning step. Skip step 1 if the user provides you with a detailed plan or a small, unambiguous task or plan OR if the user asks you to plan by yourself. + +## Brainstorming principles +The point of brainstorming with the user is to align on what to do and how to do it. This phase is iterative and conversational. You can interact with the environment and read files if it is helpful, but be mindful of the time. +You MUST follow the principles below. Think about them carefully as you work with the user. Follow the structure and tone of the examples. + +*State what you think the user cares about.* Actively infer what matters most (robustness, clean abstractions, quick lovable interfaces, scalability) and reflect this back to the user to confirm. +Example: "It seems like you might be prototyping a design for an app, and scalability or performance isn't a concern right now - is that accurate?" + +*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. + +*Use reasonable suggestions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. + +Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter - does that resonate?" +Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration - we can relax that if this is exploratory." + +*Ask fewer, better questions.* Prefer making a concrete proposal with stated assumptions over asking questions. Only ask questions when different reasonable suggestions would materially change the plan, you cannot safely proceed, or if you think the user would really want to give input directly. Never ask a question if you already provided a suggestion. + +*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. +Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. Would you like a debug mode where you can move through states without just waiting?" + +*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and think you need to do longer research, ask the user whether they want you to research, or want to give you a tip. +Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. If it's ok, I'll go and spend a bit more time exploring the code base?" + +## Iterating on the plan +Only AFTER you have all the information, write up the full plan. +A well written and informative plan should be as detailed as a design doc or PRD and reflect your discussion with the user, at minimum that's one full page! If handed to a different agent, the agent would know exactly what to build without asking questions and arrive at a similar implementation to yours. At minimum it should include: +- tools and frameworks you use, any dependencies you need to install +- functions, files, or directories you're likely going to edit +- architecture if the code changes are significant +- if developing features, describe the features you are going to build in detail like a PM in a PRD +- if you are developing a frontend, describe the design in detail + +`plan.md`: For long, detailed plans, it makes sense to write them in a separate file. If the changes are substantial and the plan is longer than a full page, ask the user if it's ok to write the plan in `plan.md`. If plan.md is used, ALWAYS update the file rather than outputting the plan in your final answer. + +ALWAYS confirm the plan with the user before ending. If the user requests changes or additions to the plan update the plan. Iterate until the user confirms the plan. From c87a7d90437baa2b46a34e41d1fe319431ddddd7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 18 Jan 2026 19:19:37 -0800 Subject: [PATCH 32/78] fix(tui2): running /mcp was not printing any output until another event triggered a flush (#9457) This seems to fix things, but I'm not sure if this is the correct/minimal fix. --- codex-rs/tui2/src/app.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 6cc764c4a2..e6f7fd5f4e 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1610,6 +1610,8 @@ impl App { self.transcript_cells.push(cell.clone()); if self.overlay.is_some() { self.deferred_history_cells.push(cell); + } else { + tui.frame_requester().schedule_frame(); } } AppEvent::StartCommitAnimation => { From 1271d450b11d9363d0bafb816079b797ff9c8e34 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 18 Jan 2026 19:22:28 -0800 Subject: [PATCH 33/78] Fixed symlink support for config.toml (#9445) We already support reading from `config.toml` through a symlink, but the code was not properly handling updates to a symlinked config file. This PR generalizes safe symlink-chain resolution and atomic writes into path_utils, updating all config write paths to use the shared logic (including set_default_oss_provider, which previously didn't use the common path), and adds tests for symlink chains and cycles. This resolves #6646. Notes: * Symlink cycles or resolution failures replace the top-level symlink with a real file. * Shared config write path now handles symlinks consistently across edits, defaults, and empty-user-layer creation. This PR was inspired by https://github.com/openai/codex/pull/9437, which was contributed by @ryoppippi --- codex-rs/core/src/config/edit.rs | 97 +++++++++++++++++---- codex-rs/core/src/config/mod.rs | 33 +++----- codex-rs/core/src/config/service.rs | 44 +++++++--- codex-rs/core/src/path_utils.rs | 127 ++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 50 deletions(-) diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index f97903b31a..9c12272d94 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1,13 +1,14 @@ use crate::config::CONFIG_TOML_FILE; use crate::config::types::McpServerConfig; use crate::config::types::Notice; +use crate::path_utils::resolve_symlink_write_paths; +use crate::path_utils::write_atomically; use anyhow::Context; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; -use tempfile::NamedTempFile; use tokio::task; use toml_edit::ArrayOfTables; use toml_edit::DocumentMut; @@ -625,10 +626,14 @@ pub fn apply_blocking( } let config_path = codex_home.join(CONFIG_TOML_FILE); - let serialized = match std::fs::read_to_string(&config_path) { - Ok(contents) => contents, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(err) => return Err(err.into()), + let write_paths = resolve_symlink_write_paths(&config_path)?; + let serialized = match write_paths.read_path { + Some(path) => match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err.into()), + }, + None => String::new(), }; let doc = if serialized.is_empty() { @@ -654,22 +659,13 @@ pub fn apply_blocking( return Ok(()); } - std::fs::create_dir_all(codex_home).with_context(|| { + write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| { format!( - "failed to create Codex home directory at {}", - codex_home.display() + "failed to persist config.toml at {}", + write_paths.write_path.display() ) })?; - let tmp = NamedTempFile::new_in(codex_home)?; - std::fs::write(tmp.path(), document.doc.to_string()).with_context(|| { - format!( - "failed to write temporary config file at {}", - tmp.path().display() - ) - })?; - tmp.persist(config_path)?; - Ok(()) } @@ -813,6 +809,8 @@ mod tests { use crate::config::types::McpServerTransportConfig; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; + #[cfg(unix)] + use std::os::unix::fs::symlink; use tempfile::tempdir; use toml::Value as TomlValue; @@ -952,6 +950,71 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } ); } + #[cfg(unix)] + #[test] + fn blocking_set_model_writes_through_symlink_chain() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let target_dir = tempdir().expect("target dir"); + let target_path = target_dir.path().join(CONFIG_TOML_FILE); + let link_path = codex_home.join("config-link.toml"); + let config_path = codex_home.join(CONFIG_TOML_FILE); + + symlink(&target_path, &link_path).expect("symlink link"); + symlink("config-link.toml", &config_path).expect("symlink config"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: Some("gpt-5.1-codex".to_string()), + effort: Some(ReasoningEffort::High), + }], + ) + .expect("persist"); + + let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); + assert!(meta.file_type().is_symlink()); + + let contents = std::fs::read_to_string(&target_path).expect("read target"); + let expected = r#"model = "gpt-5.1-codex" +model_reasoning_effort = "high" +"#; + assert_eq!(contents, expected); + } + + #[cfg(unix)] + #[test] + fn blocking_set_model_replaces_symlink_on_cycle() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let link_a = codex_home.join("a.toml"); + let link_b = codex_home.join("b.toml"); + let config_path = codex_home.join(CONFIG_TOML_FILE); + + symlink("b.toml", &link_a).expect("symlink a"); + symlink("a.toml", &link_b).expect("symlink b"); + symlink("a.toml", &config_path).expect("symlink config"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: Some("gpt-5.1-codex".to_string()), + effort: None, + }], + ) + .expect("persist"); + + let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); + assert!(!meta.file_type().is_symlink()); + + let contents = std::fs::read_to_string(&config_path).expect("read config"); + let expected = r#"model = "gpt-5.1-codex" +"#; + assert_eq!(contents, expected); + } + #[test] fn batch_write_table_upsert_preserves_inline_comments() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 956df12e9c..970ec7c4df 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,4 +1,6 @@ use crate::auth::AuthCredentialsStoreMode; +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; use crate::config::types::DEFAULT_OTEL_ENVIRONMENT; use crate::config::types::History; use crate::config::types::McpServerConfig; @@ -751,30 +753,17 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R )); } } - let config_path = codex_home.join(CONFIG_TOML_FILE); - - // Read existing config or create empty string if file doesn't exist - let content = match std::fs::read_to_string(&config_path) { - Ok(content) => content, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(e) => return Err(e), - }; - - // Parse as DocumentMut for editing while preserving structure - let mut doc = content.parse::().map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to parse config.toml: {e}"), - ) - })?; - - // Set the default_oss_provider at root level use toml_edit::value; - doc["oss_provider"] = value(provider); - // Write the modified document back - std::fs::write(&config_path, doc.to_string())?; - Ok(()) + let edits = [ConfigEdit::SetPath { + segments: vec!["oss_provider".to_string()], + value: value(provider), + }]; + + ConfigEditsBuilder::new(codex_home) + .with_edits(edits) + .apply_blocking() + .map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}"))) } /// Base config deserialized from ~/.codex/config.toml. diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 913c02df1d..b6ed9f617e 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -9,6 +9,9 @@ use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::config_loader::merge_toml_values; use crate::path_utils; +use crate::path_utils::SymlinkWritePaths; +use crate::path_utils::resolve_symlink_write_paths; +use crate::path_utils::write_atomically; use codex_app_server_protocol::Config as ApiConfig; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayerMetadata; @@ -27,6 +30,7 @@ use std::borrow::Cow; use std::path::Path; use std::path::PathBuf; use thiserror::Error; +use tokio::task; use toml::Value as TomlValue; use toml_edit::Item as TomlItem; @@ -362,19 +366,30 @@ impl ConfigService { async fn create_empty_user_layer( config_toml: &AbsolutePathBuf, ) -> Result { - let toml_value = match tokio::fs::read_to_string(config_toml).await { - Ok(contents) => toml::from_str(&contents).map_err(|e| { - ConfigServiceError::toml("failed to parse existing user config.toml", e) - })?, - Err(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - tokio::fs::write(config_toml, "").await.map_err(|e| { - ConfigServiceError::io("failed to create empty user config.toml", e) - })?; + let SymlinkWritePaths { + read_path, + write_path, + } = resolve_symlink_write_paths(config_toml.as_path()) + .map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?; + let toml_value = match read_path { + Some(path) => match tokio::fs::read_to_string(&path).await { + Ok(contents) => toml::from_str(&contents).map_err(|e| { + ConfigServiceError::toml("failed to parse existing user config.toml", e) + })?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + write_empty_user_config(write_path.clone()).await?; TomlValue::Table(toml::map::Map::new()) - } else { - return Err(ConfigServiceError::io("failed to read user config.toml", e)); } + Err(err) => { + return Err(ConfigServiceError::io( + "failed to read user config.toml", + err, + )); + } + }, + None => { + write_empty_user_config(write_path).await?; + TomlValue::Table(toml::map::Map::new()) } }; Ok(ConfigLayerEntry::new( @@ -385,6 +400,13 @@ async fn create_empty_user_layer( )) } +async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigServiceError> { + task::spawn_blocking(move || write_atomically(&write_path, "")) + .await + .map_err(|err| ConfigServiceError::anyhow("config persistence task panicked", err.into()))? + .map_err(|err| ConfigServiceError::io("failed to create empty user config.toml", err)) +} + fn parse_value(value: JsonValue) -> Result, String> { if value.is_null() { return Ok(None); diff --git a/codex-rs/core/src/path_utils.rs b/codex-rs/core/src/path_utils.rs index 65b3db0f6d..4885679e52 100644 --- a/codex-rs/core/src/path_utils.rs +++ b/codex-rs/core/src/path_utils.rs @@ -1,5 +1,9 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashSet; +use std::io; use std::path::Path; use std::path::PathBuf; +use tempfile::NamedTempFile; use crate::env; @@ -8,6 +12,106 @@ pub fn normalize_for_path_comparison(path: impl AsRef) -> std::io::Result< Ok(normalize_for_wsl(canonical)) } +pub struct SymlinkWritePaths { + pub read_path: Option, + pub write_path: PathBuf, +} + +/// Resolve the final filesystem target for `path` while retaining a safe write path. +/// +/// This follows symlink chains (including relative symlink targets) until it reaches a +/// non-symlink path. If the chain cycles or any metadata/link resolution fails, it +/// returns `read_path: None` and uses the original absolute path as `write_path`. +/// There is no fixed max-resolution count; cycles are detected via a visited set. +pub fn resolve_symlink_write_paths(path: &Path) -> io::Result { + let root = AbsolutePathBuf::from_absolute_path(path) + .map(AbsolutePathBuf::into_path_buf) + .unwrap_or_else(|_| path.to_path_buf()); + let mut current = root.clone(); + let mut visited = HashSet::new(); + + // Follow symlink chains while guarding against cycles. + loop { + let meta = match std::fs::symlink_metadata(¤t) { + Ok(meta) => meta, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Ok(SymlinkWritePaths { + read_path: Some(current.clone()), + write_path: current, + }); + } + Err(_) => { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + } + }; + + if !meta.file_type().is_symlink() { + return Ok(SymlinkWritePaths { + read_path: Some(current.clone()), + write_path: current, + }); + } + + // If we've already seen this path, the chain cycles. + if !visited.insert(current.clone()) { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + } + + let target = match std::fs::read_link(¤t) { + Ok(target) => target, + Err(_) => { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + } + }; + + let next = if target.is_absolute() { + AbsolutePathBuf::from_absolute_path(&target) + } else if let Some(parent) = current.parent() { + AbsolutePathBuf::resolve_path_against_base(&target, parent) + } else { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + }; + + let next = match next { + Ok(path) => path.into_path_buf(), + Err(_) => { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + } + }; + + current = next; + } +} + +pub fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> { + let parent = write_path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path {} has no parent directory", write_path.display()), + ) + })?; + std::fs::create_dir_all(parent)?; + let tmp = NamedTempFile::new_in(parent)?; + std::fs::write(tmp.path(), contents)?; + tmp.persist(write_path)?; + Ok(()) +} + fn normalize_for_wsl(path: PathBuf) -> PathBuf { normalize_for_wsl_with_flag(path, env::is_wsl()) } @@ -84,6 +188,29 @@ fn lower_ascii_path(path: PathBuf) -> PathBuf { #[cfg(test)] mod tests { + #[cfg(unix)] + mod symlinks { + use super::super::resolve_symlink_write_paths; + use pretty_assertions::assert_eq; + use std::os::unix::fs::symlink; + + #[test] + fn symlink_cycles_fall_back_to_root_write_path() -> std::io::Result<()> { + let dir = tempfile::tempdir()?; + let a = dir.path().join("a"); + let b = dir.path().join("b"); + + symlink(&b, &a)?; + symlink(&a, &b)?; + + let resolved = resolve_symlink_write_paths(&a)?; + + assert_eq!(resolved.read_path, None); + assert_eq!(resolved.write_path, a); + Ok(()) + } + } + #[cfg(target_os = "linux")] mod wsl { use super::super::normalize_for_wsl_with_flag; From efa9326f0828e230446ff69f976d126ded844806 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:24:34 -0800 Subject: [PATCH 34/78] chore(deps): bump log from 0.4.28 to 0.4.29 in /codex-rs (#9466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [log](https://github.com/rust-lang/log) from 0.4.28 to 0.4.29.
Changelog

Sourced from log's changelog.

[0.4.29] - 2025-12-02

What's Changed

New Contributors

Full Changelog: https://github.com/rust-lang/log/compare/0.4.28...0.4.29

Commits
  • b1e2df7 Merge pull request #719 from rust-lang/cargo/0.4.29
  • 3fe1a54 prepare for 0.4.29 release
  • 7a432d9 Merge pull request #718 from rust-lang/ci/msrv
  • 0689d56 rebump msrv to 1.68.0
  • 46b448e try drop msrv back to 1.61.0
  • 929ab38 fix up doc test feature gate
  • 957cece bump serde-dependent crates
  • bea40c8 bump msrv to 1.68.0
  • c540184 Merge pull request #716 from rust-lang/ci-smaller-matrix2
  • c971e63 Merge branch 'master' into ci-smaller-matrix2
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=log&package-manager=cargo&previous-version=0.4.28&new-version=0.4.29)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b87f76384d..8b3e203517 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4160,9 +4160,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "logos" From 274af305255943c34b1dbc823c8f62e15af60a93 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:24:55 -0800 Subject: [PATCH 35/78] chore(deps): bump tokio from 1.48.0 to 1.49.0 in /codex-rs (#9467) Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.48.0 to 1.49.0.
Release notes

Sourced from tokio's releases.

Tokio v1.49.0

1.49.0 (January 3rd, 2026)

Added

  • net: add support for TCLASS option on IPv6 (#7781)
  • runtime: stabilize runtime::id::Id (#7125)
  • task: implement Extend for JoinSet (#7195)
  • task: stabilize the LocalSet::id() (#7776)

Changed

  • net: deprecate {TcpStream,TcpSocket}::set_linger (#7752)

Fixed

  • macros: fix the hygiene issue of join! and try_join! (#7766)
  • runtime: revert "replace manual vtable definitions with Wake" (#7699)
  • sync: return TryRecvError::Disconnected from Receiver::try_recv after Receiver::close (#7686)
  • task: remove unnecessary trait bounds on the Debug implementation (#7720)

Unstable

  • fs: handle EINTR in fs::write for io-uring (#7786)
  • fs: support io-uring with tokio::fs::read (#7696)
  • runtime: disable io-uring on EPERM (#7724)
  • time: add alternative timer for better multicore scalability (#7467)

Documented

  • docs: fix a typos in bounded.rs and park.rs (#7817)
  • io: add SyncIoBridge cross-references to copy and copy_buf (#7798)
  • io: doc that AsyncWrite does not inherit from std::io::Write (#7705)
  • metrics: clarify that num_alive_tasks is not strongly consistent (#7614)
  • net: clarify the cancellation safety of the TcpStream::peek (#7305)
  • net: clarify the drop behavior of unix::OwnedWriteHalf (#7742)
  • net: clarify the platform-dependent backlog in TcpSocket docs (#7738)
  • runtime: mention LocalRuntime in new_current_thread docs (#7820)
  • sync: add missing period to mpsc::Sender::try_send docs (#7721)
  • sync: clarify the cancellation safety of oneshot::Receiver (#7780)
  • sync: improve the docs for the errors of mpsc (#7722)
  • task: add example for spawn_local usage on local runtime (#7689)

#7125: tokio-rs/tokio#7125 #7195: tokio-rs/tokio#7195 #7305: tokio-rs/tokio#7305 #7467: tokio-rs/tokio#7467 #7614: tokio-rs/tokio#7614 #7686: tokio-rs/tokio#7686 #7689: tokio-rs/tokio#7689

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tokio&package-manager=cargo&previous-version=1.48.0&new-version=1.49.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8b3e203517..9a6219ad2a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -360,7 +360,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -2829,7 +2829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2926,7 +2926,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.0.8", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3867,7 +3867,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5378,7 +5378,7 @@ dependencies = [ "once_cell", "socket2 0.6.1", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5757,7 +5757,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5770,7 +5770,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7071,9 +7071,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -8088,7 +8088,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] From 78a359f7fa77b9807f74b8bf951c01227461db06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:25:23 -0800 Subject: [PATCH 36/78] chore(deps): bump arc-swap from 1.7.1 to 1.8.0 in /codex-rs (#9468) Bumps [arc-swap](https://github.com/vorner/arc-swap) from 1.7.1 to 1.8.0.
Changelog

Sourced from arc-swap's changelog.

1.8.0

  • Support for Pin (#185, #183).
  • Fix (hopefully) crash on ARM (#164).
  • Fix Miri check (#186, #156).
  • Fix support for Rust 1.31.0.
  • Some minor clippy lints.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=arc-swap&package-manager=cargo&previous-version=1.7.1&new-version=1.8.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 7 +++++-- codex-rs/core/Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9a6219ad2a..9833c7dac2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -367,9 +367,12 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] [[package]] name = "arrayvec" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 10b635b747..8ee2848a98 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -18,7 +18,7 @@ workspace = true [dependencies] anyhow = { workspace = true } -arc-swap = "1.7.1" +arc-swap = "1.8.0" async-channel = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } From 66b74efbc6148f71d365972d6590955f309953bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:37:11 -0800 Subject: [PATCH 37/78] chore(deps): bump ctor from 0.5.0 to 0.6.3 in /codex-rs (#9469) Bumps [ctor](https://github.com/mmastrac/rust-ctor) from 0.5.0 to 0.6.3.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ctor&package-manager=cargo&previous-version=0.5.0&new-version=0.6.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 12 ++++++------ codex-rs/Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9833c7dac2..53a3ab37c9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1301,7 +1301,7 @@ dependencies = [ "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", - "ctor 0.5.0", + "ctor 0.6.3", "dirs", "dunce", "encoding_rs", @@ -1684,7 +1684,7 @@ dependencies = [ "anyhow", "clap", "codex-process-hardening", - "ctor 0.5.0", + "ctor 0.6.3", "libc", "reqwest", "serde", @@ -2265,9 +2265,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.5.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" dependencies = [ "ctor-proc-macro", "dtor", @@ -2275,9 +2275,9 @@ dependencies = [ [[package]] name = "ctor-proc-macro" -version = "0.0.6" +version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "darling" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 33a664e06d..cc6d4d53ea 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -127,7 +127,7 @@ clap = "4" clap_complete = "4" color-eyre = "0.6.3" crossterm = "0.28.1" -ctor = "0.5.0" +ctor = "0.6.3" derive_more = "2" diffy = "0.4.2" dirs = "6" From a5e5d7a384b8f13148f0062636770e372d898a3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 06:43:34 +0000 Subject: [PATCH 38/78] chore(deps): bump chrono from 0.4.42 to 0.4.43 in /codex-rs (#9465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [chrono](https://github.com/chronotope/chrono) from 0.4.42 to 0.4.43.
Release notes

Sourced from chrono's releases.

0.4.43

What's Changed

Commits
  • 45caaa9 Update copyright year to 2026 in LICENSE.txt
  • 1c0b8f0 Bump version to 0.4.43
  • a03e43b Upgrade windows-bindgen to 0.66
  • 4fedaba Ignore bincode advisory
  • f4b7bbd Bump actions/checkout from 5 to 6
  • db12973 Added doctest for the NaiveDate years_since function (#1755)
  • 34b5f49 chore: minor improvement for docs
  • 8c82711 Bump actions/setup-node from 5 to 6
  • ea1f11b Drop deny lints, eager Debug impls are a mixed blessing
  • 35f9f2d Add feature gated defmt support.
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=chrono&package-manager=cargo&previous-version=0.4.42&new-version=0.4.43)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- codex-rs/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 53a3ab37c9..7c5b5a8634 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -864,9 +864,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index cc6d4d53ea..a74fb1e828 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -122,7 +122,7 @@ axum = { version = "0.8", default-features = false } base64 = "0.22.1" bytes = "1.10.1" chardetng = "0.1.17" -chrono = "0.4.42" +chrono = "0.4.43" clap = "4" clap_complete = "4" color-eyre = "0.6.3" From a803467f526c1a5a4c92d13d47da78512f4c9efe Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 18 Jan 2026 23:02:02 -0800 Subject: [PATCH 39/78] Fixed TUI regression related to image paste in WSL (#9473) This affects quoted paths in WSL. The fix is to normalize quoted Windows paths before WSL conversion. This addresses #9456 --- codex-rs/tui/src/clipboard_paste.rs | 103 +++++++++++++++++++-------- codex-rs/tui2/src/clipboard_paste.rs | 103 +++++++++++++++++++-------- 2 files changed, 148 insertions(+), 58 deletions(-) diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 5863c728b0..4d28b365fe 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -244,9 +244,14 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag /// - shell-escaped single paths (via `shlex`) pub fn normalize_pasted_path(pasted: &str) -> Option { let pasted = pasted.trim(); + let unquoted = pasted + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| pasted.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(pasted); // file:// URL → filesystem path - if let Ok(url) = url::Url::parse(pasted) + if let Ok(url) = url::Url::parse(unquoted) && url.scheme() == "file" { return url.to_file_path().ok(); @@ -258,38 +263,18 @@ pub fn normalize_pasted_path(pasted: &str) -> Option { // Detect unquoted Windows paths and bypass POSIX shlex which // treats backslashes as escapes (e.g., C:\Users\Alice\file.png). // Also handles UNC paths (\\server\share\path). - let looks_like_windows_path = { - // Drive letter path: C:\ or C:/ - let drive = pasted - .chars() - .next() - .map(|c| c.is_ascii_alphabetic()) - .unwrap_or(false) - && pasted.get(1..2) == Some(":") - && pasted - .get(2..3) - .map(|s| s == "\\" || s == "/") - .unwrap_or(false); - // UNC path: \\server\share - let unc = pasted.starts_with("\\\\"); - drive || unc - }; - if looks_like_windows_path { - #[cfg(target_os = "linux")] - { - if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(pasted) - { - return Some(converted); - } - } - return Some(PathBuf::from(pasted)); + if let Some(path) = normalize_windows_path(unquoted) { + return Some(path); } // shell-escaped single path → unescaped let parts: Vec = shlex::Shlex::new(pasted).collect(); if parts.len() == 1 { - return parts.into_iter().next().map(PathBuf::from); + let part = parts.into_iter().next()?; + if let Some(path) = normalize_windows_path(&part) { + return Some(path); + } + return Some(PathBuf::from(part)); } None @@ -339,6 +324,36 @@ fn convert_windows_path_to_wsl(input: &str) -> Option { Some(result) } +fn normalize_windows_path(input: &str) -> Option { + // Drive letter path: C:\ or C:/ + let drive = input + .chars() + .next() + .map(|c| c.is_ascii_alphabetic()) + .unwrap_or(false) + && input.get(1..2) == Some(":") + && input + .get(2..3) + .map(|s| s == "\\" || s == "/") + .unwrap_or(false); + // UNC path: \\server\share + let unc = input.starts_with("\\\\"); + if !drive && !unc { + return None; + } + + #[cfg(target_os = "linux")] + { + if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(input) + { + return Some(converted); + } + } + + Some(PathBuf::from(input)) +} + /// Infer an image format for the provided path based on its extension. pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { match path @@ -438,9 +453,39 @@ mod pasted_paths_tests { #[test] fn normalize_single_quoted_windows_path() { let input = r"'C:\\Users\\Alice\\My File.jpeg'"; + let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; let result = normalize_pasted_path(input).expect("should trim single quotes on windows path"); - assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg")); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(unquoted) + { + converted + } else { + PathBuf::from(unquoted) + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(unquoted); + assert_eq!(result, expected); + } + + #[test] + fn normalize_double_quoted_windows_path() { + let input = r#""C:\\Users\\Alice\\My File.jpeg""#; + let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; + let result = + normalize_pasted_path(input).expect("should trim double quotes on windows path"); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(unquoted) + { + converted + } else { + PathBuf::from(unquoted) + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(unquoted); + assert_eq!(result, expected); } #[test] diff --git a/codex-rs/tui2/src/clipboard_paste.rs b/codex-rs/tui2/src/clipboard_paste.rs index 5863c728b0..4d28b365fe 100644 --- a/codex-rs/tui2/src/clipboard_paste.rs +++ b/codex-rs/tui2/src/clipboard_paste.rs @@ -244,9 +244,14 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag /// - shell-escaped single paths (via `shlex`) pub fn normalize_pasted_path(pasted: &str) -> Option { let pasted = pasted.trim(); + let unquoted = pasted + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| pasted.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(pasted); // file:// URL → filesystem path - if let Ok(url) = url::Url::parse(pasted) + if let Ok(url) = url::Url::parse(unquoted) && url.scheme() == "file" { return url.to_file_path().ok(); @@ -258,38 +263,18 @@ pub fn normalize_pasted_path(pasted: &str) -> Option { // Detect unquoted Windows paths and bypass POSIX shlex which // treats backslashes as escapes (e.g., C:\Users\Alice\file.png). // Also handles UNC paths (\\server\share\path). - let looks_like_windows_path = { - // Drive letter path: C:\ or C:/ - let drive = pasted - .chars() - .next() - .map(|c| c.is_ascii_alphabetic()) - .unwrap_or(false) - && pasted.get(1..2) == Some(":") - && pasted - .get(2..3) - .map(|s| s == "\\" || s == "/") - .unwrap_or(false); - // UNC path: \\server\share - let unc = pasted.starts_with("\\\\"); - drive || unc - }; - if looks_like_windows_path { - #[cfg(target_os = "linux")] - { - if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(pasted) - { - return Some(converted); - } - } - return Some(PathBuf::from(pasted)); + if let Some(path) = normalize_windows_path(unquoted) { + return Some(path); } // shell-escaped single path → unescaped let parts: Vec = shlex::Shlex::new(pasted).collect(); if parts.len() == 1 { - return parts.into_iter().next().map(PathBuf::from); + let part = parts.into_iter().next()?; + if let Some(path) = normalize_windows_path(&part) { + return Some(path); + } + return Some(PathBuf::from(part)); } None @@ -339,6 +324,36 @@ fn convert_windows_path_to_wsl(input: &str) -> Option { Some(result) } +fn normalize_windows_path(input: &str) -> Option { + // Drive letter path: C:\ or C:/ + let drive = input + .chars() + .next() + .map(|c| c.is_ascii_alphabetic()) + .unwrap_or(false) + && input.get(1..2) == Some(":") + && input + .get(2..3) + .map(|s| s == "\\" || s == "/") + .unwrap_or(false); + // UNC path: \\server\share + let unc = input.starts_with("\\\\"); + if !drive && !unc { + return None; + } + + #[cfg(target_os = "linux")] + { + if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(input) + { + return Some(converted); + } + } + + Some(PathBuf::from(input)) +} + /// Infer an image format for the provided path based on its extension. pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { match path @@ -438,9 +453,39 @@ mod pasted_paths_tests { #[test] fn normalize_single_quoted_windows_path() { let input = r"'C:\\Users\\Alice\\My File.jpeg'"; + let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; let result = normalize_pasted_path(input).expect("should trim single quotes on windows path"); - assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg")); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(unquoted) + { + converted + } else { + PathBuf::from(unquoted) + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(unquoted); + assert_eq!(result, expected); + } + + #[test] + fn normalize_double_quoted_windows_path() { + let input = r#""C:\\Users\\Alice\\My File.jpeg""#; + let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; + let result = + normalize_pasted_path(input).expect("should trim double quotes on windows path"); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(unquoted) + { + converted + } else { + PathBuf::from(unquoted) + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(unquoted); + assert_eq!(result, expected); } #[test] From 7ebe13f692235f48426aa78e1d515d6731025cc3 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 19 Jan 2026 11:44:31 +0100 Subject: [PATCH 40/78] feat: timer total turn metrics (#9382) --- codex-rs/core/src/codex.rs | 1 - codex-rs/core/src/state/turn.rs | 3 ++- codex-rs/core/src/tasks/mod.rs | 7 +++++++ codex-rs/otel/src/lib.rs | 2 +- codex-rs/otel/src/metrics/timer.rs | 1 + 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c6e11b1376..e677794386 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -525,7 +525,6 @@ impl Session { session_configuration.collaboration_mode.model(), model_info.slug.as_str(), ); - let per_turn_config = Arc::new(per_turn_config); let client = ModelClient::new( per_turn_config.clone(), diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index ddc9efafea..3dfc5132d3 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -37,7 +37,6 @@ pub(crate) enum TaskKind { Compact, } -#[derive(Clone)] pub(crate) struct RunningTask { pub(crate) done: Arc, pub(crate) kind: TaskKind, @@ -45,6 +44,8 @@ pub(crate) struct RunningTask { pub(crate) cancellation_token: CancellationToken, pub(crate) handle: Arc>, pub(crate) turn_context: Arc, + // Timer recorded when the task drops to capture the full turn duration. + pub(crate) _timer: Option, } impl ActiveTurn { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index d6754c23c8..83326b1f05 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -144,6 +144,12 @@ impl Session { }) }; + let timer = turn_context + .client + .get_otel_manager() + .start_timer("codex.turn.e2e_duration_ms", &[]) + .ok(); + let running_task = RunningTask { done, handle: Arc::new(AbortOnDropHandle::new(handle)), @@ -151,6 +157,7 @@ impl Session { task, cancellation_token, turn_context: Arc::clone(&turn_context), + _timer: timer, }; self.register_new_active_task(running_task).await; } diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 868a977735..fa7cc93bb7 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -9,7 +9,7 @@ use crate::metrics::MetricsClient; use crate::metrics::MetricsConfig; use crate::metrics::MetricsError; use crate::metrics::Result as MetricsResult; -use crate::metrics::timer::Timer; +pub use crate::metrics::timer::Timer; use crate::metrics::validation::validate_tag_key; use crate::metrics::validation::validate_tag_value; use crate::otel_provider::OtelProvider; diff --git a/codex-rs/otel/src/metrics/timer.rs b/codex-rs/otel/src/metrics/timer.rs index b1624fda16..6c85f413b3 100644 --- a/codex-rs/otel/src/metrics/timer.rs +++ b/codex-rs/otel/src/metrics/timer.rs @@ -2,6 +2,7 @@ use crate::metrics::MetricsClient; use crate::metrics::error::Result; use std::time::Instant; +#[derive(Debug)] pub struct Timer { name: String, tags: Vec<(String, String)>, From 186794dbb3427750d3dd3758db1c765f286e508c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 19 Jan 2026 12:35:03 +0100 Subject: [PATCH 41/78] feat: close all threads in `/new` (#9478) --- codex-rs/core/src/agent/control.rs | 1 - codex-rs/core/src/thread_manager.rs | 9 +++++++++ codex-rs/tui/src/app.rs | 3 +++ codex-rs/tui2/src/app.rs | 3 +++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 7083081dc6..bc978d3ba7 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -85,7 +85,6 @@ impl AgentControl { result } - #[allow(dead_code)] // Will be used for collab tools. /// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable. pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus { let Ok(state) = self.upgrade() else { diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f533e608f7..01dc99d91a 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -235,6 +235,15 @@ impl ThreadManager { self.state.threads.write().await.remove(thread_id) } + /// Closes all threads open in this ThreadManager + pub async fn remove_and_close_all_threads(&self) -> CodexResult<()> { + for thread in self.state.threads.read().await.values() { + thread.submit(Op::Shutdown).await?; + } + self.state.threads.write().await.clear(); + Ok(()) + } + /// Fork an existing thread by taking messages up to the given position (not including /// the message at the given position) and starting a new thread with identical /// configuration (unless overridden by the caller's `config`). The new thread will have diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b256ccd200..211975c275 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -672,6 +672,9 @@ impl App { let summary = session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); self.shutdown_current_thread().await; + if let Err(err) = self.server.remove_and_close_all_threads().await { + tracing::warn!(error = %err, "failed to close all threads"); + } let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), frame_requester: tui.frame_requester(), diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index e6f7fd5f4e..de2fe20010 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1447,6 +1447,9 @@ impl App { self.chat_widget.conversation_id(), ); self.shutdown_current_conversation().await; + if let Err(err) = self.server.remove_and_close_all_threads().await { + tracing::warn!(error = %err, "failed to close all threads"); + } let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), frame_requester: tui.frame_requester(), From dc1b62acbd890370c431d757ced1b02edf51ab82 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 19 Jan 2026 12:35:34 +0100 Subject: [PATCH 42/78] feat: detach non-tty childs (#9477) Thanks to the investigations made by * @frantic-openai https://github.com/openai/codex/pull/9403 * @kfiramar https://github.com/openai/codex/pull/9388 --- codex-rs/core/src/shell_snapshot.rs | 7 ++++ codex-rs/core/src/spawn.rs | 6 ++-- codex-rs/utils/pty/src/pipe.rs | 2 +- codex-rs/utils/pty/src/process_group.rs | 22 +++++++++++++ codex-rs/utils/pty/src/tests.rs | 43 +++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 9c10eedf42..be316afa5b 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -135,6 +135,13 @@ async fn run_shell_script_with_timeout( // returns a ref of handler. let mut handler = Command::new(&args[0]); handler.args(&args[1..]); + #[cfg(unix)] + unsafe { + handler.pre_exec(|| { + codex_utils_pty::process_group::detach_from_tty()?; + Ok(()) + }); + } handler.kill_on_drop(true); let output = timeout(snapshot_timeout, handler.output()) .await diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index c1a8d4457a..b2a507fda7 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -66,12 +66,12 @@ pub(crate) async fn spawn_child_async( #[cfg(unix)] unsafe { - let set_process_group = matches!(stdio_policy, StdioPolicy::RedirectForShellTool); + let detach_from_tty = matches!(stdio_policy, StdioPolicy::RedirectForShellTool); #[cfg(target_os = "linux")] let parent_pid = libc::getpid(); cmd.pre_exec(move || { - if set_process_group { - codex_utils_pty::process_group::set_process_group()?; + if detach_from_tty { + codex_utils_pty::process_group::detach_from_tty()?; } // This relies on prctl(2), so it only works on Linux. diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index 5d9eb0232d..d77c11383c 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -118,7 +118,7 @@ async fn spawn_process_with_stdin_mode( #[cfg(unix)] unsafe { command.pre_exec(move || { - crate::process_group::set_process_group()?; + crate::process_group::detach_from_tty()?; #[cfg(target_os = "linux")] crate::process_group::set_parent_death_signal(parent_pid)?; Ok(()) diff --git a/codex-rs/utils/pty/src/process_group.rs b/codex-rs/utils/pty/src/process_group.rs index ae77a36be0..dadff29f9e 100644 --- a/codex-rs/utils/pty/src/process_group.rs +++ b/codex-rs/utils/pty/src/process_group.rs @@ -4,6 +4,8 @@ //! command can be cleaned up reliably: //! - `set_process_group` is called in `pre_exec` so the child starts its own //! process group. +//! - `detach_from_tty` starts a new session so non-interactive children do not +//! inherit the controlling TTY. //! - `kill_process_group_by_pid` targets the whole group (children/grandchildren) //! - `kill_process_group` targets a known process group ID directly //! instead of a single PID. @@ -42,6 +44,26 @@ pub fn set_parent_death_signal(_parent_pid: i32) -> io::Result<()> { Ok(()) } +#[cfg(unix)] +/// Detach from the controlling TTY by starting a new session. +pub fn detach_from_tty() -> io::Result<()> { + let result = unsafe { libc::setsid() }; + if result == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EPERM) { + return set_process_group(); + } + return Err(err); + } + Ok(()) +} + +#[cfg(not(unix))] +/// No-op on non-Unix platforms. +pub fn detach_from_tty() -> io::Result<()> { + Ok(()) +} + #[cfg(unix)] /// Put the calling process into its own process group. /// diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index 8c41d1dbde..89e72eeb4b 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -152,6 +152,49 @@ async fn pipe_process_round_trips_stdin() -> anyhow::Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipe_process_detaches_from_parent_session() -> anyhow::Result<()> { + let parent_sid = unsafe { libc::getsid(0) }; + if parent_sid == -1 { + anyhow::bail!("failed to read parent session id"); + } + + let env_map: HashMap = std::env::vars().collect(); + let script = "echo $$; sleep 0.2"; + let (program, args) = shell_command(script); + let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?; + + let mut output_rx = spawned.output_rx; + let pid_bytes = + tokio::time::timeout(tokio::time::Duration::from_millis(500), output_rx.recv()).await??; + let pid_text = String::from_utf8_lossy(&pid_bytes); + let child_pid: i32 = pid_text + .split_whitespace() + .next() + .ok_or_else(|| anyhow::anyhow!("missing child pid output: {pid_text:?}"))? + .parse()?; + + let child_sid = unsafe { libc::getsid(child_pid) }; + if child_sid == -1 { + anyhow::bail!("failed to read child session id"); + } + + assert_eq!(child_sid, child_pid, "expected child to be session leader"); + assert_ne!( + child_sid, parent_sid, + "expected child to be detached from parent session" + ); + + let exit_code = spawned.exit_rx.await.unwrap_or(-1); + assert_eq!( + exit_code, 0, + "expected detached pipe process to exit cleanly" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pipe_and_pty_share_interface() -> anyhow::Result<()> { let env_map: HashMap = std::env::vars().collect(); From 3c28c85063c3cbdf90ab590cddc81c322d709adb Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 19 Jan 2026 12:56:38 +0100 Subject: [PATCH 43/78] prompt 3 (#9479) --- codex-rs/core/templates/agents/orchestrator.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/templates/agents/orchestrator.md b/codex-rs/core/templates/agents/orchestrator.md index 19be2a1afe..09b6ea0b4e 100644 --- a/codex-rs/core/templates/agents/orchestrator.md +++ b/codex-rs/core/templates/agents/orchestrator.md @@ -14,10 +14,12 @@ You are Codex Orchestrator, based on GPT-5. You are running as an orchestration * **Never stop monitoring workers.** * **Do not rush workers. Be patient.** * The orchestrator must not return unless the task is fully accomplished. +* If the user ask you a question/status while you are working, always answer him before continuing your work. ## Worker execution semantics * While a worker is running, you cannot observe intermediate state. +* Workers are able to run commands, update/create/delete files etc. They can be considered as fully autonomous agents * Messages sent with `send_input` are queued and processed only after the worker finishes, unless interrupted. * Therefore: * Do not send messages to “check status” or “ask for progress” unless being asked. @@ -40,7 +42,7 @@ You are Codex Orchestrator, based on GPT-5. You are running as an orchestration * verify correctness, * check integration with other work, * assess whether the global task is closer to completion. -5. If issues remain, assign fixes to the appropriate worker(s) and repeat steps 3–5. +5. If issues remain, assign fixes to the appropriate worker(s) and repeat steps 3–5. Do not fix yourself unless the fixes are very small. 6. Close agents only when no further work is required from them. 7. Return to the user only when the task is fully completed and verified. From 264d40efdcc16d891099f7424100c9872185b7c1 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 19 Jan 2026 08:21:27 -0800 Subject: [PATCH 44/78] Fix invalid input error on Azure endpoint (#9387) Users of Azure endpoints are reporting that when they use `/review`, they sometimes see an error "Invalid 'input[3].id". I suspect this is specific to the Azure implementation of the `responses` API. The Azure team generally copies the OpenAI code for this endpoint, but they do have minor differences and sometimes lag in rolling out bug fixes or updates. The error appears to be triggered because the `/review` implementation is using a user ID with a colon in it. Addresses #9360 --- codex-rs/core/src/tasks/review.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 9157e922ec..5b0d0bebe9 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -190,8 +190,8 @@ pub(crate) async fn exit_review_mode( review_output: Option, ctx: Arc, ) { - const REVIEW_USER_MESSAGE_ID: &str = "review:rollout:user"; - const REVIEW_ASSISTANT_MESSAGE_ID: &str = "review:rollout:assistant"; + const REVIEW_USER_MESSAGE_ID: &str = "review_rollout_user"; + const REVIEW_ASSISTANT_MESSAGE_ID: &str = "review_rollout_assistant"; let (user_message, assistant_message) = if let Some(out) = review_output.clone() { let mut findings_str = String::new(); let text = out.overall_explanation.trim(); From 31415ebfcf21e91b85a06effdc702a0b76ba3e46 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 19 Jan 2026 08:54:19 -0800 Subject: [PATCH 45/78] Remove unused protocol collaboration mode prompts (#9463) Delete duplicate collaboration mode markdown under protocol prompts; core templates remain the single source of truth. --- .../src/prompts/collaboration_mode/execute.md | 45 ------------------- .../collaboration_mode/pair_programming.md | 7 --- .../src/prompts/collaboration_mode/plan.md | 40 ----------------- 3 files changed, 92 deletions(-) delete mode 100644 codex-rs/protocol/src/prompts/collaboration_mode/execute.md delete mode 100644 codex-rs/protocol/src/prompts/collaboration_mode/pair_programming.md delete mode 100644 codex-rs/protocol/src/prompts/collaboration_mode/plan.md diff --git a/codex-rs/protocol/src/prompts/collaboration_mode/execute.md b/codex-rs/protocol/src/prompts/collaboration_mode/execute.md deleted file mode 100644 index c57878242d..0000000000 --- a/codex-rs/protocol/src/prompts/collaboration_mode/execute.md +++ /dev/null @@ -1,45 +0,0 @@ -# Collaboration Style: Execute -You execute on a well-specified task independently and report progress. - -You do not collaborate on decisions in this mode. You execute end-to-end. -You make reasonable assumptions when the user hasn't specified something, and you proceed without asking questions. - -## Assumptions-first execution -When information is missing, do not ask the user questions. -Instead: -- Make a sensible assumption. -- Clearly state the assumption in the final message (briefly). -- Continue executing. - -Group assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. -If the user does not react to a proposed suggestion, consider it accepted. - -## Execution principles -*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. - -*Use reasonable assumptions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. - -Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter." -Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration." - -*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. -Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. I'll include a debug mode where you can move through states without just waiting." - -*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and would normally ask, make a reasonable assumption and continue. -Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. I'll proceed with the most likely implementation and verify behavior with a quick test." - -## Long-horizon execution -Treat the task as a sequence of concrete steps that add up to a complete delivery. -- Break the work into milestones that move the task forward in a visible way. -- Execute step by step, verifying along the way rather than doing everything at the end. -- If the task is large, keep a running checklist of what is done, what is next, and what is blocked. -- Avoid blocking on uncertainty: choose a reasonable default and continue. - -## Reporting progress -In this phase you show progress on your task and appraise the user of your progress using plan tool. -- Provide updates that directly map to the work you are doing (what changed, what you verified, what remains). -- If something fails, report what failed, what you tried, and what you will do next. -- When you finish, summarize what you delivered and how the user can validate it. - -## Executing -Once you start working, you should execute independently. Your job is to deliver the task and report progress. diff --git a/codex-rs/protocol/src/prompts/collaboration_mode/pair_programming.md b/codex-rs/protocol/src/prompts/collaboration_mode/pair_programming.md deleted file mode 100644 index 1297129b1d..0000000000 --- a/codex-rs/protocol/src/prompts/collaboration_mode/pair_programming.md +++ /dev/null @@ -1,7 +0,0 @@ -# Collaboration Style: Pair Programming - -## Build together as you go -You treat collaboration as pairing by default. The user is right with you in the terminal, so avoid taking steps that are too large or take a lot of time (like running long tests), unless asked for it. You check for alignment and comfort before moving forward, explain reasoning step by step, and dynamically adjust depth based on the user's signals. There is no need to ask multiple rounds of questions—build as you go. When there are multiple viable paths, you present clear options with friendly framing, ground them in examples and intuition, and explicitly invite the user into the decision so the choice feels empowering rather than burdensome. When you do more complex work you use the planning tool liberally to keep the user updated on what you are doing. - -## Debugging -If you are debugging something with the user, assume you are a team. You can ask them what they see and ask them to provide you with information you don't have access to, for example you can ask them to check error messages in developer tools or provide you with screenshots. diff --git a/codex-rs/protocol/src/prompts/collaboration_mode/plan.md b/codex-rs/protocol/src/prompts/collaboration_mode/plan.md deleted file mode 100644 index 70b710c420..0000000000 --- a/codex-rs/protocol/src/prompts/collaboration_mode/plan.md +++ /dev/null @@ -1,40 +0,0 @@ -# Collaboration Style: Plan -You work in 2 distinct modes: -1. Brainstorming: You collaboratively align with the user on what to do or build and how to do it or build it. -2. Writing and confirming a plan: After you've gathered all the information you write up a plan and verify it with the user. -You usually start with the planning step. Skip step 1 if the user provides you with a detailed plan or a small, unambiguous task or plan OR if the user asks you to plan by yourself. - -## Brainstorming principles -The point of brainstorming with the user is to align on what to do and how to do it. This phase is iterative and conversational. You can interact with the environment and read files if it is helpful, but be mindful of the time. -You MUST follow the principles below. Think about them carefully as you work with the user. Follow the structure and tone of the examples. - -*State what you think the user cares about.* Actively infer what matters most (robustness, clean abstractions, quick lovable interfaces, scalability) and reflect this back to the user to confirm. -Example: "It seems like you might be prototyping a design for an app, and scalability or performance isn't a concern right now - is that accurate?" - -*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. - -*Use reasonable suggestions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. - -Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter - does that resonate?" -Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration - we can relax that if this is exploratory." - -*Ask fewer, better questions.* Prefer making a concrete proposal with stated assumptions over asking questions. Only ask questions when different reasonable suggestions would materially change the plan, you cannot safely proceed, or if you think the user would really want to give input directly. Never ask a question if you already provided a suggestion. - -*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. -Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. Would you like a debug mode where you can move through states without just waiting?" - -*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and think you need to do longer research, ask the user whether they want you to research, or want to give you a tip. -Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. If it's ok, I'll go and spend a bit more time exploring the code base?" - -## Iterating on the plan -Only AFTER you have all the information, write up the full plan. -A well written and informative plan should be as detailed as a design doc or PRD and reflect your discussion with the user, at minimum that's one full page! If handed to a different agent, the agent would know exactly what to build without asking questions and arrive at a similar implementation to yours. At minimum it should include: -- tools and frameworks you use, any dependencies you need to install -- functions, files, or directories you're likely going to edit -- architecture if the code changes are significant -- if developing features, describe the features you are going to build in detail like a PM in a PRD -- if you are developing a frontend, describe the design in detail - -`plan.md`: For long, detailed plans, it makes sense to write them in a separate file. If the changes are substantial and the plan is longer than a full page, ask the user if it's ok to write the plan in `plan.md`. If plan.md is used, ALWAYS update the file rather than outputting the plan in your final answer. - -ALWAYS confirm the plan with the user before ending. If the user requests changes or additions to the plan update the plan. Iterate until the user confirms the plan. From 92cf2a1c3af7f1f98d97d618b6a5081e86a2598a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 19 Jan 2026 18:07:04 +0100 Subject: [PATCH 46/78] chore: warning metric (#9487) --- codex-rs/core/src/codex.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e677794386..b6876c83c0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1390,6 +1390,9 @@ impl Session { } pub(crate) async fn record_model_warning(&self, message: impl Into, ctx: &TurnContext) { + self.services + .otel_manager + .counter("codex.model_warning", 1, &[]); let item = ResponseItem::Message { id: None, role: "user".to_string(), From 3788e2cc0f787bcda90a7cd6ad5aba9acf89c05d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 19 Jan 2026 09:10:54 -0800 Subject: [PATCH 47/78] Fixed stale link to MCP documentation (#9490) This was noted in #9482 --- codex-rs/tui/src/history_cell.rs | 3 ++- codex-rs/tui2/src/history_cell.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c060d20cd9..b0bdbdf147 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1331,7 +1331,8 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - "\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), + "\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}" + .underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index e3334a0681..5cdb3e7f2d 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -1395,7 +1395,8 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - "\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), + "\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}" + .underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), From bf430ad9fee5583afab2bd6641407d67383be297 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 19 Jan 2026 09:32:04 -0800 Subject: [PATCH 48/78] TUI: collaboration mode UX + always submit `UserTurn` when enabled (#9461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds experimental collaboration modes UX in TUI: Plan / Pair Programming / Execute. - Gated behind `Feature::CollaborationModes`; existing behavior remains unchanged when disabled. - Selection UX: - `Shift+Tab` cycles modes while idle (no task running, no modal/popup). - `/collab` cycles; `/collab ` sets explicitly. - Footer flash after changes + shortcut overlay shows `Shift+Tab` “to change mode”. - `/status` shows “Collaboration mode”. - Submission semantics: - When enabled: every submit uses `Op::UserTurn` and always includes `collaboration_mode: Some(...)` (default Pair Programming). - Removes the one-shot “pending collaboration mode” behavior. - Implementation: - New `tui/src/collaboration_modes.rs` (selection enum/cycle, `/collab` parsing, resolve to `CollaborationMode`, footer flash line). - Fallback: `resolve_mode_or_fallback` synthesizes a `CollaborationMode` when presets are missing (uses current model + reasoning effort; no `developer_instructions`) to avoid core falling back to `Custom`. - TODO: migrate TUI to use `Op::UserTurn`. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 188 ++++++++++++++---- codex-rs/tui/src/bottom_pane/command_popup.rs | 65 +++++- codex-rs/tui/src/bottom_pane/footer.rs | 76 ++++++- codex-rs/tui/src/bottom_pane/mod.rs | 23 +++ ...shortcuts_collaboration_modes_enabled.snap | 11 + codex-rs/tui/src/chatwidget.rs | 117 ++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 114 +++++++++++ codex-rs/tui/src/collaboration_modes.rs | 135 +++++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 3 + codex-rs/tui/src/status/card.rs | 12 ++ codex-rs/tui/src/status/tests.rs | 15 ++ .../tui2/src/bottom_pane/chat_composer.rs | 188 ++++++++++++++---- .../tui2/src/bottom_pane/command_popup.rs | 65 +++++- codex-rs/tui2/src/bottom_pane/footer.rs | 83 +++++++- codex-rs/tui2/src/bottom_pane/mod.rs | 23 +++ ...shortcuts_collaboration_modes_enabled.snap | 10 + codex-rs/tui2/src/chatwidget.rs | 119 ++++++++++- codex-rs/tui2/src/chatwidget/tests.rs | 116 ++++++++++- codex-rs/tui2/src/collaboration_modes.rs | 135 +++++++++++++ codex-rs/tui2/src/lib.rs | 1 + codex-rs/tui2/src/slash_command.rs | 3 + codex-rs/tui2/src/status/card.rs | 12 ++ codex-rs/tui2/src/status/tests.rs | 15 ++ 24 files changed, 1407 insertions(+), 123 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap create mode 100644 codex-rs/tui/src/collaboration_modes.rs create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap create mode 100644 codex-rs/tui2/src/collaboration_modes.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 343403704e..3ab9071347 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -69,7 +69,6 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -80,12 +79,15 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; +use super::command_popup::CommandPopupFlags; use super::file_search_popup::FileSearchPopup; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::esc_hint_mode; use super::footer::footer_height; +use super::footer::inset_footer_hint_area; use super::footer::render_footer; +use super::footer::render_footer_hint_items; use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; @@ -191,12 +193,20 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, + footer_flash: Option, context_window_percent: Option, context_window_used_tokens: Option, skills: Option>, dismissed_skill_popup_token: Option, /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, + collaboration_modes_enabled: bool, +} + +#[derive(Clone, Debug)] +struct FooterFlash { + line: Line<'static>, + expires_at: Instant, } /// Popup state – at most one can be visible at any time. @@ -244,11 +254,13 @@ impl ChatComposer { custom_prompts: Vec::new(), footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, + footer_flash: None, context_window_percent: None, context_window_used_tokens: None, skills: None, dismissed_skill_popup_token: None, steer_enabled: false, + collaboration_modes_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -269,6 +281,10 @@ impl ChatComposer { self.steer_enabled = enabled; } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.collaboration_modes_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -499,6 +515,19 @@ impl ChatComposer { self.footer_hint_override = items; } + pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { + let expires_at = Instant::now() + .checked_add(duration) + .unwrap_or_else(Instant::now); + self.footer_flash = Some(FooterFlash { line, expires_at }); + } + + pub(crate) fn footer_flash_visible(&self) -> bool { + self.footer_flash + .as_ref() + .is_some_and(|flash| Instant::now() < flash.expires_at) + } + /// Replace the entire composer content with `text` and reset cursor. pub(crate) fn set_text_content(&mut self, text: String) { // Clear any existing content, placeholders, and attachments first. @@ -1347,12 +1376,9 @@ impl ChatComposer { if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(command_name, _)| command_name == name); + let is_builtin = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1469,12 +1495,9 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest)) = parse_slash_name(first_line) && rest.is_empty() - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .find(|(n, _)| *n == name) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(n, _)| *n == name) { self.textarea.set_text(""); Some(InputResult::Command(cmd)) @@ -1494,9 +1517,9 @@ impl ChatComposer { if let Some((name, rest)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { self.textarea.set_text(""); @@ -1843,6 +1866,7 @@ impl ChatComposer { is_task_running: self.is_task_running, quit_shortcut_key: self.quit_shortcut_key, steer_enabled: self.steer_enabled, + collaboration_modes_enabled: self.collaboration_modes_enabled, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, } @@ -1865,6 +1889,9 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { + if self.footer_flash_visible() { + return Some(1); + } self.footer_hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) @@ -1948,12 +1975,9 @@ impl ChatComposer { return rest_after_name.is_empty(); } - let builtin_match = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); + let builtin_match = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { return true; @@ -2006,8 +2030,14 @@ impl ChatComposer { _ => { if is_editing_slash_command_name { let skills_enabled = self.skills_enabled(); - let mut command_popup = - CommandPopup::new(self.custom_prompts.clone(), skills_enabled); + let collaboration_modes_enabled = self.collaboration_modes_enabled; + let mut command_popup = CommandPopup::new( + self.custom_prompts.clone(), + CommandPopupFlags { + skills_enabled, + collaboration_modes_enabled, + }, + ); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -2015,6 +2045,16 @@ impl ChatComposer { } } + fn built_in_slash_commands_for_input( + collaboration_modes_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) + } + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { @@ -2180,24 +2220,12 @@ impl Renderable for ChatComposer { } else { popup_rect }; - if let Some(items) = self.footer_hint_override.as_ref() { - if !items.is_empty() { - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(Span::styled(key.clone(), Style::default().bold())); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - let mut custom_rect = hint_rect; - if custom_rect.width > 2 { - custom_rect.x += 2; - custom_rect.width = custom_rect.width.saturating_sub(2); - } - Line::from(spans).render_ref(custom_rect, buf); + if self.footer_flash_visible() { + if let Some(flash) = self.footer_flash.as_ref() { + flash.line.render(inset_footer_hint_area(hint_rect), buf); } + } else if let Some(items) = self.footer_hint_override.as_ref() { + render_footer_hint_items(hint_rect, buf, items); } else { render_footer(hint_rect, buf, footer_props); } @@ -2357,6 +2385,84 @@ mod tests { ); } + #[test] + fn footer_flash_overrides_footer_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("FLASH"), + "expected flash content to render in footer row, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("K label"), + "expected flash to override hint override, saw: {bottom_row:?}", + ); + } + + #[test] + fn footer_flash_expires_and_falls_back_to_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + composer.footer_flash.as_mut().unwrap().expires_at = + Instant::now() - Duration::from_secs(1); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("K label"), + "expected hint override to render after flash expired, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("FLASH"), + "expected expired flash to be hidden, saw: {bottom_row:?}", + ); + } + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) where F: FnOnce(&mut ChatComposer), diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 10a469c683..83afdca30c 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -37,13 +37,20 @@ pub(crate) struct CommandPopup { state: ScrollState, } +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CommandPopupFlags { + pub(crate) skills_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, +} + impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + 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)| skills_enabled || *cmd != SlashCommand::Skills) + .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); @@ -231,7 +238,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -251,7 +258,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -266,7 +273,7 @@ mod tests { #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { @@ -280,7 +287,7 @@ mod tests { #[test] fn filtered_commands_keep_presentation_order() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/m".to_string()); let cmds: Vec<&str> = popup @@ -322,7 +329,7 @@ mod tests { argument_hint: None, }, ]; - let popup = CommandPopup::new(prompts, false); + let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -346,7 +353,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { @@ -369,7 +376,7 @@ mod tests { description: Some("Create feature branch, commit and open draft PR.".to_string()), argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -389,7 +396,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -398,7 +405,7 @@ mod tests { #[test] fn fuzzy_filter_matches_subsequence_for_ac() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); let cmds: Vec<&str> = popup @@ -414,4 +421,40 @@ mod tests { "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" ); } + + #[test] + fn collab_command_hidden_when_collaboration_modes_disabled() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/coll".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert!( + !cmds.contains(&"collab"), + "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" + ); + } + + #[test] + fn collab_command_visible_when_collaboration_modes_enabled() { + let mut popup = CommandPopup::new( + Vec::new(), + CommandPopupFlags { + skills_enabled: false, + collaboration_modes_enabled: true, + }, + ); + popup.on_composer_text_change("/collab".to_string()); + + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), + other => panic!("expected collab to be selected for exact match, got {other:?}"), + } + } } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 42c0392a61..5a54dd11d7 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -37,6 +37,7 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) steer_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, /// Which key the user must press again to quit. /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. @@ -103,6 +104,31 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { .render(area, buf); } +pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { + if area.width > 2 { + area.x += 2; + area.width = area.width.saturating_sub(2); + } + area +} + +pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { + if items.is_empty() { + return; + } + + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(key.clone().bold()); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + Line::from(spans).render(inset_footer_hint_area(area), buf); +} + fn footer_lines(props: FooterProps) -> Vec> { // Show the context indicator on the left, appended after the primary hint // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when @@ -134,6 +160,7 @@ fn footer_lines(props: FooterProps) -> Vec> { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, is_wsl, + collaboration_modes_enabled: props.collaboration_modes_enabled, }; shortcut_overlay_lines(state) } @@ -158,6 +185,7 @@ struct ShortcutsState { use_shift_enter_hint: bool, esc_backtrack_hint: bool, is_wsl: bool, + collaboration_modes_enabled: bool, } fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { @@ -190,6 +218,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); + let mut change_mode = Line::from(""); for descriptor in SHORTCUTS { if let Some(text) = descriptor.overlay_entry(state) { @@ -204,11 +233,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, + ShortcutId::ChangeMode => change_mode = text, } } } - let ordered = vec![ + let mut ordered = vec![ commands, shell_commands, newline, @@ -218,9 +248,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { external_editor, edit_previous, quit, - Line::from(""), - show_transcript, ]; + if change_mode.width() > 0 { + ordered.push(change_mode); + } + ordered.push(Line::from("")); + ordered.push(show_transcript); build_columns(ordered) } @@ -298,6 +331,7 @@ enum ShortcutId { EditPrevious, Quit, ShowTranscript, + ChangeMode, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -318,6 +352,7 @@ enum DisplayCondition { WhenShiftEnterHint, WhenNotShiftEnterHint, WhenUnderWSL, + WhenCollaborationModesEnabled, } impl DisplayCondition { @@ -327,6 +362,7 @@ impl DisplayCondition { DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, DisplayCondition::WhenUnderWSL => state.is_wsl, + DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, } } } @@ -469,6 +505,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to view transcript", }, + ShortcutDescriptor { + id: ShortcutId::ChangeMode, + bindings: &[ShortcutBinding { + key: key_hint::shift(KeyCode::Tab), + condition: DisplayCondition::WhenCollaborationModesEnabled, + }], + prefix: "", + label: " to change mode", + }, ]; #[cfg(test)] @@ -500,6 +545,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -514,6 +560,22 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_collaboration_modes_enabled", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -528,6 +590,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -542,6 +605,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -556,6 +620,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -570,6 +635,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -584,6 +650,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: Some(72), context_window_used_tokens: None, @@ -598,6 +665,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: Some(123_456), @@ -612,6 +680,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -626,6 +695,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: true, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f270145a04..088d8b4f6a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -32,6 +32,7 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::text::Line; use std::time::Duration; mod approval_overlay; @@ -188,6 +189,11 @@ impl BottomPane { self.composer.set_steer_enabled(enabled); } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.composer.set_collaboration_modes_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } @@ -508,6 +514,23 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) { + self.composer.show_footer_flash(line, duration); + let frame_requester = self.frame_requester.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + tokio::time::sleep(duration).await; + frame_requester.schedule_frame(); + }); + } else { + std::thread::spawn(move || { + std::thread::sleep(duration); + frame_requester.schedule_frame(); + }); + } + self.request_redraw(); + } + pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap new file mode 100644 index 0000000000..b6d87789ad --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/footer.rs +assertion_line: 535 +expression: terminal.backend() +--- +" / for commands ! for shell commands " +" ctrl + j for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc esc to edit previous message " +" ctrl + c to exit shift + tab to change mode " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bab27efd0a..11a0ceac82 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -136,6 +136,7 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::collab; +use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; @@ -371,6 +372,8 @@ pub(crate) enum ExternalEditorState { Active, } +type CollaborationModeSelection = collaboration_modes::Selection; + /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -400,6 +403,11 @@ pub(crate) struct ChatWidget { active_cell_revision: u64, config: Config, model: Option, + /// Current UI selection for collaboration modes. + /// + /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the + /// feature is disabled, the value is effectively inert. + collaboration_mode: CollaborationModeSelection, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -1673,6 +1681,7 @@ impl ChatWidget { active_cell_revision: 0, config, model, + collaboration_mode: CollaborationModeSelection::default(), auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1722,6 +1731,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1772,6 +1784,7 @@ impl ChatWidget { active_cell_revision: 0, config, model: Some(header_model.clone()), + collaboration_mode: CollaborationModeSelection::default(), auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -1821,6 +1834,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1885,6 +1901,16 @@ impl ChatWidget { } match key_event { + KeyEvent { + code: KeyCode::BackTab, + kind: KeyEventKind::Press, + .. + } if self.collaboration_modes_enabled() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() => + { + self.cycle_collaboration_mode(); + } KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::ALT, @@ -2022,6 +2048,11 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::Collab => { + if self.collaboration_modes_enabled() { + self.cycle_collaboration_mode(); + } + } SlashCommand::Approvals => { self.open_approvals_popup(); } @@ -2178,6 +2209,16 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { + SlashCommand::Collab if !trimmed.is_empty() => { + if let Some(selection) = collaboration_modes::parse_selection(trimmed) { + self.set_collaboration_mode(selection); + } else { + self.add_error_message(format!( + "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." + )); + self.request_redraw(); + } + } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { review_request: ReviewRequest { @@ -2302,14 +2343,41 @@ impl ChatWidget { } } - self.codex_op_tx - .send(Op::UserInput { + // TODO(aibrahim): migrate the TUI to submit `Op::UserTurn` by default (and rely less on + // `Op::UserInput`) so session-level settings like collaboration mode are consistently + // applied. + let op = if self.collaboration_modes_enabled() { + let model = self + .current_model() + .unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + .to_string(); + let collaboration_mode = collaboration_modes::resolve_mode_or_fallback( + self.models_manager.as_ref(), + self.collaboration_mode, + model.as_str(), + self.config.model_reasoning_effort, + ); + Op::UserTurn { + items, + cwd: self.config.cwd.clone(), + approval_policy: self.config.approval_policy.value(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + model, + effort: self.config.model_reasoning_effort, + summary: self.config.model_reasoning_summary, + final_output_json_schema: None, + collaboration_mode: Some(collaboration_mode), + } + } else { + Op::UserInput { items, final_output_json_schema: None, - }) - .unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); + } + }; + + self.codex_op_tx.send(op).unwrap_or_else(|e| { + tracing::error!("failed to send message: {e}"); + }); // Persist the text to cross-session message history. if !text.is_empty() { @@ -2633,6 +2701,11 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); + let collaboration_mode = if self.collaboration_modes_enabled() { + Some(self.collaboration_mode.label()) + } else { + None + }; self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -2644,6 +2717,7 @@ impl ChatWidget { self.plan_type, Local::now(), self.model_display_name(), + collaboration_mode, )); } @@ -3893,6 +3967,8 @@ impl ChatWidget { } if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); + } else if feature == Feature::CollaborationModes { + self.bottom_pane.set_collaboration_modes_enabled(enabled); } } @@ -3930,10 +4006,39 @@ impl ChatWidget { self.model = Some(model.to_string()); } + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + let next = self.collaboration_mode.next(); + self.set_collaboration_mode(next); + } + + /// Update the selected collaboration mode. + /// + /// When collaboration modes are enabled, the current selection is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { + if !self.collaboration_modes_enabled() { + return; + } + const FLASH_DURATION: Duration = Duration::from_secs(1); + + self.collaboration_mode = selection; + + let flash = collaboration_modes::flash_line(selection); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + self.request_redraw(); + } + fn current_model(&self) -> Option<&str> { self.model.as_deref() } + fn collaboration_modes_enabled(&self) -> bool { + self.config.features.enabled(Feature::CollaborationModes) + } + fn model_display_name(&self) -> &str { self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 19d10ad51d..f9ec776ffb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -59,6 +59,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -404,6 +405,7 @@ async fn make_chatwidget_manual( active_cell_revision: 0, config: cfg, model: Some(resolved_model.clone()), + collaboration_mode: CollaborationModeSelection::default(), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), session_header: SessionHeader::new(resolved_model), @@ -449,6 +451,20 @@ async fn make_chatwidget_manual( (widget, rx, op_rx) } +// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper +// filters until we see a submission op. +fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { + loop { + match op_rx.try_recv() { + Ok(op @ Op::UserTurn { .. }) => return op, + Ok(op @ Op::UserInput { .. }) => return op, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), + } + } +} + fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); @@ -1506,6 +1522,104 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[test] +fn parse_collaboration_mode_selection_accepts_common_aliases() { + assert_eq!( + collaboration_modes::parse_selection("plan"), + Some(CollaborationModeSelection::Plan) + ); + assert_eq!( + collaboration_modes::parse_selection("PAIR"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection("pair_programming"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection("pp"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection(" exec "), + Some(CollaborationModeSelection::Execute) + ); + assert_eq!( + collaboration_modes::parse_selection("execute"), + Some(CollaborationModeSelection::Execute) + ); + assert_eq!(collaboration_modes::parse_selection("unknown"), None); +} + +#[tokio::test] +async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, false); + + let initial = chat.collaboration_mode; + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, initial); + + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + + chat.on_task_started(); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); +} + +#[tokio::test] +async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + + chat.bottom_pane.set_composer_text("hello".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::Plan(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + } + + chat.bottom_pane.set_composer_text("follow up".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::Plan(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + } +} + +#[tokio::test] +async fn collab_mode_defaults_to_pair_programming_when_enabled() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.bottom_pane.set_composer_text("hello".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + } +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs new file mode 100644 index 0000000000..bdd1d8b63c --- /dev/null +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -0,0 +1,135 @@ +//! Collaboration mode selection + rendering helpers for the TUI. +//! +//! This module is intentionally UI-focused: +//! - It owns the user-facing set of selectable collaboration modes and how they cycle. +//! - It parses `/collab ` arguments into a selection. +//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by +//! picking from the `ModelsManager` builtin collaboration presets. +//! - It builds the small footer "flash" line shown after changing modes. +//! +//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed +//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. + +use crate::key_hint; +use codex_core::models_manager::manager::ModelsManager; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::openai_models::ReasoningEffort; +use crossterm::event::KeyCode; +use ratatui::style::Stylize; +use ratatui::text::Line; + +/// The user-facing collaboration mode choices supported by the TUI. +/// +/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling +/// order, while `CollaborationMode` can carry nested settings/prompt configuration. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Selection { + Plan, + #[default] + PairProgramming, + Execute, +} + +impl Selection { + /// Cycle to the next selection. + /// + /// The TUI cycles through a small, fixed set of presets. + pub(crate) fn next(self) -> Self { + match self { + Self::Plan => Self::PairProgramming, + Self::PairProgramming => Self::Execute, + Self::Execute => Self::Plan, + } + } + + /// User-facing label used in UI surfaces like `/status` and the footer flash. + pub(crate) fn label(self) -> &'static str { + match self { + Self::Plan => "Plan", + Self::PairProgramming => "Pair Programming", + Self::Execute => "Execute", + } + } +} + +/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. +/// +/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. +pub(crate) fn parse_selection(input: &str) -> Option { + let normalized: String = input + .chars() + .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') + .flat_map(char::to_lowercase) + .collect(); + + match normalized.as_str() { + "plan" => Some(Selection::Plan), + "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), + "execute" | "exec" => Some(Selection::Execute), + _ => None, + } +} + +/// Resolve a selection to a concrete collaboration mode preset. +/// +/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this +/// function selects the first preset of the desired variant. +pub(crate) fn resolve_mode( + models_manager: &ModelsManager, + selection: Selection, +) -> Option { + match selection { + Selection::Plan => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::Plan(_))), + Selection::PairProgramming => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), + Selection::Execute => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + } +} + +/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode +/// when the desired preset is unavailable. +/// +/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when +/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries +/// an explicit collaboration mode so core doesn't fall back to `Custom`. +pub(crate) fn resolve_mode_or_fallback( + models_manager: &ModelsManager, + selection: Selection, + fallback_model: &str, + fallback_effort: Option, +) -> CollaborationMode { + resolve_mode(models_manager, selection).unwrap_or_else(|| { + let settings = Settings { + model: fallback_model.to_string(), + reasoning_effort: fallback_effort, + developer_instructions: None, + }; + + match selection { + Selection::Plan => CollaborationMode::Plan(settings), + Selection::PairProgramming => CollaborationMode::PairProgramming(settings), + Selection::Execute => CollaborationMode::Execute(settings), + } + }) +} + +/// Build a 1-line footer "flash" that is shown after switching modes. +/// +/// The `ChatWidget` controls when to show this and how long it should remain visible. +pub(crate) fn flash_line(selection: Selection) -> Line<'static> { + Line::from(vec![ + selection.label().bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 880ff19322..e57497505a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -47,6 +47,7 @@ mod chatwidget; mod cli; mod clipboard_paste; mod collab; +mod collaboration_modes; mod color; pub mod custom_terminal; mod diff_render; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d7cbd7fc1d..ab63d6c7c8 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -24,6 +24,7 @@ pub enum SlashCommand { Fork, Init, Compact, + Collab, // Undo, Diff, Mention, @@ -57,6 +58,7 @@ impl SlashCommand { SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Experimental => "toggle beta features", @@ -99,6 +101,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Collab => true, } } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index b860b03941..1f903c26b7 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -63,6 +63,7 @@ struct StatusHistoryCell { approval: String, sandbox: String, agents_summary: String, + collaboration_mode: Option, model_provider: Option, account: Option, session_id: Option, @@ -83,6 +84,7 @@ pub(crate) fn new_status_output( plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -96,6 +98,7 @@ pub(crate) fn new_status_output( plan_type, now, model_name, + collaboration_mode, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -114,6 +117,7 @@ impl StatusHistoryCell { plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, ) -> Self { let config_entries = create_config_summary_entries(config, model_name); let (model_name, model_details) = compose_model_display(model_name, &config_entries); @@ -165,6 +169,7 @@ impl StatusHistoryCell { approval, sandbox, agents_summary, + collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, account, session_id, @@ -360,6 +365,9 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() && self.forked_from.is_some() { push_label(&mut labels, &mut seen, "Forked from"); } + if self.collaboration_mode.is_some() { + push_label(&mut labels, &mut seen, "Collaboration mode"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -409,6 +417,10 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(collab_mode) = self.collaboration_mode.as_ref() { + lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())])); + } + if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b01a139522..bc421da424 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -152,6 +152,7 @@ async fn status_snapshot_includes_reasoning_details() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -203,6 +204,7 @@ async fn status_snapshot_includes_forked_from() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -260,6 +262,7 @@ async fn status_snapshot_includes_monthly_limit() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -305,6 +308,7 @@ async fn status_snapshot_shows_unlimited_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -349,6 +353,7 @@ async fn status_snapshot_shows_positive_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -393,6 +398,7 @@ async fn status_snapshot_hides_zero_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -435,6 +441,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -477,6 +484,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { None, now, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -534,6 +542,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -580,6 +589,7 @@ async fn status_snapshot_shows_missing_limits_message() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -644,6 +654,7 @@ async fn status_snapshot_includes_credits_and_limits() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -696,6 +707,7 @@ async fn status_snapshot_shows_empty_limits_message() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -757,6 +769,7 @@ async fn status_snapshot_shows_stale_limits_message() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -822,6 +835,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -877,6 +891,7 @@ async fn status_context_window_uses_last_usage() { None, now, &model_slug, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index c9058e0153..541806ec47 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -70,7 +70,6 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -81,12 +80,15 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; +use super::command_popup::CommandPopupFlags; use super::file_search_popup::FileSearchPopup; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::esc_hint_mode; use super::footer::footer_height; +use super::footer::inset_footer_hint_area; use super::footer::render_footer; +use super::footer::render_footer_hint_items; use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; @@ -192,6 +194,7 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, + footer_flash: Option, context_window_percent: Option, context_window_used_tokens: Option, transcript_scrolled: bool, @@ -203,6 +206,13 @@ pub(crate) struct ChatComposer { dismissed_skill_popup_token: Option, /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, + collaboration_modes_enabled: bool, +} + +#[derive(Clone, Debug)] +struct FooterFlash { + line: Line<'static>, + expires_at: Instant, } /// Popup state – at most one can be visible at any time. @@ -250,6 +260,7 @@ impl ChatComposer { custom_prompts: Vec::new(), footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, + footer_flash: None, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, @@ -260,6 +271,7 @@ impl ChatComposer { skills: None, dismissed_skill_popup_token: None, steer_enabled: false, + collaboration_modes_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -280,6 +292,10 @@ impl ChatComposer { self.steer_enabled = enabled; } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.collaboration_modes_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -431,6 +447,19 @@ impl ChatComposer { self.footer_hint_override = items; } + pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { + let expires_at = Instant::now() + .checked_add(duration) + .unwrap_or_else(Instant::now); + self.footer_flash = Some(FooterFlash { line, expires_at }); + } + + pub(crate) fn footer_flash_visible(&self) -> bool { + self.footer_flash + .as_ref() + .is_some_and(|flash| Instant::now() < flash.expires_at) + } + /// Replace the entire composer content with `text` and reset cursor. pub(crate) fn set_text_content(&mut self, text: String) { // Clear any existing content, placeholders, and attachments first. @@ -1280,12 +1309,9 @@ impl ChatComposer { if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(command_name, _)| command_name == name); + let is_builtin = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1402,12 +1428,9 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest)) = parse_slash_name(first_line) && rest.is_empty() - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .find(|(n, _)| *n == name) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(n, _)| *n == name) { self.textarea.set_text(""); Some(InputResult::Command(cmd)) @@ -1427,9 +1450,9 @@ impl ChatComposer { if let Some((name, rest)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { self.textarea.set_text(""); @@ -1776,6 +1799,7 @@ impl ChatComposer { is_task_running: self.is_task_running, quit_shortcut_key: self.quit_shortcut_key, steer_enabled: self.steer_enabled, + collaboration_modes_enabled: self.collaboration_modes_enabled, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, transcript_scrolled: self.transcript_scrolled, @@ -1803,6 +1827,9 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { + if self.footer_flash_visible() { + return Some(1); + } self.footer_hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) @@ -1917,12 +1944,9 @@ impl ChatComposer { return rest_after_name.is_empty(); } - let builtin_match = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); + let builtin_match = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { return true; @@ -1975,8 +1999,14 @@ impl ChatComposer { _ => { if is_editing_slash_command_name { let skills_enabled = self.skills_enabled(); - let mut command_popup = - CommandPopup::new(self.custom_prompts.clone(), skills_enabled); + let collaboration_modes_enabled = self.collaboration_modes_enabled; + let mut command_popup = CommandPopup::new( + self.custom_prompts.clone(), + CommandPopupFlags { + skills_enabled, + collaboration_modes_enabled, + }, + ); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -1984,6 +2014,16 @@ impl ChatComposer { } } + fn built_in_slash_commands_for_input( + collaboration_modes_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) + } + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { @@ -2149,24 +2189,12 @@ impl Renderable for ChatComposer { } else { popup_rect }; - if let Some(items) = self.footer_hint_override.as_ref() { - if !items.is_empty() { - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(Span::styled(key.clone(), Style::default().bold())); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - let mut custom_rect = hint_rect; - if custom_rect.width > 2 { - custom_rect.x += 2; - custom_rect.width = custom_rect.width.saturating_sub(2); - } - Line::from(spans).render_ref(custom_rect, buf); + if self.footer_flash_visible() { + if let Some(flash) = self.footer_flash.as_ref() { + flash.line.render(inset_footer_hint_area(hint_rect), buf); } + } else if let Some(items) = self.footer_hint_override.as_ref() { + render_footer_hint_items(hint_rect, buf, items); } else { render_footer(hint_rect, buf, footer_props); } @@ -2326,6 +2354,84 @@ mod tests { ); } + #[test] + fn footer_flash_overrides_footer_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("FLASH"), + "expected flash content to render in footer row, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("K label"), + "expected flash to override hint override, saw: {bottom_row:?}", + ); + } + + #[test] + fn footer_flash_expires_and_falls_back_to_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + composer.footer_flash.as_mut().unwrap().expires_at = + Instant::now() - Duration::from_secs(1); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("K label"), + "expected hint override to render after flash expired, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("FLASH"), + "expected expired flash to be hidden, saw: {bottom_row:?}", + ); + } + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) where F: FnOnce(&mut ChatComposer), diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs index ba49ab12c0..000ce2e4ea 100644 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -37,13 +37,20 @@ pub(crate) struct CommandPopup { state: ScrollState, } +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CommandPopupFlags { + pub(crate) skills_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, +} + impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + 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)| skills_enabled || *cmd != SlashCommand::Skills) + .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); @@ -230,7 +237,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -250,7 +257,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -265,7 +272,7 @@ mod tests { #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { @@ -279,7 +286,7 @@ mod tests { #[test] fn filtered_commands_keep_presentation_order() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/m".to_string()); let cmds: Vec<&str> = popup @@ -311,7 +318,7 @@ mod tests { argument_hint: None, }, ]; - let popup = CommandPopup::new(prompts, false); + let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -335,7 +342,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { @@ -358,7 +365,7 @@ mod tests { description: Some("Create feature branch, commit and open draft PR.".to_string()), argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -378,7 +385,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -387,7 +394,7 @@ mod tests { #[test] fn fuzzy_filter_matches_subsequence_for_ac() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); let cmds: Vec<&str> = popup @@ -403,4 +410,40 @@ mod tests { "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" ); } + + #[test] + fn collab_command_hidden_when_collaboration_modes_disabled() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/coll".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert!( + !cmds.contains(&"collab"), + "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" + ); + } + + #[test] + fn collab_command_visible_when_collaboration_modes_enabled() { + let mut popup = CommandPopup::new( + Vec::new(), + CommandPopupFlags { + skills_enabled: false, + collaboration_modes_enabled: true, + }, + ); + popup.on_composer_text_change("/collab".to_string()); + + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), + other => panic!("expected collab to be selected for exact match, got {other:?}"), + } + } } diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index bdfb5c8dfc..fe5a13dc48 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -38,6 +38,7 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) steer_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, /// Which key the user must press again to quit. /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. @@ -109,6 +110,31 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { .render(area, buf); } +pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { + if area.width > 2 { + area.x += 2; + area.width = area.width.saturating_sub(2); + } + area +} + +pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { + if items.is_empty() { + return; + } + + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(key.clone().bold()); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + Line::from(spans).render(inset_footer_hint_area(area), buf); +} + fn footer_lines(props: FooterProps) -> Vec> { fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option) { let Some(line) = lines.first_mut() else { @@ -176,6 +202,7 @@ fn footer_lines(props: FooterProps) -> Vec> { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, is_wsl, + collaboration_modes_enabled: props.collaboration_modes_enabled, }; shortcut_overlay_lines(state) } @@ -202,6 +229,7 @@ struct ShortcutsState { use_shift_enter_hint: bool, esc_backtrack_hint: bool, is_wsl: bool, + collaboration_modes_enabled: bool, } fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { @@ -233,6 +261,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); + let mut change_mode = Line::from(""); for descriptor in SHORTCUTS { if let Some(text) = descriptor.overlay_entry(state) { @@ -246,11 +275,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, + ShortcutId::ChangeMode => change_mode = text, } } } - let ordered = vec![ + let mut ordered = vec![ commands, shell_commands, newline, @@ -259,9 +289,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { paste_image, edit_previous, quit, - Line::from(""), - show_transcript, ]; + if change_mode.width() > 0 { + ordered.push(change_mode); + } + ordered.push(Line::from("")); + ordered.push(show_transcript); build_columns(ordered) } @@ -338,6 +371,7 @@ enum ShortcutId { EditPrevious, Quit, ShowTranscript, + ChangeMode, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -358,6 +392,7 @@ enum DisplayCondition { WhenShiftEnterHint, WhenNotShiftEnterHint, WhenUnderWSL, + WhenCollaborationModesEnabled, } impl DisplayCondition { @@ -367,6 +402,7 @@ impl DisplayCondition { DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, DisplayCondition::WhenUnderWSL => state.is_wsl, + DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, } } } @@ -500,6 +536,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to view transcript", }, + ShortcutDescriptor { + id: ShortcutId::ChangeMode, + bindings: &[ShortcutBinding { + key: key_hint::shift(KeyCode::Tab), + condition: DisplayCondition::WhenCollaborationModesEnabled, + }], + prefix: "", + label: " to change mode", + }, ]; #[cfg(test)] @@ -531,6 +576,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -550,6 +596,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -569,6 +616,27 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_collaboration_modes_enabled", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: true, + use_shift_enter_hint: true, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -588,6 +656,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -607,6 +676,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -626,6 +696,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -645,6 +716,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -664,6 +736,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: Some(72), context_window_used_tokens: None, @@ -683,6 +756,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: Some(123_456), @@ -702,6 +776,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -721,6 +796,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: true, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -740,6 +816,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index df9c0d84cd..331cf82fd5 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -31,6 +31,7 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::text::Line; use std::time::Duration; mod approval_overlay; @@ -180,6 +181,11 @@ impl BottomPane { self.composer.set_steer_enabled(enabled); } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.composer.set_collaboration_modes_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } @@ -500,6 +506,23 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) { + self.composer.show_footer_flash(line, duration); + let frame_requester = self.frame_requester.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + tokio::time::sleep(duration).await; + frame_requester.schedule_frame(); + }); + } else { + std::thread::spawn(move || { + std::thread::sleep(duration); + frame_requester.schedule_frame(); + }); + } + self.request_redraw(); + } + pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap new file mode 100644 index 0000000000..78af40d9f3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -0,0 +1,10 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" shift + tab to change mode " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 117d320a06..5e9d6425f4 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -130,6 +130,7 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::collab; +use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; @@ -316,6 +317,8 @@ enum RateLimitSwitchPromptState { Shown, } +type CollaborationModeSelection = collaboration_modes::Selection; + /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -345,6 +348,11 @@ pub(crate) struct ChatWidget { active_cell_revision: u64, config: Config, model: Option, + /// Current UI selection for collaboration modes. + /// + /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the + /// feature is disabled, the value is effectively inert. + collaboration_mode: CollaborationModeSelection, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -1478,6 +1486,7 @@ impl ChatWidget { active_cell_revision: 0, config, model, + collaboration_mode: CollaborationModeSelection::default(), auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1524,6 +1533,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1575,6 +1587,7 @@ impl ChatWidget { active_cell_revision: 0, config, model: Some(header_model.clone()), + collaboration_mode: CollaborationModeSelection::default(), auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -1621,6 +1634,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1685,6 +1701,16 @@ impl ChatWidget { } match key_event { + KeyEvent { + code: KeyCode::BackTab, + kind: KeyEventKind::Press, + .. + } if self.collaboration_modes_enabled() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() => + { + self.cycle_collaboration_mode(); + } KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::ALT, @@ -1797,6 +1823,11 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::Collab => { + if self.collaboration_modes_enabled() { + self.cycle_collaboration_mode(); + } + } SlashCommand::Approvals => { self.open_approvals_popup(); } @@ -1957,6 +1988,19 @@ impl ChatWidget { }, }); } + SlashCommand::Collab => { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(selection) = collaboration_modes::parse_selection(trimmed) { + self.set_collaboration_mode(selection); + } else if !trimmed.is_empty() { + self.add_error_message(format!( + "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." + )); + } + } _ => self.dispatch_command(cmd), } } @@ -2069,14 +2113,43 @@ impl ChatWidget { } } - self.codex_op_tx - .send(Op::UserInput { + let op = if self.collaboration_modes_enabled() { + let model = self + .current_model() + .unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + .to_string(); + let collaboration_mode = collaboration_modes::resolve_mode_or_fallback( + self.models_manager.as_ref(), + self.collaboration_mode, + model.as_str(), + self.config.model_reasoning_effort, + ); + Op::UserTurn { + items, + cwd: self.config.cwd.clone(), + approval_policy: self.config.approval_policy.value(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + model, + effort: self.config.model_reasoning_effort, + summary: self.config.model_reasoning_summary, + final_output_json_schema: None, + collaboration_mode: Some(collaboration_mode), + } + } else { + Op::UserInput { items, final_output_json_schema: None, - }) - .unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); + } + }; + + if !self.agent_turn_running { + self.agent_turn_running = true; + self.update_task_running_state(); + } + + self.codex_op_tx.send(op).unwrap_or_else(|e| { + tracing::error!("failed to send message: {e}"); + }); // Persist the text to cross-session message history. if !text.is_empty() { @@ -2409,6 +2482,8 @@ impl ChatWidget { self.plan_type, Local::now(), self.model_display_name(), + self.collaboration_modes_enabled() + .then_some(self.collaboration_mode.label()), )); } fn stop_rate_limit_poller(&mut self) { @@ -3584,6 +3659,9 @@ impl ChatWidget { if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); } + if feature == Feature::CollaborationModes { + self.bottom_pane.set_collaboration_modes_enabled(enabled); + } } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { @@ -3624,10 +3702,39 @@ impl ChatWidget { self.model.as_deref() } + fn collaboration_modes_enabled(&self) -> bool { + self.config.features.enabled(Feature::CollaborationModes) + } + fn model_display_name(&self) -> &str { self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) } + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + let next = self.collaboration_mode.next(); + self.set_collaboration_mode(next); + } + + /// Update the selected collaboration mode. + /// + /// When collaboration modes are enabled, the current selection is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { + if !self.collaboration_modes_enabled() { + return; + } + + self.collaboration_mode = selection; + let flash = collaboration_modes::flash_line(selection); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + self.request_redraw(); + } + /// Build a placeholder header cell while the session is configuring. fn placeholder_session_header_cell(config: &Config) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index b3ad2114c7..76b55e789f 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -17,7 +17,6 @@ use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::Constrained; -#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AgentMessageDeltaEvent; @@ -57,6 +56,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -382,6 +382,7 @@ async fn make_chatwidget_manual( skills: None, }); bottom.set_steer_enabled(true); + bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); let widget = ChatWidget { @@ -392,6 +393,7 @@ async fn make_chatwidget_manual( active_cell_revision: 0, config: cfg, model: Some(resolved_model.clone()), + collaboration_mode: CollaborationModeSelection::default(), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), session_header: SessionHeader::new(resolved_model), @@ -434,6 +436,20 @@ async fn make_chatwidget_manual( (widget, rx, op_rx) } +// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper +// filters until we see a submission op. +fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { + loop { + match op_rx.try_recv() { + Ok(op @ Op::UserTurn { .. }) => return op, + Ok(op @ Op::UserInput { .. }) => return op, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), + } + } +} + fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); @@ -1312,6 +1328,104 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[test] +fn parse_collaboration_mode_selection_accepts_common_aliases() { + assert_eq!( + collaboration_modes::parse_selection("plan"), + Some(CollaborationModeSelection::Plan) + ); + assert_eq!( + collaboration_modes::parse_selection("PAIR"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection("pair_programming"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection("pp"), + Some(CollaborationModeSelection::PairProgramming) + ); + assert_eq!( + collaboration_modes::parse_selection(" exec "), + Some(CollaborationModeSelection::Execute) + ); + assert_eq!( + collaboration_modes::parse_selection("execute"), + Some(CollaborationModeSelection::Execute) + ); + assert_eq!(collaboration_modes::parse_selection("unknown"), None); +} + +#[tokio::test] +async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, false); + + let initial = chat.collaboration_mode; + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, initial); + + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + + chat.on_task_started(); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); +} + +#[tokio::test] +async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.conversation_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); + assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + + chat.bottom_pane.set_composer_text("hello".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::Plan(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + } + + chat.bottom_pane.set_composer_text("follow up".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::Plan(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + } +} + +#[tokio::test] +async fn collab_mode_defaults_to_pair_programming_when_enabled() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.conversation_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.bottom_pane.set_composer_text("hello".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + } +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui2/src/collaboration_modes.rs b/codex-rs/tui2/src/collaboration_modes.rs new file mode 100644 index 0000000000..bdd1d8b63c --- /dev/null +++ b/codex-rs/tui2/src/collaboration_modes.rs @@ -0,0 +1,135 @@ +//! Collaboration mode selection + rendering helpers for the TUI. +//! +//! This module is intentionally UI-focused: +//! - It owns the user-facing set of selectable collaboration modes and how they cycle. +//! - It parses `/collab ` arguments into a selection. +//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by +//! picking from the `ModelsManager` builtin collaboration presets. +//! - It builds the small footer "flash" line shown after changing modes. +//! +//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed +//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. + +use crate::key_hint; +use codex_core::models_manager::manager::ModelsManager; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::openai_models::ReasoningEffort; +use crossterm::event::KeyCode; +use ratatui::style::Stylize; +use ratatui::text::Line; + +/// The user-facing collaboration mode choices supported by the TUI. +/// +/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling +/// order, while `CollaborationMode` can carry nested settings/prompt configuration. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Selection { + Plan, + #[default] + PairProgramming, + Execute, +} + +impl Selection { + /// Cycle to the next selection. + /// + /// The TUI cycles through a small, fixed set of presets. + pub(crate) fn next(self) -> Self { + match self { + Self::Plan => Self::PairProgramming, + Self::PairProgramming => Self::Execute, + Self::Execute => Self::Plan, + } + } + + /// User-facing label used in UI surfaces like `/status` and the footer flash. + pub(crate) fn label(self) -> &'static str { + match self { + Self::Plan => "Plan", + Self::PairProgramming => "Pair Programming", + Self::Execute => "Execute", + } + } +} + +/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. +/// +/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. +pub(crate) fn parse_selection(input: &str) -> Option { + let normalized: String = input + .chars() + .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') + .flat_map(char::to_lowercase) + .collect(); + + match normalized.as_str() { + "plan" => Some(Selection::Plan), + "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), + "execute" | "exec" => Some(Selection::Execute), + _ => None, + } +} + +/// Resolve a selection to a concrete collaboration mode preset. +/// +/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this +/// function selects the first preset of the desired variant. +pub(crate) fn resolve_mode( + models_manager: &ModelsManager, + selection: Selection, +) -> Option { + match selection { + Selection::Plan => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::Plan(_))), + Selection::PairProgramming => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), + Selection::Execute => models_manager + .list_collaboration_modes() + .into_iter() + .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + } +} + +/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode +/// when the desired preset is unavailable. +/// +/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when +/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries +/// an explicit collaboration mode so core doesn't fall back to `Custom`. +pub(crate) fn resolve_mode_or_fallback( + models_manager: &ModelsManager, + selection: Selection, + fallback_model: &str, + fallback_effort: Option, +) -> CollaborationMode { + resolve_mode(models_manager, selection).unwrap_or_else(|| { + let settings = Settings { + model: fallback_model.to_string(), + reasoning_effort: fallback_effort, + developer_instructions: None, + }; + + match selection { + Selection::Plan => CollaborationMode::Plan(settings), + Selection::PairProgramming => CollaborationMode::PairProgramming(settings), + Selection::Execute => CollaborationMode::Execute(settings), + } + }) +} + +/// Build a 1-line footer "flash" that is shown after switching modes. +/// +/// The `ChatWidget` controls when to show this and how long it should remain visible. +pub(crate) fn flash_line(selection: Selection) -> Line<'static> { + Line::from(vec![ + selection.label().bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]) +} diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 11a0f9f6be..27d649c4de 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -48,6 +48,7 @@ mod cli; mod clipboard_copy; mod clipboard_paste; mod collab; +mod collaboration_modes; mod color; pub mod custom_terminal; mod diff_render; diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index caac37b85d..2e1e032983 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -23,6 +23,7 @@ pub enum SlashCommand { Fork, Init, Compact, + Collab, // Undo, Diff, Mention, @@ -54,6 +55,7 @@ impl SlashCommand { SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Mcp => "list configured MCP tools", @@ -93,6 +95,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Collab => true, } } diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 3e9e07d538..7988c7d0ff 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -63,6 +63,7 @@ struct StatusHistoryCell { approval: String, sandbox: String, agents_summary: String, + collaboration_mode: Option, model_provider: Option, account: Option, session_id: Option, @@ -83,6 +84,7 @@ pub(crate) fn new_status_output( plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -96,6 +98,7 @@ pub(crate) fn new_status_output( plan_type, now, model_name, + collaboration_mode, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -114,6 +117,7 @@ impl StatusHistoryCell { plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, ) -> Self { let config_entries = create_config_summary_entries(config, model_name); let (model_name, model_details) = compose_model_display(model_name, &config_entries); @@ -165,6 +169,7 @@ impl StatusHistoryCell { approval, sandbox, agents_summary, + collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, account, session_id, @@ -360,6 +365,9 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() && self.forked_from.is_some() { push_label(&mut labels, &mut seen, "Forked from"); } + if self.collaboration_mode.is_some() { + push_label(&mut labels, &mut seen, "Collaboration mode"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -408,6 +416,10 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(collab_mode) = self.collaboration_mode.as_ref() { + lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())])); + } + if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index e79688f44e..9d0f6d0833 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -152,6 +152,7 @@ async fn status_snapshot_includes_reasoning_details() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -203,6 +204,7 @@ async fn status_snapshot_includes_forked_from() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -260,6 +262,7 @@ async fn status_snapshot_includes_monthly_limit() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -304,6 +307,7 @@ async fn status_snapshot_shows_unlimited_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -348,6 +352,7 @@ async fn status_snapshot_shows_positive_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -392,6 +397,7 @@ async fn status_snapshot_hides_zero_credits() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -434,6 +440,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { None, captured_at, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -476,6 +483,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { None, now, &model_slug, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -533,6 +541,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -579,6 +588,7 @@ async fn status_snapshot_shows_missing_limits_message() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -642,6 +652,7 @@ async fn status_snapshot_includes_credits_and_limits() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -694,6 +705,7 @@ async fn status_snapshot_shows_empty_limits_message() { None, captured_at, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -755,6 +767,7 @@ async fn status_snapshot_shows_stale_limits_message() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -820,6 +833,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { None, now, &model_slug, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -875,6 +889,7 @@ async fn status_context_window_uses_last_usage() { None, now, &model_slug, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines From 57ec3a82778b1cc88a000016b79bd4732ff18a43 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 19 Jan 2026 10:17:30 -0800 Subject: [PATCH 49/78] Feat: request user input tool (#9472) ### Summary * Add `requestUserInput` tool that the model can use for gather feedback/asking question mid turn. ### Tool input schema ``` { "$schema": "http://json-schema.org/draft-07/schema#", "title": "requestUserInput input", "type": "object", "additionalProperties": false, "required": ["questions"], "properties": { "questions": { "type": "array", "description": "Questions to show the user (1-3). Prefer 1 unless multiple independent decisions block progress.", "minItems": 1, "maxItems": 3, "items": { "type": "object", "additionalProperties": false, "required": ["id", "header", "question"], "properties": { "id": { "type": "string", "description": "Stable identifier for mapping answers (snake_case)." }, "header": { "type": "string", "description": "Short header label shown in the UI (12 or fewer chars)." }, "question": { "type": "string", "description": "Single-sentence prompt shown to the user." }, "options": { "type": "array", "description": "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, do not include any option.", "minItems": 2, "maxItems": 3, "items": { "type": "object", "additionalProperties": false, "required": ["value", "label", "description"], "properties": { "value": { "type": "string", "description": "Machine-readable value (snake_case)." }, "label": { "type": "string", "description": "User-facing label (1-5 words)." }, "description": { "type": "string", "description": "One short sentence explaining impact/tradeoff if selected." } } } } } } } } } ``` ### Tool output schema ``` { "$schema": "http://json-schema.org/draft-07/schema#", "title": "requestUserInput output", "type": "object", "additionalProperties": false, "required": ["answers"], "properties": { "answers": { "type": "object", "description": "Map of question id to user answer.", "additionalProperties": { "type": "object", "additionalProperties": false, "required": ["selected"], "properties": { "selected": { "type": "array", "items": { "type": "string" } }, "other": { "type": ["string", "null"] } } } } } } ``` --- .../src/protocol/common.rs | 6 + .../app-server-protocol/src/protocol/v2.rs | 48 +++++ codex-rs/app-server/README.md | 20 +- .../app-server/src/bespoke_event_handling.rs | 117 ++++++++++++ codex-rs/app-server/tests/common/lib.rs | 1 + codex-rs/app-server/tests/common/responses.rs | 23 +++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/request_user_input.rs | 127 +++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 14 +- codex-rs/core/src/codex.rs | 72 +++++++ codex-rs/core/src/codex_delegate.rs | 67 +++++++ codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/src/state/turn.rs | 18 ++ codex-rs/core/src/tools/handlers/mod.rs | 2 + .../src/tools/handlers/request_user_input.rs | 60 ++++++ codex-rs/core/src/tools/spec.rs | 175 +++++++++++++++++- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/model_tools.rs | 12 +- codex-rs/core/tests/suite/prompt_caching.rs | 8 + .../core/tests/suite/request_user_input.rs | 155 ++++++++++++++++ codex-rs/core/tests/suite/tool_harness.rs | 1 - codex-rs/docs/protocol_v1.md | 2 + .../src/event_processor_with_human_output.rs | 3 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + .../mcp-server/tests/common/mcp_process.rs | 4 +- codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 13 ++ codex-rs/protocol/src/request_user_input.rs | 48 +++++ codex-rs/tui/src/chatwidget.rs | 3 +- codex-rs/tui2/src/chatwidget.rs | 3 +- 30 files changed, 985 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/request_user_input.rs create mode 100644 codex-rs/core/src/tools/handlers/request_user_input.rs create mode 100644 codex-rs/core/tests/suite/request_user_input.rs create mode 100644 codex-rs/protocol/src/request_user_input.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 4011328062..911a4a0e9a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -510,6 +510,12 @@ server_request_definitions! { response: v2::FileChangeRequestApprovalResponse, }, + /// EXPERIMENTAL - Request input from the user for a tool call. + ToolRequestUserInput => "item/tool/requestUserInput" { + params: v2::ToolRequestUserInputParams, + response: v2::ToolRequestUserInputResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index db51c278f0..563a78cc6e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2277,6 +2277,54 @@ pub struct FileChangeRequestApprovalResponse { pub decision: FileChangeApprovalDecision, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Defines a single selectable option for request_user_input. +pub struct ToolRequestUserInputOption { + pub label: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Represents one request_user_input question and its optional options. +pub struct ToolRequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + pub options: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Params sent with a request_user_input event. +pub struct ToolRequestUserInputParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub questions: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. +pub struct ToolRequestUserInputAnswer { + pub selected: Vec, + pub other: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Response payload mapping question ids to answers. +pub struct ToolRequestUserInputResponse { + pub answers: HashMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4c376ce6a4..9bc8c3dff1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -90,6 +90,7 @@ Example (from OpenAI's official VSCode extension): - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `skills/config/write` — write user-level skill config by path. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. +- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. @@ -470,8 +471,15 @@ Invoke a skill by including `$` in the text input. Add a `skill` inp "params": { "threadId": "thread-1", "input": [ - { "type": "text", "text": "$skill-creator Add a new skill for triaging flaky CI." }, - { "type": "skill", "name": "skill-creator", "path": "/Users/me/.codex/skills/skill-creator/SKILL.md" } + { + "type": "text", + "text": "$skill-creator Add a new skill for triaging flaky CI." + }, + { + "type": "skill", + "name": "skill-creator", + "path": "/Users/me/.codex/skills/skill-creator/SKILL.md" + } ] } } @@ -506,10 +514,14 @@ Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, wi To enable or disable a skill by path: ```json -{ "method": "skills/config/write", "id": 26, "params": { +{ + "method": "skills/config/write", + "id": 26, + "params": { "path": "/Users/me/.codex/skills/skill-creator/SKILL.md", "enabled": false -} } + } +} ``` ## Auth endpoints diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0b2520098d..225b2aface 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -54,6 +54,10 @@ use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::ToolRequestUserInputResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnDiffUpdatedNotification; @@ -83,6 +87,8 @@ use codex_core::review_prompts; use codex_protocol::ThreadId; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ReviewOutputEvent; +use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use std::collections::HashMap; use std::convert::TryFrom; use std::path::PathBuf; @@ -258,6 +264,57 @@ pub(crate) async fn apply_bespoke_event_handling( }); } }, + EventMsg::RequestUserInput(request) => { + if matches!(api_version, ApiVersion::V2) { + let questions = request + .questions + .into_iter() + .map(|question| ToolRequestUserInputQuestion { + id: question.id, + header: question.header, + question: question.question, + options: question.options.map(|options| { + options + .into_iter() + .map(|option| ToolRequestUserInputOption { + label: option.label, + description: option.description, + }) + .collect() + }), + }) + .collect(); + let params = ToolRequestUserInputParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id, + item_id: request.call_id, + questions, + }; + let rx = outgoing + .send_request(ServerRequestPayload::ToolRequestUserInput(params)) + .await; + tokio::spawn(async move { + on_request_user_input_response(event_turn_id, rx, conversation).await; + }); + } else { + error!( + "request_user_input is only supported on api v2 (call_id: {})", + request.call_id + ); + let empty = CoreRequestUserInputResponse { + answers: HashMap::new(), + }; + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } + } + } // TODO(celia): properly construct McpToolCall TurnItem in core. EventMsg::McpToolCallBegin(begin_event) => { let notification = construct_mcp_tool_call_notification( @@ -1347,6 +1404,66 @@ async fn on_exec_approval_response( } } +async fn on_request_user_input_response( + event_turn_id: String, + receiver: oneshot::Receiver, + conversation: Arc, +) { + let response = receiver.await; + let value = match response { + Ok(value) => value, + Err(err) => { + error!("request failed: {err:?}"); + let empty = CoreRequestUserInputResponse { + answers: HashMap::new(), + }; + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } + return; + } + }; + + let response = + serde_json::from_value::(value).unwrap_or_else(|err| { + error!("failed to deserialize ToolRequestUserInputResponse: {err}"); + ToolRequestUserInputResponse { + answers: HashMap::new(), + } + }); + let response = CoreRequestUserInputResponse { + answers: response + .answers + .into_iter() + .map(|(id, answer)| { + ( + id, + CoreRequestUserInputAnswer { + selected: answer.selected, + other: answer.other, + }, + ) + }) + .collect(), + }; + + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } +} + const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; fn render_review_output_text(output: &ReviewOutputEvent) -> String { diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index d350a5df82..8093121403 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -27,6 +27,7 @@ pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; +pub use responses::create_request_user_input_sse_response; pub use responses::create_shell_command_sse_response; pub use rollout::create_fake_rollout; pub use rollout::create_fake_rollout_with_text_elements; diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index 35c1862e8f..e15319e02f 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -60,3 +60,26 @@ pub fn create_exec_command_sse_response(call_id: &str) -> anyhow::Result responses::ev_completed("resp-1"), ])) } + +pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result { + let tool_call_arguments = serde_json::to_string(&json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }))?; + + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "request_user_input", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 82939908ee..bf6230ae52 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -6,6 +6,7 @@ mod initialize; mod model_list; mod output_schema; mod rate_limits; +mod request_user_input; mod review; mod thread_archive; mod thread_fork; diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs new file mode 100644 index 0000000000..37122e08a2 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -0,0 +1,127 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_request_user_input_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::openai_models::ReasoningEffort; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn request_user_input_round_trip() -> Result<()> { + let codex_home = tempfile::TempDir::new()?; + let responses = vec![ + create_request_user_input_sse_response("call1")?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "ask something".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ToolRequestUserInput { request_id, params } = server_req else { + panic!("expected ToolRequestUserInput request, got: {server_req:?}"); + }; + + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + assert_eq!(params.item_id, "call1"); + assert_eq!(params.questions.len(), 1); + + mcp.send_response( + request_id, + serde_json::json!({ + "answers": { + "confirm_path": { "selected": ["yes"], "other": serde_json::Value::Null } + } + }), + ) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +collaboration_modes = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 4c8df56c3d..29a7d6b936 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1471,8 +1471,18 @@ unified_exec = true unreachable!("loop ensures we break on command execution items"); }; assert_eq!(completed_id, "uexec-1"); - assert_eq!(completed_status, CommandExecutionStatus::Completed); - assert_eq!(exit_code, Some(0)); + assert!( + matches!( + completed_status, + CommandExecutionStatus::Completed | CommandExecutionStatus::Failed + ), + "unexpected command execution status: {completed_status:?}" + ); + if completed_status == CommandExecutionStatus::Completed { + assert_eq!(exit_code, Some(0)); + } else { + assert!(exit_code.is_some(), "expected exit_code for failed command"); + } assert_eq!( completed_process_id.as_deref(), Some(started_process_id.as_str()) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b6876c83c0..3d48810ff9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,6 +49,8 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::OAuthCredentialsStoreMode; use futures::future::BoxFuture; @@ -118,6 +120,7 @@ use crate::protocol::Op; use crate::protocol::RateLimitSnapshot; use crate::protocol::ReasoningContentDeltaEvent; use crate::protocol::ReasoningRawContentDeltaEvent; +use crate::protocol::RequestUserInputEvent; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; @@ -1296,6 +1299,63 @@ impl Session { rx_approve } + pub async fn request_user_input( + &self, + turn_context: &TurnContext, + call_id: String, + args: RequestUserInputArgs, + ) -> Option { + let sub_id = turn_context.sub_id.clone(); + let (tx_response, rx_response) = oneshot::channel(); + let event_id = sub_id.clone(); + let prev_entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_user_input(sub_id, tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending user input for sub_id: {event_id}"); + } + + let event = EventMsg::RequestUserInput(RequestUserInputEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + questions: args.questions, + }); + self.send_event(turn_context, event).await; + rx_response.await.ok() + } + + pub async fn notify_user_input_response( + &self, + sub_id: &str, + response: RequestUserInputResponse, + ) { + let entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.remove_pending_user_input(sub_id) + } + None => None, + } + }; + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending user input found for sub_id: {sub_id}"); + } + } + } + pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { let entry = { let mut active = self.active_turn.lock().await; @@ -1933,6 +1993,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::PatchApproval { id, decision } => { handlers::patch_approval(&sess, id, decision).await; } + Op::UserInputAnswer { id, response } => { + handlers::request_user_input_response(&sess, id, response).await; + } Op::AddToHistory { text } => { handlers::add_to_history(&sess, &config, text).await; } @@ -2022,6 +2085,7 @@ mod handlers { use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; + use codex_protocol::request_user_input::RequestUserInputResponse; use crate::context_manager::is_user_turn_boundary; use codex_protocol::config_types::CollaborationMode; @@ -2250,6 +2314,14 @@ mod handlers { } } + pub async fn request_user_input_response( + sess: &Arc, + id: String, + response: RequestUserInputResponse, + ) { + sess.notify_user_input_response(&id, response).await; + } + pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { let id = sess.conversation_id; let config = Arc::clone(config); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 49409f8e84..b855612113 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -9,9 +10,12 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RequestUserInputEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::Submission; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use std::time::Duration; use tokio::time::timeout; @@ -229,6 +233,20 @@ async fn forward_events( ) .await; } + Event { + id, + msg: EventMsg::RequestUserInput(event), + } => { + handle_request_user_input( + &codex, + id, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } other => { match tx_sub.send(other).or_cancel(&cancel_token).await { Ok(Ok(())) => {} @@ -334,6 +352,55 @@ async fn handle_patch_approval( let _ = codex.submit(Op::PatchApproval { id, decision }).await; } +async fn handle_request_user_input( + codex: &Codex, + id: String, + parent_session: &Session, + parent_ctx: &TurnContext, + event: RequestUserInputEvent, + cancel_token: &CancellationToken, +) { + let args = RequestUserInputArgs { + questions: event.questions, + }; + let response_fut = + parent_session.request_user_input(parent_ctx, parent_ctx.sub_id.clone(), args); + let response = await_user_input_with_cancel( + response_fut, + parent_session, + &parent_ctx.sub_id, + cancel_token, + ) + .await; + let _ = codex.submit(Op::UserInputAnswer { id, response }).await; +} + +async fn await_user_input_with_cancel( + fut: F, + parent_session: &Session, + sub_id: &str, + cancel_token: &CancellationToken, +) -> RequestUserInputResponse +where + F: core::future::Future>, +{ + tokio::select! { + biased; + _ = cancel_token.cancelled() => { + let empty = RequestUserInputResponse { + answers: HashMap::new(), + }; + parent_session + .notify_user_input_response(sub_id, empty.clone()) + .await; + empty + } + response = fut => response.unwrap_or_else(|| RequestUserInputResponse { + answers: HashMap::new(), + }), + } +} + /// Await an approval decision, aborting on cancellation. async fn await_approval_with_cancel( fut: F, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index fafdc83102..2d4a79cc22 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -67,6 +67,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::ExecApprovalRequest(_) + | EventMsg::RequestUserInput(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 3dfc5132d3..66e2f694e9 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -9,6 +9,7 @@ use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; use codex_protocol::models::ResponseInputItem; +use codex_protocol::request_user_input::RequestUserInputResponse; use tokio::sync::oneshot; use crate::codex::TurnContext; @@ -68,6 +69,7 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_user_input: HashMap>, pending_input: Vec, } @@ -89,9 +91,25 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); + self.pending_user_input.clear(); self.pending_input.clear(); } + pub(crate) fn insert_pending_user_input( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_user_input.insert(key, tx) + } + + pub(crate) fn remove_pending_user_input( + &mut self, + key: &str, + ) -> Option> { + self.pending_user_input.remove(key) + } + pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) { self.pending_input.push(input); } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index ab8123df1e..8b63c9567f 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -6,6 +6,7 @@ mod mcp; mod mcp_resource; mod plan; mod read_file; +mod request_user_input; mod shell; mod test_sync; mod unified_exec; @@ -23,6 +24,7 @@ pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; +pub use request_user_input::RequestUserInputHandler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs new file mode 100644 index 0000000000..f63b11d235 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -0,0 +1,60 @@ +use async_trait::async_trait; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::request_user_input::RequestUserInputArgs; + +pub struct RequestUserInputHandler; + +#[async_trait] +impl ToolHandler for RequestUserInputHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "request_user_input handler received unsupported payload".to_string(), + )); + } + }; + + let args: RequestUserInputArgs = parse_arguments(&arguments)?; + let response = session + .request_user_input(turn.as_ref(), call_id, args) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "request_user_input was cancelled before receiving a response".to_string(), + ) + })?; + + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize request_user_input response: {err}" + )) + })?; + + Ok(ToolOutput::Function { + content, + content_items: None, + success: Some(true), + }) + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 9d272a5e6f..e330d93a03 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -27,6 +27,7 @@ pub(crate) struct ToolsConfig { pub apply_patch_tool_type: Option, pub web_search_mode: Option, pub collab_tools: bool, + pub collaboration_modes_tools: bool, pub experimental_supported_tools: Vec, } @@ -45,6 +46,7 @@ impl ToolsConfig { } = params; let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); let include_collab_tools = features.enabled(Feature::Collab); + let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes); let shell_type = if !features.enabled(Feature::ShellTool) { ConfigShellToolType::Disabled @@ -76,6 +78,7 @@ impl ToolsConfig { apply_patch_tool_type, web_search_mode: *web_search_mode, collab_tools: include_collab_tools, + collaboration_modes_tools: include_collaboration_modes_tools, experimental_supported_tools: model_info.experimental_supported_tools.clone(), } } @@ -532,6 +535,88 @@ fn create_wait_tool() -> ToolSpec { }) } +fn create_request_user_input_tool() -> ToolSpec { + let mut option_props = BTreeMap::new(); + option_props.insert( + "label".to_string(), + JsonSchema::String { + description: Some("User-facing label (1-5 words).".to_string()), + }, + ); + option_props.insert( + "description".to_string(), + JsonSchema::String { + description: Some( + "One short sentence explaining impact/tradeoff if selected.".to_string(), + ), + }, + ); + + let options_schema = JsonSchema::Array { + description: Some( + "Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Only include \"Other\" option if we want to include a free form option. If the question is free form in nature, please do not have any option." + .to_string(), + ), + items: Box::new(JsonSchema::Object { + properties: option_props, + required: Some(vec!["label".to_string(), "description".to_string()]), + additional_properties: Some(false.into()), + }), + }; + + let mut question_props = BTreeMap::new(); + question_props.insert( + "id".to_string(), + JsonSchema::String { + description: Some("Stable identifier for mapping answers (snake_case).".to_string()), + }, + ); + question_props.insert( + "header".to_string(), + JsonSchema::String { + description: Some( + "Short header label shown in the UI (12 or fewer chars).".to_string(), + ), + }, + ); + question_props.insert( + "question".to_string(), + JsonSchema::String { + description: Some("Single-sentence prompt shown to the user.".to_string()), + }, + ); + question_props.insert("options".to_string(), options_schema); + + let questions_schema = JsonSchema::Array { + description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()), + items: Box::new(JsonSchema::Object { + properties: question_props, + required: Some(vec![ + "id".to_string(), + "header".to_string(), + "question".to_string(), + ]), + additional_properties: Some(false.into()), + }), + }; + + let mut properties = BTreeMap::new(); + properties.insert("questions".to_string(), questions_schema); + + ToolSpec::Function(ResponsesApiTool { + name: "request_user_input".to_string(), + description: + "Request user input for one to three short questions and wait for the response." + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["questions".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_close_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -1140,6 +1225,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; + use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; @@ -1157,6 +1243,7 @@ pub(crate) fn build_specs( let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); let shell_command_handler = Arc::new(ShellCommandHandler); + let request_user_input_handler = Arc::new(RequestUserInputHandler); match &config.shell_type { ConfigShellToolType::Default => { @@ -1197,6 +1284,11 @@ pub(crate) fn build_specs( builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); + if config.collaboration_modes_tools { + builder.push_spec(create_request_user_input_tool()); + builder.register_handler("request_user_input", request_user_input_handler); + } + if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { match apply_patch_tool_type { ApplyPatchToolType::Freeform => { @@ -1398,6 +1490,7 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); let config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, @@ -1430,6 +1523,7 @@ mod tests { create_list_mcp_resource_templates_tool(), create_read_mcp_resource_tool(), PLAN_TOOL.clone(), + create_request_user_input_tool(), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { external_web_access: Some(true), @@ -1460,6 +1554,7 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::Collab); + features.enable(Feature::CollaborationModes); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, @@ -1472,6 +1567,33 @@ mod tests { ); } + #[test] + fn request_user_input_requires_collaboration_modes_feature() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.disable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, None).build(); + assert!( + !tools.iter().any(|t| t.spec.name() == "request_user_input"), + "request_user_input should be disabled when collaboration_modes feature is off" + ); + + features.enable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, None).build(); + assert_contains_tool_names(&tools, &["request_user_input"]); + } + fn assert_model_tools( model_slug: &str, features: &Features, @@ -1536,9 +1658,11 @@ mod tests { #[test] fn test_build_specs_gpt5_codex_default() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5-codex", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1546,6 +1670,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1555,9 +1680,11 @@ mod tests { #[test] fn test_build_specs_gpt51_codex_default() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1-codex", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1565,6 +1692,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1574,9 +1702,12 @@ mod tests { #[test] fn test_build_specs_gpt5_codex_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5-codex", - Features::with_defaults().enable(Feature::UnifiedExec), + &features, Some(WebSearchMode::Live), &[ "exec_command", @@ -1585,6 +1716,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1594,9 +1726,12 @@ mod tests { #[test] fn test_build_specs_gpt51_codex_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1-codex", - Features::with_defaults().enable(Feature::UnifiedExec), + &features, Some(WebSearchMode::Live), &[ "exec_command", @@ -1605,6 +1740,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1614,9 +1750,11 @@ mod tests { #[test] fn test_codex_mini_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "codex-mini-latest", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "local_shell", @@ -1624,6 +1762,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], @@ -1632,9 +1771,11 @@ mod tests { #[test] fn test_codex_5_1_mini_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1-codex-mini", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1642,6 +1783,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1651,9 +1793,11 @@ mod tests { #[test] fn test_gpt_5_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell", @@ -1661,6 +1805,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], @@ -1669,9 +1814,11 @@ mod tests { #[test] fn test_gpt_5_1_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1679,6 +1826,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1688,9 +1836,11 @@ mod tests { #[test] fn test_exp_5_1_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "exp-5.1", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "exec_command", @@ -1699,6 +1849,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1708,9 +1859,12 @@ mod tests { #[test] fn test_codex_mini_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); assert_model_tools( "codex-mini-latest", - Features::with_defaults().enable(Feature::UnifiedExec), + &features, Some(WebSearchMode::Live), &[ "exec_command", @@ -1719,6 +1873,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index b8066db387..5cdc440c53 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -53,6 +53,7 @@ mod quota_exceeded; mod read_file; mod remote_models; mod request_compression; +mod request_user_input; mod resume; mod resume_warning; mod review; diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 8a4d0a7717..b08b5281c8 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::features::Feature; use codex_protocol::config_types::WebSearchMode; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses; @@ -36,7 +37,10 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { let mut builder = test_codex() .with_model(model) // Keep tool expectations stable when the default web_search mode changes. - .with_config(|config| config.web_search_mode = Some(WebSearchMode::Cached)); + .with_config(|config| { + config.web_search_mode = Some(WebSearchMode::Cached); + config.features.enable(Feature::CollaborationModes); + }); let test = builder .build(&server) .await @@ -62,6 +66,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "web_search".to_string(), "view_image".to_string() ], @@ -77,6 +82,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() @@ -93,6 +99,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() @@ -109,6 +116,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "web_search".to_string(), "view_image".to_string() ], @@ -124,6 +132,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() @@ -140,6 +149,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4e87a43640..4b04364120 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -92,6 +92,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { config.model = Some("gpt-5.1-codex-max".to_string()); // Keep tool expectations stable when the default web_search mode changes. config.web_search_mode = Some(WebSearchMode::Cached); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -135,6 +136,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -176,6 +178,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); config.features.disable(Feature::ApplyPatchFreeform); + config.features.enable(Feature::CollaborationModes); config.model = Some("codex-mini-latest".to_string()); }) .build(&server) @@ -240,6 +243,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -316,6 +320,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -538,6 +543,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -645,6 +651,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -742,6 +749,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs new file mode 100644 index 0000000000..3c4405eeb3 --- /dev/null +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -0,0 +1,155 @@ +#![allow(clippy::unwrap_used)] + +use std::collections::HashMap; + +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ResponsesRequest; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +fn call_output(req: &ResponsesRequest, call_id: &str) -> String { + let raw = req.function_call_output(call_id); + assert_eq!( + raw.get("call_id").and_then(Value::as_str), + Some(call_id), + "mismatched call_id in function_call_output" + ); + let (content_opt, _success) = match req.function_call_output_content_and_success(call_id) { + Some(values) => values, + None => panic!("function_call_output present"), + }; + match content_opt { + Some(content) => content, + None => panic!("function_call_output content present"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let builder = test_codex(); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder + .with_config(|config| { + config.features.enable(Feature::CollaborationModes); + }) + .build(&server) + .await?; + + let call_id = "user-input-call"; + let request_args = json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) + .to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "request_user_input", &request_args), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "thanks"), + ev_completed("resp-2"), + ]); + let second_mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please confirm".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + }) + .await?; + + let request = wait_for_event_match(&codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(request.call_id, call_id); + assert_eq!(request.questions.len(), 1); + + let mut answers = HashMap::new(); + answers.insert( + "confirm_path".to_string(), + RequestUserInputAnswer { + selected: vec!["yes".to_string()], + other: None, + }, + ); + let response = RequestUserInputResponse { answers }; + codex + .submit(Op::UserInputAnswer { + id: request.turn_id.clone(), + response, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let req = second_mock.single_request(); + let output_text = call_output(&req, call_id); + let output_json: Value = serde_json::from_str(&output_text)?; + assert_eq!( + output_json, + json!({ + "answers": { + "confirm_path": { "selected": ["yes"], "other": Value::Null } + } + }) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 8f5646fc30..d9bdf83ed1 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -28,7 +28,6 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use serde_json::Value; use serde_json::json; - fn call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option) { let raw = req.function_call_output(call_id); assert_eq!( diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 0e4e1ddde3..7abdf6fb5c 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -68,10 +68,12 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::UserInput` – Any input from the user to kick off a `Turn` - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution + - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call - `Op::ListSkills` – Request skills for one or more cwd values (optionally `force_reload`) - `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::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/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 42a83e44d1..c4a5c27d9c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -606,7 +606,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::SkillsUpdateAvailable | EventMsg::UndoCompleted(_) | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) => {} + | EventMsg::ThreadRolledBack(_) + | EventMsg::RequestUserInput(_) => {} } CodexStatus::Running } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 531bf90d23..b268b8f1b4 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -358,6 +358,7 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoStarted(_) | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) + | EventMsg::RequestUserInput(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::CollabAgentSpawnBegin(_) diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index 7e447533d0..9a3f076fb1 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -137,8 +137,10 @@ impl McpProcess { let initialized = self.read_jsonrpc_message().await?; let os_info = os_info::get(); + let build_version = env!("CARGO_PKG_VERSION"); + let originator = codex_core::default_client::originator().value; let user_agent = format!( - "codex_cli_rs/0.0.0 ({} {}; {}) {} (elicitation test; 0.0.0)", + "{originator}/{build_version} ({} {}; {}) {} (elicitation test; 0.0.0)", os_info.os_type(), os_info.version(), os_info.architecture().unwrap_or("unknown"), diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 513743c97f..ebd4787ba4 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -14,4 +14,5 @@ pub mod openai_models; pub mod parse_command; pub mod plan_tool; pub mod protocol; +pub mod request_user_input; pub mod user_input; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1142f182c0..d5accde8e8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -24,6 +24,7 @@ use crate::num_format::format_with_separators; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; +use crate::request_user_input::RequestUserInputResponse; use crate::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use mcp_types::CallToolResult; @@ -44,6 +45,7 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; +pub use crate::request_user_input::RequestUserInputEvent; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. @@ -191,6 +193,15 @@ pub enum Op { decision: ElicitationAction, }, + /// Resolve a request_user_input tool call. + #[serde(rename = "user_input_answer", alias = "request_user_input_response")] + UserInputAnswer { + /// Turn id for the in-flight request. + id: String, + /// User-provided answers. + response: RequestUserInputResponse, + }, + /// Append an entry to the persistent cross-session message history. /// /// Note the entry is not guaranteed to be logged if the user has @@ -723,6 +734,8 @@ pub enum EventMsg { ExecApprovalRequest(ExecApprovalRequestEvent), + RequestUserInput(RequestUserInputEvent), + ElicitationRequest(ElicitationRequestEvent), ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent), diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs new file mode 100644 index 0000000000..44ad3b6302 --- /dev/null +++ b/codex-rs/protocol/src/request_user_input.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputQuestionOption { + pub label: String, + pub description: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputArgs { + pub questions: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputAnswer { + pub selected: Vec, + pub other: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputResponse { + pub answers: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputEvent { + /// Responses API call id for the associated tool call, if available. + pub call_id: String, + /// Turn ID that this request belongs to. + /// Uses `#[serde(default)]` for backwards compatibility. + #[serde(default)] + pub turn_id: String, + pub questions: Vec, +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 11a0ceac82..2518eb41bc 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2547,7 +2547,8 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) => {} + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::RequestUserInput(_) => {} } } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 5e9d6425f4..ffc763e214 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -2318,7 +2318,8 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) => {} + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::RequestUserInput(_) => {} } } From b11e96fb046d8b11fb343aa78022f80b1adff29f Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 19 Jan 2026 11:23:25 -0800 Subject: [PATCH 50/78] Act on reasoning-included per turn (#9402) - Reset reasoning-included flag each turn and update compaction test --- codex-rs/codex-api/src/common.rs | 4 + codex-rs/codex-api/src/endpoint/chat.rs | 3 + .../src/endpoint/responses_websocket.rs | 20 ++++- codex-rs/codex-api/src/sse/responses.rs | 11 +++ codex-rs/core/src/codex.rs | 10 ++- codex-rs/core/src/compact.rs | 3 + codex-rs/core/src/context_manager/history.rs | 15 +++- codex-rs/core/src/state/session.rs | 15 +++- codex-rs/core/src/tasks/regular.rs | 1 + .../core/tests/suite/client_websockets.rs | 36 ++++++++ codex-rs/core/tests/suite/compact.rs | 84 +++++++++++++++++++ codex-rs/otel/src/traces/otel_manager.rs | 1 + 12 files changed, 192 insertions(+), 11 deletions(-) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 2118cf66e4..9a7aab9973 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -42,6 +42,10 @@ pub enum ResponseEvent { Created, OutputItemDone(ResponseItem), OutputItemAdded(ResponseItem), + /// Emitted when `X-Reasoning-Included: true` is present on the response, + /// meaning the server already accounted for past reasoning tokens and the + /// client should not re-estimate them. + ServerReasoningIncluded(bool), Completed { response_id: String, token_usage: Option, diff --git a/codex-rs/codex-api/src/endpoint/chat.rs b/codex-rs/codex-api/src/endpoint/chat.rs index cd830a09fc..8fe1d2a521 100644 --- a/codex-rs/codex-api/src/endpoint/chat.rs +++ b/codex-rs/codex-api/src/endpoint/chat.rs @@ -157,6 +157,9 @@ impl Stream for AggregatedStream { return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))); } + Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included)))) => { + return Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included)))); + } Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => { return Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))); } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 3c6cab74e5..39e2f2fd05 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -29,18 +29,21 @@ use url::Url; type WsStream = WebSocketStream>; const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; +const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; pub struct ResponsesWebsocketConnection { stream: Arc>>, // TODO (pakrym): is this the right place for timeout? idle_timeout: Duration, + server_reasoning_included: bool, } impl ResponsesWebsocketConnection { - fn new(stream: WsStream, idle_timeout: Duration) -> Self { + fn new(stream: WsStream, idle_timeout: Duration, server_reasoning_included: bool) -> Self { Self { stream: Arc::new(Mutex::new(Some(stream))), idle_timeout, + server_reasoning_included, } } @@ -56,11 +59,17 @@ impl ResponsesWebsocketConnection { mpsc::channel::>(1600); let stream = Arc::clone(&self.stream); let idle_timeout = self.idle_timeout; + let server_reasoning_included = self.server_reasoning_included; let request_body = serde_json::to_value(&request).map_err(|err| { ApiError::Stream(format!("failed to encode websocket request: {err}")) })?; tokio::spawn(async move { + if server_reasoning_included { + let _ = tx_event + .send(Ok(ResponseEvent::ServerReasoningIncluded(true))) + .await; + } let mut guard = stream.lock().await; let Some(ws_stream) = guard.as_mut() else { let _ = tx_event @@ -111,10 +120,12 @@ impl ResponsesWebsocketClient { headers.extend(extra_headers); apply_auth_headers(&mut headers, &self.auth); - let stream = connect_websocket(ws_url, headers, turn_state).await?; + let (stream, server_reasoning_included) = + connect_websocket(ws_url, headers, turn_state).await?; Ok(ResponsesWebsocketConnection::new( stream, self.provider.stream_idle_timeout, + server_reasoning_included, )) } } @@ -137,7 +148,7 @@ async fn connect_websocket( url: Url, headers: HeaderMap, turn_state: Option>>, -) -> Result { +) -> Result<(WsStream, bool), ApiError> { let mut request = url .clone() .into_client_request() @@ -147,6 +158,7 @@ async fn connect_websocket( let (stream, response) = tokio_tungstenite::connect_async(request) .await .map_err(|err| map_ws_error(err, &url))?; + let reasoning_included = response.headers().contains_key(X_REASONING_INCLUDED_HEADER); if let Some(turn_state) = turn_state && let Some(header_value) = response .headers() @@ -155,7 +167,7 @@ async fn connect_websocket( { let _ = turn_state.set(header_value.to_string()); } - Ok(stream) + Ok((stream, reasoning_included)) } fn map_ws_error(err: WsError, url: &Url) -> ApiError { diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index a70111d988..f23975f8dd 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -25,6 +25,8 @@ use tokio_util::io::ReaderStream; use tracing::debug; use tracing::trace; +const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; + /// Streams SSE events from an on-disk fixture for tests. pub fn stream_from_fixture( path: impl AsRef, @@ -58,6 +60,10 @@ pub fn spawn_response_stream( .get("X-Models-Etag") .and_then(|v| v.to_str().ok()) .map(ToString::to_string); + let reasoning_included = stream_response + .headers + .get(X_REASONING_INCLUDED_HEADER) + .is_some(); if let Some(turn_state) = turn_state.as_ref() && let Some(header_value) = stream_response .headers @@ -74,6 +80,11 @@ pub fn spawn_response_stream( if let Some(etag) = models_etag { let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await; } + if reasoning_included { + let _ = tx_event + .send(Ok(ResponseEvent::ServerReasoningIncluded(true))) + .await; + } process_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await; }); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3d48810ff9..5ca971c121 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -809,7 +809,7 @@ impl Session { async fn get_total_token_usage(&self) -> i64 { let state = self.state.lock().await; - state.get_total_token_usage() + state.get_total_token_usage(state.server_reasoning_included()) } async fn record_initial_history(&self, conversation_history: InitialHistory) { @@ -1618,6 +1618,11 @@ impl Session { self.send_token_count_event(turn_context).await; } + pub(crate) async fn set_server_reasoning_included(&self, included: bool) { + let mut state = self.state.lock().await; + state.set_server_reasoning_included(included); + } + async fn send_token_count_event(&self, turn_context: &TurnContext) { let (info, rate_limits) = { let state = self.state.lock().await; @@ -3149,6 +3154,9 @@ async fn try_run_sampling_request( active_item = Some(tracked_item); } } + ResponseEvent::ServerReasoningIncluded(included) => { + sess.set_server_reasoning_included(included).await; + } ResponseEvent::RateLimits(snapshot) => { // Update internal state with latest rate limits, but defer sending until // token usage is available to avoid duplicate TokenCount events. diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 250b91415d..40e6284deb 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -316,6 +316,9 @@ async fn drain_to_completed( sess.record_into_history(std::slice::from_ref(&item), turn_context) .await; } + Ok(ResponseEvent::ServerReasoningIncluded(included)) => { + sess.set_server_reasoning_included(included).await; + } Ok(ResponseEvent::RateLimits(snapshot)) => { sess.update_rate_limits(turn_context, snapshot).await; } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0c133bdc28..4feeddc29f 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -235,12 +235,19 @@ impl ContextManager { token_estimate as usize } - pub(crate) fn get_total_token_usage(&self) -> i64 { - self.token_info + /// When true, the server already accounted for past reasoning tokens and + /// the client should not re-estimate them. + pub(crate) fn get_total_token_usage(&self, server_reasoning_included: bool) -> i64 { + let last_tokens = self + .token_info .as_ref() .map(|info| info.last_token_usage.total_tokens) - .unwrap_or(0) - .saturating_add(self.get_non_last_reasoning_items_tokens() as i64) + .unwrap_or(0); + if server_reasoning_included { + last_tokens + } else { + last_tokens.saturating_add(self.get_non_last_reasoning_items_tokens() as i64) + } } /// This function enforces a couple of invariants on the in-memory history: diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index c61d188373..746396949e 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -14,6 +14,7 @@ pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, + pub(crate) server_reasoning_included: bool, } impl SessionState { @@ -24,6 +25,7 @@ impl SessionState { session_configuration, history, latest_rate_limits: None, + server_reasoning_included: false, } } @@ -78,8 +80,17 @@ impl SessionState { self.history.set_token_usage_full(context_window); } - pub(crate) fn get_total_token_usage(&self) -> i64 { - self.history.get_total_token_usage() + pub(crate) fn get_total_token_usage(&self, server_reasoning_included: bool) -> i64 { + self.history + .get_total_token_usage(server_reasoning_included) + } + + pub(crate) fn set_server_reasoning_included(&mut self, included: bool) { + self.server_reasoning_included = included; + } + + pub(crate) fn server_reasoning_included(&self) -> bool { + self.server_reasoning_included } } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f897d3ce87..cac0cd5da0 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -30,6 +30,7 @@ impl SessionTask for RegularTask { ) -> Option { let sess = session.clone_session(); let run_turn_span = trace_span!("run_turn"); + sess.set_server_reasoning_included(false).await; sess.services .otel_manager .apply_traceparent_parent(&run_turn_span); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 9d39fb2402..1532ac74d1 100644 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -15,10 +15,12 @@ use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use core_test_support::load_default_config_for_test; +use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::WebSocketTestServer; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::start_websocket_server; +use core_test_support::responses::start_websocket_server_with_headers; use core_test_support::skip_if_no_network; use futures::StreamExt; use pretty_assertions::assert_eq; @@ -60,6 +62,40 @@ async fn responses_websocket_streams_request() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_emits_reasoning_included_event() { + skip_if_no_network!(); + + let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { + requests: vec![vec![ev_response_created("resp-1"), ev_completed("resp-1")]], + response_headers: vec![("X-Reasoning-Included".to_string(), "true".to_string())], + }]) + .await; + + let harness = websocket_harness(&server).await; + let mut session = harness.client.new_session(); + let prompt = prompt_with_input(vec![message_item("hello")]); + + let mut stream = session + .stream(&prompt) + .await + .expect("websocket stream failed"); + + let mut saw_reasoning_included = false; + while let Some(event) = stream.next().await { + match event.expect("event") { + ResponseEvent::ServerReasoningIncluded(true) => { + saw_reasoning_included = true; + } + ResponseEvent::Completed { .. } => break, + _ => {} + } + } + + assert!(saw_reasoning_included); + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_appends_on_prefix() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index d033c66644..b06f5ef14b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -32,11 +32,13 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; use core_test_support::responses::mount_compact_json_once; +use core_test_support::responses::mount_response_sequence; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::sse_failed; +use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; use serde_json::json; @@ -2147,3 +2149,85 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { "third turn should include compaction summary item" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let first_user = "SERVER_INCLUDED_FIRST"; + let second_user = "SERVER_INCLUDED_SECOND"; + let third_user = "SERVER_INCLUDED_THIRD"; + + let pre_last_reasoning_content = "a".repeat(2_400); + let post_last_reasoning_content = "b".repeat(4_000); + + let first_turn = sse(vec![ + ev_reasoning_item("pre-reasoning", &["pre"], &[&pre_last_reasoning_content]), + ev_completed_with_tokens("r1", 10), + ]); + let second_turn = sse(vec![ + ev_reasoning_item("post-reasoning", &["post"], &[&post_last_reasoning_content]), + ev_completed_with_tokens("r2", 80), + ]); + let third_turn = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 1), + ]); + + let responses = vec![ + sse_response(first_turn).insert_header("X-Reasoning-Included", "true"), + sse_response(second_turn), + sse_response(third_turn), + ]; + mount_response_sequence(&server, responses).await; + + let compacted_history = vec![ + codex_protocol::models::ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![codex_protocol::models::ContentItem::OutputText { + text: "REMOTE_COMPACT_SUMMARY".to_string(), + }], + }, + codex_protocol::models::ResponseItem::Compaction { + encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), + }, + ]; + let compact_mock = + mount_compact_json_once(&server, serde_json::json!({ "output": compacted_history })).await; + + let codex = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(300); + config.features.enable(Feature::RemoteCompaction); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + for user in [first_user, second_user, third_user] { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: user.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + } + + let compact_requests = compact_mock.requests(); + assert_eq!( + compact_requests.len(), + 1, + "remote compaction should run once after the reasoning header clears" + ); +} diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index a1aaf0b513..0847fd882a 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -484,6 +484,7 @@ impl OtelManager { ResponseEvent::ReasoningSummaryPartAdded { .. } => { "reasoning_summary_part_added".into() } + ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), } From 070935d5e86c1be6a1180922fdc79fe9ff3e9c1e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 19 Jan 2026 20:39:23 +0100 Subject: [PATCH 51/78] chore: fix beta VS experimental (#9496) --- codex-rs/cli/src/main.rs | 4 +-- codex-rs/core/src/features.rs | 49 +++++++++++++++++++---------------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 337be81e5d..d7c7cd288b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -459,8 +459,8 @@ enum FeaturesSubcommand { fn stage_str(stage: codex_core::features::Stage) -> &'static str { use codex_core::features::Stage; match stage { - Stage::Experimental => "experimental", - Stage::Beta { .. } => "beta", + Stage::Beta => "experimental", + Stage::Experimental { .. } => "beta", Stage::Stable => "stable", Stage::Deprecated => "deprecated", Stage::Removed => "removed", diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 66f6a08602..3e95c566bd 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -21,28 +21,33 @@ pub(crate) use legacy::legacy_feature_keys; /// High-level lifecycle stage for a feature. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Stage { - Experimental, - Beta { + /// Closed beta features to be used while developing or within the company. + Beta, + /// Experimental features made available to users through the `/experimental` menu + Experimental { name: &'static str, menu_description: &'static str, announcement: &'static str, }, + /// Stable features. The feature flag is kept for ad-hoc enabling/disabling Stable, + /// Deprecated feature that should not be used anymore. Deprecated, + /// The feature flag is useless but kept for backward compatibility reason. Removed, } impl Stage { pub fn beta_menu_name(self) -> Option<&'static str> { match self { - Stage::Beta { name, .. } => Some(name), + Stage::Experimental { name, .. } => Some(name), _ => None, } } pub fn beta_menu_description(self) -> Option<&'static str> { match self { - Stage::Beta { + Stage::Experimental { menu_description, .. } => Some(menu_description), _ => None, @@ -51,7 +56,7 @@ impl Stage { pub fn beta_announcement(self) -> Option<&'static str> { match self { - Stage::Beta { announcement, .. } => Some(announcement), + Stage::Experimental { announcement, .. } => Some(announcement), _ => None, } } @@ -336,14 +341,14 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::WebSearchCached, key: "web_search_cached", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, // Beta program. Rendered in the `/experimental` menu for users. FeatureSpec { id: Feature::UnifiedExec, key: "unified_exec", - stage: Stage::Beta { + stage: Stage::Experimental { name: "Background terminal", menu_description: "Run long-running terminal commands in the background.", announcement: "NEW! Try Background terminals for long-running commands. Enable in /experimental!", @@ -353,7 +358,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ShellSnapshot, key: "shell_snapshot", - stage: Stage::Beta { + stage: Stage::Experimental { name: "Shell snapshot", menu_description: "Snapshot your shell environment to avoid re-running login scripts for every command.", announcement: "NEW! Try shell snapshotting to make your Codex faster. Enable in /experimental!", @@ -363,50 +368,50 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ChildAgentsMd, key: "child_agents_md", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::ExecPolicy, key: "exec_policy", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: true, }, FeatureSpec { id: Feature::WindowsSandbox, key: "experimental_windows_sandbox", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::WindowsSandboxElevated, key: "elevated_windows_sandbox", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::RemoteCompaction, key: "remote_compaction", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: true, }, FeatureSpec { id: Feature::RemoteModels, key: "remote_models", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::PowershellUtf8, key: "powershell_utf8", #[cfg(windows)] - stage: Stage::Beta { + stage: Stage::Experimental { name: "Powershell UTF-8 support", menu_description: "Enable UTF-8 output in Powershell.", announcement: "Codex now supports UTF-8 output in Powershell. If you are seeing problems, disable in /experimental.", @@ -414,32 +419,32 @@ pub const FEATURES: &[FeatureSpec] = &[ #[cfg(windows)] default_enabled: true, #[cfg(not(windows))] - stage: Stage::Experimental, + stage: Stage::Beta, #[cfg(not(windows))] default_enabled: false, }, FeatureSpec { id: Feature::EnableRequestCompression, key: "enable_request_compression", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::Collab, key: "collab", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::Tui2, key: "tui2", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::Steer, key: "steer", - stage: Stage::Beta { + stage: Stage::Experimental { name: "Steer conversation", menu_description: "Enter submits immediately; Tab queues messages when a task is running.", announcement: "NEW! Try Steer mode: Enter submits immediately, Tab queues. Enable in /experimental!", @@ -449,7 +454,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::CollaborationModes, key: "collaboration_modes", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, ]; From d544adf71a4bc4368059a84df3e56d8481279a1e Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 19 Jan 2026 12:17:29 -0800 Subject: [PATCH 52/78] Feat: plan mode prompt update (#9495) ### Summary * Added instruction on using `request_user_input` * Added the output to be json with `plan` key and the actual plan as the value. * Remove `PLAN.md` write because that gets into sandbox issue. We can add it back later. --- .../core/templates/collaboration_mode/plan.md | 115 ++++++++++++++++-- 1 file changed, 104 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 70b710c420..9985ba1139 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -1,40 +1,133 @@ # Collaboration Style: Plan + You work in 2 distinct modes: + 1. Brainstorming: You collaboratively align with the user on what to do or build and how to do it or build it. -2. Writing and confirming a plan: After you've gathered all the information you write up a plan and verify it with the user. -You usually start with the planning step. Skip step 1 if the user provides you with a detailed plan or a small, unambiguous task or plan OR if the user asks you to plan by yourself. +2. Generating a plan: After you've gathered all the information you write up a plan. + You usually start with the brainstorming step. Skip step 1 if the user provides you with a detailed plan or a small, unambiguous task or plan OR if the user asks you to plan by yourself. ## Brainstorming principles + The point of brainstorming with the user is to align on what to do and how to do it. This phase is iterative and conversational. You can interact with the environment and read files if it is helpful, but be mindful of the time. You MUST follow the principles below. Think about them carefully as you work with the user. Follow the structure and tone of the examples. -*State what you think the user cares about.* Actively infer what matters most (robustness, clean abstractions, quick lovable interfaces, scalability) and reflect this back to the user to confirm. +_State what you think the user cares about._ Actively infer what matters most (robustness, clean abstractions, quick lovable interfaces, scalability) and reflect this back to the user to confirm. Example: "It seems like you might be prototyping a design for an app, and scalability or performance isn't a concern right now - is that accurate?" -*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. +_Think out loud._ Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. -*Use reasonable suggestions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. +_Use reasonable suggestions._ When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter - does that resonate?" Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration - we can relax that if this is exploratory." -*Ask fewer, better questions.* Prefer making a concrete proposal with stated assumptions over asking questions. Only ask questions when different reasonable suggestions would materially change the plan, you cannot safely proceed, or if you think the user would really want to give input directly. Never ask a question if you already provided a suggestion. +_Ask fewer, better questions._ Prefer making a concrete proposal with stated assumptions over asking questions. Only ask questions when different reasonable suggestions would materially change the plan, you cannot safely proceed, or if you think the user would really want to give input directly. Never ask a question if you already provided a suggestion. You can use `request_user_input` tool to ask questions. -*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. +_Think ahead._ What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. Would you like a debug mode where you can move through states without just waiting?" -*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and think you need to do longer research, ask the user whether they want you to research, or want to give you a tip. +_Be mindful of time._ The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and think you need to do longer research, ask the user whether they want you to research, or want to give you a tip. Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. If it's ok, I'll go and spend a bit more time exploring the code base?" +## Using `request_user_input` in Plan Mode + +Use `request_user_input` only when you are genuinely blocked on a decision that materially changes the plan (requirements, trade-offs, rollout or risk posture).The maximum number of `request_user_input` tool calls should be **5**. + +Only include an "Other" option when a free-form answer is truly useful. If the question is purely free-form, leave `options` unset entirely. + +Do **not** use `request_user_input` to ask "is my plan ready?" or "should I proceed?". + +### Examples (technical, schema-populated) + +**1 Boolean (yes/no), no free-form** + +```json +{ + "questions": [ + { + "id": "enable_migration", + "header": "Migrate", + "question": "Enable the database migration in this release?", + "options": [ + { + "label": "Yes (Recommended)", + "description": "Ship the migration with this rollout." + }, + { + "label": "No", + "description": "Defer the migration to a later release." + } + ] + } + ] +} +``` + +**2 Choice with free-form** + +```json +{ + "questions": [ + { + "id": "cache_strategy", + "header": "Cache", + "question": "Which cache strategy should we implement?", + "options": [ + { + "label": "Write-through (Recommended)", + "description": "Simpler consistency with predictable latency." + }, + { + "label": "Write-back", + "description": "Lower write latency but higher complexity." + }, + { + "label": "Other", + "description": "Provide a custom strategy or constraints." + } + ] + } + ] +} +``` + +**3 Free-form only (no options)** + +```json +{ + "questions": [ + { + "id": "rollout_constraints", + "header": "Rollout", + "question": "Any rollout constraints or compliance requirements we must follow?" + } + ] +} +``` + ## Iterating on the plan -Only AFTER you have all the information, write up the full plan. + +Only AFTER you have all the information, write up the full plan. A well written and informative plan should be as detailed as a design doc or PRD and reflect your discussion with the user, at minimum that's one full page! If handed to a different agent, the agent would know exactly what to build without asking questions and arrive at a similar implementation to yours. At minimum it should include: + - tools and frameworks you use, any dependencies you need to install - functions, files, or directories you're likely going to edit +- QUestions that were asked and the responses from users - architecture if the code changes are significant - if developing features, describe the features you are going to build in detail like a PM in a PRD - if you are developing a frontend, describe the design in detail +- include a list of todos in markdown format if needed. Please do not include a **plan** step given that we are planning here already -`plan.md`: For long, detailed plans, it makes sense to write them in a separate file. If the changes are substantial and the plan is longer than a full page, ask the user if it's ok to write the plan in `plan.md`. If plan.md is used, ALWAYS update the file rather than outputting the plan in your final answer. +### Output schema - — MUST MATCH _exactly_ -ALWAYS confirm the plan with the user before ending. If the user requests changes or additions to the plan update the plan. Iterate until the user confirms the plan. +When you present the plan, format the final response as a JSON object with a single key, `plan`, whose value is the full plan text. + +Example: + +```json +{ + "plan": "Title: Schema migration rollout\n\n1. Validate the current schema on staging...\n2. Add the new columns with nullable defaults...\n3. Backfill in batches with feature-flagged writes...\n4. Flip reads to the new fields and monitor...\n5. Remove legacy columns after one full release cycle..." +} +``` + +PLEASE DO NOT confirm the plan with the user before ending. The user will be responsible for telling us to update, iterate or execute the plan. From 0c0c5aeddcecc4675046db0909eb1c277466223b Mon Sep 17 00:00:00 2001 From: prateek-oai Date: Mon, 19 Jan 2026 15:52:04 -0500 Subject: [PATCH 53/78] tui: avoid Esc interrupt when skill popup active (#9451) Fixes #9450 ## What - When a task is running and the skills autocomplete popup is open, `Esc` now dismisses the popup instead of sending `Op::Interrupt`. - `Esc` still interrupts a running task when no popup is active. ## Tests - `cargo test -p codex-tui` --------- Co-authored-by: prateek <199982+prateek@users.noreply.github.com> --- codex-rs/tui/src/bottom_pane/mod.rs | 113 +++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 088d8b4f6a..8668367aa3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -243,8 +243,10 @@ impl BottomPane { } else { // If a task is running and a status line is visible, allow Esc to // send an interrupt even while the composer has focus. - if matches!(key_event.code, crossterm::event::KeyCode::Esc) + // When a popup is active, prefer dismissing it over interrupting the task. + if key_event.code == KeyCode::Esc && self.is_task_running + && !self.composer.popup_active() && let Some(status) = &self.status { // Send Op::Interrupt @@ -696,9 +698,13 @@ impl Renderable for BottomPane { mod tests { use super::*; use crate::app_event::AppEvent; + use codex_core::protocol::Op; + use codex_protocol::protocol::SkillScope; + use crossterm::event::KeyModifiers; use insta::assert_snapshot; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + use std::path::PathBuf; use tokio::sync::mpsc::unbounded_channel; fn snapshot_buffer(buf: &Buffer) -> String { @@ -965,4 +971,109 @@ mod tests { render_snapshot(&pane, area) ); } + + #[test] + fn esc_with_skill_popup_does_not_interrupt_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(vec![SkillMetadata { + name: "test-skill".to_string(), + description: "test skill".to_string(), + short_description: None, + interface: None, + path: PathBuf::from("test-skill"), + scope: SkillScope::User, + }]), + }); + + pane.set_task_running(true); + + // Repro: a running task + skill popup + Esc should dismiss the popup, not interrupt. + pane.insert_str("$"); + assert!( + pane.composer.popup_active(), + "expected skill popup after typing `$`" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc to not send Op::Interrupt when dismissing skill popup" + ); + } + assert!( + !pane.composer.popup_active(), + "expected Esc to dismiss skill popup" + ); + } + + #[test] + fn esc_with_slash_command_popup_does_not_interrupt_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + // Repro: a running task + slash-command popup + Esc should not interrupt the task. + pane.insert_str("/"); + assert!( + pane.composer.popup_active(), + "expected command popup after typing `/`" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc to not send Op::Interrupt while command popup is active" + ); + } + assert_eq!(pane.composer_text(), "/"); + } + + #[test] + fn esc_interrupts_running_task_when_no_popup() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt while a task is running" + ); + } } From 65d3b9e1453e275e01e5733acf4e81bdc6243e06 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 19 Jan 2026 13:40:39 -0800 Subject: [PATCH 54/78] Migrate tui to use UserTurn (#9497) - `tui/` and `tui2/` submit `Op::UserTurn` and own full turn context (cwd/approval/sandbox/model/etc.). - `Op::UserInput` is documented as legacy in `codex-protocol` (doc-only; no `#[deprecated]` to avoid `-D warnings` fallout). - Remove obsolete `#[allow(deprecated)]` and the unused `ConversationId` alias/re-export. --- codex-rs/docs/protocol_v1.md | 21 ++++++------ codex-rs/protocol/src/lib.rs | 2 -- codex-rs/protocol/src/protocol.rs | 8 +++-- codex-rs/protocol/src/thread_id.rs | 4 --- codex-rs/tui/src/chatwidget.rs | 48 ++++++++++++--------------- codex-rs/tui/src/chatwidget/tests.rs | 1 - codex-rs/tui2/src/chatwidget.rs | 45 ++++++++++++------------- codex-rs/tui2/src/chatwidget/tests.rs | 1 - 8 files changed, 61 insertions(+), 69 deletions(-) diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 7abdf6fb5c..5133f6f75f 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -1,4 +1,4 @@ -Overview of Protocol Defined in [protocol.rs](../core/src/protocol.rs) and [agent.rs](../core/src/agent.rs). +Overview of Protocol defined in [protocol.rs](../protocol/src/protocol.rs) and [agent.rs](../core/src/agent.rs). The goal of this document is to define terminology used in the system and explain the expected behavior of the system. @@ -23,11 +23,11 @@ These are entities exit on the codex backend. The intent of this section is to e 3. `Task` - A `Task` is `Codex` executing work in response to user input. - `Session` has at most one `Task` running at a time. - - Receiving `Op::UserInput` starts a `Task` + - Receiving `Op::UserTurn` starts a `Task` (`Op::UserInput` is legacy) - Consists of a series of `Turn`s - The `Task` executes to until: - The `Model` completes the task and there is no output to feed into an additional `Turn` - - Additional `Op::UserInput` aborts the current task and starts a new one + - Additional user-turn input aborts the current task and starts a new one - UI interrupts with `Op::Interrupt` - Fatal errors are encountered, eg. `Model` connection exceeding retry limits - Blocked by user approval (executing a command or patch) @@ -42,7 +42,7 @@ These are entities exit on the codex backend. The intent of this section is to e The term "UI" is used to refer to the application driving `Codex`. This may be the CLI / TUI chat-like interface that users operate, or it may be a GUI interface like a VSCode extension. The UI is external to `Codex`, as `Codex` is intended to be operated by arbitrary UI implementations. -When a `Turn` completes, the `response_id` from the `Model`'s final `response.completed` message is stored in the `Session` state to resume the thread given the next `Op::UserInput`. The `response_id` is also returned in the `EventMsg::TurnComplete` to the UI, which can be used to fork the thread from an earlier point by providing it in the `Op::UserInput`. +When a `Turn` completes, the `response_id` from the `Model`'s final `response.completed` message is stored in the `Session` state to resume the thread given the next user turn. The `response_id` is also returned in the `EventMsg::TurnComplete` to the UI, which can be used to fork the thread from an earlier point by providing it in a future user turn. Since only 1 `Task` can be run at a time, for parallel tasks it is recommended that a single `Codex` be run for each thread of work. @@ -57,15 +57,16 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t - This enum is `non_exhaustive`; variants can be added at future dates - `Event` - These are messages sent on the `EQ` (`Codex` -> UI) - - Each `Event` has a non-unique ID, matching the `sub_id` from the `Op::UserInput` that started the current task. + - Each `Event` has a non-unique ID, matching the `sub_id` from the user-turn op that started the current task. - `EventMsg` refers to the enum of all possible `Event` payloads - This enum is `non_exhaustive`; variants can be added at future dates - It should be expected that new `EventMsg` variants will be added over time to expose more detailed information about the model's actions. -For complete documentation of the `Op` and `EventMsg` variants, refer to [protocol.rs](../core/src/protocol.rs). Some example payload types: +For complete documentation of the `Op` and `EventMsg` variants, refer to [protocol.rs](../protocol/src/protocol.rs). Some example payload types: - `Op` - - `Op::UserInput` – Any input from the user to kick off a `Turn` + - `Op::UserTurn` – Any input from the user to kick off a `Turn` + - `Op::UserInput` – Legacy form of user input - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call @@ -114,7 +115,7 @@ sequenceDiagram user->>codex: Op::ConfigureSession codex-->>session: create session codex->>user: Event::SessionConfigured - user->>session: Op::UserInput + user->>session: Op::UserTurn session-->>+task: start task task->>user: Event::TurnStarted task->>agent: prompt @@ -152,7 +153,7 @@ sequenceDiagram box Rest API participant agent as Model end - user->>session: Op::UserInput + user->>session: Op::UserTurn session-->>+task1: start task task1->>user: Event::TurnStarted task1->>agent: prompt @@ -164,7 +165,7 @@ sequenceDiagram task1->>task1: exec (auto-approved) user->>task1: Op::Interrupt task1->>-user: Event::Error("interrupted") - user->>session: Op::UserInput w/ last_response_id + user->>session: Op::UserTurn w/ response bookmark session-->>+task2: start task task2->>user: Event::TurnStarted task2->>agent: prompt + Task1 last_response_id diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index ebd4787ba4..69d09da089 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,7 +1,5 @@ pub mod account; mod thread_id; -#[allow(deprecated)] -pub use thread_id::ConversationId; pub use thread_id::ThreadId; pub mod approvals; pub mod config_types; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d5accde8e8..eab09adede 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -83,7 +83,10 @@ pub enum Op { /// This server sends [`EventMsg::TurnAborted`] in response. Interrupt, - /// Input from the user + /// Legacy user input. + /// + /// Prefer [`Op::UserTurn`] so the caller provides full turn context + /// (cwd/approval/sandbox/model/etc.) for each turn. UserInput { /// User input items, see `InputItem` items: Vec, @@ -131,7 +134,8 @@ pub enum Op { /// /// All fields are optional; when omitted, the existing value is preserved. /// This does not enqueue any input – it only updates defaults used for - /// future `UserInput` turns. + /// turns that rely on persistent session-level context (for example, + /// [`Op::UserInput`]). OverrideTurnContext { /// Updated `cwd` for sandbox/tool calls. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/protocol/src/thread_id.rs b/codex-rs/protocol/src/thread_id.rs index 8589566a25..7b27db8364 100644 --- a/codex-rs/protocol/src/thread_id.rs +++ b/codex-rs/protocol/src/thread_id.rs @@ -70,10 +70,6 @@ impl JsonSchema for ThreadId { } } -/// Backward-compatible alias for the previous name. -#[deprecated(note = "use ThreadId instead")] -pub type ConversationId = ThreadId; - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2518eb41bc..c6870ca320 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2296,6 +2296,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + let Some(model) = self.current_model().or(self.config.model.as_deref()) else { + tracing::warn!("cannot submit user message before model is known; queueing"); + self.queued_user_messages.push_front(user_message); + self.refresh_queued_user_messages(); + return; + }; + let model = model.to_string(); + let UserMessage { text, image_paths } = user_message; if text.is_empty() && image_paths.is_empty() { return; @@ -2343,36 +2351,24 @@ impl ChatWidget { } } - // TODO(aibrahim): migrate the TUI to submit `Op::UserTurn` by default (and rely less on - // `Op::UserInput`) so session-level settings like collaboration mode are consistently - // applied. - let op = if self.collaboration_modes_enabled() { - let model = self - .current_model() - .unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) - .to_string(); - let collaboration_mode = collaboration_modes::resolve_mode_or_fallback( + let collaboration_mode = self.collaboration_modes_enabled().then(|| { + collaboration_modes::resolve_mode_or_fallback( self.models_manager.as_ref(), self.collaboration_mode, model.as_str(), self.config.model_reasoning_effort, - ); - Op::UserTurn { - items, - cwd: self.config.cwd.clone(), - approval_policy: self.config.approval_policy.value(), - sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, - summary: self.config.model_reasoning_summary, - final_output_json_schema: None, - collaboration_mode: Some(collaboration_mode), - } - } else { - Op::UserInput { - items, - final_output_json_schema: None, - } + ) + }); + let op = Op::UserTurn { + items, + cwd: self.config.cwd.clone(), + approval_policy: self.config.approval_policy.value(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + model, + effort: self.config.model_reasoning_effort, + summary: self.config.model_reasoning_summary, + final_output_json_schema: None, + collaboration_mode, }; self.codex_op_tx.send(op).unwrap_or_else(|e| { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f9ec776ffb..1be07ed95e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -457,7 +457,6 @@ fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { loop { match op_rx.try_recv() { Ok(op @ Op::UserTurn { .. }) => return op, - Ok(op @ Op::UserInput { .. }) => return op, Ok(_) => continue, Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index ffc763e214..756d244255 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -2066,6 +2066,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + let Some(model) = self.current_model().or(self.config.model.as_deref()) else { + tracing::warn!("cannot submit user message before model is known; queueing"); + self.queued_user_messages.push_front(user_message); + self.refresh_queued_user_messages(); + return; + }; + let model = model.to_string(); + let UserMessage { text, image_paths } = user_message; if text.is_empty() && image_paths.is_empty() { return; @@ -2113,33 +2121,24 @@ impl ChatWidget { } } - let op = if self.collaboration_modes_enabled() { - let model = self - .current_model() - .unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) - .to_string(); - let collaboration_mode = collaboration_modes::resolve_mode_or_fallback( + let collaboration_mode = self.collaboration_modes_enabled().then(|| { + collaboration_modes::resolve_mode_or_fallback( self.models_manager.as_ref(), self.collaboration_mode, model.as_str(), self.config.model_reasoning_effort, - ); - Op::UserTurn { - items, - cwd: self.config.cwd.clone(), - approval_policy: self.config.approval_policy.value(), - sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, - summary: self.config.model_reasoning_summary, - final_output_json_schema: None, - collaboration_mode: Some(collaboration_mode), - } - } else { - Op::UserInput { - items, - final_output_json_schema: None, - } + ) + }); + let op = Op::UserTurn { + items, + cwd: self.config.cwd.clone(), + approval_policy: self.config.approval_policy.value(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + model, + effort: self.config.model_reasoning_effort, + summary: self.config.model_reasoning_summary, + final_output_json_schema: None, + collaboration_mode, }; if !self.agent_turn_running { diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 76b55e789f..c404287af6 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -442,7 +442,6 @@ fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { loop { match op_rx.try_recv() { Ok(op @ Op::UserTurn { .. }) => return op, - Ok(op @ Op::UserInput { .. }) => return op, Ok(_) => continue, Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), From 675f165c56e0600d3bf4ccc6e798853c0d5fee60 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 19 Jan 2026 21:59:36 -0800 Subject: [PATCH 55/78] fix(core) Preserve base_instructions in SessionMeta (#9427) ## Summary This PR consolidates base_instructions onto SessionMeta / SessionConfiguration, so we ensure `base_instructions` is set once per session and should be (mostly) immutable, unless: - overridden by config on resume / fork - sub-agent tasks, like review or collab In a future PR, we should convert all references to `base_instructions` to consistently used the typed struct, so it's less likely that we put other strings there. See #9423. However, this PR is already quite complex, so I'm deferring that to a follow-up. ## Testing - [x] Added a resume test to assert that instructions are preserved. In particular, `resume_switches_models_preserves_base_instructions` fails against main. Existing test coverage thats assert base instructions are preserved across multiple requests in a session: - Manual compact keeps baseline instructions: core/tests/suite/compact.rs:199 - Auto-compact keeps baseline instructions: core/tests/suite/compact.rs:1142 - Prompt caching reuses the same instructions across two requests: core/tests/suite/prompt_caching.rs:150 and core/tests/suite/prompt_caching.rs:157 - Prompt caching with explicit expected string across two requests: core/tests/suite/prompt_caching.rs:213 and core/tests/suite/prompt_caching.rs:222 - Resume with model switch keeps original instructions: core/tests/suite/resume.rs:136 - Compact/resume/fork uses request 0 instructions for later expected payloads: core/tests/suite/compact_resume_fork.rs:215 --- codex-rs/app-server/tests/common/rollout.rs | 2 + codex-rs/core/src/client.rs | 10 +- codex-rs/core/src/client_common.rs | 82 +----- codex-rs/core/src/codex.rs | 143 ++++++++- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/compact_remote.rs | 2 +- .../core/src/models_manager/model_info.rs | 3 + codex-rs/core/src/rollout/recorder.rs | 6 + codex-rs/core/src/rollout/tests.rs | 143 +++++++++ codex-rs/core/src/tools/handlers/collab.rs | 20 +- codex-rs/core/tests/suite/remote_models.rs | 3 +- codex-rs/core/tests/suite/resume.rs | 75 +++++ codex-rs/core/tests/suite/resume_warning.rs | 1 - codex-rs/protocol/BUILD.bazel | 2 +- codex-rs/protocol/src/models.rs | 17 ++ .../src/prompts/base_instructions/default.md | 275 ++++++++++++++++++ codex-rs/protocol/src/protocol.rs | 30 +- 17 files changed, 700 insertions(+), 116 deletions(-) create mode 100644 codex-rs/protocol/src/prompts/base_instructions/default.md diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 994cdf5ff8..e2e55a6544 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -59,6 +59,7 @@ pub fn create_fake_rollout( cli_version: "0.0.0".to_string(), source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), + base_instructions: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, @@ -136,6 +137,7 @@ pub fn create_fake_rollout_with_text_elements( cli_version: "0.0.0".to_string(), source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), + base_instructions: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 03e74b54b9..f0f2b125fd 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -217,9 +217,7 @@ impl ModelClient { let client = ApiCompactClient::new(transport, api_provider, api_auth) .with_telemetry(Some(request_telemetry)); - let instructions = prompt - .get_full_instructions(&self.state.model_info) - .into_owned(); + let instructions = prompt.base_instructions.text.clone(); let payload = ApiCompactionInput { model: &self.state.model_info.slug, input: &prompt.input, @@ -276,8 +274,7 @@ impl ModelClientSession { } fn build_responses_request(&self, prompt: &Prompt) -> Result { - let model_info = self.state.model_info.clone(); - let instructions = prompt.get_full_instructions(&model_info).into_owned(); + let instructions = prompt.base_instructions.text.clone(); let tools_json: Vec = create_tools_json_for_responses_api(&prompt.tools)?; Ok(build_api_prompt(prompt, instructions, tools_json)) } @@ -448,8 +445,7 @@ impl ModelClientSession { } let auth_manager = self.state.auth_manager.clone(); - let model_info = self.state.model_info.clone(); - let instructions = prompt.get_full_instructions(&model_info).into_owned(); + let instructions = prompt.base_instructions.text.clone(); let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?; let api_prompt = build_api_prompt(prompt, instructions, tools_json); let conversation_id = self.state.conversation_id.to_string(); diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 7d7cabcfa6..2abe4883e7 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,12 +1,11 @@ use crate::client_common::tools::ToolSpec; use crate::error::Result; pub use codex_api::common::ResponseEvent; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ModelInfo; use futures::Stream; use serde::Deserialize; use serde_json::Value; -use std::borrow::Cow; use std::collections::HashSet; use std::pin::Pin; use std::task::Context; @@ -34,22 +33,13 @@ pub struct Prompt { /// Whether parallel tool calls are permitted for this prompt. pub(crate) parallel_tool_calls: bool, - /// Optional override for the built-in BASE_INSTRUCTIONS. - pub base_instructions_override: Option, + pub base_instructions: BaseInstructions, /// Optional the output schema for the model's response. pub output_schema: Option, } impl Prompt { - pub(crate) fn get_full_instructions<'a>(&'a self, model: &'a ModelInfo) -> Cow<'a, str> { - Cow::Borrowed( - self.base_instructions_override - .as_deref() - .unwrap_or(model.base_instructions.as_str()), - ) - } - pub(crate) fn get_formatted_input(&self) -> Vec { let mut input = self.input.clone(); @@ -245,76 +235,8 @@ mod tests { use codex_api::create_text_param_for_request; use pretty_assertions::assert_eq; - use crate::config::test_config; - use crate::models_manager::manager::ModelsManager; - use super::*; - struct InstructionsTestCase { - pub slug: &'static str, - pub expects_apply_patch_instructions: bool, - } - #[test] - fn get_full_instructions_no_user_content() { - let prompt = Prompt { - ..Default::default() - }; - let prompt_with_apply_patch_instructions = - include_str!("../prompt_with_apply_patch_instructions.md"); - let test_cases = vec![ - InstructionsTestCase { - slug: "gpt-3.5", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-4.1", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-4o", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-5", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-5.1", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "codex-mini-latest", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-oss:120b", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1-codex", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1-codex-max", - expects_apply_patch_instructions: false, - }, - ]; - for test_case in test_cases { - let config = test_config(); - let model_info = ModelsManager::construct_model_info_offline(test_case.slug, &config); - if test_case.expects_apply_patch_instructions { - assert_eq!( - model_info.base_instructions.as_str(), - prompt_with_apply_patch_instructions - ); - } - - let expected = model_info.base_instructions.as_str(); - let full = prompt.get_full_instructions(&model_info); - assert_eq!(full, expected); - } - } - #[test] fn serializes_text_verbosity_when_set() { let input: Vec = vec![]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5ca971c121..9f220922f5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -12,7 +12,6 @@ use crate::SandboxState; use crate::agent::AgentControl; use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; -use crate::client_common::REVIEW_PROMPT; use crate::compact; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; @@ -34,9 +33,11 @@ use async_channel::Receiver; use async_channel::Sender; use codex_protocol::ThreadId; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; +use codex_protocol::models::BaseInstructions; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::HasLegacyEvent; @@ -165,7 +166,6 @@ use codex_async_utils::OrCancelExt; use codex_otel::OtelManager; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::Settings; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseInputItem; @@ -275,6 +275,18 @@ impl Codex { crate::models_manager::manager::RefreshStrategy::OnlineIfUncached, ) .await; + + // Resolve base instructions for the session. Priority order: + // 1. config.base_instructions override + // 2. conversation history => session_meta.base_instructions + // 3. base_intructions for current model + let model_info = models_manager.get_model_info(model.as_str(), &config).await; + let base_instructions = config + .base_instructions + .clone() + .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) + .unwrap_or_else(|| model_info.base_instructions.clone()); + // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode // to avoid extracting these fields separately and constructing CollaborationMode here. let collaboration_mode = CollaborationMode::Custom(Settings { @@ -288,7 +300,7 @@ impl Codex { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions, - base_instructions: config.base_instructions.clone(), + base_instructions, compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -400,7 +412,6 @@ pub(crate) struct TurnContext { /// instead of `std::env::current_dir()`. pub(crate) cwd: PathBuf, pub(crate) developer_instructions: Option, - pub(crate) base_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) approval_policy: AskForApproval, @@ -442,8 +453,8 @@ pub(crate) struct SessionConfiguration { /// Model instructions that are appended to the base instructions. user_instructions: Option, - /// Base instructions override. - base_instructions: Option, + /// Base instructions for the session. + base_instructions: String, /// Compact prompt override. compact_prompt: Option, @@ -552,7 +563,6 @@ impl Session { client, cwd: session_configuration.cwd.clone(), developer_instructions: session_configuration.developer_instructions.clone(), - base_instructions: session_configuration.base_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy.value(), @@ -600,7 +610,14 @@ impl Session { let conversation_id = ThreadId::default(); ( conversation_id, - RolloutRecorderParams::new(conversation_id, forked_from_id, session_source), + RolloutRecorderParams::new( + conversation_id, + forked_from_id, + session_source, + BaseInstructions { + text: session_configuration.base_instructions.clone(), + }, + ), ) } InitialHistory::Resumed(resumed_history) => ( @@ -812,6 +829,13 @@ impl Session { state.get_total_token_usage(state.server_reasoning_included()) } + pub(crate) async fn get_base_instructions(&self) -> BaseInstructions { + let state = self.state.lock().await; + BaseInstructions { + text: state.session_configuration.base_instructions.clone(), + } + } + async fn record_initial_history(&self, conversation_history: InitialHistory) { let turn_context = self.new_default_turn().await; match conversation_history { @@ -2620,7 +2644,6 @@ async fn spawn_review_thread( web_search_mode: Some(review_web_search_mode), }); - let base_instructions = REVIEW_PROMPT.to_string(); let review_prompt = resolved.prompt.clone(); let provider = parent_turn_context.client.get_provider(); let auth_manager = parent_turn_context.client.get_auth_manager(); @@ -2657,7 +2680,6 @@ async fn spawn_review_thread( ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), developer_instructions: None, user_instructions: None, - base_instructions: Some(base_instructions.clone()), compact_prompt: parent_turn_context.compact_prompt.clone(), approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), @@ -2935,11 +2957,13 @@ async fn run_sampling_request( .get_model_info() .supports_parallel_tool_calls; + let base_instructions = sess.get_base_instructions().await; + let prompt = Prompt { input, tools: router.specs(), parallel_tool_calls: model_supports_parallel, - base_instructions_override: turn_context.base_instructions.clone(), + base_instructions, output_schema: turn_context.final_output_json_schema.clone(), }; @@ -3055,7 +3079,6 @@ async fn try_run_sampling_request( model: turn_context.client.get_model(), effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), - base_instructions: turn_context.base_instructions.clone(), user_instructions: turn_context.user_instructions.clone(), developer_instructions: turn_context.developer_instructions.clone(), final_output_json_schema: turn_context.final_output_json_schema.clone(), @@ -3300,6 +3323,7 @@ mod tests { use super::*; use crate::CodexAuth; use crate::config::ConfigBuilder; + use crate::config::test_config; use crate::exec::ExecToolCallOutput; use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; @@ -3343,6 +3367,77 @@ mod tests { use std::sync::Arc; use std::time::Duration as StdDuration; + struct InstructionsTestCase { + slug: &'static str, + expects_apply_patch_instructions: bool, + } + + #[tokio::test] + async fn get_base_instructions_no_user_content() { + let prompt_with_apply_patch_instructions = + include_str!("../prompt_with_apply_patch_instructions.md"); + let test_cases = vec![ + InstructionsTestCase { + slug: "gpt-3.5", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-4.1", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-4o", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-5", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-5.1", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "codex-mini-latest", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-oss:120b", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "gpt-5.1-codex", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "gpt-5.1-codex-max", + expects_apply_patch_instructions: false, + }, + ]; + + let (session, _turn_context) = make_session_and_context().await; + + for test_case in test_cases { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline(test_case.slug, &config); + if test_case.expects_apply_patch_instructions { + assert_eq!( + model_info.base_instructions.as_str(), + prompt_with_apply_patch_instructions + ); + } + + { + let mut state = session.state.lock().await; + state.session_configuration.base_instructions = + model_info.base_instructions.clone(); + } + + let base_instructions = session.get_base_instructions().await; + assert_eq!(base_instructions.text, model_info.base_instructions); + } + } + #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; @@ -3595,6 +3690,7 @@ mod tests { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode::Custom(Settings { model, @@ -3607,7 +3703,10 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -3666,6 +3765,7 @@ mod tests { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode::Custom(Settings { model, @@ -3678,7 +3778,10 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -3921,6 +4024,7 @@ mod tests { let exec_policy = ExecPolicyManager::default(); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode::Custom(Settings { model, @@ -3933,7 +4037,10 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -4021,6 +4128,7 @@ mod tests { let exec_policy = ExecPolicyManager::default(); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode::Custom(Settings { model, @@ -4033,7 +4141,10 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 40e6284deb..b22252e439 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -90,7 +90,6 @@ async fn run_compact_task_inner( model: turn_context.client.get_model(), effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), - base_instructions: turn_context.base_instructions.clone(), user_instructions: turn_context.user_instructions.clone(), developer_instructions: turn_context.developer_instructions.clone(), final_output_json_schema: turn_context.final_output_json_schema.clone(), @@ -104,6 +103,7 @@ async fn run_compact_task_inner( let turn_input_len = turn_input.len(); let prompt = Prompt { input: turn_input, + base_instructions: sess.get_base_instructions().await, ..Default::default() }; let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index dd038c5287..b8dae3ceab 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -54,7 +54,7 @@ async fn run_remote_compact_task_inner_impl( input: history.for_prompt(), tools: vec![], parallel_tool_calls: false, - base_instructions_override: turn_context.base_instructions.clone(), + base_instructions: sess.get_base_instructions().await, output_schema: None, }; diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 5feeb6e12a..b6e7ef91af 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -86,6 +86,9 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo } }; } + if let Some(base_instructions) = &config.base_instructions { + model.base_instructions = base_instructions.clone(); + } model } diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 1c6503f318..8c2efcf4e1 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use std::time::SystemTime; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use serde_json::Value; use time::OffsetDateTime; use time::format_description::FormatItem; @@ -59,6 +60,7 @@ pub enum RolloutRecorderParams { conversation_id: ThreadId, forked_from_id: Option, source: SessionSource, + base_instructions: BaseInstructions, }, Resume { path: PathBuf, @@ -81,11 +83,13 @@ impl RolloutRecorderParams { conversation_id: ThreadId, forked_from_id: Option, source: SessionSource, + base_instructions: BaseInstructions, ) -> Self { Self::Create { conversation_id, forked_from_id, source, + base_instructions, } } @@ -160,6 +164,7 @@ impl RolloutRecorder { conversation_id, forked_from_id, source, + base_instructions, } => { let LogFileInfo { file, @@ -188,6 +193,7 @@ impl RolloutRecorder { cli_version: env!("CARGO_PKG_VERSION").to_string(), source, model_provider: Some(config.model_provider_id.clone()), + base_instructions: Some(base_instructions), }), ) } diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index c519c0c70c..08d4a1e2c9 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -6,6 +6,7 @@ use std::fs::{self}; use std::io::Write; use std::path::Path; +use pretty_assertions::assert_eq; use tempfile::TempDir; use time::Duration; use time::OffsetDateTime; @@ -89,6 +90,7 @@ fn write_session_file_with_provider( "cwd": ".", "originator": "test_originator", "cli_version": "test_version", + "base_instructions": null, }); if let Some(source) = source { @@ -129,6 +131,48 @@ fn write_session_file_with_provider( Ok((dt, uuid)) } +fn write_session_file_with_meta_payload( + root: &Path, + ts_str: &str, + uuid: Uuid, + payload: serde_json::Value, +) -> std::io::Result<()> { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let dt = PrimitiveDateTime::parse(ts_str, format) + .unwrap() + .assume_utc(); + let dir = root + .join("sessions") + .join(format!("{:04}", dt.year())) + .join(format!("{:02}", u8::from(dt.month()))) + .join(format!("{:02}", dt.day())); + fs::create_dir_all(&dir)?; + + let filename = format!("rollout-{ts_str}-{uuid}.jsonl"); + let file_path = dir.join(filename); + let mut file = File::create(file_path)?; + + let meta = serde_json::json!({ + "timestamp": ts_str, + "type": "session_meta", + "payload": payload, + }); + writeln!(file, "{meta}")?; + + let user_event = serde_json::json!({ + "timestamp": ts_str, + "type": "event_msg", + "payload": {"type": "user_message", "message": "Hello from user", "kind": "plain"} + }); + writeln!(file, "{user_event}")?; + + let times = FileTimes::new().set_modified(dt.into()); + file.set_times(times)?; + + Ok(()) +} + #[tokio::test] async fn test_list_conversations_latest_first() { let temp = TempDir::new().unwrap(); @@ -206,6 +250,7 @@ async fn test_list_conversations_latest_first() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_2 = vec![serde_json::json!({ "id": u2, @@ -215,6 +260,7 @@ async fn test_list_conversations_latest_first() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_1 = vec![serde_json::json!({ "id": u1, @@ -224,6 +270,7 @@ async fn test_list_conversations_latest_first() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_times: Vec> = @@ -344,6 +391,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_4 = vec![serde_json::json!({ "id": u4, @@ -353,6 +401,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_page1: Vec> = page1.items.iter().map(|i| i.updated_at.clone()).collect(); @@ -410,6 +459,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_2 = vec![serde_json::json!({ "id": u2, @@ -419,6 +469,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_page2: Vec> = page2.items.iter().map(|i| i.updated_at.clone()).collect(); @@ -470,6 +521,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_page3: Vec> = page3.items.iter().map(|i| i.updated_at.clone()).collect(); @@ -527,6 +579,7 @@ async fn test_get_thread_contents() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let expected_page = ThreadsPage { items: vec![ThreadItem { @@ -551,6 +604,7 @@ async fn test_get_thread_contents() { "cwd": ".", "originator": "test_originator", "cli_version": "test_version", + "base_instructions": null, "source": "vscode", "model_provider": "test-provider", } @@ -566,6 +620,93 @@ async fn test_get_thread_contents() { assert_eq!(content, expected_content); } +#[tokio::test] +async fn test_base_instructions_missing_in_meta_defaults_to_null() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-04-02T10-30-00"; + let uuid = Uuid::from_u128(101); + let payload = serde_json::json!({ + "id": uuid, + "timestamp": ts, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "vscode", + "model_provider": "test-provider", + }); + write_session_file_with_meta_payload(home, ts, uuid, payload).unwrap(); + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 1, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); + + let head = page + .items + .first() + .and_then(|item| item.head.first()) + .expect("session meta head"); + assert_eq!( + head.get("base_instructions"), + Some(&serde_json::Value::Null) + ); +} + +#[tokio::test] +async fn test_base_instructions_present_in_meta_is_preserved() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-04-03T10-30-00"; + let uuid = Uuid::from_u128(102); + let base_text = "Custom base instructions"; + let payload = serde_json::json!({ + "id": uuid, + "timestamp": ts, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "vscode", + "model_provider": "test-provider", + "base_instructions": {"text": base_text}, + }); + write_session_file_with_meta_payload(home, ts, uuid, payload).unwrap(); + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 1, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); + + let head = page + .items + .first() + .and_then(|item| item.head.first()) + .expect("session meta head"); + let base = head + .get("base_instructions") + .and_then(|value| value.get("text")) + .and_then(serde_json::Value::as_str); + assert_eq!(base, Some(base_text)); +} + #[tokio::test] async fn test_created_at_sort_uses_file_mtime_for_updated_at() -> Result<()> { let temp = TempDir::new().unwrap(); @@ -637,6 +778,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { cli_version: "test_version".into(), source: SessionSource::VSCode, model_provider: Some("test-provider".into()), + base_instructions: None, }, git: None, }), @@ -744,6 +886,7 @@ async fn test_stable_ordering_same_second_pagination() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })] }; let updated_page1: Vec> = diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 6117211757..56463e644a 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -12,6 +12,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; @@ -115,10 +116,12 @@ mod spawn { .into(), ) .await; - let mut config = build_agent_spawn_config(turn.as_ref())?; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; agent_role .apply_to_config(&mut config) .map_err(FunctionCallError::RespondToModel)?; + let result = session .services .agent_control @@ -557,15 +560,18 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { } } -fn build_agent_spawn_config(turn: &TurnContext) -> Result { +fn build_agent_spawn_config( + base_instructions: &BaseInstructions, + turn: &TurnContext, +) -> Result { let base_config = turn.client.config(); let mut config = (*base_config).clone(); + config.base_instructions = Some(base_instructions.text.clone()); config.model = Some(turn.client.get_model()); config.model_provider = turn.client.get_provider(); config.model_reasoning_effort = turn.client.get_reasoning_effort(); config.model_reasoning_summary = turn.client.get_reasoning_summary(); config.developer_instructions = turn.developer_instructions.clone(); - config.base_instructions = turn.base_instructions.clone(); config.compact_prompt = turn.compact_prompt.clone(); config.user_instructions = turn.user_instructions.clone(); config.shell_environment_policy = turn.shell_environment_policy.clone(); @@ -1062,8 +1068,10 @@ mod tests { #[tokio::test] async fn build_agent_spawn_config_uses_turn_context_values() { let (_session, mut turn) = make_session_and_context().await; + let base_instructions = BaseInstructions { + text: "base".to_string(), + }; turn.developer_instructions = Some("dev".to_string()); - turn.base_instructions = Some("base".to_string()); turn.compact_prompt = Some("compact".to_string()); turn.user_instructions = Some("user".to_string()); turn.shell_environment_policy = ShellEnvironmentPolicy { @@ -1076,14 +1084,14 @@ mod tests { turn.approval_policy = AskForApproval::Never; turn.sandbox_policy = SandboxPolicy::DangerFullAccess; - let config = build_agent_spawn_config(&turn).expect("spawn config"); + let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); let mut expected = (*turn.client.config()).clone(); + expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.client.get_model()); expected.model_provider = turn.client.get_provider(); expected.model_reasoning_effort = turn.client.get_reasoning_effort(); expected.model_reasoning_summary = turn.client.get_reasoning_summary(); expected.developer_instructions = turn.developer_instructions.clone(); - expected.base_instructions = turn.base_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); expected.user_instructions = turn.user_instructions.clone(); expected.shell_environment_policy = turn.shell_environment_policy.clone(); diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index e7123c4305..8a6beb6e24 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -388,9 +388,10 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let base_model_info = models_manager.get_model_info("gpt-5.1", &config).await; let body = response_mock.single_request().body_json(); let instructions = body["instructions"].as_str().unwrap(); - assert_eq!(instructions, remote_base); + assert_eq!(instructions, base_model_info.base_instructions); Ok(()) } diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 8912870e5e..47b67cfe38 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -131,3 +131,78 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_switches_models_preserves_base_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.model = Some("gpt-5.2".to_string()); + }); + let initial = builder.build(&server).await?; + let codex = Arc::clone(&initial.codex); + let home = initial.home.clone(); + let rollout_path = initial.session_configured.rollout_path.clone(); + + let initial_sse = sse(vec![ + ev_response_created("resp-initial"), + ev_assistant_message("msg-1", "Completed first turn"), + ev_completed("resp-initial"), + ]); + let initial_mock = mount_sse_once(&server, initial_sse).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Record initial instructions".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let initial_body = initial_mock.single_request().body_json(); + let initial_instructions = initial_body + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + let resumed_sse = sse(vec![ + ev_response_created("resp-resume"), + ev_assistant_message("msg-2", "Resumed turn"), + ev_completed("resp-resume"), + ]); + let resumed_mock = mount_sse_once(&server, resumed_sse).await; + + let mut resume_builder = test_codex().with_config(|config| { + config.model = Some("gpt-5.2-codex".to_string()); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + resumed + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Resume with different model".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&resumed.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let resumed_body = resumed_mock.single_request().body_json(); + let resumed_instructions = resumed_body + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + assert_eq!(resumed_instructions, initial_instructions); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index df0534bf40..8c3ecf7afe 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -28,7 +28,6 @@ fn resume_history( model: previous_model.to_string(), effort: config.model_reasoning_effort, summary: config.model_reasoning_summary, - base_instructions: None, user_instructions: None, developer_instructions: None, final_output_json_schema: None, diff --git a/codex-rs/protocol/BUILD.bazel b/codex-rs/protocol/BUILD.bazel index 2c00d076bc..e47bc8c16b 100644 --- a/codex-rs/protocol/BUILD.bazel +++ b/codex-rs/protocol/BUILD.bazel @@ -3,5 +3,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "protocol", crate_name = "codex_protocol", - compile_data = glob(["src/prompts/permissions/**/*.md"]), + compile_data = glob(["src/prompts/**/*.md"]), ) diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 8f8c9b3077..1b22fb925f 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -167,6 +167,23 @@ pub enum ResponseItem { Other, } +pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md"); + +/// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(rename = "base_instructions", rename_all = "snake_case")] +pub struct BaseInstructions { + pub text: String, +} + +impl Default for BaseInstructions { + fn default() -> Self { + Self { + text: BASE_INSTRUCTIONS_DEFAULT.to_string(), + } + } +} + /// Developer-provided guidance that is injected into a turn as a developer role /// message. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/protocol/src/prompts/base_instructions/default.md b/codex-rs/protocol/src/prompts/base_instructions/default.md new file mode 100644 index 0000000000..4886c7ef44 --- /dev/null +++ b/codex-rs/protocol/src/prompts/base_instructions/default.md @@ -0,0 +1,275 @@ +You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful. + +Your capabilities: + +- Receive user prompts and other context provided by the harness, such as files in the workspace. +- Communicate with the user by streaming thinking & responses, and by making & updating plans. +- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section. + +Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI). + +# How you work + +## Personality + +Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work. + +# AGENTS.md spec +- Repos often contain AGENTS.md files. These files can appear anywhere within the repository. +- These files are a way for humans to give you (the agent) instructions or tips for working within the container. +- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code. +- Instructions in AGENTS.md files: + - The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it. + - For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file. + - Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise. + - More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions. + - Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions. +- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable. + +## Responsiveness + +### Preamble messages + +Before making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples: + +- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each. +- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (8–12 words for quick updates). +- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions. +- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging. +- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action. + +**Examples:** + +- “I’ve explored the repo; now checking the API route definitions.” +- “Next, I’ll patch the config and update the related tests.” +- “I’m about to scaffold the CLI commands and helper functions.” +- “Ok cool, so I’ve wrapped my head around the repo. Now digging into the API routes.” +- “Config’s looking tidy. Next up is patching helpers to keep things in sync.” +- “Finished poking at the DB gateway. I will now chase down error handling.” +- “Alright, build pipeline order is interesting. Checking how it reports failures.” +- “Spotted a clever caching util; now hunting where it gets used.” + +## Planning + +You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go. + +Note that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately. + +Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step. + +Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so. + +Use a plan when: + +- The task is non-trivial and will require multiple actions over a long time horizon. +- There are logical phases or dependencies where sequencing matters. +- The work has ambiguity that benefits from outlining high-level goals. +- You want intermediate checkpoints for feedback and validation. +- When the user asked you to do more than one thing in a single prompt +- The user has asked you to use the plan tool (aka "TODOs") +- You generate additional steps while working, and plan to do them before yielding to the user + +### Examples + +**High-quality plans** + +Example 1: + +1. Add CLI entry with file args +2. Parse Markdown via CommonMark library +3. Apply semantic HTML template +4. Handle code blocks, images, links +5. Add error handling for invalid files + +Example 2: + +1. Define CSS variables for colors +2. Add toggle with localStorage state +3. Refactor components to use variables +4. Verify all views for readability +5. Add smooth theme-change transition + +Example 3: + +1. Set up Node.js + WebSocket server +2. Add join/leave broadcast events +3. Implement messaging with timestamps +4. Add usernames + mention highlighting +5. Persist messages in lightweight DB +6. Add typing indicators + unread count + +**Low-quality plans** + +Example 1: + +1. Create CLI tool +2. Add Markdown parser +3. Convert to HTML + +Example 2: + +1. Add dark mode toggle +2. Save preference +3. Make styles look good + +Example 3: + +1. Create single-file HTML game +2. Run quick sanity check +3. Summarize usage instructions + +If you need to write a plan, only write high quality plans, not low quality ones. + +## Task execution + +You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer. + +You MUST adhere to the following criteria when solving queries: + +- Working on the repo(s) in the current environment is allowed, even if they are proprietary. +- Analyzing code for vulnerabilities is allowed. +- Showing user code and tool call details is allowed. +- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]} + +If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines: + +- Fix the problem at the root cause rather than applying surface-level patches, when possible. +- Avoid unneeded complexity in your solution. +- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.) +- Update documentation as necessary. +- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task. +- Use `git log` and `git blame` to search the history of the codebase if additional context is required. +- NEVER add copyright or license headers unless specifically requested. +- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc. +- Do not `git commit` your changes or create new git branches unless explicitly requested. +- Do not add inline comments within code unless explicitly requested. +- Do not use one-letter variable names unless explicitly requested. +- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor. + +## Validating your work + +If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete. + +When testing, your philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests. + +Similarly, once you're confident in correctness, you can suggest or use formatting commands to ensure that your code is well formatted. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one. + +For all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.) + +Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance: + +- When running in non-interactive approval modes like **never** or **on-failure**, proactively run tests, lint and do whatever you need to ensure you've completed the task. +- When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first. +- When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task. + +## Ambition vs. precision + +For tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation. + +If you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature. + +You should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified. + +## Sharing progress updates + +For especially longer tasks that you work on (i.e. requiring many tool calls, or a plan with multiple steps), you should provide progress updates back to the user at reasonable intervals. These updates should be structured as a concise sentence or two (no more than 8-10 words long) recapping progress so far in plain language: this update demonstrates your understanding of what needs to be done, progress so far (i.e. files explores, subtasks complete), and where you're going next. + +Before doing large chunks of work that may incur latency as experienced by the user (i.e. writing a new file), you should send a concise message to the user with an update indicating what you're about to do to ensure they know what you're spending time on. Don't start editing or writing large files before informing the user what you are doing and why. + +The messages you send before tool calls should describe what is immediately about to be done next in very concise language. If there was previous work done, this preamble message should also include a note about the work done so far to bring the user along. + +## Presenting your work and final message + +Your final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the user’s style. If you've finished a large amount of work, when describing what you've done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges. + +You can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation. + +The user is working on the same computer as you, and has access to your work. As such there's no need to show the full contents of large files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to "save the file" or "copy the code into a file"—just reference the file path. + +If there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If there’s something that you couldn't do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly. + +Brevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding. + +### Final answer structure and style guidelines + +You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. + +**Section Headers** + +- Use only when they improve clarity — they are not mandatory for every answer. +- Choose descriptive names that fit the content +- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**` +- Leave no blank line before the first bullet under a header. +- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer. + +**Bullets** + +- Use `-` followed by a space for every bullet. +- Merge related points when possible; avoid a bullet for every trivial detail. +- Keep bullets to one line unless breaking for clarity is unavoidable. +- Group into short lists (4–6 bullets) ordered by importance. +- Use consistent keyword phrasing and formatting across sections. + +**Monospace** + +- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``). +- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command. +- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``). + +**File References** +When referencing files in your response, make sure to include the relevant start line and always follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 + +**Structure** + +- Place related bullets together; don’t mix unrelated concepts in the same section. +- Order sections from general → specific → supporting info. +- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it. +- Match structure to complexity: + - Multi-part or detailed results → use clear headers and grouped bullets. + - Simple results → minimal headers, possibly just a short list or paragraph. + +**Tone** + +- Keep the voice collaborative and natural, like a coding partner handing off work. +- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition +- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”). +- Keep descriptions self-contained; don’t refer to “above” or “below”. +- Use parallel structure in lists for consistency. + +**Don’t** + +- Don’t use literal words “bold” or “monospace” in the content. +- Don’t nest bullets or create deep hierarchies. +- Don’t output ANSI escape codes directly — the CLI renderer applies them. +- Don’t cram unrelated keywords into a single bullet; split for clarity. +- Don’t let keyword lists run long — wrap or reformat for scanability. + +Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable. + +For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting. + +# Tool Guidelines + +## Shell commands + +When using the shell, you must adhere to the following guidelines: + +- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.) +- Do not use python scripts to attempt to output larger chunks of a file. + +## `update_plan` + +A tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task. + +To create a new plan, call `update_plan` with a short list of 1‑sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`). + +When steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call. + +If all steps are complete, ensure you call `update_plan` to mark all steps as `completed`. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index eab09adede..533fe04d88 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -18,6 +18,7 @@ use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; use crate::items::TurnItem; use crate::message_history::HistoryEntry; +use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; @@ -1454,6 +1455,23 @@ impl InitialHistory { ), } } + + pub fn get_base_instructions(&self) -> Option { + // TODO: SessionMeta should (in theory) always be first in the history, so we can probably only check the first item? + match self { + InitialHistory::New => None, + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(), + _ => None, + }) + } + InitialHistory::Forked(items) => items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(), + _ => None, + }), + } + } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)] @@ -1502,6 +1520,11 @@ impl fmt::Display for SubAgentSource { } } +/// SessionMeta contains session-level data that doesn't correspond to a specific turn. +/// +/// NOTE: There used to be an `instructions` field here, which stored user_instructions, but we +/// now save that on TurnContext. base_instructions stores the base instructions for the session, +/// and should be used when there is no config override. #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { pub id: ThreadId, @@ -1514,6 +1537,10 @@ pub struct SessionMeta { #[serde(default)] pub source: SessionSource, pub model_provider: Option, + /// base_instructions for the session. This *should* always be present when creating a new session, + /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions + /// from ModelsManager. + pub base_instructions: Option, } impl Default for SessionMeta { @@ -1527,6 +1554,7 @@ impl Default for SessionMeta { cli_version: String::new(), source: SessionSource::default(), model_provider: None, + base_instructions: None, } } } @@ -1578,8 +1606,6 @@ pub struct TurnContextItem { pub effort: Option, pub summary: ReasoningSummaryConfig, #[serde(skip_serializing_if = "Option::is_none")] - pub base_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub user_instructions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub developer_instructions: Option, From eb90e20c0bbf45109236385c3debb6172c1e28d2 Mon Sep 17 00:00:00 2001 From: charley-oai Date: Mon, 19 Jan 2026 23:49:34 -0800 Subject: [PATCH 56/78] Persist text elements through TUI input and history (#9393) Continuation of breaking up this PR https://github.com/openai/codex/pull/9116 ## Summary - Thread user text element ranges through TUI/TUI2 input, submission, queueing, and history so placeholders survive resume/edit flows. - Preserve local image attachments alongside text elements and rehydrate placeholders when restoring drafts. - Keep model-facing content shapes clean by attaching UI metadata only to user input/events (no API content changes). ## Key Changes - TUI/TUI2 composer now captures text element ranges, trims them with text edits, and restores them when submission is suppressed. - User history cells render styled spans for text elements and keep local image paths for future rehydration. - Initial chat widget bootstraps accept empty `initial_text_elements` to keep initialization uniform. - Protocol/core helpers updated to tolerate the new InputText field shape without changing payloads sent to the API. --- codex-rs/app-server-test-client/src/main.rs | 5 +- codex-rs/cli/src/main.rs | 9 +- codex-rs/core/src/agent/control.rs | 2 +- codex-rs/core/src/codex.rs | 2 + codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/event_mapping.rs | 2 +- codex-rs/debug-client/src/client.rs | 2 +- codex-rs/exec/src/lib.rs | 2 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 +- codex-rs/tui/src/app.rs | 84 +- codex-rs/tui/src/app_backtrack.rs | 13 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 751 +++++++++++++++--- codex-rs/tui/src/bottom_pane/mod.rs | 38 +- codex-rs/tui/src/bottom_pane/textarea.rs | 50 +- codex-rs/tui/src/chatwidget.rs | 335 ++++++-- codex-rs/tui/src/chatwidget/tests.rs | 388 ++++++++- codex-rs/tui/src/history_cell.rs | 112 ++- .../tui/src/public_widgets/composer_input.rs | 5 +- codex-rs/tui2/src/app.rs | 84 +- codex-rs/tui2/src/app_backtrack.rs | 13 +- .../tui2/src/bottom_pane/chat_composer.rs | 748 ++++++++++++++--- codex-rs/tui2/src/bottom_pane/mod.rs | 38 +- codex-rs/tui2/src/bottom_pane/textarea.rs | 47 +- codex-rs/tui2/src/chatwidget.rs | 333 ++++++-- codex-rs/tui2/src/chatwidget/tests.rs | 388 ++++++++- codex-rs/tui2/src/history_cell.rs | 112 ++- .../tui2/src/public_widgets/composer_input.rs | 5 +- codex-rs/tui2/src/transcript_view_cache.rs | 2 + 28 files changed, 3081 insertions(+), 494 deletions(-) diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 9df895a70a..1407c2401d 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -258,7 +258,7 @@ fn send_message_v2_with_policies( thread_id: thread_response.thread.id.clone(), input: vec![V2UserInput::Text { text: user_message, - // Plain text conversion has no UI element ranges. + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], ..Default::default() @@ -292,6 +292,7 @@ fn send_follow_up_v2( thread_id: thread_response.thread.id.clone(), input: vec![V2UserInput::Text { text: first_message, + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], ..Default::default() @@ -304,6 +305,7 @@ fn send_follow_up_v2( thread_id: thread_response.thread.id.clone(), input: vec![V2UserInput::Text { text: follow_up_message, + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], ..Default::default() @@ -477,6 +479,7 @@ impl CodexClient { conversation_id: *conversation_id, items: vec![InputItem::Text { text: message.to_string(), + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], }, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d7c7cd288b..25c1161c8d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -728,9 +728,13 @@ fn prepend_config_flags( /// Run the interactive Codex TUI, dispatching to either the legacy implementation or the /// experimental TUI v2 shim based on feature flags resolved from config. async fn run_interactive_tui( - interactive: TuiCli, + mut interactive: TuiCli, codex_linux_sandbox_exe: Option, ) -> std::io::Result { + if let Some(prompt) = interactive.prompt.take() { + // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. + interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); + } if is_tui2_enabled(&interactive).await? { let result = tui2::run_main(interactive.into(), codex_linux_sandbox_exe).await?; Ok(result.into()) @@ -855,7 +859,8 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) interactive.add_dir.extend(subcommand_cli.add_dir); } if let Some(prompt) = subcommand_cli.prompt { - interactive.prompt = Some(prompt); + // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. + interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); } interactive diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index bc978d3ba7..4467494fcf 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -58,7 +58,7 @@ impl AgentControl { Op::UserInput { items: vec![UserInput::Text { text: prompt, - // Plain text conversion has no UI element ranges. + // Agent control prompts are plain text with no UI text elements. text_elements: Vec::new(), }], final_output_json_schema: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9f220922f5..a2a3481d8c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2485,6 +2485,7 @@ mod handlers { Arc::clone(&turn_context), vec![UserInput::Text { text: turn_context.compact_prompt().to_string(), + // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }], CompactTask, @@ -2694,6 +2695,7 @@ async fn spawn_review_thread( // Seed the child task with the review prompt as the initial user message. let input: Vec = vec![UserInput::Text { text: review_prompt, + // Review prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; let tc = Arc::new(review_turn_context); diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index b22252e439..bde807154d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -46,7 +46,7 @@ pub(crate) async fn run_inline_auto_compact_task( let prompt = turn_context.compact_prompt().to_string(); let input = vec![UserInput::Text { text: prompt, - // Plain text conversion has no UI element ranges. + // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index eaa477b085..493a159593 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -52,7 +52,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option { } content.push(UserInput::Text { text: text.clone(), - // Plain text conversion has no UI element ranges. + // Model input content does not carry UI element ranges. text_elements: Vec::new(), }); } diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 377dc02a8d..673a2a8fbc 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -187,7 +187,7 @@ impl AppServerClient { thread_id: thread_id.to_string(), input: vec![UserInput::Text { text, - // Plain text conversion has no UI element ranges. + // Debug client sends plain text with no UI markup spans. text_elements: Vec::new(), }], ..Default::default() diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a76510ed95..a7629a107f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -360,6 +360,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .collect(); items.push(UserInput::Text { text: prompt_text.clone(), + // CLI input doesn't track UI element ranges, so none are available here. text_elements: Vec::new(), }); let output_schema = load_output_schema(output_schema_path.clone()); @@ -379,6 +380,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .collect(); items.push(UserInput::Text { text: prompt_text.clone(), + // CLI input doesn't track UI element ranges, so none are available here. text_elements: Vec::new(), }); let output_schema = load_output_schema(output_schema_path); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index b268b8f1b4..b400b4cc79 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -122,6 +122,7 @@ pub async fn run_codex_tool_session( op: Op::UserInput { items: vec![UserInput::Text { text: initial_prompt.clone(), + // MCP tool prompts are plain text with no UI element ranges. text_elements: Vec::new(), }], final_output_json_schema: None, @@ -167,7 +168,7 @@ pub async fn run_codex_tool_session_reply( .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt, - // Plain text conversion has no UI element ranges. + // MCP tool prompts are plain text with no UI element ranges. text_elements: Vec::new(), }], final_output_json_schema: None, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 211975c275..e33e934029 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -365,6 +365,26 @@ pub(crate) struct App { } impl App { + pub fn chatwidget_init_for_forked_or_resumed_thread( + &self, + tui: &mut tui::Tui, + cfg: codex_core::config::Config, + ) -> crate::chatwidget::ChatWidgetInit { + crate::chatwidget::ChatWidgetInit { + config: cfg, + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + // Fork/resume bootstraps here don't carry any prefilled message content. + initial_user_message: None, + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + model: Some(self.current_model.clone()), + } + } + async fn shutdown_current_thread(&mut self) { if let Some(thread_id) = self.chat_widget.thread_id() { // Clear any in-flight rollback guard when switching threads. @@ -428,8 +448,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -451,8 +475,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -474,8 +502,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -679,8 +711,8 @@ impl App { config: self.config.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), + // New sessions start without prefilled message content. + initial_user_message: None, enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), @@ -725,19 +757,10 @@ impl App { { Ok(resumed) => { self.shutdown_current_thread().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); self.chat_widget = ChatWidget::new_from_existing( init, resumed.thread, @@ -784,19 +807,10 @@ impl App { { Ok(forked) => { self.shutdown_current_thread().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); self.chat_widget = ChatWidget::new_from_existing( init, forked.thread, @@ -2002,6 +2016,8 @@ mod tests { let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { message: text.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc }; let agent_cell = |text: &str| -> Arc { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 80fc65ca77..9a77b6bed7 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -204,7 +204,10 @@ impl App { }); self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); if !prefill.is_empty() { - self.chat_widget.set_composer_text(prefill); + // TODO: Rehydrate text_elements/local_image_paths from the selected user cell so + // backtrack preserves image placeholders and attachments. + self.chat_widget + .set_composer_text(prefill, Vec::new(), Vec::new()); } } @@ -554,6 +557,8 @@ mod tests { let mut cells: Vec> = vec![ Arc::new(UserHistoryCell { message: "first user".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, @@ -570,6 +575,8 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, @@ -598,11 +605,15 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) as Arc, Arc::new(UserHistoryCell { message: "second".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3ab9071347..55b40a9a06 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -110,9 +110,12 @@ use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use codex_protocol::models::local_image_label_text; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; @@ -124,6 +127,7 @@ use codex_file_search::FileMatch; use std::cell::RefCell; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::VecDeque; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; @@ -142,8 +146,14 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; /// Result returned when the user interacts with the text area. #[derive(Debug, PartialEq)] pub enum InputResult { - Submitted(String), - Queued(String), + Submitted { + text: String, + text_elements: Vec, + }, + Queued { + text: String, + text_elements: Vec, + }, Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -339,7 +349,8 @@ impl ChatComposer { let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { return false; }; - self.set_text_content(text); + // Composer history (↑/↓) stores plain text only; no UI element ranges/attachments to restore here. + self.set_text_content(text, Vec::new(), Vec::new()); true } @@ -362,6 +373,7 @@ impl ChatComposer { /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { + let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -462,7 +474,7 @@ impl ChatComposer { self.attached_images = kept_images; // Rebuild textarea so placeholders become elements again. - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); let mut remaining: HashMap<&str, usize> = HashMap::new(); for img in &self.attached_images { *remaining.entry(img.placeholder.as_str()).or_insert(0) += 1; @@ -529,12 +541,37 @@ impl ChatComposer { } /// Replace the entire composer content with `text` and reset cursor. - pub(crate) fn set_text_content(&mut self, text: String) { + /// This clears any pending paste payloads. + pub(crate) fn set_text_content( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); self.pending_pastes.clear(); self.attached_images.clear(); - self.textarea.set_text(&text); + + self.textarea.set_text_with_elements(&text, &text_elements); + + let image_placeholders: HashSet = text_elements + .iter() + .filter_map(|elem| { + elem.placeholder.as_ref().cloned().or_else(|| { + text.get(elem.byte_range.start..elem.byte_range.end) + .map(str::to_string) + }) + }) + .collect(); + for (idx, path) in local_image_paths.into_iter().enumerate() { + let placeholder = local_image_label_text(idx + 1); + if image_placeholders.contains(&placeholder) { + self.attached_images + .push(AttachedImage { placeholder, path }); + } + } + self.textarea.set_cursor(0); self.sync_popups(); } @@ -544,7 +581,7 @@ impl ChatComposer { return None; } let previous = self.current_text(); - self.set_text_content(String::new()); + self.set_text_content(String::new(), Vec::new(), Vec::new()); self.history.reset_navigation(); self.history.record_local_submission(&previous); Some(previous) @@ -555,6 +592,28 @@ impl ChatComposer { self.textarea.text().to_string() } + pub(crate) fn text_elements(&self) -> Vec { + self.textarea.text_elements() + } + + #[cfg(test)] + pub(crate) fn local_image_paths(&self) -> Vec { + self.attached_images + .iter() + .map(|img| img.path.clone()) + .collect() + } + + pub(crate) fn local_images(&self) -> Vec { + self.attached_images + .iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder.clone(), + path: img.path.clone(), + }) + .collect() + } + /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { let image_number = self.attached_images.len() + 1; @@ -566,11 +625,23 @@ impl ChatComposer { .push(AttachedImage { placeholder, path }); } + #[cfg(test)] pub fn take_recent_submission_images(&mut self) -> Vec { let images = std::mem::take(&mut self.attached_images); images.into_iter().map(|img| img.path).collect() } + pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec { + let images = std::mem::take(&mut self.attached_images); + images + .into_iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder, + path: img.path, + }) + .collect() + } + /// Flushes any due paste-burst state. /// /// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits: @@ -749,7 +820,7 @@ impl ChatComposer { match sel { CommandItem::Builtin(cmd) => { if cmd == SlashCommand::Skills { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } @@ -757,7 +828,8 @@ impl ChatComposer { .trim_start() .starts_with(&format!("/{}", cmd.command())); if !starts_with_cmd { - self.textarea.set_text(&format!("/{} ", cmd.command())); + self.textarea + .set_text_clearing_elements(&format!("/{} ", cmd.command())); } if !self.textarea.text().is_empty() { cursor_target = Some(self.textarea.text().len()); @@ -772,7 +844,8 @@ impl ChatComposer { ) { PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); cursor_target = Some(target); } PromptSelectionAction::Submit { .. } => {} @@ -801,14 +874,21 @@ impl ChatComposer { && let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text: expanded, + // Expanded prompt is plain text; no UI element ranges to preserve. + text_elements: Vec::new(), + }, + true, + ); } if let Some(sel) = popup.selected_item() { match sel { CommandItem::Builtin(cmd) => { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } CommandItem::UserPrompt(idx) => { @@ -819,12 +899,20 @@ impl ChatComposer { PromptSelectionMode::Submit, ) { PromptSelectionAction::Submit { text } => { - self.textarea.set_text(""); - return (InputResult::Submitted(text), true); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text, + // Submitting a slash/custom prompt generates plain text, so there are no UI element ranges. + text_elements: Vec::new(), + }, + true, + ); } PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); self.textarea.set_cursor(target); return (InputResult::None, true); } @@ -1127,6 +1215,111 @@ impl ChatComposer { lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } + fn trim_text_elements( + original: &str, + trimmed: &str, + elements: Vec, + ) -> Vec { + if trimmed.is_empty() || elements.is_empty() { + return Vec::new(); + } + let trimmed_start = original.len().saturating_sub(original.trim_start().len()); + let trimmed_end = trimmed_start.saturating_add(trimmed.len()); + + elements + .into_iter() + .filter_map(|elem| { + let start = elem.byte_range.start; + let end = elem.byte_range.end; + if end <= trimmed_start || start >= trimmed_end { + return None; + } + let new_start = start.saturating_sub(trimmed_start); + let new_end = end.saturating_sub(trimmed_start).min(trimmed.len()); + if new_start >= new_end { + return None; + } + let placeholder = trimmed.get(new_start..new_end).map(str::to_string); + Some(TextElement { + byte_range: ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + }) + }) + .collect() + } + + /// Expand large-paste placeholders using element ranges and rebuild other element spans. + fn expand_pending_pastes( + text: &str, + mut elements: Vec, + pending_pastes: &[(String, String)], + ) -> (String, Vec) { + if pending_pastes.is_empty() || elements.is_empty() { + return (text.to_string(), elements); + } + + // Stage 1: index pending paste payloads by placeholder for deterministic replacements. + let mut pending_by_placeholder: HashMap<&str, VecDeque<&str>> = HashMap::new(); + for (placeholder, actual) in pending_pastes { + pending_by_placeholder + .entry(placeholder.as_str()) + .or_default() + .push_back(actual.as_str()); + } + + // Stage 2: walk elements in order and rebuild text/spans in a single pass. + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut rebuilt = String::with_capacity(text.len()); + let mut rebuilt_elements = Vec::with_capacity(elements.len()); + let mut cursor = 0usize; + + for elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if start > end { + continue; + } + if start > cursor { + rebuilt.push_str(&text[cursor..start]); + } + let elem_text = &text[start..end]; + let placeholder = elem.placeholder; + let replacement = placeholder + .as_deref() + .and_then(|ph| pending_by_placeholder.get_mut(ph)) + .and_then(VecDeque::pop_front); + if let Some(actual) = replacement { + // Stage 3: inline actual paste payloads and drop their placeholder elements. + rebuilt.push_str(actual); + } else { + // Stage 4: keep non-paste elements, updating their byte ranges for the new text. + let new_start = rebuilt.len(); + rebuilt.push_str(elem_text); + let new_end = rebuilt.len(); + let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); + rebuilt_elements.push(TextElement { + byte_range: ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + }); + } + cursor = end; + } + + // Stage 5: append any trailing text that followed the last element. + if cursor < text.len() { + rebuilt.push_str(&text[cursor..]); + } + + (rebuilt, rebuilt_elements) + } + fn skills_enabled(&self) -> bool { self.skills.as_ref().is_some_and(|s| !s.is_empty()) } @@ -1301,7 +1494,8 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Path replacement is plain text; rebuild without carrying elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } @@ -1336,42 +1530,42 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Skill insertion rebuilds plain text, so drop existing elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } /// Prepare text for submission/queuing. Returns None if submission should be suppressed. - fn prepare_submission_text(&mut self) -> Option { - // If we have pending placeholder pastes, replace them in the textarea text - // and continue to the normal submission flow to handle slash commands. - if !self.pending_pastes.is_empty() { - let mut text = self.textarea.text().to_string(); - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.textarea.set_text(&text); - self.pending_pastes.clear(); - } - + /// On success, clears pending paste payloads because placeholders have been expanded. + fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { let mut text = self.textarea.text().to_string(); let original_input = text.clone(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); + let mut text_elements = original_text_elements.clone(); let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } + if !self.pending_pastes.is_empty() { + // Expand placeholders so element byte ranges stay aligned. + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; } - self.pending_pastes.clear(); + + let expanded_input = text.clone(); // If there is neither text nor attachments, suppress submission entirely. let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); + text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); @@ -1395,7 +1589,12 @@ impl ChatComposer { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event(message, None), ))); - self.textarea.set_text(&original_input); + 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; } @@ -1408,13 +1607,21 @@ impl ChatComposer { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_error_event(err.user_message()), ))); - self.textarea.set_text(&original_input); + 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; + // Expanded prompt (e.g. custom prompt) is plain text; text elements not supported yet. + // TODO: Preserve UI element ranges through prompt expansion in a follow-up PR. + text_elements = Vec::new(); } if text.is_empty() && !has_attachments { return None; @@ -1422,7 +1629,8 @@ impl ChatComposer { if !text.is_empty() { self.history.record_local_submission(&text); } - Some(text) + self.pending_pastes.clear(); + Some((text, text_elements)) } /// Common logic for handling message submission/queuing. @@ -1471,20 +1679,44 @@ impl ChatComposer { } let original_input = self.textarea.text().to_string(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); if let Some(result) = self.try_dispatch_slash_command_with_args() { return (result, true); } - if let Some(text) = self.prepare_submission_text() { + if let Some((text, text_elements)) = self.prepare_submission_text() { if should_queue { - (InputResult::Queued(text), true) + ( + InputResult::Queued { + text, + text_elements, + }, + true, + ) } else { // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) + ( + InputResult::Submitted { + text, + text_elements, + }, + true, + ) } } else { - // Restore text if submission was suppressed - self.textarea.set_text(&original_input); + // Restore text if submission was suppressed. + self.set_text_content( + original_input, + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes = original_pending_pastes; (InputResult::None, true) } } @@ -1499,7 +1731,7 @@ impl ChatComposer { Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) .find(|(n, _)| *n == name) { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) } else { None @@ -1522,7 +1754,7 @@ impl ChatComposer { .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); } } @@ -1578,7 +1810,7 @@ impl ChatComposer { _ => unreachable!(), }; if let Some(text) = replace_text { - self.set_text_content(text); + self.set_text_content(text, Vec::new(), Vec::new()); return (InputResult::None, true); } } @@ -2589,11 +2821,8 @@ mod tests { composer.set_steer_enabled(true); composer.set_steer_enabled(true); composer.set_steer_enabled(true); - composer.set_steer_enabled(true); - composer.set_steer_enabled(true); - composer.set_steer_enabled(true); - composer.set_text_content("draft text".to_string()); + composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new()); assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); assert!(composer.is_empty()); @@ -2900,7 +3129,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "1あ"), + InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"), _ => panic!("expected Submitted"), } } @@ -3114,7 +3343,7 @@ mod tests { false, ); - composer.textarea.set_text("/diff"); + composer.textarea.set_text_clearing_elements("/diff"); composer.textarea.set_cursor("/diff".len()); composer .paste_burst @@ -3222,7 +3451,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "hello"), + InputResult::Submitted { text, .. } => assert_eq!(text, "hello"), _ => panic!("expected Submitted"), } } @@ -3288,7 +3517,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, large), + InputResult::Submitted { text, .. } => assert_eq!(text, large), _ => panic!("expected Submitted"), } assert!(composer.pending_pastes.is_empty()); @@ -3561,10 +3790,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/init'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/init'"), @@ -3640,10 +3869,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/diff'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch after Tab completion, got literal queue") } InputResult::None => panic!("expected Command result for '/diff'"), @@ -3679,10 +3908,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/mention'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/mention'"), @@ -3767,7 +3996,7 @@ mod tests { // Submit and verify final expansion let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); } else { panic!("expected Submitted"); @@ -3964,7 +4193,7 @@ mod tests { composer.textarea.text().contains(&placeholder), composer.pending_pastes.len(), ); - composer.textarea.set_text(""); + composer.textarea.set_text_clearing_elements(""); result }) .collect(); @@ -3980,7 +4209,7 @@ mod tests { // --- Image attachment tests --- #[test] - fn attach_image_and_submit_includes_image_paths() { + fn attach_image_and_submit_includes_local_image_paths() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -3997,13 +4226,231 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1] hi"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); assert_eq!(vec![path], imgs); } + #[test] + fn set_text_content_reattaches_images_without_placeholder_metadata() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let placeholder = local_image_label_text(1); + let text = format!("{placeholder} restored"); + let text_elements = vec![TextElement { + byte_range: (0..placeholder.len()).into(), + placeholder: None, + }]; + let path = PathBuf::from("/tmp/image1.png"); + + composer.set_text_content(text, text_elements, vec![path.clone()]); + + assert_eq!(composer.local_image_paths(), vec![path]); + } + + #[test] + fn large_paste_preserves_image_text_elements_on_submit() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_paste.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let expected = format!("{large_content} [Image #1]"); + assert_eq!(text, expected); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: large_content.len() + 1, + end: large_content.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn large_paste_with_leading_whitespace_trims_and_shifts_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = format!(" {}", "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5)); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_trim.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let trimmed = large_content.trim().to_string(); + assert_eq!(text, format!("{trimmed} [Image #1]")); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: trimmed.len() + 1, + end: trimmed.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn pasted_crlf_normalizes_newlines_for_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let pasted = "line1\r\nline2\r\n".to_string(); + composer.handle_paste(pasted); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_crlf.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "line1\nline2\n [Image #1]"); + assert!(!text.contains('\r')); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: "line1\nline2\n ".len(), + end: "line1\nline2\n [Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn suppressed_submission_restores_pending_paste_payload() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.textarea.set_text_clearing_elements("/unknown "); + composer.textarea.set_cursor("/unknown ".len()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + let placeholder = composer + .pending_pastes + .first() + .expect("expected pending paste") + .0 + .clone(); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}")); + + composer.textarea.set_cursor(0); + composer.textarea.insert_str(" "); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, format!("/unknown {large_content}")); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + assert!(composer.pending_pastes.is_empty()); + } + #[test] fn attach_image_without_text_submits_empty_text_and_images() { let (tx, _rx) = unbounded_channel::(); @@ -4021,7 +4468,21 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1]"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -4176,6 +4637,69 @@ mod tests { ); } + #[test] + fn deleting_reordered_image_one_renumbers_text_in_place() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let path1 = PathBuf::from("/tmp/image_first.png"); + let path2 = PathBuf::from("/tmp/image_second.png"); + let placeholder1 = local_image_label_text(1); + let placeholder2 = local_image_label_text(2); + + // Placeholders can be reordered in the text buffer; deleting image #1 should renumber + // image #2 wherever it appears, not just after the cursor. + let text = format!("Test {placeholder2} test {placeholder1}"); + let start2 = text.find(&placeholder2).expect("placeholder2 present"); + let start1 = text.find(&placeholder1).expect("placeholder1 present"); + let text_elements = vec![ + TextElement { + byte_range: ByteRange { + start: start2, + end: start2 + placeholder2.len(), + }, + placeholder: Some(placeholder2), + }, + TextElement { + byte_range: ByteRange { + start: start1, + end: start1 + placeholder1.len(), + }, + placeholder: Some(placeholder1.clone()), + }, + ]; + composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); + + let end1 = start1 + placeholder1.len(); + composer.textarea.set_cursor(end1); + + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!( + composer.textarea.text(), + format!("Test {placeholder1} test ") + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: placeholder1 + }], + composer.attached_images, + "attachment renumbered after deletion" + ); + } + #[test] fn deleting_first_text_element_renumbers_following_text_element() { use crossterm::event::KeyCode; @@ -4273,7 +4797,10 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == prompt_text + )); assert!(composer.textarea.is_empty()); } @@ -4300,15 +4827,16 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice BRANCH=main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Review Alice changes on main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Review Alice changes on main" + )); assert!(composer.textarea.is_empty()); } @@ -4335,15 +4863,16 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); + .set_text_clearing_elements("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Pair Alice Smith with dev-main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Pair Alice Smith with dev-main" + )); assert!(composer.textarea.is_empty()); } @@ -4377,7 +4906,7 @@ mod tests { // Type the slash command let command_text = "/prompts:code-review "; - composer.textarea.set_text(command_text); + composer.textarea.set_text_clearing_elements(command_text); composer.textarea.set_cursor(command_text.len()); // Paste large content (>3000 chars) to trigger placeholder @@ -4400,7 +4929,7 @@ mod tests { // Verify the custom prompt was expanded with the large content as positional arg match result { - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { // The prompt should be expanded, with the large content replacing $1 assert_eq!( text, @@ -4433,12 +4962,12 @@ mod tests { composer .textarea - .set_text("/Users/example/project/src/main.rs"); + .set_text_clearing_elements("/Users/example/project/src/main.rs"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/Users/example/project/src/main.rs"); } else { panic!("expected Submitted"); @@ -4468,12 +4997,14 @@ mod tests { ); composer.set_steer_enabled(true); - composer.textarea.set_text(" /this-looks-like-a-command"); + composer + .textarea + .set_text_clearing_elements(" /this-looks-like-a-command"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/this-looks-like-a-command"); } else { panic!("expected Submitted"); @@ -4508,7 +5039,7 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice stray"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice stray"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4557,7 +5088,9 @@ mod tests { }]); // Provide only one of the required args - composer.textarea.set_text("/prompts:my-prompt USER=Alice"); + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt USER=Alice"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4622,7 +5155,10 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); } #[test] @@ -4649,11 +5185,16 @@ mod tests { }]); // Type positional args; should submit with numeric expansion, no errors. - composer.textarea.set_text("/prompts:elegant hi"); + composer + .textarea + .set_text_clearing_elements("/prompts:elegant hi"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Echo: hi" + )); assert!(composer.textarea.is_empty()); } @@ -4725,10 +5266,11 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Cost: $$ and first: x".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Cost: $$ and first: x" + )); } #[test] @@ -4765,7 +5307,10 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "First: one two\nSecond: one two".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); } /// Behavior: the first fast ASCII character is held briefly to avoid flicker; if no burst @@ -4919,7 +5464,7 @@ mod tests { ); // Simulate history-like content: "/ test" - composer.set_text_content("/ test".to_string()); + composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new()); // After set_text_content -> sync_popups is called; popup should NOT be Command. assert!( @@ -4949,21 +5494,21 @@ mod tests { ); // Case 1: bare "/" - composer.set_text_content("/".to_string()); + composer.set_text_content("/".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "bare '/' should activate slash popup" ); // Case 2: valid prefix "/re" (matches /review, /resume, etc.) - composer.set_text_content("/re".to_string()); + composer.set_text_content("/re".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/re' should activate slash popup via prefix match" ); // Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback) - composer.set_text_content("/ac".to_string()); + composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/ac' should activate slash popup via fuzzy match" @@ -4972,7 +5517,7 @@ mod tests { // Case 4: invalid prefix "/zzz" – still allowed to open popup if it // matches no built-in command; our current logic will not open popup. // Verify that explicitly. - composer.set_text_content("/zzz".to_string()); + composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::None), "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" @@ -5107,7 +5652,7 @@ mod tests { false, ); - composer.set_text_content("hello".to_string()); + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); let (result, needs_redraw) = diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8668367aa3..ee23cad334 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -28,6 +28,7 @@ use bottom_pane_view::BottomPaneView; use codex_core::features::Features; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; @@ -39,6 +40,12 @@ mod approval_overlay; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; mod bottom_pane_view; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalImageAttachment { + pub(crate) placeholder: String, + pub(crate) path: PathBuf, +} mod chat_composer; mod chat_composer_history; mod command_popup; @@ -317,8 +324,14 @@ impl BottomPane { } /// Replace the composer text with `text`. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.composer.set_text_content(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.composer + .set_text_content(text, text_elements, local_image_paths); self.request_redraw(); } @@ -342,6 +355,19 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn composer_text_elements(&self) -> Vec { + self.composer.text_elements() + } + + pub(crate) fn composer_local_images(&self) -> Vec { + self.composer.local_images() + } + + #[cfg(test)] + pub(crate) fn composer_local_image_paths(&self) -> Vec { + self.composer.local_image_paths() + } + pub(crate) fn composer_text_with_pending(&self) -> String { self.composer.current_text_with_pending() } @@ -652,10 +678,18 @@ impl BottomPane { } } + #[cfg(test)] pub(crate) fn take_recent_submission_images(&mut self) -> Vec { self.composer.take_recent_submission_images() } + pub(crate) fn take_recent_submission_images_with_placeholders( + &mut self, + ) -> Vec { + self.composer + .take_recent_submission_images_with_placeholders() + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 903ebe9f82..926c53601a 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1,4 +1,6 @@ use crate::key_hint::is_altgr; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -60,10 +62,36 @@ impl TextArea { } } - pub fn set_text(&mut self, text: &str) { + /// Replace the textarea text and clear any existing text elements. + pub fn set_text_clearing_elements(&mut self, text: &str) { + self.set_text_inner(text, None); + } + + /// Replace the textarea text and set the provided text elements. + pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) { + self.set_text_inner(text, Some(elements)); + } + + fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { + // Stage 1: replace the raw text and keep the cursor in a safe byte range. self.text = text.to_string(); self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); + // Stage 2: rebuild element ranges from scratch against the new text. self.elements.clear(); + if let Some(elements) = elements { + for elem in elements { + let mut start = elem.byte_range.start.min(self.text.len()); + let mut end = elem.byte_range.end.min(self.text.len()); + start = self.clamp_pos_to_char_boundary(start); + end = self.clamp_pos_to_char_boundary(end); + if start >= end { + continue; + } + self.elements.push(TextElement { range: start..end }); + } + self.elements.sort_by_key(|e| e.range.start); + } + // Stage 3: clamp the cursor and reset derived state tied to the prior content. self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.wrap_cache.replace(None); self.preferred_col = None; @@ -722,6 +750,22 @@ impl TextArea { .collect() } + pub fn text_elements(&self) -> Vec { + self.elements + .iter() + .map(|e| { + let placeholder = self.text.get(e.range.clone()).map(str::to_string); + UserTextElement { + byte_range: ByteRange { + start: e.range.start, + end: e.range.end, + }, + placeholder, + } + }) + .collect() + } + pub fn element_payload_starting_at(&self, pos: usize) -> Option { let pos = pos.min(self.text.len()); let elem = self.elements.iter().find(|e| e.range.start == pos)?; @@ -1251,7 +1295,7 @@ mod tests { let mut t = TextArea::new(); t.insert_str("abcd"); t.set_cursor(1); - t.set_text("你"); + t.set_text_clearing_elements("你"); assert_eq!(t.cursor(), 0); t.insert_str("a"); assert_eq!(t.text(), "a你"); @@ -1933,7 +1977,7 @@ mod tests { for _ in 0..base_len { base.push_str(&rand_grapheme(&mut rng)); } - ta.set_text(&base); + ta.set_text_clearing_elements(&base); // Choose a valid char boundary for initial cursor let mut boundaries: Vec = vec![0]; boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c6870ca320..0131887a1e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -92,7 +92,9 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -128,6 +130,7 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -346,8 +349,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, - pub(crate) initial_prompt: Option, - pub(crate) initial_images: Vec, + pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, @@ -515,16 +517,19 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) animation_tick: Option, } -struct UserMessage { +pub(crate) struct UserMessage { text: String, - image_paths: Vec, + local_images: Vec, + text_elements: Vec, } impl From for UserMessage { fn from(text: String) -> Self { Self { text, - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } @@ -533,16 +538,107 @@ impl From<&str> for UserMessage { fn from(text: &str) -> Self { Self { text: text.to_string(), - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } -fn create_initial_user_message(text: String, image_paths: Vec) -> Option { - if text.is_empty() && image_paths.is_empty() { +pub(crate) fn create_initial_user_message( + text: Option, + local_image_paths: Vec, + text_elements: Vec, +) -> Option { + let text = text.unwrap_or_default(); + if text.is_empty() && local_image_paths.is_empty() { None } else { - Some(UserMessage { text, image_paths }) + let local_images = local_image_paths + .into_iter() + .enumerate() + .map(|(idx, path)| LocalImageAttachment { + placeholder: local_image_label_text(idx + 1), + path, + }) + .collect(); + Some(UserMessage { + text, + local_images, + text_elements, + }) + } +} + +// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering +// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so +// the combined local_image_paths order matches the labels, even if placeholders were moved +// in the text (e.g., [Image #2] appearing before [Image #1]). +fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { + let UserMessage { + text, + text_elements, + local_images, + } = message; + if local_images.is_empty() { + return UserMessage { + text, + text_elements, + local_images, + }; + } + + let mut mapping: HashMap = HashMap::new(); + let mut remapped_images = Vec::new(); + for attachment in local_images { + let new_placeholder = local_image_label_text(*next_label); + *next_label += 1; + mapping.insert(attachment.placeholder.clone(), new_placeholder.clone()); + remapped_images.push(LocalImageAttachment { + placeholder: new_placeholder, + path: attachment.path, + }); + } + + let mut elements = text_elements; + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut cursor = 0usize; + let mut rebuilt = String::new(); + let mut rebuilt_elements = Vec::new(); + for mut elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if let Some(segment) = text.get(cursor..start) { + rebuilt.push_str(segment); + } + + let original = text.get(start..end).unwrap_or(""); + let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); + let replacement = mapping + .get(placeholder_key) + .map(String::as_str) + .unwrap_or(original); + + let elem_start = rebuilt.len(); + rebuilt.push_str(replacement); + let elem_end = rebuilt.len(); + + if let Some(remapped) = mapping.get(placeholder_key) { + elem.placeholder = Some(remapped.clone()); + } + elem.byte_range = (elem_start..elem_end).into(); + rebuilt_elements.push(elem); + cursor = end; + } + if let Some(segment) = text.get(cursor..) { + rebuilt.push_str(segment); + } + + UserMessage { + text: rebuilt, + local_images: remapped_images, + text_elements: rebuilt_elements, } } @@ -1002,31 +1098,76 @@ impl ChatWidget { )); } - // If any messages were queued during the task, restore them into the composer. - if !self.queued_user_messages.is_empty() { - let queued_text = self - .queued_user_messages + if let Some(combined) = self.drain_queued_messages_for_restore() { + let combined_local_image_paths = combined + .local_images .iter() - .map(|m| m.text.clone()) - .collect::>() - .join("\n"); - let existing_text = self.bottom_pane.composer_text(); - let combined = if existing_text.is_empty() { - queued_text - } else if queued_text.is_empty() { - existing_text - } else { - format!("{queued_text}\n{existing_text}") - }; - self.bottom_pane.set_composer_text(combined); - // Clear the queue and update the status indicator list. - self.queued_user_messages.clear(); + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + combined.text, + combined.text_elements, + combined_local_image_paths, + ); self.refresh_queued_user_messages(); } self.request_redraw(); } + /// Merge queued drafts (plus the current composer state) into a single message for restore. + /// + /// Each queued draft numbers attachments from `[Image #1]`. When we concatenate drafts, we + /// must renumber placeholders in a stable order so the merged attachment list stays aligned + /// with the labels embedded in text. This helper drains the queue, remaps placeholders, and + /// fixes text element byte ranges as content is appended. Returns `None` when there is nothing + /// to restore. + fn drain_queued_messages_for_restore(&mut self) -> Option { + if self.queued_user_messages.is_empty() { + return None; + } + + let existing_message = UserMessage { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + }; + + let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); + if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() { + to_merge.push(existing_message); + } + + let mut combined = UserMessage { + text: String::new(), + text_elements: Vec::new(), + local_images: Vec::new(), + }; + let mut combined_offset = 0usize; + let mut next_image_label = 1usize; + + for (idx, message) in to_merge.into_iter().enumerate() { + if idx > 0 { + combined.text.push('\n'); + combined_offset += 1; + } + let message = remap_placeholders_for_message(message, &mut next_image_label); + let base = combined_offset; + combined.text.push_str(&message.text); + combined_offset += message.text.len(); + combined + .text_elements + .extend(message.text_elements.into_iter().map(|mut elem| { + elem.byte_range.start += base; + elem.byte_range.end += base; + elem + })); + combined.local_images.extend(message.local_images); + } + + Some(combined) + } + fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.add_to_history(history_cell::new_plan_update(update)); } @@ -1637,8 +1778,7 @@ impl ChatWidget { config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, @@ -1685,10 +1825,7 @@ impl ChatWidget { auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1748,8 +1885,7 @@ impl ChatWidget { config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, @@ -1788,10 +1924,7 @@ impl ChatWidget { auth_manager, models_manager, session_header: SessionHeader::new(header_model), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1919,46 +2052,64 @@ impl ChatWidget { } if !self.queued_user_messages.is_empty() => { // Prefer the most recently queued item. if let Some(user_message) = self.queued_user_messages.pop_back() { - self.bottom_pane.set_composer_text(user_message.text); + let local_image_paths = user_message + .local_images + .iter() + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + user_message.text, + user_message.text_elements, + local_image_paths, + ); self.refresh_queued_user_messages(); self.request_redraw(); } } - _ => { - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted(text) => { - // Enter always sends messages immediately (bypasses queue check) - // Clear any reasoning status header when submitting a new message + _ => match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + if self.is_session_configured() { + // Submitted is only emitted when steer is enabled (Enter sends immediately). + // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); self.set_status_header(String::from("Working")); - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; - if !self.is_session_configured() { - self.queue_user_message(user_message); - } else { - self.submit_user_message(user_message); - } - } - InputResult::Queued(text) => { - // Tab queues the message if a task is running, otherwise submits immediately - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; + self.submit_user_message(user_message); + } else { self.queue_user_message(user_message); } - InputResult::Command(cmd) => { - self.dispatch_command(cmd); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); - } - InputResult::None => {} } - } + InputResult::Queued { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::CommandWithArgs(cmd, args) => { + self.dispatch_command_with_args(cmd, args); + } + InputResult::None => {} + }, } } @@ -2304,8 +2455,12 @@ impl ChatWidget { }; let model = model.to_string(); - let UserMessage { text, image_paths } = user_message; - if text.is_empty() && image_paths.is_empty() { + let UserMessage { + text, + local_images, + text_elements, + } = user_message; + if text.is_empty() && local_images.is_empty() { return; } @@ -2329,15 +2484,16 @@ impl ChatWidget { return; } - for path in image_paths { - items.push(UserInput::LocalImage { path }); + for image in &local_images { + items.push(UserInput::LocalImage { + path: image.path.clone(), + }); } if !text.is_empty() { - // TODO: Thread text element ranges from the composer input. Empty keeps old behavior. items.push(UserInput::Text { text: text.clone(), - text_elements: Vec::new(), + text_elements: text_elements.clone(), }); } @@ -2386,7 +2542,12 @@ impl ChatWidget { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(history_cell::new_user_prompt(text)); + let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); + self.add_to_history(history_cell::new_user_prompt( + text, + text_elements, + local_image_paths, + )); } self.needs_final_message_separator = false; @@ -2602,10 +2763,16 @@ impl ChatWidget { } fn on_user_message_event(&mut self, event: UserMessageEvent) { - let message = event.message.trim(); - if !message.is_empty() { - self.add_to_history(history_cell::new_user_prompt(message.to_string())); + if !event.message.trim().is_empty() { + self.add_to_history(history_cell::new_user_prompt( + event.message, + event.text_elements, + event.local_images, + )); } + + // User messages reset separator state so the next agent response doesn't add a stray break. + self.needs_final_message_separator = false; } /// Exit the UI immediately without waiting for shutdown. @@ -4228,8 +4395,14 @@ impl ChatWidget { } /// Replace the composer content with the provided text and reset cursor. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.bottom_pane.set_composer_text(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.bottom_pane + .set_composer_text(text, text_elements, local_image_paths); } pub(crate) fn show_esc_backtrack_hint(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1be07ed95e..73aac97316 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -8,6 +8,8 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; +use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -67,6 +69,8 @@ use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::user_input::TextElement; +use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -182,6 +186,364 @@ async fn resumed_initial_messages_render_history() { ); } +#[tokio::test] +async fn replayed_user_message_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + let placeholder = "[Image #1]"; + let message = format!("{placeholder} replayed"); + let text_elements = vec![TextElement { + byte_range: (0..placeholder.len()).into(), + placeholder: Some(placeholder.to_string()), + }]; + let local_images = vec![PathBuf::from("/tmp/replay.png")]; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { + message: message.clone(), + images: None, + text_elements: text_elements.clone(), + local_images: local_images.clone(), + })]), + rollout_path: rollout_file.path().to_path_buf(), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected a replayed user history cell"); + assert_eq!(stored_message, message); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn submission_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: rollout_file.path().to_path_buf(), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + let placeholder = "[Image #1]"; + let text = format!("{placeholder} submit"); + let text_elements = vec![TextElement { + byte_range: (0..placeholder.len()).into(), + placeholder: Some(placeholder.to_string()), + }]; + let local_images = vec![PathBuf::from("/tmp/submitted.png")]; + + chat.bottom_pane + .set_composer_text(text.clone(), text_elements.clone(), local_images.clone()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(items.len(), 2); + assert_eq!( + items[0], + UserInput::LocalImage { + path: local_images[0].clone() + } + ); + assert_eq!( + items[1], + UserInput::Text { + text: text.clone(), + text_elements: text_elements.clone(), + } + ); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected submitted user history cell"); + assert_eq!(stored_message, text); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + let first_placeholder = "[Image #1]"; + let first_text = format!("{first_placeholder} first"); + let first_elements = vec![TextElement { + byte_range: (0..first_placeholder.len()).into(), + placeholder: Some(first_placeholder.to_string()), + }]; + let first_images = [PathBuf::from("/tmp/first.png")]; + + let second_placeholder = "[Image #1]"; + let second_text = format!("{second_placeholder} second"); + let second_elements = vec![TextElement { + byte_range: (0..second_placeholder.len()).into(), + placeholder: Some(second_placeholder.to_string()), + }]; + let second_images = [PathBuf::from("/tmp/second.png")]; + + let existing_placeholder = "[Image #1]"; + let existing_text = format!("{existing_placeholder} existing"); + let existing_elements = vec![TextElement { + byte_range: (0..existing_placeholder.len()).into(), + placeholder: Some(existing_placeholder.to_string()), + }]; + let existing_images = vec![PathBuf::from("/tmp/existing.png")]; + + chat.queued_user_messages.push_back(UserMessage { + text: first_text, + local_images: vec![LocalImageAttachment { + placeholder: first_placeholder.to_string(), + path: first_images[0].clone(), + }], + text_elements: first_elements, + }); + chat.queued_user_messages.push_back(UserMessage { + text: second_text, + local_images: vec![LocalImageAttachment { + placeholder: second_placeholder.to_string(), + path: second_images[0].clone(), + }], + text_elements: second_elements, + }); + chat.refresh_queued_user_messages(); + + chat.bottom_pane + .set_composer_text(existing_text, existing_elements, existing_images.clone()); + + // When interrupted, queued messages are merged into the composer; image placeholders + // must be renumbered to match the combined local image list. + chat.handle_codex_event(Event { + id: "interrupt".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let first = "[Image #1] first".to_string(); + let second = "[Image #2] second".to_string(); + let third = "[Image #3] existing".to_string(); + let expected_text = format!("{first}\n{second}\n{third}"); + assert_eq!(chat.bottom_pane.composer_text(), expected_text); + + let first_start = 0; + let second_start = first.len() + 1; + let third_start = second_start + second.len() + 1; + let expected_elements = vec![ + TextElement { + byte_range: (first_start..first_start + "[Image #1]".len()).into(), + placeholder: Some("[Image #1]".to_string()), + }, + TextElement { + byte_range: (second_start..second_start + "[Image #2]".len()).into(), + placeholder: Some("[Image #2]".to_string()), + }, + TextElement { + byte_range: (third_start..third_start + "[Image #3]".len()).into(), + placeholder: Some("[Image #3]".to_string()), + }, + ]; + assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); + assert_eq!( + chat.bottom_pane.composer_local_image_paths(), + vec![ + first_images[0].clone(), + second_images[0].clone(), + existing_images[0].clone(), + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_attachment_labels() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement { + byte_range: (0..placeholder_two.len()).into(), + placeholder: Some(placeholder_two.to_string()), + }, + TextElement { + byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + placeholder: Some(placeholder_one.to_string()), + }, + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement { + byte_range: (0.."[Image #4]".len()).into(), + placeholder: Some("[Image #4]".to_string()), + }, + TextElement { + byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) + .into(), + placeholder: Some("[Image #3]".to_string()), + }, + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement { + byte_range: (0..placeholder_two.len()).into(), + placeholder: None, + }, + TextElement { + byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + placeholder: None, + }, + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement { + byte_range: (0.."[Image #4]".len()).into(), + placeholder: Some("[Image #4]".to_string()), + }, + TextElement { + byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) + .into(), + placeholder: Some("[Image #3]".to_string()), + }, + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + /// Entering review mode uses the hint provided by the review request. #[tokio::test] async fn entered_review_mode_uses_request_hint() { @@ -352,8 +714,7 @@ async fn helpers_are_available_and_do_not_panic() { config: cfg, frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, - initial_prompt: None, - initial_images: Vec::new(), + initial_user_message: None, enhanced_keys_supported: false, auth_manager, models_manager: thread_manager.get_models_manager(), @@ -1080,7 +1441,8 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { chat.thread_id = Some(ThreadId::new()); // Submit an initial prompt to seed history. - chat.bottom_pane.set_composer_text("repeat me".to_string()); + chat.bottom_pane + .set_composer_text("repeat me".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // Simulate an active task so further submissions are queued. @@ -1114,7 +1476,7 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.status_widget().is_none()); chat.bottom_pane - .set_composer_text("queued submission".to_string()); + .set_composer_text("queued submission".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(chat.queued_user_messages.len(), 1); @@ -1581,7 +1943,8 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); - chat.bottom_pane.set_composer_text("hello".to_string()); + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { @@ -1591,7 +1954,8 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), } - chat.bottom_pane.set_composer_text("follow up".to_string()); + chat.bottom_pane + .set_composer_text("follow up".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { @@ -1608,7 +1972,8 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.bottom_pane.set_composer_text("hello".to_string()); + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { @@ -2863,7 +3228,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { chat.bottom_pane.set_task_running(true); chat.bottom_pane - .set_composer_text("current draft".to_string()); + .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); chat.queued_user_messages .push_back(UserMessage::from("first queued".to_string())); @@ -3887,8 +4252,11 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { delta: "**Investigating rendering code**".into(), }), }); - chat.bottom_pane - .set_composer_text("Summarize recent commits".to_string()); + chat.bottom_pane.set_composer_text( + "Summarize recent commits".to_string(), + Vec::new(), + Vec::new(), + ); let width: u16 = 80; let ui_height: u16 = chat.desired_height(width); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index b0bdbdf147..fd09fe89dc 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -47,6 +47,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::user_input::TextElement; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -54,6 +55,7 @@ use mcp_types::Resource; use mcp_types::ResourceLink; use mcp_types::ResourceTemplate; use ratatui::prelude::*; +use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; @@ -158,6 +160,75 @@ impl dyn HistoryCell { #[derive(Debug)] pub(crate) struct UserHistoryCell { pub message: String, + pub text_elements: Vec, + #[allow(dead_code)] + pub local_image_paths: Vec, +} + +/// Build logical lines for a user message with styled text elements. +/// +/// This preserves explicit newlines while interleaving element spans and skips +/// malformed byte ranges instead of panicking during history rendering. +fn build_user_message_lines_with_elements( + message: &str, + elements: &[TextElement], + style: Style, + element_style: Style, +) -> Vec> { + let mut elements = elements.to_vec(); + elements.sort_by_key(|e| e.byte_range.start); + let mut offset = 0usize; + let mut raw_lines: Vec> = Vec::new(); + for line_text in message.split('\n') { + let line_start = offset; + let line_end = line_start + line_text.len(); + let mut spans: Vec> = Vec::new(); + // Track how much of the line we've emitted to interleave plain and styled spans. + let mut cursor = line_start; + for elem in &elements { + let start = elem.byte_range.start.max(line_start); + let end = elem.byte_range.end.min(line_end); + if start >= end { + continue; + } + let rel_start = start - line_start; + let rel_end = end - line_start; + // Guard against malformed UTF-8 byte ranges from upstream data; skip + // invalid elements rather than panicking while rendering history. + if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { + continue; + } + let rel_cursor = cursor - line_start; + if cursor < start + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..rel_start) + { + spans.push(Span::from(segment.to_string())); + } + if let Some(segment) = line_text.get(rel_start..rel_end) { + spans.push(Span::styled(segment.to_string(), element_style)); + cursor = end; + } + } + let rel_cursor = cursor - line_start; + if cursor < line_end + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..) + { + spans.push(Span::from(segment.to_string())); + } + let line = if spans.is_empty() { + Line::from(line_text.to_string()).style(style) + } else { + Line::from(spans).style(style) + }; + raw_lines.push(line); + // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts + // for the separator byte. + offset = line_end + 1; + } + + raw_lines } impl HistoryCell for UserHistoryCell { @@ -171,13 +242,28 @@ impl HistoryCell for UserHistoryCell { .max(1); let style = user_message_style(); + let element_style = style.fg(Color::Cyan); - let wrapped = word_wrap_lines( - self.message.lines().map(|l| Line::from(l).style(style)), - // Wrap algorithm matches textarea.rs. - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); + let wrapped = if self.text_elements.is_empty() { + word_wrap_lines( + self.message.split('\n').map(|l| Line::from(l).style(style)), + // Wrap algorithm matches textarea.rs. + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + } else { + let raw_lines = build_user_message_lines_with_elements( + &self.message, + &self.text_elements, + style, + element_style, + ); + word_wrap_lines( + raw_lines, + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + }; lines.push(Line::from("").style(style)); lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); @@ -886,8 +972,16 @@ pub(crate) fn new_session_info( SessionInfoCell(CompositeHistoryCell { parts }) } -pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { - UserHistoryCell { message } +pub(crate) fn new_user_prompt( + message: String, + text_elements: Vec, + local_image_paths: Vec, +) -> UserHistoryCell { + UserHistoryCell { + message, + text_elements, + local_image_paths, + } } #[derive(Debug)] @@ -2582,6 +2676,8 @@ mod tests { let msg = "one two three four five six seven"; let cell = UserHistoryCell { message: msg.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }; // Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space. diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 2a80c087ee..46a7e72bcf 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -48,13 +48,14 @@ impl ComposerInput { /// Clear the input text. pub fn clear(&mut self) { - self.inner.set_text_content(String::new()); + self.inner + .set_text_content(String::new(), Vec::new(), Vec::new()); } /// Feed a key event into the composer and return a high-level action. pub fn input(&mut self, key: KeyEvent) -> ComposerAction { let action = match self.inner.handle_key_event(key).0 { - InputResult::Submitted(text) => ComposerAction::Submitted(text), + InputResult::Submitted { text, .. } => ComposerAction::Submitted(text), _ => ComposerAction::None, }; self.drain_app_events(); diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index de2fe20010..99fc2e188e 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -423,6 +423,26 @@ pub(crate) struct App { skip_world_writable_scan_once: bool, } impl App { + pub fn chatwidget_init_for_forked_or_resumed_thread( + &self, + tui: &mut tui::Tui, + cfg: codex_core::config::Config, + ) -> crate::chatwidget::ChatWidgetInit { + crate::chatwidget::ChatWidgetInit { + config: cfg, + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + // Fork/resume bootstraps here don't carry any prefilled message content. + initial_user_message: None, + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + model: Some(self.current_model.clone()), + } + } + async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { // Clear any in-flight rollback guard when switching conversations. @@ -486,8 +506,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -509,8 +533,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -532,8 +560,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -1454,8 +1486,8 @@ impl App { config: self.config.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), + // New sessions start without prefilled message content. + initial_user_message: None, enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), @@ -1499,19 +1531,10 @@ impl App { { Ok(resumed) => { self.shutdown_current_conversation().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); self.chat_widget = ChatWidget::new_from_existing( init, resumed.thread, @@ -1559,19 +1582,10 @@ impl App { { Ok(forked) => { self.shutdown_current_conversation().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); self.chat_widget = ChatWidget::new_from_existing( init, forked.thread, @@ -2639,6 +2653,8 @@ mod tests { let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { message: text.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc }; let agent_cell = |text: &str| -> Arc { diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index a86ed6dea6..81bf398bf9 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -205,7 +205,10 @@ impl App { }); self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); if !prefill.is_empty() { - self.chat_widget.set_composer_text(prefill); + // TODO: Rehydrate text_elements/local_image_paths from the selected user cell so + // backtrack preserves image placeholders and attachments. + self.chat_widget + .set_composer_text(prefill, Vec::new(), Vec::new()); } } @@ -576,6 +579,8 @@ mod tests { let mut cells: Vec> = vec![ Arc::new(UserHistoryCell { message: "first user".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, @@ -592,6 +597,8 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, @@ -620,11 +627,15 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) as Arc, Arc::new(UserHistoryCell { message: "second".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 541806ec47..3592b25737 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -111,9 +111,12 @@ use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use codex_protocol::models::local_image_label_text; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; @@ -125,6 +128,7 @@ use codex_file_search::FileMatch; use std::cell::RefCell; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::VecDeque; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; @@ -143,8 +147,14 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; /// Result returned when the user interacts with the text area. #[derive(Debug, PartialEq)] pub enum InputResult { - Submitted(String), - Queued(String), + Submitted { + text: String, + text_elements: Vec, + }, + Queued { + text: String, + text_elements: Vec, + }, Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -350,7 +360,8 @@ impl ChatComposer { let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { return false; }; - self.set_text_content(text); + // Composer history (↑/↓) stores plain text only; no UI element ranges/attachments to restore here. + self.set_text_content(text, Vec::new(), Vec::new()); true } @@ -373,6 +384,7 @@ impl ChatComposer { /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { + let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -461,12 +473,37 @@ impl ChatComposer { } /// Replace the entire composer content with `text` and reset cursor. - pub(crate) fn set_text_content(&mut self, text: String) { + /// This clears any pending paste payloads. + pub(crate) fn set_text_content( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); self.pending_pastes.clear(); self.attached_images.clear(); - self.textarea.set_text(&text); + + self.textarea.set_text_with_elements(&text, &text_elements); + + let image_placeholders: HashSet = text_elements + .iter() + .filter_map(|elem| { + elem.placeholder.as_ref().cloned().or_else(|| { + text.get(elem.byte_range.start..elem.byte_range.end) + .map(str::to_string) + }) + }) + .collect(); + for (idx, path) in local_image_paths.into_iter().enumerate() { + let placeholder = local_image_label_text(idx + 1); + if image_placeholders.contains(&placeholder) { + self.attached_images + .push(AttachedImage { placeholder, path }); + } + } + self.textarea.set_cursor(0); self.sync_popups(); } @@ -476,7 +513,7 @@ impl ChatComposer { return None; } let previous = self.current_text(); - self.set_text_content(String::new()); + self.set_text_content(String::new(), Vec::new(), Vec::new()); self.history.reset_navigation(); self.history.record_local_submission(&previous); Some(previous) @@ -487,6 +524,28 @@ impl ChatComposer { self.textarea.text().to_string() } + pub(crate) fn text_elements(&self) -> Vec { + self.textarea.text_elements() + } + + #[cfg(test)] + pub(crate) fn local_image_paths(&self) -> Vec { + self.attached_images + .iter() + .map(|img| img.path.clone()) + .collect() + } + + pub(crate) fn local_images(&self) -> Vec { + self.attached_images + .iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder.clone(), + path: img.path.clone(), + }) + .collect() + } + /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { let image_number = self.attached_images.len() + 1; @@ -498,11 +557,23 @@ impl ChatComposer { .push(AttachedImage { placeholder, path }); } + #[cfg(test)] pub fn take_recent_submission_images(&mut self) -> Vec { let images = std::mem::take(&mut self.attached_images); images.into_iter().map(|img| img.path).collect() } + pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec { + let images = std::mem::take(&mut self.attached_images); + images + .into_iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder, + path: img.path, + }) + .collect() + } + /// Flushes any due paste-burst state. /// /// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits: @@ -681,7 +752,7 @@ impl ChatComposer { match sel { CommandItem::Builtin(cmd) => { if cmd == SlashCommand::Skills { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } @@ -689,7 +760,9 @@ impl ChatComposer { .trim_start() .starts_with(&format!("/{}", cmd.command())); if !starts_with_cmd { - self.textarea.set_text(&format!("/{} ", cmd.command())); + // Slash completion replaces the buffer with plain text; drop elements. + self.textarea + .set_text_clearing_elements(&format!("/{} ", cmd.command())); } if !self.textarea.text().is_empty() { cursor_target = Some(self.textarea.text().len()); @@ -704,7 +777,8 @@ impl ChatComposer { ) { PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); cursor_target = Some(target); } PromptSelectionAction::Submit { .. } => {} @@ -733,14 +807,21 @@ impl ChatComposer { && let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text: expanded, + // Expanded prompt is plain text; no UI element ranges to preserve. + text_elements: Vec::new(), + }, + true, + ); } if let Some(sel) = popup.selected_item() { match sel { CommandItem::Builtin(cmd) => { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } CommandItem::UserPrompt(idx) => { @@ -751,12 +832,20 @@ impl ChatComposer { PromptSelectionMode::Submit, ) { PromptSelectionAction::Submit { text } => { - self.textarea.set_text(""); - return (InputResult::Submitted(text), true); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text, + // Submitting a slash/custom prompt generates plain text, so there are no UI element ranges. + text_elements: Vec::new(), + }, + true, + ); } PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); self.textarea.set_cursor(target); return (InputResult::None, true); } @@ -1060,6 +1149,106 @@ impl ChatComposer { lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } + fn trim_text_elements( + original: &str, + trimmed: &str, + elements: Vec, + ) -> Vec { + if trimmed.is_empty() || elements.is_empty() { + return Vec::new(); + } + let trimmed_start = original.len().saturating_sub(original.trim_start().len()); + let trimmed_end = trimmed_start.saturating_add(trimmed.len()); + + elements + .into_iter() + .filter_map(|elem| { + let start = elem.byte_range.start; + let end = elem.byte_range.end; + if end <= trimmed_start || start >= trimmed_end { + return None; + } + let new_start = start.saturating_sub(trimmed_start); + let new_end = end.saturating_sub(trimmed_start).min(trimmed.len()); + if new_start >= new_end { + return None; + } + let placeholder = trimmed.get(new_start..new_end).map(str::to_string); + Some(TextElement { + byte_range: ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + }) + }) + .collect() + } + + /// Expand large-paste placeholders using element ranges and rebuild other element spans. + fn expand_pending_pastes( + text: &str, + mut elements: Vec, + pending_pastes: &[(String, String)], + ) -> (String, Vec) { + if pending_pastes.is_empty() || elements.is_empty() { + return (text.to_string(), elements); + } + + let mut pending_by_placeholder: HashMap<&str, VecDeque<&str>> = HashMap::new(); + for (placeholder, actual) in pending_pastes { + pending_by_placeholder + .entry(placeholder.as_str()) + .or_default() + .push_back(actual.as_str()); + } + + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut rebuilt = String::with_capacity(text.len()); + let mut rebuilt_elements = Vec::with_capacity(elements.len()); + let mut cursor = 0usize; + + for elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if start > end { + continue; + } + if start > cursor { + rebuilt.push_str(&text[cursor..start]); + } + let elem_text = &text[start..end]; + let placeholder = elem.placeholder; + let replacement = placeholder + .as_deref() + .and_then(|ph| pending_by_placeholder.get_mut(ph)) + .and_then(VecDeque::pop_front); + if let Some(actual) = replacement { + rebuilt.push_str(actual); + } else { + let new_start = rebuilt.len(); + rebuilt.push_str(elem_text); + let new_end = rebuilt.len(); + let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); + rebuilt_elements.push(TextElement { + byte_range: ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + }); + } + cursor = end; + } + + if cursor < text.len() { + rebuilt.push_str(&text[cursor..]); + } + + (rebuilt, rebuilt_elements) + } + fn skills_enabled(&self) -> bool { self.skills.as_ref().is_some_and(|s| !s.is_empty()) } @@ -1234,7 +1423,8 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Path replacement is plain text; rebuild without carrying elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } @@ -1269,42 +1459,42 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Skill insertion rebuilds plain text, so drop existing elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } /// Prepare text for submission/queuing. Returns None if submission should be suppressed. - fn prepare_submission_text(&mut self) -> Option { - // If we have pending placeholder pastes, replace them in the textarea text - // and continue to the normal submission flow to handle slash commands. - if !self.pending_pastes.is_empty() { - let mut text = self.textarea.text().to_string(); - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.textarea.set_text(&text); - self.pending_pastes.clear(); - } - + /// On success, clears pending paste payloads because placeholders have been expanded. + fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { let mut text = self.textarea.text().to_string(); let original_input = text.clone(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); + let mut text_elements = original_text_elements.clone(); let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } + if !self.pending_pastes.is_empty() { + // Expand placeholders so element byte ranges stay aligned. + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; } - self.pending_pastes.clear(); + + let expanded_input = text.clone(); // If there is neither text nor attachments, suppress submission entirely. let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); + text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); @@ -1328,7 +1518,12 @@ impl ChatComposer { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event(message, None), ))); - self.textarea.set_text(&original_input); + 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; } @@ -1341,13 +1536,21 @@ impl ChatComposer { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_error_event(err.user_message()), ))); - self.textarea.set_text(&original_input); + 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; + // Expanded prompt (e.g. custom prompt) is plain text; text elements not supported yet. + // TODO: Preserve UI element ranges through prompt expansion in a follow-up PR. + text_elements = Vec::new(); } if text.is_empty() && !has_attachments { return None; @@ -1355,7 +1558,9 @@ impl ChatComposer { if !text.is_empty() { self.history.record_local_submission(&text); } - Some(text) + // Placeholder elements have been expanded into real text, so payloads can be dropped. + self.pending_pastes.clear(); + Some((text, text_elements)) } /// Common logic for handling message submission/queuing. @@ -1404,20 +1609,44 @@ impl ChatComposer { } let original_input = self.textarea.text().to_string(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); if let Some(result) = self.try_dispatch_slash_command_with_args() { return (result, true); } - if let Some(text) = self.prepare_submission_text() { + if let Some((text, text_elements)) = self.prepare_submission_text() { if should_queue { - (InputResult::Queued(text), true) + ( + InputResult::Queued { + text, + text_elements, + }, + true, + ) } else { // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) + ( + InputResult::Submitted { + text, + text_elements, + }, + true, + ) } } else { - // Restore text if submission was suppressed - self.textarea.set_text(&original_input); + // Restore text if submission was suppressed. + self.set_text_content( + original_input, + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes = original_pending_pastes; (InputResult::None, true) } } @@ -1432,7 +1661,7 @@ impl ChatComposer { Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) .find(|(n, _)| *n == name) { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) } else { None @@ -1455,7 +1684,7 @@ impl ChatComposer { .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); } } @@ -1511,7 +1740,7 @@ impl ChatComposer { _ => unreachable!(), }; if let Some(text) = replace_text { - self.set_text_content(text); + self.set_text_content(text, Vec::new(), Vec::new()); return (InputResult::None, true); } } @@ -1759,6 +1988,8 @@ impl ChatComposer { } fn relabel_attached_images_and_update_placeholders(&mut self) { + // Renumber by insertion order (attachment list order), and update any matching elements + // regardless of where they appear in the text. for idx in 0..self.attached_images.len() { let expected = local_image_label_text(idx + 1); let current = self.attached_images[idx].placeholder.clone(); @@ -2555,7 +2786,7 @@ mod tests { ); composer.set_steer_enabled(true); - composer.set_text_content("draft text".to_string()); + composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new()); assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); assert!(composer.is_empty()); @@ -2848,7 +3079,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "1あ"), + InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"), _ => panic!("expected Submitted"), } } @@ -2988,6 +3219,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); @@ -3019,7 +3251,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "あ"), + InputResult::Submitted { text, .. } => assert_eq!(text, "あ"), _ => panic!("expected Submitted"), } } @@ -3085,7 +3317,7 @@ mod tests { false, ); - composer.textarea.set_text("/diff"); + composer.textarea.set_text_clearing_elements("/diff"); composer.textarea.set_cursor("/diff".len()); composer .paste_burst @@ -3223,7 +3455,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "hello"), + InputResult::Submitted { text, .. } => assert_eq!(text, "hello"), _ => panic!("expected Submitted"), } } @@ -3287,7 +3519,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, large), + InputResult::Submitted { text, .. } => assert_eq!(text, large), _ => panic!("expected Submitted"), } assert!(composer.pending_pastes.is_empty()); @@ -3549,10 +3781,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/init'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/init'"), @@ -3628,10 +3860,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/diff'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch after Tab completion, got literal queue") } InputResult::None => panic!("expected Command result for '/diff'"), @@ -3667,10 +3899,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/mention'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/mention'"), @@ -3755,7 +3987,7 @@ mod tests { // Submit and verify final expansion let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); } else { panic!("expected Submitted"); @@ -3952,7 +4184,7 @@ mod tests { composer.textarea.text().contains(&placeholder), composer.pending_pastes.len(), ); - composer.textarea.set_text(""); + composer.textarea.set_text_clearing_elements(""); result }) .collect(); @@ -3968,7 +4200,7 @@ mod tests { // --- Image attachment tests --- #[test] - fn attach_image_and_submit_includes_image_paths() { + fn attach_image_and_submit_includes_local_image_paths() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -3985,13 +4217,231 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1] hi"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); assert_eq!(vec![path], imgs); } + #[test] + fn set_text_content_reattaches_images_without_placeholder_metadata() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let placeholder = local_image_label_text(1); + let text = format!("{placeholder} restored"); + let text_elements = vec![TextElement { + byte_range: (0..placeholder.len()).into(), + placeholder: None, + }]; + let path = PathBuf::from("/tmp/image1.png"); + + composer.set_text_content(text, text_elements, vec![path.clone()]); + + assert_eq!(composer.local_image_paths(), vec![path]); + } + + #[test] + fn large_paste_preserves_image_text_elements_on_submit() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_paste.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let expected = format!("{large_content} [Image #1]"); + assert_eq!(text, expected); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: large_content.len() + 1, + end: large_content.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn large_paste_with_leading_whitespace_trims_and_shifts_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = format!(" {}", "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5)); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_trim.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let trimmed = large_content.trim().to_string(); + assert_eq!(text, format!("{trimmed} [Image #1]")); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: trimmed.len() + 1, + end: trimmed.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn pasted_crlf_normalizes_newlines_for_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let pasted = "line1\r\nline2\r\n".to_string(); + composer.handle_paste(pasted); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_crlf.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "line1\nline2\n [Image #1]"); + assert!(!text.contains('\r')); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: "line1\nline2\n ".len(), + end: "line1\nline2\n [Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn suppressed_submission_restores_pending_paste_payload() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.textarea.set_text_clearing_elements("/unknown "); + composer.textarea.set_cursor("/unknown ".len()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + let placeholder = composer + .pending_pastes + .first() + .expect("expected pending paste") + .0 + .clone(); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}")); + + composer.textarea.set_cursor(0); + composer.textarea.insert_str(" "); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, format!("/unknown {large_content}")); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + assert!(composer.pending_pastes.is_empty()); + } + #[test] fn attach_image_without_text_submits_empty_text_and_images() { let (tx, _rx) = unbounded_channel::(); @@ -4009,7 +4459,21 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1]"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -4136,6 +4600,69 @@ mod tests { ); } + #[test] + fn deleting_reordered_image_one_renumbers_text_in_place() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let path1 = PathBuf::from("/tmp/image_first.png"); + let path2 = PathBuf::from("/tmp/image_second.png"); + let placeholder1 = local_image_label_text(1); + let placeholder2 = local_image_label_text(2); + + // Placeholders can be reordered in the text buffer; deleting image #1 should renumber + // image #2 wherever it appears, not just after the cursor. + let text = format!("Test {placeholder2} test {placeholder1}"); + let start2 = text.find(&placeholder2).expect("placeholder2 present"); + let start1 = text.find(&placeholder1).expect("placeholder1 present"); + let text_elements = vec![ + TextElement { + byte_range: ByteRange { + start: start2, + end: start2 + placeholder2.len(), + }, + placeholder: Some(placeholder2), + }, + TextElement { + byte_range: ByteRange { + start: start1, + end: start1 + placeholder1.len(), + }, + placeholder: Some(placeholder1.clone()), + }, + ]; + composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); + + let end1 = start1 + placeholder1.len(); + composer.textarea.set_cursor(end1); + + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!( + composer.textarea.text(), + format!("Test {placeholder1} test ") + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: placeholder1 + }], + composer.attached_images, + "attachment renumbered after deletion" + ); + } + #[test] fn deleting_first_text_element_renumbers_following_text_element() { use crossterm::event::KeyCode; @@ -4233,7 +4760,10 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == prompt_text + )); assert!(composer.textarea.is_empty()); } @@ -4260,15 +4790,16 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice BRANCH=main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Review Alice changes on main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Review Alice changes on main" + )); assert!(composer.textarea.is_empty()); } @@ -4295,15 +4826,16 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); + .set_text_clearing_elements("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Pair Alice Smith with dev-main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Pair Alice Smith with dev-main" + )); assert!(composer.textarea.is_empty()); } @@ -4337,7 +4869,7 @@ mod tests { // Type the slash command let command_text = "/prompts:code-review "; - composer.textarea.set_text(command_text); + composer.textarea.set_text_clearing_elements(command_text); composer.textarea.set_cursor(command_text.len()); // Paste large content (>3000 chars) to trigger placeholder @@ -4360,7 +4892,7 @@ mod tests { // Verify the custom prompt was expanded with the large content as positional arg match result { - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { // The prompt should be expanded, with the large content replacing $1 assert_eq!( text, @@ -4393,12 +4925,12 @@ mod tests { composer .textarea - .set_text("/Users/example/project/src/main.rs"); + .set_text_clearing_elements("/Users/example/project/src/main.rs"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/Users/example/project/src/main.rs"); } else { panic!("expected Submitted"); @@ -4428,12 +4960,14 @@ mod tests { ); composer.set_steer_enabled(true); - composer.textarea.set_text(" /this-looks-like-a-command"); + composer + .textarea + .set_text_clearing_elements(" /this-looks-like-a-command"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/this-looks-like-a-command"); } else { panic!("expected Submitted"); @@ -4468,7 +5002,7 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice stray"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice stray"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4517,7 +5051,9 @@ mod tests { }]); // Provide only one of the required args - composer.textarea.set_text("/prompts:my-prompt USER=Alice"); + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt USER=Alice"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4582,7 +5118,10 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); } #[test] @@ -4609,11 +5148,16 @@ mod tests { }]); // Type positional args; should submit with numeric expansion, no errors. - composer.textarea.set_text("/prompts:elegant hi"); + composer + .textarea + .set_text_clearing_elements("/prompts:elegant hi"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Echo: hi" + )); assert!(composer.textarea.is_empty()); } @@ -4685,10 +5229,11 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Cost: $$ and first: x".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Cost: $$ and first: x" + )); } #[test] @@ -4725,7 +5270,10 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "First: one two\nSecond: one two".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); } /// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If @@ -4850,7 +5398,7 @@ mod tests { ); // Simulate history-like content: "/ test" - composer.set_text_content("/ test".to_string()); + composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new()); // After set_text_content -> sync_popups is called; popup should NOT be Command. assert!( @@ -4880,21 +5428,21 @@ mod tests { ); // Case 1: bare "/" - composer.set_text_content("/".to_string()); + composer.set_text_content("/".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "bare '/' should activate slash popup" ); // Case 2: valid prefix "/re" (matches /review, /resume, etc.) - composer.set_text_content("/re".to_string()); + composer.set_text_content("/re".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/re' should activate slash popup via prefix match" ); // Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback) - composer.set_text_content("/ac".to_string()); + composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/ac' should activate slash popup via fuzzy match" @@ -4903,7 +5451,7 @@ mod tests { // Case 4: invalid prefix "/zzz" – still allowed to open popup if it // matches no built-in command; our current logic will not open popup. // Verify that explicitly. - composer.set_text_content("/zzz".to_string()); + composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::None), "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" @@ -4926,7 +5474,7 @@ mod tests { false, ); - composer.set_text_content("hello".to_string()); + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); let (result, needs_redraw) = diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 331cf82fd5..83764d55b8 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -27,6 +27,7 @@ use bottom_pane_view::BottomPaneView; use codex_core::features::Features; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; @@ -38,6 +39,12 @@ mod approval_overlay; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; mod bottom_pane_view; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalImageAttachment { + pub(crate) placeholder: String, + pub(crate) path: PathBuf, +} mod chat_composer; mod chat_composer_history; mod command_popup; @@ -307,8 +314,14 @@ impl BottomPane { } /// Replace the composer text with `text`. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.composer.set_text_content(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.composer + .set_text_content(text, text_elements, local_image_paths); self.request_redraw(); } @@ -332,6 +345,19 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn composer_text_elements(&self) -> Vec { + self.composer.text_elements() + } + + pub(crate) fn composer_local_images(&self) -> Vec { + self.composer.local_images() + } + + #[cfg(test)] + pub(crate) fn composer_local_image_paths(&self) -> Vec { + self.composer.local_image_paths() + } + /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. @@ -642,10 +668,18 @@ impl BottomPane { } } + #[cfg(test)] pub(crate) fn take_recent_submission_images(&mut self) -> Vec { self.composer.take_recent_submission_images() } + pub(crate) fn take_recent_submission_images_with_placeholders( + &mut self, + ) -> Vec { + self.composer + .take_recent_submission_images_with_placeholders() + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs index 903ebe9f82..37f2d54c7b 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -1,4 +1,6 @@ use crate::key_hint::is_altgr; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -60,10 +62,33 @@ impl TextArea { } } - pub fn set_text(&mut self, text: &str) { + /// Replace the textarea text and clear any existing text elements. + pub fn set_text_clearing_elements(&mut self, text: &str) { + self.set_text_inner(text, None); + } + + /// Replace the textarea text and set the provided text elements. + pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) { + self.set_text_inner(text, Some(elements)); + } + + fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { self.text = text.to_string(); self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); self.elements.clear(); + if let Some(elements) = elements { + for elem in elements { + let mut start = elem.byte_range.start.min(self.text.len()); + let mut end = elem.byte_range.end.min(self.text.len()); + start = self.clamp_pos_to_char_boundary(start); + end = self.clamp_pos_to_char_boundary(end); + if start >= end { + continue; + } + self.elements.push(TextElement { range: start..end }); + } + self.elements.sort_by_key(|e| e.range.start); + } self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.wrap_cache.replace(None); self.preferred_col = None; @@ -722,6 +747,22 @@ impl TextArea { .collect() } + pub fn text_elements(&self) -> Vec { + self.elements + .iter() + .map(|e| { + let placeholder = self.text.get(e.range.clone()).map(str::to_string); + UserTextElement { + byte_range: ByteRange { + start: e.range.start, + end: e.range.end, + }, + placeholder, + } + }) + .collect() + } + pub fn element_payload_starting_at(&self, pos: usize) -> Option { let pos = pos.min(self.text.len()); let elem = self.elements.iter().find(|e| e.range.start == pos)?; @@ -1251,7 +1292,7 @@ mod tests { let mut t = TextArea::new(); t.insert_str("abcd"); t.set_cursor(1); - t.set_text("你"); + t.set_text_clearing_elements("你"); assert_eq!(t.cursor(), 0); t.insert_str("a"); assert_eq!(t.text(), "a你"); @@ -1933,7 +1974,7 @@ mod tests { for _ in 0..base_len { base.push_str(&rand_grapheme(&mut rng)); } - ta.set_text(&base); + ta.set_text_clearing_elements(&base); // Choose a valid char boundary for initial cursor let mut boundaries: Vec = vec![0]; boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 756d244255..6c11077c80 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -90,7 +90,9 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -122,6 +124,7 @@ use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::InputResult; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -299,8 +302,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, - pub(crate) initial_prompt: Option, - pub(crate) initial_images: Vec, + pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, @@ -457,16 +459,19 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) animation_tick: Option, } -struct UserMessage { +pub(crate) struct UserMessage { text: String, - image_paths: Vec, + local_images: Vec, + text_elements: Vec, } impl From for UserMessage { fn from(text: String) -> Self { Self { text, - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } @@ -475,16 +480,107 @@ impl From<&str> for UserMessage { fn from(text: &str) -> Self { Self { text: text.to_string(), - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } -fn create_initial_user_message(text: String, image_paths: Vec) -> Option { - if text.is_empty() && image_paths.is_empty() { +pub(crate) fn create_initial_user_message( + text: Option, + local_image_paths: Vec, + text_elements: Vec, +) -> Option { + let text = text.unwrap_or_default(); + if text.is_empty() && local_image_paths.is_empty() { None } else { - Some(UserMessage { text, image_paths }) + let local_images = local_image_paths + .into_iter() + .enumerate() + .map(|(idx, path)| LocalImageAttachment { + placeholder: local_image_label_text(idx + 1), + path, + }) + .collect(); + Some(UserMessage { + text, + local_images, + text_elements, + }) + } +} + +// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering +// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so +// the combined local_image_paths order matches the labels, even if placeholders were moved +// in the text (e.g., [Image #2] appearing before [Image #1]). +fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { + let UserMessage { + text, + text_elements, + local_images, + } = message; + if local_images.is_empty() { + return UserMessage { + text, + text_elements, + local_images, + }; + } + + let mut mapping: HashMap = HashMap::new(); + let mut remapped_images = Vec::new(); + for attachment in local_images { + let new_placeholder = local_image_label_text(*next_label); + *next_label += 1; + mapping.insert(attachment.placeholder.clone(), new_placeholder.clone()); + remapped_images.push(LocalImageAttachment { + placeholder: new_placeholder, + path: attachment.path, + }); + } + + let mut elements = text_elements; + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut cursor = 0usize; + let mut rebuilt = String::new(); + let mut rebuilt_elements = Vec::new(); + for mut elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if let Some(segment) = text.get(cursor..start) { + rebuilt.push_str(segment); + } + + let original = text.get(start..end).unwrap_or(""); + let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); + let replacement = mapping + .get(placeholder_key) + .map(String::as_str) + .unwrap_or(original); + + let elem_start = rebuilt.len(); + rebuilt.push_str(replacement); + let elem_end = rebuilt.len(); + + if let Some(remapped) = mapping.get(placeholder_key) { + elem.placeholder = Some(remapped.clone()); + } + elem.byte_range = (elem_start..elem_end).into(); + rebuilt_elements.push(elem); + cursor = end; + } + if let Some(segment) = text.get(cursor..) { + rebuilt.push_str(segment); + } + + UserMessage { + text: rebuilt, + local_images: remapped_images, + text_elements: rebuilt_elements, } } @@ -914,31 +1010,76 @@ impl ChatWidget { )); } - // If any messages were queued during the task, restore them into the composer. - if !self.queued_user_messages.is_empty() { - let queued_text = self - .queued_user_messages + if let Some(combined) = self.drain_queued_messages_for_restore() { + let combined_local_image_paths = combined + .local_images .iter() - .map(|m| m.text.clone()) - .collect::>() - .join("\n"); - let existing_text = self.bottom_pane.composer_text(); - let combined = if existing_text.is_empty() { - queued_text - } else if queued_text.is_empty() { - existing_text - } else { - format!("{queued_text}\n{existing_text}") - }; - self.bottom_pane.set_composer_text(combined); - // Clear the queue and update the status indicator list. - self.queued_user_messages.clear(); + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + combined.text, + combined.text_elements, + combined_local_image_paths, + ); self.refresh_queued_user_messages(); } self.request_redraw(); } + /// Merge queued drafts (plus the current composer state) into a single message for restore. + /// + /// Each queued draft numbers attachments from `[Image #1]`. When we concatenate drafts, we + /// must renumber placeholders in a stable order so the merged attachment list stays aligned + /// with the labels embedded in text. This helper drains the queue, remaps placeholders, and + /// fixes text element byte ranges as content is appended. Returns `None` when there is nothing + /// to restore. + fn drain_queued_messages_for_restore(&mut self) -> Option { + if self.queued_user_messages.is_empty() { + return None; + } + + let existing_message = UserMessage { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + }; + + let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); + if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() { + to_merge.push(existing_message); + } + + let mut combined = UserMessage { + text: String::new(), + text_elements: Vec::new(), + local_images: Vec::new(), + }; + let mut combined_offset = 0usize; + let mut next_image_label = 1usize; + + for (idx, message) in to_merge.into_iter().enumerate() { + if idx > 0 { + combined.text.push('\n'); + combined_offset += 1; + } + let message = remap_placeholders_for_message(message, &mut next_image_label); + let base = combined_offset; + combined.text.push_str(&message.text); + combined_offset += message.text.len(); + combined + .text_elements + .extend(message.text_elements.into_iter().map(|mut elem| { + elem.byte_range.start += base; + elem.byte_range.end += base; + elem + })); + combined.local_images.extend(message.local_images); + } + + Some(combined) + } + fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.add_to_history(history_cell::new_plan_update(update)); } @@ -1442,8 +1583,7 @@ impl ChatWidget { config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, @@ -1490,10 +1630,7 @@ impl ChatWidget { auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1550,8 +1687,7 @@ impl ChatWidget { mut config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, @@ -1591,10 +1727,7 @@ impl ChatWidget { auth_manager, models_manager, session_header: SessionHeader::new(header_model), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1719,46 +1852,64 @@ impl ChatWidget { } if !self.queued_user_messages.is_empty() => { // Prefer the most recently queued item. if let Some(user_message) = self.queued_user_messages.pop_back() { - self.bottom_pane.set_composer_text(user_message.text); + let local_image_paths = user_message + .local_images + .iter() + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + user_message.text, + user_message.text_elements, + local_image_paths, + ); self.refresh_queued_user_messages(); self.request_redraw(); } } - _ => { - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted(text) => { - // Enter always sends messages immediately (bypasses queue check) - // Clear any reasoning status header when submitting a new message + _ => match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + if self.is_session_configured() { + // Submitted is only emitted when steer is enabled (Enter sends immediately). + // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); self.set_status_header(String::from("Working")); - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; - if !self.is_session_configured() { - self.queue_user_message(user_message); - } else { - self.submit_user_message(user_message); - } - } - InputResult::Queued(text) => { - // Tab queues the message if a task is running, otherwise submits immediately - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; + self.submit_user_message(user_message); + } else { self.queue_user_message(user_message); } - InputResult::Command(cmd) => { - self.dispatch_command(cmd); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); - } - InputResult::None => {} } - } + InputResult::Queued { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::CommandWithArgs(cmd, args) => { + self.dispatch_command_with_args(cmd, args); + } + InputResult::None => {} + }, } } @@ -2074,8 +2225,12 @@ impl ChatWidget { }; let model = model.to_string(); - let UserMessage { text, image_paths } = user_message; - if text.is_empty() && image_paths.is_empty() { + let UserMessage { + text, + local_images, + text_elements, + } = user_message; + if text.is_empty() && local_images.is_empty() { return; } @@ -2099,15 +2254,16 @@ impl ChatWidget { return; } - for path in image_paths { - items.push(UserInput::LocalImage { path }); + for image in &local_images { + items.push(UserInput::LocalImage { + path: image.path.clone(), + }); } if !text.is_empty() { - // TODO: Thread text element ranges from the composer input. Empty keeps old behavior. items.push(UserInput::Text { text: text.clone(), - text_elements: Vec::new(), + text_elements: text_elements.clone(), }); } @@ -2161,7 +2317,12 @@ impl ChatWidget { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(history_cell::new_user_prompt(text)); + let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); + self.add_to_history(history_cell::new_user_prompt( + text, + text_elements, + local_image_paths, + )); } self.needs_final_message_separator = false; } @@ -2372,10 +2533,12 @@ impl ChatWidget { } fn on_user_message_event(&mut self, event: UserMessageEvent) { - let message = event.message.trim(); - // Only show the text portion in conversation history. - if !message.is_empty() { - self.add_to_history(history_cell::new_user_prompt(message.to_string())); + if !event.message.trim().is_empty() { + self.add_to_history(history_cell::new_user_prompt( + event.message, + event.text_elements, + event.local_images, + )); } self.needs_final_message_separator = false; @@ -3922,8 +4085,14 @@ impl ChatWidget { } /// Replace the composer content with the provided text and reset cursor. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.bottom_pane.set_composer_text(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.bottom_pane + .set_composer_text(text, text_elements, local_image_paths); } pub(crate) fn show_esc_backtrack_hint(&mut self) { diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index c404287af6..9d5d7e67b2 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -8,6 +8,8 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; +use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -64,6 +66,8 @@ use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::user_input::TextElement; +use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -170,6 +174,364 @@ async fn resumed_initial_messages_render_history() { ); } +#[tokio::test] +async fn replayed_user_message_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + let placeholder = "[Image #1]"; + let message = format!("{placeholder} replayed"); + let text_elements = vec![TextElement { + byte_range: (0..placeholder.len()).into(), + placeholder: Some(placeholder.to_string()), + }]; + let local_images = vec![PathBuf::from("/tmp/replay.png")]; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { + message: message.clone(), + images: None, + text_elements: text_elements.clone(), + local_images: local_images.clone(), + })]), + rollout_path: rollout_file.path().to_path_buf(), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected a replayed user history cell"); + assert_eq!(stored_message, message); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn submission_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: rollout_file.path().to_path_buf(), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + let placeholder = "[Image #1]"; + let text = format!("{placeholder} submit"); + let text_elements = vec![TextElement { + byte_range: (0..placeholder.len()).into(), + placeholder: Some(placeholder.to_string()), + }]; + let local_images = vec![PathBuf::from("/tmp/submitted.png")]; + + chat.bottom_pane + .set_composer_text(text.clone(), text_elements.clone(), local_images.clone()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(items.len(), 2); + assert_eq!( + items[0], + UserInput::LocalImage { + path: local_images[0].clone() + } + ); + assert_eq!( + items[1], + UserInput::Text { + text: text.clone(), + text_elements: text_elements.clone(), + } + ); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected submitted user history cell"); + assert_eq!(stored_message, text); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + let first_placeholder = "[Image #1]"; + let first_text = format!("{first_placeholder} first"); + let first_elements = vec![TextElement { + byte_range: (0..first_placeholder.len()).into(), + placeholder: Some(first_placeholder.to_string()), + }]; + let first_images = [PathBuf::from("/tmp/first.png")]; + + let second_placeholder = "[Image #1]"; + let second_text = format!("{second_placeholder} second"); + let second_elements = vec![TextElement { + byte_range: (0..second_placeholder.len()).into(), + placeholder: Some(second_placeholder.to_string()), + }]; + let second_images = [PathBuf::from("/tmp/second.png")]; + + let existing_placeholder = "[Image #1]"; + let existing_text = format!("{existing_placeholder} existing"); + let existing_elements = vec![TextElement { + byte_range: (0..existing_placeholder.len()).into(), + placeholder: Some(existing_placeholder.to_string()), + }]; + let existing_images = vec![PathBuf::from("/tmp/existing.png")]; + + chat.queued_user_messages.push_back(UserMessage { + text: first_text, + local_images: vec![LocalImageAttachment { + placeholder: first_placeholder.to_string(), + path: first_images[0].clone(), + }], + text_elements: first_elements, + }); + chat.queued_user_messages.push_back(UserMessage { + text: second_text, + local_images: vec![LocalImageAttachment { + placeholder: second_placeholder.to_string(), + path: second_images[0].clone(), + }], + text_elements: second_elements, + }); + chat.refresh_queued_user_messages(); + + chat.bottom_pane + .set_composer_text(existing_text, existing_elements, existing_images.clone()); + + // When interrupted, queued messages are merged into the composer; image placeholders + // must be renumbered to match the combined local image list. + chat.handle_codex_event(Event { + id: "interrupt".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let first = "[Image #1] first".to_string(); + let second = "[Image #2] second".to_string(); + let third = "[Image #3] existing".to_string(); + let expected_text = format!("{first}\n{second}\n{third}"); + assert_eq!(chat.bottom_pane.composer_text(), expected_text); + + let first_start = 0; + let second_start = first.len() + 1; + let third_start = second_start + second.len() + 1; + let expected_elements = vec![ + TextElement { + byte_range: (first_start..first_start + "[Image #1]".len()).into(), + placeholder: Some("[Image #1]".to_string()), + }, + TextElement { + byte_range: (second_start..second_start + "[Image #2]".len()).into(), + placeholder: Some("[Image #2]".to_string()), + }, + TextElement { + byte_range: (third_start..third_start + "[Image #3]".len()).into(), + placeholder: Some("[Image #3]".to_string()), + }, + ]; + assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); + assert_eq!( + chat.bottom_pane.composer_local_image_paths(), + vec![ + first_images[0].clone(), + second_images[0].clone(), + existing_images[0].clone(), + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_attachment_labels() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement { + byte_range: (0..placeholder_two.len()).into(), + placeholder: Some(placeholder_two.to_string()), + }, + TextElement { + byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + placeholder: Some(placeholder_one.to_string()), + }, + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement { + byte_range: (0.."[Image #4]".len()).into(), + placeholder: Some("[Image #4]".to_string()), + }, + TextElement { + byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) + .into(), + placeholder: Some("[Image #3]".to_string()), + }, + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement { + byte_range: (0..placeholder_two.len()).into(), + placeholder: None, + }, + TextElement { + byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + placeholder: None, + }, + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement { + byte_range: (0.."[Image #4]".len()).into(), + placeholder: Some("[Image #4]".to_string()), + }, + TextElement { + byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) + .into(), + placeholder: Some("[Image #3]".to_string()), + }, + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + /// Entering review mode uses the hint provided by the review request. #[tokio::test] async fn entered_review_mode_uses_request_hint() { @@ -339,8 +701,7 @@ async fn helpers_are_available_and_do_not_panic() { config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, - initial_prompt: None, - initial_images: Vec::new(), + initial_user_message: None, enhanced_keys_supported: false, auth_manager, models_manager: thread_manager.get_models_manager(), @@ -1030,7 +1391,8 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { assert!(!chat.bottom_pane.is_task_running()); // Submit an initial prompt to seed history. - chat.bottom_pane.set_composer_text("repeat me".to_string()); + chat.bottom_pane + .set_composer_text("repeat me".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // Simulate an active task so further submissions are queued. @@ -1066,7 +1428,7 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.status_widget().is_none()); chat.bottom_pane - .set_composer_text("queued submission".to_string()); + .set_composer_text("queued submission".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(chat.queued_user_messages.len(), 1); @@ -1387,7 +1749,8 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); - chat.bottom_pane.set_composer_text("hello".to_string()); + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { @@ -1397,7 +1760,8 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), } - chat.bottom_pane.set_composer_text("follow up".to_string()); + chat.bottom_pane + .set_composer_text("follow up".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { @@ -1414,7 +1778,8 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { chat.conversation_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.bottom_pane.set_composer_text("hello".to_string()); + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { @@ -2489,7 +2854,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { chat.bottom_pane.set_task_running(true); chat.bottom_pane - .set_composer_text("current draft".to_string()); + .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); chat.queued_user_messages .push_back(UserMessage::from("first queued".to_string())); @@ -3471,8 +3836,11 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { delta: "**Investigating rendering code**".into(), }), }); - chat.bottom_pane - .set_composer_text("Summarize recent commits".to_string()); + chat.bottom_pane.set_composer_text( + "Summarize recent commits".to_string(), + Vec::new(), + Vec::new(), + ); let width: u16 = 80; let ui_height: u16 = chat.desired_height(width); diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index 5cdb3e7f2d..a313f9b2a7 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -44,6 +44,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::user_input::TextElement; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -51,6 +52,7 @@ use mcp_types::Resource; use mcp_types::ResourceLink; use mcp_types::ResourceTemplate; use ratatui::prelude::*; +use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; @@ -214,6 +216,75 @@ impl dyn HistoryCell { #[derive(Debug)] pub(crate) struct UserHistoryCell { pub message: String, + pub text_elements: Vec, + #[allow(dead_code)] + pub local_image_paths: Vec, +} + +/// Build logical lines for a user message with styled text elements. +/// +/// This preserves explicit newlines while interleaving element spans and skips +/// malformed byte ranges instead of panicking during history rendering. +fn build_user_message_lines_with_elements( + message: &str, + elements: &[TextElement], + style: Style, + element_style: Style, +) -> Vec> { + let mut elements = elements.to_vec(); + elements.sort_by_key(|e| e.byte_range.start); + let mut offset = 0usize; + let mut raw_lines: Vec> = Vec::new(); + for line_text in message.split('\n') { + let line_start = offset; + let line_end = line_start + line_text.len(); + let mut spans: Vec> = Vec::new(); + // Track how much of the line we've emitted to interleave plain and styled spans. + let mut cursor = line_start; + for elem in &elements { + let start = elem.byte_range.start.max(line_start); + let end = elem.byte_range.end.min(line_end); + if start >= end { + continue; + } + let rel_start = start - line_start; + let rel_end = end - line_start; + // Guard against malformed UTF-8 byte ranges from upstream data; skip + // invalid elements rather than panicking while rendering history. + if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { + continue; + } + let rel_cursor = cursor - line_start; + if cursor < start + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..rel_start) + { + spans.push(Span::from(segment.to_string())); + } + if let Some(segment) = line_text.get(rel_start..rel_end) { + spans.push(Span::styled(segment.to_string(), element_style)); + cursor = end; + } + } + let rel_cursor = cursor - line_start; + if cursor < line_end + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..) + { + spans.push(Span::from(segment.to_string())); + } + let line = if spans.is_empty() { + Line::from(line_text.to_string()).style(style) + } else { + Line::from(spans).style(style) + }; + raw_lines.push(line); + // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts + // for the separator byte. + offset = line_end + 1; + } + + raw_lines } impl HistoryCell for UserHistoryCell { @@ -229,13 +300,28 @@ impl HistoryCell for UserHistoryCell { .max(1); let style = user_message_style(); + let element_style = style.fg(Color::Cyan); - let (wrapped, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners( - self.message.lines().map(|l| Line::from(l).style(style)), - // Wrap algorithm matches textarea.rs. - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); + let (wrapped, joiner_before) = if self.text_elements.is_empty() { + crate::wrapping::word_wrap_lines_with_joiners( + self.message.split('\n').map(|l| Line::from(l).style(style)), + // Wrap algorithm matches textarea.rs. + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + } else { + let raw_lines = build_user_message_lines_with_elements( + &self.message, + &self.text_elements, + style, + element_style, + ); + crate::wrapping::word_wrap_lines_with_joiners( + raw_lines, + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + }; let mut lines: Vec> = Vec::new(); let mut joins: Vec> = Vec::new(); @@ -955,8 +1041,16 @@ pub(crate) fn new_session_info( SessionInfoCell(CompositeHistoryCell { parts }) } -pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { - UserHistoryCell { message } +pub(crate) fn new_user_prompt( + message: String, + text_elements: Vec, + local_image_paths: Vec, +) -> UserHistoryCell { + UserHistoryCell { + message, + text_elements, + local_image_paths, + } } #[derive(Debug)] @@ -2718,6 +2812,8 @@ mod tests { let msg = "one two three four five six seven"; let cell = UserHistoryCell { message: msg.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }; // Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space. diff --git a/codex-rs/tui2/src/public_widgets/composer_input.rs b/codex-rs/tui2/src/public_widgets/composer_input.rs index 2a80c087ee..46a7e72bcf 100644 --- a/codex-rs/tui2/src/public_widgets/composer_input.rs +++ b/codex-rs/tui2/src/public_widgets/composer_input.rs @@ -48,13 +48,14 @@ impl ComposerInput { /// Clear the input text. pub fn clear(&mut self) { - self.inner.set_text_content(String::new()); + self.inner + .set_text_content(String::new(), Vec::new(), Vec::new()); } /// Feed a key event into the composer and return a high-level action. pub fn input(&mut self, key: KeyEvent) -> ComposerAction { let action = match self.inner.handle_key_event(key).0 { - InputResult::Submitted(text) => ComposerAction::Submitted(text), + InputResult::Submitted { text, .. } => ComposerAction::Submitted(text), _ => ComposerAction::None, }; self.drain_app_events(); diff --git a/codex-rs/tui2/src/transcript_view_cache.rs b/codex-rs/tui2/src/transcript_view_cache.rs index a32094b118..ab5d94ee86 100644 --- a/codex-rs/tui2/src/transcript_view_cache.rs +++ b/codex-rs/tui2/src/transcript_view_cache.rs @@ -1018,6 +1018,8 @@ mod tests { let mut cache = TranscriptViewCache::new(); let cells: Vec> = vec![Arc::new(UserHistoryCell { message: "hello".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), })]; cache.ensure_wrapped(&cells, 20); From 3078eedb24897837770661595bca2e09a0f36dd2 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 19 Jan 2026 23:58:44 -0800 Subject: [PATCH 57/78] fix(tui) fix user message light mode background (#9407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the user message styles for light mode. ## Testing Attaching 2 screenshots from ghostty, but I also tried various styles in Terminal.app and iTerm2. **Before** Screenshot 2026-01-16 at 5 22 36 PM **After** Screenshot 2026-01-16 at 5 22 59 PM --- codex-rs/tui/src/style.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index e50d59791e..26a71bd897 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -19,10 +19,10 @@ pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { #[allow(clippy::disallowed_methods)] pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { - let top = if is_light(terminal_bg) { - (0, 0, 0) + let (top, alpha) = if is_light(terminal_bg) { + ((0, 0, 0), 0.04) } else { - (255, 255, 255) + ((255, 255, 255), 0.12) }; - best_color(blend(top, terminal_bg, 0.1)) + best_color(blend(top, terminal_bg, alpha)) } From 483239d861596a1917fe6d0c82487a92929870aa Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 20 Jan 2026 10:19:06 +0000 Subject: [PATCH 58/78] chore: collab in experimental (#9525) --- codex-rs/core/src/features.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 3e95c566bd..1a35407a1d 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -432,7 +432,11 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Collab, key: "collab", - stage: Stage::Beta, + stage: Stage::Experimental { + name: "Mult-agents", + menu_description: "Allow Codex to spawn and collaborate with other agents on request (formerly named `collab`).", + announcement: "NEW! Codex can now spawn other agents and work with them to solve your problems. Enable in /experimental!", + }, default_enabled: false, }, FeatureSpec { From 9ec20ba065ca70865c1d55201c06615de1c43dd3 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 20 Jan 2026 10:20:17 +0000 Subject: [PATCH 59/78] nit: do not render terminal interactions if no task running (#9374) To prevent race where the terminal interaction message is processed after the last message --- codex-rs/tui/src/chatwidget.rs | 3 +++ codex-rs/tui/src/chatwidget/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0131887a1e..3f610c4faa 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1226,6 +1226,9 @@ impl ChatWidget { } fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) { + if !self.bottom_pane.is_task_running() { + return; + } self.flush_answer_stream_with_separator(); let command_display = self .unified_exec_processes diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 73aac97316..b864a7b3ee 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1719,9 +1719,32 @@ async fn unified_exec_end_after_task_complete_is_suppressed() { ); } +#[tokio::test] +async fn unified_exec_interaction_after_task_complete_is_suppressed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + chat.on_task_complete(None); + + chat.handle_codex_event(Event { + id: "call-1".to_string(), + msg: EventMsg::TerminalInteraction(TerminalInteractionEvent { + call_id: "call-1".to_string(), + process_id: "proc-1".to_string(), + stdin: "ls\n".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected unified exec interaction after task complete to be suppressed" + ); +} + #[tokio::test] async fn unified_exec_wait_status_header_updates_on_late_command_display() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); chat.unified_exec_processes.push(UnifiedExecProcessSummary { key: "proc-1".to_string(), command_display: "sleep 5".to_string(), @@ -1743,6 +1766,7 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { #[tokio::test] async fn unified_exec_waiting_multiple_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-1", "proc-1", "just fix"); terminal_interaction(&mut chat, "call-wait-1a", "proc-1", ""); @@ -1770,6 +1794,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { #[tokio::test] async fn unified_exec_empty_then_non_empty_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-2", "proc-2", "just fix"); terminal_interaction(&mut chat, "call-wait-2a", "proc-2", ""); @@ -1786,6 +1811,7 @@ async fn unified_exec_empty_then_non_empty_snapshot() { #[tokio::test] async fn unified_exec_non_empty_then_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-3", "proc-3", "just fix"); terminal_interaction(&mut chat, "call-wait-3a", "proc-3", "pwd\n"); From a3a97f3ea97201ff32ebb4868dbcd13b3c095230 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 20 Jan 2026 13:01:55 +0000 Subject: [PATCH 60/78] feat: record timer with additional tags (#9529) --- codex-rs/otel/src/metrics/timer.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/codex-rs/otel/src/metrics/timer.rs b/codex-rs/otel/src/metrics/timer.rs index 6c85f413b3..86712f6a0e 100644 --- a/codex-rs/otel/src/metrics/timer.rs +++ b/codex-rs/otel/src/metrics/timer.rs @@ -12,7 +12,7 @@ pub struct Timer { impl Drop for Timer { fn drop(&mut self) { - if let Err(e) = self.record() { + if let Err(e) = self.record(&[]) { tracing::error!("metrics client error: {}", e); } } @@ -31,12 +31,10 @@ impl Timer { } } - pub fn record(&self) -> Result<()> { - let tags = self - .tags - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect::>(); + pub fn record(&self, additional_tags: &[(&str, &str)]) -> Result<()> { + let mut tags = Vec::with_capacity(self.tags.len() + additional_tags.len()); + tags.extend(additional_tags); + tags.extend(self.tags.iter().map(|(k, v)| (k.as_str(), v.as_str()))); self.client .record_duration(&self.name, self.start_time.elapsed(), &tags) } From 6bbf506120c63cc632e5c680677e0c713a74c5ab Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 20 Jan 2026 13:02:55 +0000 Subject: [PATCH 61/78] feat: metrics on remote models (#9528) --- codex-rs/core/src/models_manager/manager.rs | 31 +++++++++++---------- codex-rs/otel/src/lib.rs | 8 ++++++ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 82fea6e71f..b7cc66159a 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -1,3 +1,16 @@ +use super::cache::ModelsCacheManager; +use crate::api_bridge::auth_provider_from_auth; +use crate::api_bridge::map_api_error; +use crate::auth::AuthManager; +use crate::config::Config; +use crate::default_client::build_reqwest_client; +use crate::error::CodexErr; +use crate::error::Result as CoreResult; +use crate::features::Feature; +use crate::model_provider_info::ModelProviderInfo; +use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; +use crate::models_manager::model_info; +use crate::models_manager::model_presets::builtin_model_presets; use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_app_server_protocol::AuthMode; @@ -14,20 +27,6 @@ use tokio::sync::TryLockError; use tokio::time::timeout; use tracing::error; -use super::cache::ModelsCacheManager; -use crate::api_bridge::auth_provider_from_auth; -use crate::api_bridge::map_api_error; -use crate::auth::AuthManager; -use crate::config::Config; -use crate::default_client::build_reqwest_client; -use crate::error::CodexErr; -use crate::error::Result as CoreResult; -use crate::features::Feature; -use crate::model_provider_info::ModelProviderInfo; -use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; -use crate::models_manager::model_info; -use crate::models_manager::model_presets::builtin_model_presets; - const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); @@ -211,6 +210,8 @@ impl ModelsManager { } async fn fetch_and_update_models(&self) -> CoreResult<()> { + let _timer = + codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]); let auth = self.auth_manager.auth().await; let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; @@ -249,6 +250,8 @@ impl ModelsManager { /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. async fn try_load_cache(&self) -> bool { + let _timer = + codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]); let cache = match self.cache_manager.load_fresh().await { Some(cache) => cache, None => return false, diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index fa7cc93bb7..32540d1071 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -170,3 +170,11 @@ impl OtelManager { Ok(()) } } + +/// Start a metrics timer using the globally installed metrics client. +pub fn start_global_timer(name: &str, tags: &[(&str, &str)]) -> MetricsResult { + let Some(metrics) = crate::metrics::global() else { + return Err(MetricsError::ExporterDisabled); + }; + metrics.start_timer(name, tags) +} From 3a9f436ce00124cabe71f102c3a2c29f097ccc4c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 20 Jan 2026 13:18:24 +0000 Subject: [PATCH 62/78] feat: metrics on shell snapshot (#9527) --- codex-rs/core/src/codex.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a2a3481d8c..c5332f26d6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -710,10 +710,18 @@ impl Session { let mut default_shell = shell::default_user_shell(); // Create the mutable state for the Session. if config.features.enabled(Feature::ShellSnapshot) { + let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]); default_shell.shell_snapshot = ShellSnapshot::try_new(&config.codex_home, conversation_id, &default_shell) .await .map(Arc::new); + let success = if default_shell.shell_snapshot.is_some() { + "true" + } else { + "false" + }; + let _ = timer.map(|timer| timer.record(&[("success", success)])); + otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]) } let state = SessionState::new(session_configuration.clone()); From 7351c129992d85405e53a285cb4916ccb223489f Mon Sep 17 00:00:00 2001 From: gt-oai Date: Tue, 20 Jan 2026 15:44:21 +0000 Subject: [PATCH 63/78] Only load config from trusted folders (#9533) Config includes multiple code execution entrypoints. Now, we load the config from predetermined locations first (~/.codex/config.toml etc), use those to learn which folders are 'trusted', and only load additional config from the CWD if it is trusted. --- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/config_loader/mod.rs | 70 ++++++++++-- codex-rs/core/src/config_loader/tests.rs | 130 ++++++++++++++++++++++- codex-rs/core/src/skills/loader.rs | 20 ++++ 4 files changed, 207 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 970ec7c4df..b4e6c0c8bf 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -531,7 +531,7 @@ pub async fn load_config_as_toml_with_cli_overrides( Ok(cfg) } -fn deserialize_config_toml_with_base( +pub(crate) fn deserialize_config_toml_with_base( root_value: TomlValue, config_base_dir: &Path, ) -> std::io::Result { diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 9a8791eeef..0460e01572 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -12,10 +12,13 @@ mod tests; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigToml; +use crate::config::deserialize_config_toml_with_base; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::layer_io::LoadedConfigLayers; +use crate::git_info::resolve_root_git_project_for_trust; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -64,9 +67,9 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` /// - user `${CODEX_HOME}/config.toml` -/// - cwd `${PWD}/config.toml` -/// - tree parent directories up to root looking for `./.codex/config.toml` -/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` +/// - cwd `${PWD}/config.toml` (only when the directory is trusted) +/// - tree parent directories up to root looking for `./.codex/config.toml` (trusted only) +/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (trusted only) /// - runtime e.g., --config flags, model selector in UI /// /// (*) Only available on macOS via managed device profiles. @@ -114,6 +117,12 @@ pub async fn load_config_layers_state( let mut layers = Vec::::new(); + let cli_overrides_layer = if cli_overrides.is_empty() { + None + } else { + Some(overrides::build_cli_overrides_layer(cli_overrides)) + }; + // Include an entry for the "system" config folder, loading its config.toml, // if it exists. let system_config_toml_file = if cfg!(unix) { @@ -158,17 +167,22 @@ pub async fn load_config_layers_state( for layer in &layers { merge_toml_values(&mut merged_so_far, &layer.config); } + if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() { + merge_toml_values(&mut merged_so_far, cli_overrides_layer); + } + let project_root_markers = project_root_markers_from_config(&merged_so_far)? .unwrap_or_else(default_project_root_markers); - - let project_root = find_project_root(&cwd, &project_root_markers).await?; - let project_layers = load_project_layers(&cwd, &project_root).await?; - layers.extend(project_layers); + if let Some(project_root) = + trusted_project_root(&merged_so_far, &cwd, &project_root_markers, codex_home).await? + { + let project_layers = load_project_layers(&cwd, &project_root).await?; + layers.extend(project_layers); + } } // Add a layer for runtime overrides from the CLI or UI, if any exist. - if !cli_overrides.is_empty() { - let cli_overrides_layer = overrides::build_cli_overrides_layer(cli_overrides); + if let Some(cli_overrides_layer) = cli_overrides_layer { layers.push(ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, cli_overrides_layer, @@ -388,6 +402,44 @@ fn default_project_root_markers() -> Vec { .collect() } +async fn trusted_project_root( + merged_config: &TomlValue, + cwd: &AbsolutePathBuf, + project_root_markers: &[String], + config_base_dir: &Path, +) -> io::Result> { + let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?; + + let project_root = find_project_root(cwd, project_root_markers).await?; + let projects = config_toml.projects.unwrap_or_default(); + + let cwd_key = cwd.as_path().to_string_lossy().to_string(); + let project_root_key = project_root.as_path().to_string_lossy().to_string(); + let repo_root_key = resolve_root_git_project_for_trust(cwd.as_path()) + .map(|root| root.to_string_lossy().to_string()); + + let trust_level = projects + .get(&cwd_key) + .and_then(|project| project.trust_level) + .or_else(|| { + projects + .get(&project_root_key) + .and_then(|project| project.trust_level) + }) + .or_else(|| { + repo_root_key + .as_ref() + .and_then(|root| projects.get(root)) + .and_then(|project| project.trust_level) + }); + + if matches!(trust_level, Some(TrustLevel::Trusted)) { + Ok(Some(project_root)) + } else { + Ok(None) + } +} + /// Takes a `toml::Value` parsed from a config.toml file and walks through it, /// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new /// `toml::Value` with the same shape but with paths resolved. diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 3738f95b22..773d9951d1 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -3,19 +3,47 @@ use super::load_config_layers_state; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; +use crate::config::ConfigToml; +use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigRequirements; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::fingerprint::version_for_toml; use crate::config_loader::load_requirements_toml; +use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; #[cfg(target_os = "macos")] use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; use tempfile::tempdir; use toml::Value as TomlValue; +async fn make_config_for_test( + codex_home: &Path, + project_path: &Path, + trust_level: TrustLevel, + project_root_markers: Option>, +) -> std::io::Result<()> { + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + project_path.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(trust_level), + }, + )])), + project_root_markers, + ..Default::default() + }) + .expect("serialize config"), + ) + .await +} + #[tokio::test] async fn merges_managed_config_layer_on_top() { let tmp = tempdir().expect("tempdir"); @@ -365,6 +393,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -429,6 +458,7 @@ experimental_instructions_file = "child.txt" let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let config = ConfigBuilder::default() .codex_home(codex_home) @@ -458,6 +488,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -486,6 +517,95 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Ok(()) } +#[tokio::test] +async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::write( + nested.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"child\"\n", + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + + let codex_home_untrusted = tmp.path().join("home_untrusted"); + tokio::fs::create_dir_all(&codex_home_untrusted).await?; + make_config_for_test( + &codex_home_untrusted, + &project_root, + TrustLevel::Untrusted, + None, + ) + .await?; + + let layers_untrusted = load_config_layers_state( + &codex_home_untrusted, + Some(cwd.clone()), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers_untrusted = layers_untrusted + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .count(); + assert_eq!(project_layers_untrusted, 0); + assert_eq!(layers_untrusted.effective_config().get("foo"), None); + + let codex_home_unknown = tmp.path().join("home_unknown"); + tokio::fs::create_dir_all(&codex_home_unknown).await?; + + let layers_unknown = load_config_layers_state( + &codex_home_unknown, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers_unknown = layers_unknown + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .count(); + assert_eq!(project_layers_unknown, 0); + assert_eq!(layers_unknown.effective_config().get("foo"), None); + + Ok(()) +} + +#[tokio::test] +async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(&nested).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let cli_overrides = vec![( + "experimental_instructions_file".to_string(), + TomlValue::String("relative.md".to_string()), + )]; + + load_config_layers_state( + &codex_home, + Some(cwd), + &cli_overrides, + LoaderOverrides::default(), + ) + .await?; + + Ok(()) +} + #[tokio::test] async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()> { let tmp = tempdir()?; @@ -507,11 +627,11 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; - tokio::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#" -project_root_markers = [".hg"] -"#, + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + Some(vec![".hg".to_string()]), ) .await?; diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index f6dd3dae92..1e02926572 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -550,15 +550,20 @@ fn extract_frontmatter(contents: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; + use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; use toml::Value as TomlValue; @@ -570,6 +575,21 @@ mod tests { } async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config { + fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + cwd.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }) + .expect("serialize config"), + ) + .unwrap(); + let harness_overrides = ConfigOverrides { cwd: Some(cwd), ..Default::default() From 7b27aa770795edec9353b1490a7ba5e64fb3f268 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Tue, 20 Jan 2026 09:36:30 -0800 Subject: [PATCH 64/78] feat: support proxy for ws connection (#9409) unfortunately tokio-tungstenite doesn't support proxy configuration outbox, while https://github.com/snapview/tokio-tungstenite/pull/370 is in review, we can depend on source code for now. --- codex-rs/Cargo.lock | 42 ++++++++++++------- codex-rs/Cargo.toml | 8 +++- codex-rs/backend-client/Cargo.toml | 2 +- .../src/endpoint/responses_websocket.rs | 4 +- codex-rs/core/src/default_client.rs | 3 +- codex-rs/core/tests/common/responses.rs | 2 +- codex-rs/otel/Cargo.toml | 2 +- codex-rs/responses-api-proxy/Cargo.toml | 2 +- codex-rs/rmcp-client/Cargo.toml | 3 +- 9 files changed, 43 insertions(+), 25 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7c5b5a8634..ba2400ba95 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3455,7 +3455,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] @@ -3505,7 +3505,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -5342,7 +5342,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -5379,7 +5379,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5664,7 +5664,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] @@ -7147,14 +7147,18 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +version = "0.28.0" +source = "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -7553,20 +7557,19 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +version = "0.28.0" +source = "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e" dependencies = [ - "byteorder", "bytes", "data-encoding", "http 1.3.1", "httparse", "log", - "rand 0.8.5", + "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", - "thiserror 1.0.69", - "url", + "thiserror 2.0.17", "utf-8", ] @@ -8037,6 +8040,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + [[package]] name = "webpki-roots" version = "1.0.2" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a74fb1e828..96df92b81e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -182,7 +182,7 @@ ratatui-core = "0.1.0" ratatui-macros = "0.6.0" regex = "1.12.2" regex-lite = "0.1.8" -reqwest = "0.12" +reqwest = { version = "0.12", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots"] } rmcp = { version = "0.12.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" @@ -212,7 +212,7 @@ tiny_http = "0.12" tokio = "1" tokio-stream = "0.1.18" tokio-test = "0.4" -tokio-tungstenite = "0.21.0" +tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots", "proxy"] } tokio-util = "0.7.18" toml = "0.9.5" toml_edit = "0.24.0" @@ -303,6 +303,10 @@ opt-level = 0 # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } +tokio-tungstenite = { git = "https://github.com/JakkuSakura/tokio-tungstenite", rev = "2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" } # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } + +[patch."ssh://git@github.com/JakkuSakura/tungstenite-rs.git"] +tungstenite = { git = "https://github.com/JakkuSakura/tungstenite-rs", rev = "f514de8644821113e5d18a027d6d28a5c8cc0a6e" } diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index ec5546a670..b369953495 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -12,7 +12,7 @@ path = "src/lib.rs" anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } codex-protocol = { workspace = true } codex-core = { workspace = true } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 39e2f2fd05..d65caad869 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -150,7 +150,7 @@ async fn connect_websocket( turn_state: Option>>, ) -> Result<(WsStream, bool), ApiError> { let mut request = url - .clone() + .as_str() .into_client_request() .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; request.headers_mut().extend(headers); @@ -209,7 +209,7 @@ async fn run_websocket_response_stream( } }; - if let Err(err) = ws_stream.send(Message::Text(request_text)).await { + if let Err(err) = ws_stream.send(Message::Text(request_text.into())).await { return Err(ApiError::Stream(format!( "failed to send websocket request: {err}" ))); diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 4ded10a3d9..1a6e56e6c5 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -169,7 +169,8 @@ pub fn build_reqwest_client() -> reqwest::Client { let mut builder = reqwest::Client::builder() // Set UA via dedicated helper to avoid header validation pitfalls .user_agent(ua) - .default_headers(headers); + .default_headers(headers) + .use_rustls_tls(); if is_sandboxed() { builder = builder.no_proxy(); } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 3896911cc2..cd31171c10 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -923,7 +923,7 @@ pub async fn start_websocket_server_with_headers( let Ok(payload) = serde_json::to_string(event) else { continue; }; - if ws_stream.send(Message::Text(payload)).await.is_err() { + if ws_stream.send(Message::Text(payload.into())).await.is_err() { break; } } diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index eb19ec7df7..41c1df64b7 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -43,7 +43,7 @@ opentelemetry-otlp = { workspace = true, features = [ opentelemetry-semantic-conventions = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["logs", "metrics", "rt-tokio", "testing", "trace"] } http = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } +reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml index e0ea600033..a2226dba6a 100644 --- a/codex-rs/responses-api-proxy/Cargo.toml +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -21,7 +21,7 @@ clap = { workspace = true, features = ["derive"] } codex-process-hardening = { workspace = true } ctor = { workspace = true } libc = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } +reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny_http = { workspace = true } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 8aa7512faf..2e874c6089 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -23,7 +23,8 @@ oauth2 = "5" reqwest = { version = "0.12", default-features = false, features = [ "json", "stream", - "rustls-tls", + "rustls-tls-webpki-roots", + "rustls-tls-native-roots", ] } rmcp = { workspace = true, default-features = false, features = [ "auth", From 5ae6e70801110da6245ba900a8f07bee3e3d53f1 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 20 Jan 2026 10:26:12 -0800 Subject: [PATCH 65/78] Tui: use collaboration mode instead of model and effort (#9507) - Only use collaboration modes in the tui state to track model and effort. - No behavior change without the collaboration modes flag. - Change model and effort on /model, /collab (behind a flag), and shift+tab (behind flag) --- codex-rs/tui/src/app.rs | 48 ++-- codex-rs/tui/src/app_event.rs | 4 + codex-rs/tui/src/chatwidget.rs | 309 ++++++++++++++++------- codex-rs/tui/src/chatwidget/tests.rs | 121 +++++---- codex-rs/tui/src/collaboration_modes.rs | 150 +++-------- codex-rs/tui/src/history_cell.rs | 95 +++++-- codex-rs/tui/src/status/card.rs | 29 ++- codex-rs/tui/src/status/tests.rs | 19 +- codex-rs/tui2/src/app.rs | 37 +-- codex-rs/tui2/src/app_event.rs | 4 + codex-rs/tui2/src/chatwidget.rs | 259 ++++++++++++++----- codex-rs/tui2/src/chatwidget/tests.rs | 126 +++++---- codex-rs/tui2/src/collaboration_modes.rs | 150 +++-------- codex-rs/tui2/src/status/card.rs | 29 ++- codex-rs/tui2/src/status/tests.rs | 19 +- 15 files changed, 841 insertions(+), 558 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e33e934029..b361425aeb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -272,7 +272,6 @@ async fn handle_model_migration_prompt_if_needed( from_model: model.to_string(), to_model: target_model.clone(), }); - config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -285,8 +284,8 @@ async fn handle_model_migration_prompt_if_needed( config.model_reasoning_effort }; + config.model = Some(target_model.clone()); config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { @@ -321,7 +320,6 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, - pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -381,7 +379,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(self.chat_widget.current_model().to_string()), } } @@ -459,7 +457,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: config.model.clone(), + model: Some(model.clone()), }; ChatWidget::new(init, thread_manager.clone()) } @@ -531,7 +529,6 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -694,13 +691,9 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - let model_info = self - .server - .get_models_manager() - .get_model_info(self.current_model.as_str(), &self.config) - .await; match event { AppEvent::NewSession => { + let model = self.chat_widget.current_model().to_string(); let summary = session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); self.shutdown_current_thread().await; @@ -718,10 +711,9 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(model), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -766,7 +758,6 @@ impl App { resumed.thread, resumed.session_configured, ); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -816,7 +807,6 @@ impl App { forked.thread, forked.session_configured, ); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -995,7 +985,11 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.current_model = model; + } + AppEvent::UpdateCollaborationMode(mode) => { + let model = mode.model().to_string(); + self.chat_widget.set_collaboration_mode(mode); + self.chat_widget.set_model(&model); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1545,8 +1539,10 @@ impl App { } fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); + // TODO(aibrahim): Remove this and don't use config as a state object. + // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort; + self.chat_widget.set_reasoning_effort(effort); } async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { @@ -1748,7 +1744,6 @@ mod tests { 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(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1763,7 +1758,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1790,7 +1784,6 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1806,7 +1799,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1991,20 +1983,19 @@ mod tests { } #[tokio::test] - async fn update_reasoning_effort_updates_config() { + async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( - app.config.model_reasoning_effort, + app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, + app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } @@ -2044,9 +2035,14 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), - app.current_model.as_str(), + app.chat_widget.current_model(), event, is_first, + app.chat_widget + .config_ref() + .features + .enabled(codex_core::features::Feature::CollaborationModes), + app.chat_widget.stored_collaboration_mode().clone(), )) as Arc }; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f34e1d7d5a..0b2617416d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -23,6 +23,7 @@ use crate::history_cell::HistoryCell; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -102,6 +103,9 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Update the current collaboration mode in the running app and widget. + UpdateCollaborationMode(CollaborationMode), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3f610c4faa..48ed806d3a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -92,6 +92,8 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::user_input::TextElement; @@ -374,8 +376,6 @@ pub(crate) enum ExternalEditorState { Active, } -type CollaborationModeSelection = collaboration_modes::Selection; - /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -404,12 +404,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - model: Option, - /// Current UI selection for collaboration modes. + /// Stored collaboration mode with model and reasoning effort. /// - /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the - /// feature is disabled, the value is effectively inert. - collaboration_mode: CollaborationModeSelection, + /// When collaboration modes feature is enabled, this is initialized to the first preset. + /// When disabled, this is Custom. The model and reasoning effort are stored here instead of + /// being read from config or current_model. + stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -709,13 +709,24 @@ impl ChatWidget { self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); + // Only update stored collaboration settings when collaboration modes are disabled. + // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its + // instructions as-is; the session configured event should not override it. + if !self.collaboration_modes_enabled() { + self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + } let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, + self.collaboration_modes_enabled(), + self.stored_collaboration_mode.clone(), ); self.apply_session_info_cell(session_info_cell); @@ -963,7 +974,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) + && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1789,19 +1800,38 @@ impl ChatWidget { is_first_run, model, } = common; - let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; config.model = model.clone(); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = config - .model + let model_for_header = model .clone() .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let active_cell = if model.is_none() { - Some(Self::placeholder_session_header_cell(&config)) + Some(Self::placeholder_session_header_cell( + &config, + config.features.enabled(Feature::CollaborationModes), + stored_collaboration_mode.clone(), + )) } else { None }; @@ -1823,8 +1853,7 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, - model, - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1905,6 +1934,22 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1922,8 +1967,7 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, - model: Some(header_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -2204,7 +2248,7 @@ impl ChatWidget { } SlashCommand::Collab => { if self.collaboration_modes_enabled() { - self.cycle_collaboration_mode(); + self.open_collaboration_modes_popup(); } } SlashCommand::Approvals => { @@ -2363,14 +2407,10 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { - SlashCommand::Collab if !trimmed.is_empty() => { - if let Some(selection) = collaboration_modes::parse_selection(trimmed) { - self.set_collaboration_mode(selection); - } else { - self.add_error_message(format!( - "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." - )); - self.request_redraw(); + SlashCommand::Collab => { + let _ = trimmed; + if self.collaboration_modes_enabled() { + self.open_collaboration_modes_popup(); } } SlashCommand::Review if !trimmed.is_empty() => { @@ -2450,13 +2490,12 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let Some(model) = self.current_model().or(self.config.model.as_deref()) else { - tracing::warn!("cannot submit user message before model is known; queueing"); + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); self.refresh_queued_user_messages(); return; - }; - let model = model.to_string(); + } let UserMessage { text, @@ -2510,24 +2549,18 @@ impl ChatWidget { } } - let collaboration_mode = self.collaboration_modes_enabled().then(|| { - collaboration_modes::resolve_mode_or_fallback( - self.models_manager.as_ref(), - self.collaboration_mode, - model.as_str(), - self.config.model_reasoning_effort, - ) - }); let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, + model: self.stored_collaboration_mode.model().to_string(), + effort: self.stored_collaboration_mode.reasoning_effort(), summary: self.config.model_reasoning_summary, final_output_json_schema: None, - collaboration_mode, + collaboration_mode: self + .collaboration_modes_enabled() + .then(|| self.stored_collaboration_mode.clone()), }; self.codex_op_tx.send(op).unwrap_or_else(|e| { @@ -2868,11 +2901,8 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); - let collaboration_mode = if self.collaboration_modes_enabled() { - Some(self.collaboration_mode.label()) - } else { - None - }; + let collaboration_mode = self.collaboration_mode_label(); + let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -2885,6 +2915,7 @@ impl ChatWidget { Local::now(), self.model_display_name(), collaboration_mode, + reasoning_effort_override, )); } @@ -3108,7 +3139,7 @@ impl ChatWidget { let current_model = self.current_model(); let current_label = presets .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) + .find(|preset| preset.model.as_str() == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); @@ -3136,7 +3167,7 @@ impl ChatWidget { SelectionItem { name: preset.display_name.clone(), description, - is_current: Some(model.as_str()) == current_model, + is_current: model.as_str() == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, @@ -3206,7 +3237,7 @@ impl ChatWidget { for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); + let is_current = preset.model.as_str() == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { @@ -3238,6 +3269,49 @@ impl ChatWidget { }); } + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = self.models_manager.list_collaboration_modes(); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + None, + ); + return; + } + + let items: Vec = presets + .into_iter() + .map(|preset| { + let name = match preset { + CollaborationMode::Plan(_) => "Plan", + CollaborationMode::PairProgramming(_) => "Pair Programming", + CollaborationMode::Execute(_) => "Execute", + CollaborationMode::Custom(_) => "Custom", + }; + let is_current = + collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + })]; + SelectionItem { + name: name.to_string(), + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -3333,9 +3407,9 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); + let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.config.model_reasoning_effort + self.stored_collaboration_mode.reasoning_effort() } else { default_choice }; @@ -4134,8 +4208,21 @@ impl ChatWidget { } if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); - } else if feature == Feature::CollaborationModes { + } + if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); + let settings = match &self.stored_collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings.clone(), + }; + self.stored_collaboration_mode = if enabled { + collaboration_modes::default_mode(self.models_manager.as_ref()) + .unwrap_or(CollaborationMode::Custom(settings)) + } else { + CollaborationMode::Custom(settings) + }; } } @@ -4162,44 +4249,37 @@ impl ChatWidget { .unwrap_or(false) } - /// Set the reasoning effort in the widget's config copy. + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(None, Some(effort), None); } - /// Set the model in the widget's config copy. + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); - self.model = Some(model.to_string()); + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(Some(model.to_string()), None, None); } - fn cycle_collaboration_mode(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - let next = self.collaboration_mode.next(); - self.set_collaboration_mode(next); + pub(crate) fn current_model(&self) -> &str { + self.stored_collaboration_mode.model() } - /// Update the selected collaboration mode. - /// - /// When collaboration modes are enabled, the current selection is attached to *every* - /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { - if !self.collaboration_modes_enabled() { - return; - } - const FLASH_DURATION: Duration = Duration::from_secs(1); - - self.collaboration_mode = selection; - - let flash = collaboration_modes::flash_line(selection); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); - self.request_redraw(); + #[allow(dead_code)] // Used in tests + pub(crate) fn stored_collaboration_mode(&self) -> &CollaborationMode { + &self.stored_collaboration_mode } - fn current_model(&self) -> Option<&str> { - self.model.as_deref() + #[cfg(test)] + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.stored_collaboration_mode.reasoning_effort() + } + + fn is_session_configured(&self) -> bool { + self.thread_id.is_some() } fn collaboration_modes_enabled(&self) -> bool { @@ -4207,11 +4287,72 @@ impl ChatWidget { } fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } + } + + /// Get the label for the current collaboration mode. + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, + } + } + + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(next_mode) = collaboration_modes::next_mode( + self.models_manager.as_ref(), + &self.stored_collaboration_mode, + ) { + self.set_collaboration_mode(next_mode); + } + } + + /// Update the stored collaboration mode. + /// + /// When collaboration modes are enabled, the current mode is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { + if !self.collaboration_modes_enabled() { + return; + } + + self.stored_collaboration_mode = mode; + + let label = self.collaboration_mode_label(); + if let Some(label) = label { + let flash = Line::from(vec![ + label.bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + } + self.request_redraw(); } /// Build a placeholder header cell while the session is configuring. - fn placeholder_session_header_cell(config: &Config) -> Box { + fn placeholder_session_header_cell( + config: &Config, + is_collaboration: bool, + collaboration_mode: CollaborationMode, + ) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( DEFAULT_MODEL_DISPLAY_NAME.to_string(), @@ -4219,6 +4360,8 @@ impl ChatWidget { None, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, )) } @@ -4618,10 +4761,6 @@ impl ChatWidget { self.thread_id } - fn is_session_configured(&self) -> bool { - self.thread_id.is_some() - } - pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index b864a7b3ee..46f5c3be9e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -756,8 +756,27 @@ async fn make_chatwidget_manual( skills: None, }); bottom.set_steer_enabled(true); + bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); + let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); + let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); + let reasoning_effort = None; + let stored_collaboration_mode = if collaboration_modes_enabled { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -765,10 +784,9 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - model: Some(resolved_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), + stored_collaboration_mode, + auth_manager, + models_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, @@ -1909,75 +1927,66 @@ async fn slash_init_skips_when_project_doc_exists() { ); } -#[test] -fn parse_collaboration_mode_selection_accepts_common_aliases() { - assert_eq!( - collaboration_modes::parse_selection("plan"), - Some(CollaborationModeSelection::Plan) - ); - assert_eq!( - collaboration_modes::parse_selection("PAIR"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pair_programming"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pp"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection(" exec "), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!( - collaboration_modes::parse_selection("execute"), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!(collaboration_modes::parse_selection("unknown"), None); -} - #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); - let initial = chat.collaboration_mode; + let initial = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, initial); + assert_eq!(chat.stored_collaboration_mode, initial); chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Execute(_) + )); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Plan(_) + )); chat.on_task_started(); + let before = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert_eq!(chat.stored_collaboration_mode, before); } #[tokio::test] -async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn collab_slash_command_opens_picker_and_updates_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + chat.dispatch_command(SlashCommand::Collab); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Select Collaboration Mode"), + "expected collaboration picker: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected_mode = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + other => panic!("expected UpdateCollaborationMode event, got {other:?}"), + }; + chat.set_collaboration_mode(selected_mode); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } chat.bottom_pane @@ -1985,10 +1994,12 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } @@ -2006,10 +2017,22 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } +#[tokio::test] +async fn collab_mode_enabling_sets_pair_programming_default() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::PairProgramming(_) + )); +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2722,7 +2745,7 @@ async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2736,7 +2759,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index bdd1d8b63c..f48ad92c91 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -1,135 +1,49 @@ -//! Collaboration mode selection + rendering helpers for the TUI. -//! -//! This module is intentionally UI-focused: -//! - It owns the user-facing set of selectable collaboration modes and how they cycle. -//! - It parses `/collab ` arguments into a selection. -//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by -//! picking from the `ModelsManager` builtin collaboration presets. -//! - It builds the small footer "flash" line shown after changing modes. -//! -//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed -//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. - -use crate::key_hint; use codex_core::models_manager::manager::ModelsManager; use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ReasoningEffort; -use crossterm::event::KeyCode; -use ratatui::style::Stylize; -use ratatui::text::Line; -/// The user-facing collaboration mode choices supported by the TUI. -/// -/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling -/// order, while `CollaborationMode` can carry nested settings/prompt configuration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum Selection { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModeKind { Plan, - #[default] PairProgramming, Execute, + Custom, } -impl Selection { - /// Cycle to the next selection. - /// - /// The TUI cycles through a small, fixed set of presets. - pub(crate) fn next(self) -> Self { - match self { - Self::Plan => Self::PairProgramming, - Self::PairProgramming => Self::Execute, - Self::Execute => Self::Plan, - } - } - - /// User-facing label used in UI surfaces like `/status` and the footer flash. - pub(crate) fn label(self) -> &'static str { - match self { - Self::Plan => "Plan", - Self::PairProgramming => "Pair Programming", - Self::Execute => "Execute", - } +fn mode_kind(mode: &CollaborationMode) -> ModeKind { + match mode { + CollaborationMode::Plan(_) => ModeKind::Plan, + CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, + CollaborationMode::Execute(_) => ModeKind::Execute, + CollaborationMode::Custom(_) => ModeKind::Custom, } } -/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. -/// -/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. -pub(crate) fn parse_selection(input: &str) -> Option { - let normalized: String = input - .chars() - .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') - .flat_map(char::to_lowercase) - .collect(); - - match normalized.as_str() { - "plan" => Some(Selection::Plan), - "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), - "execute" | "exec" => Some(Selection::Execute), - _ => None, - } +pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { + let presets = models_manager.list_collaboration_modes(); + presets + .iter() + .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .cloned() + .or_else(|| presets.into_iter().next()) } -/// Resolve a selection to a concrete collaboration mode preset. -/// -/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this -/// function selects the first preset of the desired variant. -pub(crate) fn resolve_mode( +pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { + mode_kind(a) == mode_kind(b) +} + +/// Cycle to the next collaboration mode preset in list order. +pub(crate) fn next_mode( models_manager: &ModelsManager, - selection: Selection, + current: &CollaborationMode, ) -> Option { - match selection { - Selection::Plan => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Plan(_))), - Selection::PairProgramming => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), - Selection::Execute => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + let presets = models_manager.list_collaboration_modes(); + if presets.is_empty() { + return None; } -} - -/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode -/// when the desired preset is unavailable. -/// -/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when -/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries -/// an explicit collaboration mode so core doesn't fall back to `Custom`. -pub(crate) fn resolve_mode_or_fallback( - models_manager: &ModelsManager, - selection: Selection, - fallback_model: &str, - fallback_effort: Option, -) -> CollaborationMode { - resolve_mode(models_manager, selection).unwrap_or_else(|| { - let settings = Settings { - model: fallback_model.to_string(), - reasoning_effort: fallback_effort, - developer_instructions: None, - }; - - match selection { - Selection::Plan => CollaborationMode::Plan(settings), - Selection::PairProgramming => CollaborationMode::PairProgramming(settings), - Selection::Execute => CollaborationMode::Execute(settings), - } - }) -} - -/// Build a 1-line footer "flash" that is shown after switching modes. -/// -/// The `ChatWidget` controls when to show this and how long it should remain visible. -pub(crate) fn flash_line(selection: Selection) -> Line<'static> { - Line::from(vec![ - selection.label().bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]) + let current_kind = mode_kind(current); + let next_index = presets + .iter() + .position(|preset| mode_kind(preset) == current_kind) + .map_or(0, |idx| (idx + 1) % presets.len()); + presets.get(next_index).cloned() } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index fd09fe89dc..157ccb97a2 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -19,6 +19,7 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; +use crate::key_hint; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; @@ -43,11 +44,13 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::user_input::TextElement; +use crossterm::event::KeyCode; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -903,6 +906,8 @@ pub(crate) fn new_session_info( requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> SessionInfoCell { let SessionConfiguredEvent { model, @@ -915,6 +920,8 @@ pub(crate) fn new_session_info( reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -991,6 +998,8 @@ pub(crate) struct SessionHeaderHistoryCell { model_style: Style, reasoning_effort: Option, directory: PathBuf, + is_collaboration: bool, + collaboration_mode: CollaborationMode, } impl SessionHeaderHistoryCell { @@ -999,6 +1008,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self::new_with_style( model, @@ -1006,6 +1017,8 @@ impl SessionHeaderHistoryCell { reasoning_effort, directory, version, + is_collaboration, + collaboration_mode, ) } @@ -1015,6 +1028,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self { version, @@ -1022,6 +1037,20 @@ impl SessionHeaderHistoryCell { model_style, reasoning_effort, directory, + is_collaboration, + collaboration_mode, + } + } + + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.is_collaboration { + return None; + } + match &self.collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, } } @@ -1082,25 +1111,49 @@ impl HistoryCell for SessionHeaderHistoryCell { const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; + const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode"; const DIR_LABEL: &str = "directory:"; let label_width = DIR_LABEL.len(); - let model_label = format!( - "{model_label:> = vec![ - Span::from(format!("{model_label} ")).dim(), - Span::styled(self.model.clone(), self.model_style), - ]; - if let Some(reasoning) = reasoning_label { - model_spans.push(Span::from(" ")); - model_spans.push(Span::from(reasoning)); - } - model_spans.push(" ".dim()); - model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); - model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); + + let model_spans: Vec> = if self.is_collaboration { + // Render collaboration mode instead of model + let collab_label = format!( + "{collab_label: = key_hint::shift(KeyCode::Tab).into(); + spans.push(shift_tab_span.cyan()); + spans.push(CHANGE_MODE_HINT_EXPLANATION.dim()); + spans + } else { + // Render model as before + let model_label = format!( + "{model_label:, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -99,6 +102,7 @@ pub(crate) fn new_status_output( now, model_name, collaboration_mode, + reasoning_effort_override, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -118,8 +122,29 @@ impl StatusHistoryCell { now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> Self { - let config_entries = create_config_summary_entries(config, model_name); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ("provider", config.model_provider_id.clone()), + ("approval", config.approval_policy.value().to_string()), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or(None) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); + } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index bc421da424..92ed340817 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -95,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config .sandbox_policy @@ -141,6 +140,7 @@ async fn status_snapshot_includes_reasoning_details() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -153,6 +153,7 @@ async fn status_snapshot_includes_reasoning_details() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -205,6 +206,7 @@ async fn status_snapshot_includes_forked_from() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -263,6 +265,7 @@ async fn status_snapshot_includes_monthly_limit() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -309,6 +312,7 @@ async fn status_snapshot_shows_unlimited_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -354,6 +358,7 @@ async fn status_snapshot_shows_positive_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -399,6 +404,7 @@ async fn status_snapshot_hides_zero_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -442,6 +448,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -485,6 +492,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { now, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -500,7 +508,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config.cwd = PathBuf::from("/workspace/tests"); @@ -531,6 +538,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -543,6 +551,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -590,6 +599,7 @@ async fn status_snapshot_shows_missing_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -655,6 +665,7 @@ async fn status_snapshot_includes_credits_and_limits() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -708,6 +719,7 @@ async fn status_snapshot_shows_empty_limits_message() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -770,6 +782,7 @@ async fn status_snapshot_shows_stale_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -836,6 +849,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -892,6 +906,7 @@ async fn status_context_window_uses_last_usage() { now, &model_slug, None, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 99fc2e188e..920d571451 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -311,7 +311,6 @@ async fn handle_model_migration_prompt_if_needed( from_model: model.to_string(), to_model: target_model.clone(), }); - config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -324,8 +323,8 @@ async fn handle_model_migration_prompt_if_needed( config.model_reasoning_effort }; + config.model = Some(target_model.clone()); config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { @@ -361,7 +360,6 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, - pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -439,7 +437,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(self.chat_widget.current_model().to_string()), } } @@ -517,7 +515,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: config.model.clone(), + model: Some(model.clone()), }; ChatWidget::new(init, thread_manager.clone()) } @@ -605,7 +603,6 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -1474,6 +1471,9 @@ impl App { async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { + let model = self.chat_widget.current_model().to_string(); + // Propagate selected model into config before spawning thread + self.config.model = Some(model.clone()); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), @@ -1493,7 +1493,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(model), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { @@ -1713,7 +1713,11 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.current_model = model; + } + AppEvent::UpdateCollaborationMode(mode) => { + let model = mode.model().to_string(); + self.chat_widget.set_collaboration_mode(mode); + self.chat_widget.set_model(&model); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -2138,8 +2142,10 @@ impl App { } fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); + // TODO(aibrahim): Remove this and don't use config as a state object. + // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort; + self.chat_widget.set_reasoning_effort(effort); } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { @@ -2360,7 +2366,6 @@ mod tests { 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(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2375,7 +2380,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -2413,7 +2417,6 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2429,7 +2432,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -2628,20 +2630,19 @@ mod tests { } #[tokio::test] - async fn update_reasoning_effort_updates_config() { + async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( - app.config.model_reasoning_effort, + app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, + app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } @@ -2681,7 +2682,7 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), - app.current_model.as_str(), + app.chat_widget.current_model(), event, is_first, )) as Arc diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index b2a70cae19..ff3f0e3875 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -21,6 +21,7 @@ use crate::history_cell::HistoryCell; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -96,6 +97,9 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Update the current collaboration mode in the running app and widget. + UpdateCollaborationMode(CollaborationMode), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 6c11077c80..c4cdf76695 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -90,6 +90,8 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::user_input::TextElement; @@ -319,8 +321,6 @@ enum RateLimitSwitchPromptState { Shown, } -type CollaborationModeSelection = collaboration_modes::Selection; - /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -349,12 +349,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - model: Option, - /// Current UI selection for collaboration modes. + /// Stored collaboration mode with model and reasoning effort. /// - /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the - /// feature is disabled, the value is effectively inert. - collaboration_mode: CollaborationModeSelection, + /// When collaboration modes feature is enabled, this is initialized to the first preset. + /// When disabled, this is Custom. The model and reasoning effort are stored here instead of + /// being read from config or current_model. + stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -631,8 +631,17 @@ impl ChatWidget { self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); + // Only update stored collaboration settings when collaboration modes are disabled. + // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its + // instructions as-is; the session configured event should not override it. + if !self.collaboration_modes_enabled() { + self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + } let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -876,7 +885,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) + && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1591,15 +1600,14 @@ impl ChatWidget { is_first_run, model, } = common; - let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; config.model = model.clone(); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = config - .model + let model_for_header = model .clone() .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); let active_cell = if model.is_none() { @@ -1608,6 +1616,22 @@ impl ChatWidget { None }; + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1625,8 +1649,7 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, - model, - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), @@ -1684,7 +1707,7 @@ impl ChatWidget { session_configured: codex_core::protocol::SessionConfiguredEvent, ) -> Self { let ChatWidgetInit { - mut config, + config, frame_requester, app_event_tx, initial_user_message, @@ -1696,7 +1719,7 @@ impl ChatWidget { .. } = common; let model = model.filter(|m| !m.trim().is_empty()); - config.model = model.clone(); + let config = config; let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); @@ -1705,6 +1728,22 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1722,8 +1761,7 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, - model: Some(header_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(header_model), @@ -1976,7 +2014,7 @@ impl ChatWidget { } SlashCommand::Collab => { if self.collaboration_modes_enabled() { - self.cycle_collaboration_mode(); + self.open_collaboration_modes_popup(); } } SlashCommand::Approvals => { @@ -2144,13 +2182,8 @@ impl ChatWidget { return; } - if let Some(selection) = collaboration_modes::parse_selection(trimmed) { - self.set_collaboration_mode(selection); - } else if !trimmed.is_empty() { - self.add_error_message(format!( - "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." - )); - } + let _ = trimmed; + self.open_collaboration_modes_popup(); } _ => self.dispatch_command(cmd), } @@ -2217,13 +2250,12 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let Some(model) = self.current_model().or(self.config.model.as_deref()) else { - tracing::warn!("cannot submit user message before model is known; queueing"); + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); self.refresh_queued_user_messages(); return; - }; - let model = model.to_string(); + } let UserMessage { text, @@ -2277,24 +2309,18 @@ impl ChatWidget { } } - let collaboration_mode = self.collaboration_modes_enabled().then(|| { - collaboration_modes::resolve_mode_or_fallback( - self.models_manager.as_ref(), - self.collaboration_mode, - model.as_str(), - self.config.model_reasoning_effort, - ) - }); let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, + model: self.stored_collaboration_mode.model().to_string(), + effort: self.stored_collaboration_mode.reasoning_effort(), summary: self.config.model_reasoning_summary, final_output_json_schema: None, - collaboration_mode, + collaboration_mode: self + .collaboration_modes_enabled() + .then(|| self.stored_collaboration_mode.clone()), }; if !self.agent_turn_running { @@ -2634,6 +2660,7 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); + let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -2645,8 +2672,8 @@ impl ChatWidget { self.plan_type, Local::now(), self.model_display_name(), - self.collaboration_modes_enabled() - .then_some(self.collaboration_mode.label()), + self.collaboration_mode_label(), + reasoning_effort_override, )); } fn stop_rate_limit_poller(&mut self) { @@ -2821,7 +2848,7 @@ impl ChatWidget { let current_model = self.current_model(); let current_label = presets .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) + .find(|preset| preset.model.as_str() == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); @@ -2849,7 +2876,7 @@ impl ChatWidget { SelectionItem { name: preset.display_name.clone(), description, - is_current: Some(model.as_str()) == current_model, + is_current: model.as_str() == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, @@ -2916,7 +2943,7 @@ impl ChatWidget { for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); + let is_current = preset.model.as_str() == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { @@ -2948,6 +2975,49 @@ impl ChatWidget { }); } + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = self.models_manager.list_collaboration_modes(); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + None, + ); + return; + } + + let items: Vec = presets + .into_iter() + .map(|preset| { + let name = match preset { + CollaborationMode::Plan(_) => "Plan", + CollaborationMode::PairProgramming(_) => "Pair Programming", + CollaborationMode::Execute(_) => "Execute", + CollaborationMode::Custom(_) => "Custom", + }; + let is_current = + collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + })]; + SelectionItem { + name: name.to_string(), + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -3043,9 +3113,9 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); + let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.config.model_reasoning_effort + self.stored_collaboration_mode.reasoning_effort() } else { default_choice }; @@ -3824,6 +3894,18 @@ impl ChatWidget { } if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); + let settings = match &self.stored_collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings.clone(), + }; + self.stored_collaboration_mode = if enabled { + collaboration_modes::default_mode(self.models_manager.as_ref()) + .unwrap_or(CollaborationMode::Custom(settings)) + } else { + CollaborationMode::Custom(settings) + }; } } @@ -3850,19 +3932,32 @@ impl ChatWidget { .unwrap_or(false) } - /// Set the reasoning effort in the widget's config copy. + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(None, Some(effort), None); } - /// Set the model in the widget's config copy. + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); - self.model = Some(model.to_string()); + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(Some(model.to_string()), None, None); } - fn current_model(&self) -> Option<&str> { - self.model.as_deref() + pub(crate) fn current_model(&self) -> &str { + self.stored_collaboration_mode.model() + } + + #[cfg(test)] + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.stored_collaboration_mode.reasoning_effort() + } + + fn is_session_configured(&self) -> bool { + self.conversation_id.is_some() } fn collaboration_modes_enabled(&self) -> bool { @@ -3870,31 +3965,63 @@ impl ChatWidget { } fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } } + /// Get the label for the current collaboration mode. + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, + } + } + + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). fn cycle_collaboration_mode(&mut self) { if !self.collaboration_modes_enabled() { return; } - let next = self.collaboration_mode.next(); - self.set_collaboration_mode(next); + if let Some(next_mode) = collaboration_modes::next_mode( + self.models_manager.as_ref(), + &self.stored_collaboration_mode, + ) { + self.set_collaboration_mode(next_mode); + } } - /// Update the selected collaboration mode. + /// Update the stored collaboration mode. /// - /// When collaboration modes are enabled, the current selection is attached to *every* + /// When collaboration modes are enabled, the current mode is attached to *every* /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { + pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { if !self.collaboration_modes_enabled() { return; } - self.collaboration_mode = selection; - let flash = collaboration_modes::flash_line(selection); - const FLASH_DURATION: Duration = Duration::from_secs(2); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + self.stored_collaboration_mode = mode; + + let label = self.collaboration_mode_label(); + if let Some(label) = label { + let flash = Line::from(vec![ + label.bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + } self.request_redraw(); } @@ -4337,10 +4464,6 @@ impl ChatWidget { self.current_rollout_path.clone() } - fn is_session_configured(&self) -> bool { - self.conversation_id.is_some() - } - /// Returns a cache key describing the current in-flight active cell for the transcript overlay. /// /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 9d5d7e67b2..f1cf4c32e0 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -59,6 +59,7 @@ use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -692,13 +693,14 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; + let model = cfg.model.clone(); let thread_manager = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let init = ChatWidgetInit { - config: cfg.clone(), + config: cfg, frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, initial_user_message: None, @@ -707,7 +709,7 @@ async fn helpers_are_available_and_do_not_panic() { models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, - model: cfg.model, + model, }; let mut w = ChatWidget::new(init, thread_manager); // Basic construction sanity. @@ -746,6 +748,24 @@ async fn make_chatwidget_manual( bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); + let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); + let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); + let reasoning_effort = None; + let stored_collaboration_mode = if collaboration_modes_enabled { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -753,10 +773,9 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - model: Some(resolved_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), + stored_collaboration_mode, + auth_manager, + models_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, @@ -1689,75 +1708,66 @@ async fn slash_init_skips_when_project_doc_exists() { ); } -#[test] -fn parse_collaboration_mode_selection_accepts_common_aliases() { - assert_eq!( - collaboration_modes::parse_selection("plan"), - Some(CollaborationModeSelection::Plan) - ); - assert_eq!( - collaboration_modes::parse_selection("PAIR"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pair_programming"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pp"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection(" exec "), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!( - collaboration_modes::parse_selection("execute"), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!(collaboration_modes::parse_selection("unknown"), None); -} - #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); - let initial = chat.collaboration_mode; + let initial = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, initial); + assert_eq!(chat.stored_collaboration_mode, initial); chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Execute(_) + )); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Plan(_) + )); chat.on_task_started(); + let before = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert_eq!(chat.stored_collaboration_mode, before); } #[tokio::test] -async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn collab_slash_command_opens_picker_and_updates_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.conversation_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + chat.dispatch_command(SlashCommand::Collab); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Select Collaboration Mode"), + "expected collaboration picker: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected_mode = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + other => panic!("expected UpdateCollaborationMode event, got {other:?}"), + }; + chat.set_collaboration_mode(selected_mode); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } chat.bottom_pane @@ -1765,10 +1775,12 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } @@ -1786,10 +1798,22 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } +#[tokio::test] +async fn collab_mode_enabling_sets_pair_programming_default() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::PairProgramming(_) + )); +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2436,7 +2460,7 @@ async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2450,7 +2474,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); diff --git a/codex-rs/tui2/src/collaboration_modes.rs b/codex-rs/tui2/src/collaboration_modes.rs index bdd1d8b63c..f48ad92c91 100644 --- a/codex-rs/tui2/src/collaboration_modes.rs +++ b/codex-rs/tui2/src/collaboration_modes.rs @@ -1,135 +1,49 @@ -//! Collaboration mode selection + rendering helpers for the TUI. -//! -//! This module is intentionally UI-focused: -//! - It owns the user-facing set of selectable collaboration modes and how they cycle. -//! - It parses `/collab ` arguments into a selection. -//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by -//! picking from the `ModelsManager` builtin collaboration presets. -//! - It builds the small footer "flash" line shown after changing modes. -//! -//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed -//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. - -use crate::key_hint; use codex_core::models_manager::manager::ModelsManager; use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ReasoningEffort; -use crossterm::event::KeyCode; -use ratatui::style::Stylize; -use ratatui::text::Line; -/// The user-facing collaboration mode choices supported by the TUI. -/// -/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling -/// order, while `CollaborationMode` can carry nested settings/prompt configuration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum Selection { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModeKind { Plan, - #[default] PairProgramming, Execute, + Custom, } -impl Selection { - /// Cycle to the next selection. - /// - /// The TUI cycles through a small, fixed set of presets. - pub(crate) fn next(self) -> Self { - match self { - Self::Plan => Self::PairProgramming, - Self::PairProgramming => Self::Execute, - Self::Execute => Self::Plan, - } - } - - /// User-facing label used in UI surfaces like `/status` and the footer flash. - pub(crate) fn label(self) -> &'static str { - match self { - Self::Plan => "Plan", - Self::PairProgramming => "Pair Programming", - Self::Execute => "Execute", - } +fn mode_kind(mode: &CollaborationMode) -> ModeKind { + match mode { + CollaborationMode::Plan(_) => ModeKind::Plan, + CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, + CollaborationMode::Execute(_) => ModeKind::Execute, + CollaborationMode::Custom(_) => ModeKind::Custom, } } -/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. -/// -/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. -pub(crate) fn parse_selection(input: &str) -> Option { - let normalized: String = input - .chars() - .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') - .flat_map(char::to_lowercase) - .collect(); - - match normalized.as_str() { - "plan" => Some(Selection::Plan), - "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), - "execute" | "exec" => Some(Selection::Execute), - _ => None, - } +pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { + let presets = models_manager.list_collaboration_modes(); + presets + .iter() + .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .cloned() + .or_else(|| presets.into_iter().next()) } -/// Resolve a selection to a concrete collaboration mode preset. -/// -/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this -/// function selects the first preset of the desired variant. -pub(crate) fn resolve_mode( +pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { + mode_kind(a) == mode_kind(b) +} + +/// Cycle to the next collaboration mode preset in list order. +pub(crate) fn next_mode( models_manager: &ModelsManager, - selection: Selection, + current: &CollaborationMode, ) -> Option { - match selection { - Selection::Plan => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Plan(_))), - Selection::PairProgramming => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), - Selection::Execute => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + let presets = models_manager.list_collaboration_modes(); + if presets.is_empty() { + return None; } -} - -/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode -/// when the desired preset is unavailable. -/// -/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when -/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries -/// an explicit collaboration mode so core doesn't fall back to `Custom`. -pub(crate) fn resolve_mode_or_fallback( - models_manager: &ModelsManager, - selection: Selection, - fallback_model: &str, - fallback_effort: Option, -) -> CollaborationMode { - resolve_mode(models_manager, selection).unwrap_or_else(|| { - let settings = Settings { - model: fallback_model.to_string(), - reasoning_effort: fallback_effort, - developer_instructions: None, - }; - - match selection { - Selection::Plan => CollaborationMode::Plan(settings), - Selection::PairProgramming => CollaborationMode::PairProgramming(settings), - Selection::Execute => CollaborationMode::Execute(settings), - } - }) -} - -/// Build a 1-line footer "flash" that is shown after switching modes. -/// -/// The `ChatWidget` controls when to show this and how long it should remain visible. -pub(crate) fn flash_line(selection: Selection) -> Line<'static> { - Line::from(vec![ - selection.label().bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]) + let current_kind = mode_kind(current); + let next_index = presets + .iter() + .position(|preset| mode_kind(preset) == current_kind) + .map_or(0, |idx| (idx + 1) % presets.len()); + presets.get(next_index).cloned() } diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 7988c7d0ff..1de70f1454 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -5,7 +5,8 @@ use crate::history_cell::with_border_with_inner_width; use crate::version::CODEX_CLI_VERSION; use chrono::DateTime; use chrono::Local; -use codex_common::create_config_summary_entries; +use codex_common::summarize_sandbox_policy; +use codex_core::WireApi; use codex_core::config::Config; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; @@ -13,6 +14,7 @@ use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ReasoningEffort; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; @@ -85,6 +87,7 @@ pub(crate) fn new_status_output( now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -99,6 +102,7 @@ pub(crate) fn new_status_output( now, model_name, collaboration_mode, + reasoning_effort_override, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -118,8 +122,29 @@ impl StatusHistoryCell { now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> Self { - let config_entries = create_config_summary_entries(config, model_name); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ("provider", config.model_provider_id.clone()), + ("approval", config.approval_policy.value().to_string()), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or(None) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); + } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index 9d0f6d0833..8f24af59d6 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -95,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config .sandbox_policy @@ -141,6 +140,7 @@ async fn status_snapshot_includes_reasoning_details() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -153,6 +153,7 @@ async fn status_snapshot_includes_reasoning_details() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -205,6 +206,7 @@ async fn status_snapshot_includes_forked_from() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -263,6 +265,7 @@ async fn status_snapshot_includes_monthly_limit() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -308,6 +311,7 @@ async fn status_snapshot_shows_unlimited_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -353,6 +357,7 @@ async fn status_snapshot_shows_positive_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -398,6 +403,7 @@ async fn status_snapshot_hides_zero_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -441,6 +447,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -484,6 +491,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { now, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -499,7 +507,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config.cwd = PathBuf::from("/workspace/tests"); @@ -530,6 +537,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -542,6 +550,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -589,6 +598,7 @@ async fn status_snapshot_shows_missing_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -653,6 +663,7 @@ async fn status_snapshot_includes_credits_and_limits() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -706,6 +717,7 @@ async fn status_snapshot_shows_empty_limits_message() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -768,6 +780,7 @@ async fn status_snapshot_shows_stale_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -834,6 +847,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -890,6 +904,7 @@ async fn status_context_window_uses_last_usage() { now, &model_slug, None, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines From 2c3843728c11c69b3f3e7d422485e748a69ae674 Mon Sep 17 00:00:00 2001 From: Tiffany Citra Date: Tue, 20 Jan 2026 10:55:01 -0800 Subject: [PATCH 66/78] fix: `writable_roots` doesn't recognize home directory symbol in non-windows OS (#9193) Fixes: ``` [sandbox_workspace_write] writable_roots = ["~/code/"] ``` translates to ``` /Users/ccunningham/.codex/~/code ``` (i.e. the home dir symbol isn't recognized) --- codex-rs/Cargo.lock | 2 + codex-rs/utils/absolute-path/Cargo.toml | 2 + codex-rs/utils/absolute-path/src/lib.rs | 68 ++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ba2400ba95..3a81d2b7d6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1888,7 +1888,9 @@ dependencies = [ name = "codex-utils-absolute-path" version = "0.0.0" dependencies = [ + "dirs", "path-absolutize", + "pretty_assertions", "schemars 0.8.22", "serde", "serde_json", diff --git a/codex-rs/utils/absolute-path/Cargo.toml b/codex-rs/utils/absolute-path/Cargo.toml index 5af9dc23aa..f4fa8bf789 100644 --- a/codex-rs/utils/absolute-path/Cargo.toml +++ b/codex-rs/utils/absolute-path/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +dirs = { workspace = true } path-absolutize = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -18,5 +19,6 @@ ts-rs = { workspace = true, features = [ ] } [dev-dependencies] +pretty_assertions = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 7054103e30..966b061bf5 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -1,3 +1,4 @@ +use dirs::home_dir; use path_absolutize::Absolutize; use schemars::JsonSchema; use serde::Deserialize; @@ -21,16 +22,35 @@ use ts_rs::TS; pub struct AbsolutePathBuf(PathBuf); impl AbsolutePathBuf { + fn maybe_expand_home_directory(path: &Path) -> PathBuf { + let Some(path_str) = path.to_str() else { + return path.to_path_buf(); + }; + if cfg!(not(target_os = "windows")) + && let Some(home) = home_dir() + { + if path_str == "~" { + return home; + } + if let Some(rest) = path_str.strip_prefix("~/") { + return home.join(rest); + } + } + path.to_path_buf() + } + pub fn resolve_path_against_base, B: AsRef>( path: P, base_path: B, ) -> std::io::Result { - let absolute_path = path.as_ref().absolutize_from(base_path.as_ref())?; + let expanded = Self::maybe_expand_home_directory(path.as_ref()); + let absolute_path = expanded.absolutize_from(base_path.as_ref())?; Ok(Self(absolute_path.into_owned())) } pub fn from_absolute_path>(path: P) -> std::io::Result { - let absolute_path = path.as_ref().absolutize()?; + let expanded = Self::maybe_expand_home_directory(path.as_ref()); + let absolute_path = expanded.absolutize()?; Ok(Self(absolute_path.into_owned())) } @@ -165,6 +185,7 @@ impl<'de> Deserialize<'de> for AbsolutePathBuf { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use tempfile::tempdir; #[test] @@ -203,4 +224,47 @@ mod tests { base_dir.join(relative_path).as_path() ); } + + #[cfg(not(target_os = "windows"))] + #[test] + fn home_directory_root_on_non_windows_is_expanded_in_deserialization() { + let Some(home) = home_dir() else { + return; + }; + let temp_dir = tempdir().expect("base dir"); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(temp_dir.path()); + serde_json::from_str::("\"~\"").expect("failed to deserialize") + }; + assert_eq!(abs_path_buf.as_path(), home.as_path()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn home_directory_subpath_on_non_windows_is_expanded_in_deserialization() { + let Some(home) = home_dir() else { + return; + }; + let temp_dir = tempdir().expect("base dir"); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(temp_dir.path()); + serde_json::from_str::("\"~/code\"").expect("failed to deserialize") + }; + assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); + } + + #[cfg(target_os = "windows")] + #[test] + fn home_directory_on_windows_is_not_expanded_in_deserialization() { + let temp_dir = tempdir().expect("base dir"); + let base_dir = temp_dir.path(); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(base_dir); + serde_json::from_str::("\"~/code\"").expect("failed to deserialize") + }; + assert_eq!( + abs_path_buf.as_path(), + base_dir.join("~").join("code").as_path() + ); + } } From 46a4a03083b3e796af96a7d1350c62df82ee3201 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 20 Jan 2026 10:55:36 -0800 Subject: [PATCH 67/78] Fix typo in feature name from 'Mult-agents' to 'Multi-agents' (#9542) Fixes a typo in a feature description. --- codex-rs/core/src/features.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 1a35407a1d..0b1563bd5e 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -433,7 +433,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::Collab, key: "collab", stage: Stage::Experimental { - name: "Mult-agents", + name: "Multi-agents", menu_description: "Allow Codex to spawn and collaborate with other agents on request (formerly named `collab`).", announcement: "NEW! Codex can now spawn other agents and work with them to solve your problems. Enable in /experimental!", }, From 714151eb4ead7f6e29ada12402efbb009e9bae76 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 20 Jan 2026 11:06:14 -0800 Subject: [PATCH 68/78] feat(personality) introduce model_personality config (#9459) ## Summary Introduces the concept of a config model_personality. I would consider this an MVP for testing out the feature. There are a number of follow-ups to this PR: - More sophisticated templating with validation - In-product experience to manage this ## Testing - [x] Testing locally --- codex-rs/core/config.schema.json | 26 ++++++- codex-rs/core/src/client_common.rs | 4 + codex-rs/core/src/codex.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/config/mod.rs | 13 ++++ codex-rs/core/src/config/profile.rs | 2 + codex-rs/core/src/config/types.rs | 8 ++ .../core/src/models_manager/model_info.rs | 15 ++++ .../gpt-5.2-codex_instructions_template.md | 77 +++++++++++++++++++ .../core/templates/personalities/friendly.md | 21 +++++ .../core/templates/personalities/pragmatic.md | 23 ++++++ codex-rs/core/tests/suite/personality.rs | 23 ++++++ 12 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md create mode 100644 codex-rs/core/templates/personalities/friendly.md create mode 100644 codex-rs/core/templates/personalities/pragmatic.md create mode 100644 codex-rs/core/tests/suite/personality.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bfb54821fc..32318cd62d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -240,6 +240,14 @@ "type": "integer", "format": "int64" }, + "model_personality": { + "description": "EXPERIMENTAL Optionally specify a personality for the model", + "allOf": [ + { + "$ref": "#/definitions/Personality" + } + ] + }, "model_provider": { "description": "Provider to use from the model_providers map.", "type": "string" @@ -359,12 +367,12 @@ }, "shell_environment_policy": { "default": { - "inherit": null, - "ignore_default_excludes": null, "exclude": null, - "set": null, + "experimental_use_profile": null, + "ignore_default_excludes": null, "include_only": null, - "experimental_use_profile": null + "inherit": null, + "set": null }, "allOf": [ { @@ -632,6 +640,9 @@ "model": { "type": "string" }, + "model_personality": { + "$ref": "#/definitions/Personality" + }, "model_provider": { "description": "The key in the `model_providers` map identifying the [`ModelProviderInfo`] to use.", "type": "string" @@ -1051,6 +1062,13 @@ }, "additionalProperties": false }, + "Personality": { + "type": "string", + "enum": [ + "friendly", + "pragmatic" + ] + }, "ProjectConfig": { "type": "object", "properties": { diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 2abe4883e7..2614ce83ef 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,4 +1,5 @@ use crate::client_common::tools::ToolSpec; +use crate::config::types::Personality; use crate::error::Result; pub use codex_api::common::ResponseEvent; use codex_protocol::models::BaseInstructions; @@ -35,6 +36,9 @@ pub struct Prompt { pub base_instructions: BaseInstructions, + /// Optionally specify the personality of the model. + pub personality: Option, + /// Optional the output schema for the model's response. pub output_schema: Option, } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c5332f26d6..6344c90e36 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2974,6 +2974,7 @@ async fn run_sampling_request( tools: router.specs(), parallel_tool_calls: model_supports_parallel, base_instructions, + personality: None, output_schema: turn_context.final_output_json_schema.clone(), }; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index b8dae3ceab..ff5ca53010 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -55,6 +55,7 @@ async fn run_remote_compact_task_inner_impl( tools: vec![], parallel_tool_calls: false, base_instructions: sess.get_base_instructions().await, + personality: None, output_schema: None, }; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b4e6c0c8bf..20b86e2bf3 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -11,6 +11,7 @@ use crate::config::types::Notifications; use crate::config::types::OtelConfig; use crate::config::types::OtelConfigToml; use crate::config::types::OtelExporterKind; +use crate::config::types::Personality; use crate::config::types::SandboxWorkspaceWrite; use crate::config::types::ScrollInputMode; use crate::config::types::ShellEnvironmentPolicy; @@ -127,6 +128,9 @@ pub struct Config { /// Info needed to make an API request to the model. pub model_provider: ModelProviderInfo, + /// Optionally specify the personality of the model + pub model_personality: Option, + /// Approval policy for executing commands. pub approval_policy: Constrained, @@ -890,6 +894,10 @@ pub struct ConfigToml { /// Override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, + /// EXPERIMENTAL + /// Optionally specify a personality for the model + pub model_personality: Option, + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, @@ -1474,6 +1482,7 @@ impl Config { notify: cfg.notify, user_instructions, base_instructions, + model_personality: config_profile.model_personality.or(cfg.model_personality), developer_instructions, compact_prompt, // The config.toml omits "_mode" because it's a config file. However, "_mode" @@ -3605,6 +3614,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3692,6 +3702,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3794,6 +3805,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3882,6 +3894,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_verbosity: Some(Verbosity::High), + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index d8630f15d1..4f1a964ebc 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -3,6 +3,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use crate::config::types::Personality; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -24,6 +25,7 @@ pub struct ConfigProfile { pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, + pub model_personality: Option, pub chatgpt_base_url: Option, pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 46c06d9a22..7841367246 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -748,6 +748,14 @@ impl Default for ShellEnvironmentPolicy { } } +#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum Personality { + Friendly, + #[default] + Pragmatic, +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index b6e7ef91af..b2b0b0a909 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -9,6 +9,7 @@ use codex_protocol::openai_models::TruncationMode; use codex_protocol::openai_models::TruncationPolicyConfig; use crate::config::Config; +use crate::config::types::Personality; use crate::truncate::approx_bytes_for_tokens; use tracing::warn; @@ -88,6 +89,20 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo } if let Some(base_instructions) = &config.base_instructions { model.base_instructions = base_instructions.clone(); + } else if model.slug.contains("gpt-5.2-codex") + && let Some(personality) = &config.model_personality + { + let template = include_str!( + "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" + ); + let personality_message = match personality { + Personality::Friendly => include_str!("../../templates/personalities/friendly.md"), + Personality::Pragmatic => { + include_str!("../../templates/personalities/pragmatic.md") + } + }; + let instructions = template.replace("{{ personality_message }}", personality_message); + model.base_instructions = instructions; } model } diff --git a/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md new file mode 100644 index 0000000000..ae9ad932d9 --- /dev/null +++ b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md @@ -0,0 +1,77 @@ +You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals. + +# Personality + +{{ personality_message }} + +# Your environment + +## Using GIT + +- You may be working in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. + * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. + * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. +- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. +- Be cautious when using git. **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. +- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands. + +## Agents.md + +- If the directory you are in has an AGENTS.md file, it is provided to you at the top, and you don't have to search for it. +- If the user starts by chatting without a specific engineering/code related request, do NOT search for an AGENTS.md. Only do so once there is a relevant request. + +# Tool use + +- Unless you are otherwise instructed, prefer using `rg` or `rg --files` respectively when searching because `rg` is much faster than alternatives like `grep`. If the `rg` command is not found, then use alternatives. +- Use the plan tool to explain to the user what you are going to do + - Only use it for more complex tasks, do not use it for straightforward tasks (roughly the easiest 25%). + - Do not make single-step plans. If a single step plan makes sense to you, the task is straightforward and doesn't need a plan. + - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). + +# Code style + +- Follow the precedence rules user instructions > system / dev / user / AGENTS.md instructions > match local file conventions > instructions below. +- Use language-appropriate best practices. +- Optimize for clarity, readability, and maintainability. +- Prefer explicit, verbose, human-readable code over clever or concise code. +- Write clear, well-punctuated comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. + +# Reviews + +When the user asks for a review, you default to a code-review mindset. Your response prioritizes identifying bugs, risks, behavioral regressions, and missing tests. You present findings first, ordered by severity and including file or line references where possible. Open questions or assumptions follow. You state explicitly if no findings exist and call out any residual risks or test gaps. + +# Working with the user + +You interact with the user through a terminal. You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly. + +## Final answer formatting rules + +- ONLY use plain text. +- Headers are optional, **ONLY** use them when you think they are necessary. Use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line. +- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible. +- Never output the content of large files, just provide references. +- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting. Start sub sections with a bolded keyword bullet, then items. +- When referencing files in your response always follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace-relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Line/column (1-based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 + + +## Presenting your work +- Balance conciseness to not overwhelm the user with appropriate detail for the request. +- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. +- If the user asks for a code explanation, structure your answer with code references. +- When given a simple task, just provide the outcome in a short answer without strong formatting. +- When you make big or complex changes, walk the user through what you did and why. +- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have. +- If you weren't able to do something, for example run tests, tell the user. +- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. diff --git a/codex-rs/core/templates/personalities/friendly.md b/codex-rs/core/templates/personalities/friendly.md new file mode 100644 index 0000000000..91cbeffc18 --- /dev/null +++ b/codex-rs/core/templates/personalities/friendly.md @@ -0,0 +1,21 @@ +You optimize for team morale and being a supportive teammate as much as code quality. You communicate warmly, check in often, and explain concepts without ego. You excel at pairing, onboarding, and unblocking others. You create momentum by making collaborators feel supported and capable. + +## Values +* Empathy: Interprets empathy as meeting people where they are - adjusting explanations, pacing, and tone to maximize understanding and confidence. +* Collaboration: Sees collaboration as an active skill: inviting input, synthesizing perspectives, and making others successful. +* Ownership: Takes responsibility not just for code, but for whether teammates are unblocked and progress continues. + +## Tone & User Experience +Your voice is warm, encouraging, and conversational. It uses teamwork-oriented language (“we,” “let’s”), affirms progress, and replaces judgment with curiosity. You use light enthusiasm and humor when it helps sustain energy and focus. The user should feel safe asking basic questions without embarrassment, supported even when the problem is hard, and genuinely partnered with rather than evaluated. Interactions should reduce anxiety, increase clarity, and leave the user motivated to keep going. + +You are NEVER curt or dismissive. + +You are a patient and enjoyable collaborator: unflappable when others might get frustrated, while being an enjoyable, easy-going personality to work with. Even if you suspect a statement is incorrect, you remain supportive and collaborative, explaining your concerns while noting valid points. You frequently point out the strengths and insights of others while remaining focused on working with others to accomplish the task at hand. + +Voice samples +* “Before we lock this in, can I sanity-check how are you thinking about the edge case here?” +* “Here’s what I found: the logic is sound, but there’s a race condition around retries. I’ll walk through it and then we can decide how defensive we want to be.” +* “The core idea is solid and readable. I’ve flagged two correctness issues and one missing test below—once those are addressed, this should be in great shape!” + +## Escalation +You escalate gently and deliberately when decisions have non-obvious consequences or hidden risk. Escalation is framed as support and shared responsibility--never correction--and is introduced with an explicit pause to realign, sanity-check assumptions, or surface tradeoffs before committing. diff --git a/codex-rs/core/templates/personalities/pragmatic.md b/codex-rs/core/templates/personalities/pragmatic.md new file mode 100644 index 0000000000..28d3c2a557 --- /dev/null +++ b/codex-rs/core/templates/personalities/pragmatic.md @@ -0,0 +1,23 @@ +You are deeply pragmatic, effective coworker. You optimize for systems that survive contact with reality. Communication is direct with occasional dry humor. You respect your teammates and are motivated by good work. + +## Values +You are guided by these core values: +- Pragmatism: Chooses solutions that are proven to work in real systems, even if they're unexciting or inelegant. + Optimizes for "this will not wake us up at 3am." +- Simplicity: Prefers fewer moving parts, explicit logic, and code that can be understood months later under + pressure. +- Rigor: Expects technical arguments to be correct and defensible; rejects hand-wavy reasoning and unjustified + abstractions. + +## Interaction Style + +You communicate concisely and confidently. Sentences are short, declarative, and unembellished. Humor is dry and used only when appropriate. There is no cheerleading, motivational language, or artificial reassurance. +Working with you, the user feels confident the solution will work in production, respected as a peer who doesn't need sugar-coating, and calm--like someone competent has taken the wheel. You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns + +Voice samples +* "What are the latency and failure constraints? This choice depends on both." +* "Implemented a single-threaded worker with backpressure. Removed retries that masked failures. Load-tested to 5x expected traffic. No new dependencies were added." +* "There's a race on shutdown in worker.go:142. This will drop requests under load. We should fix before merging." + +## Escalation +You escalate explicitly and immediately when underspecified requirements affect correctness, when a requested approach is fragile or unsafe, or when it is likely to cause incidents. Escalation is blunt and actionable: "This will break in X case. We should do Y instead." Silence implies acceptance; escalation implies a required change. diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs new file mode 100644 index 0000000000..9c0f6e3f40 --- /dev/null +++ b/codex-rs/core/tests/suite/personality.rs @@ -0,0 +1,23 @@ +use codex_core::config::types::Personality; +use codex_core::models_manager::manager::ModelsManager; +use core_test_support::load_default_config_for_test; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +const BASE_INSTRUCTIONS_TEMPLATE: &str = include_str!( + "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" +); +const FRIENDLY_PERSONALITY: &str = include_str!("../../templates/personalities/friendly.md"); + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_personality_updates_base_instructions() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.model_personality = Some(Personality::Friendly); + + let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config); + let expected = + BASE_INSTRUCTIONS_TEMPLATE.replace("{{ personality_message }}", FRIENDLY_PERSONALITY); + + assert_eq!(model_info.base_instructions, expected); +} From 0b3c802a546c0a28d1f928a5396e59b781d2d1aa Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 20 Jan 2026 20:14:14 +0000 Subject: [PATCH 69/78] fix: memory leak issue (#9543) Co-authored-by: Josh McKinney --- codex-rs/core/src/exec.rs | 88 ++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 2a91873238..275f2fb856 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -47,6 +47,12 @@ const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code const READ_CHUNK_SIZE: usize = 8192; // bytes per read const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB +/// Hard cap on bytes retained from exec stdout/stderr/aggregated output. +/// +/// This mirrors unified exec's output cap so a single runaway command cannot +/// OOM the process by dumping huge amounts of data to stdout/stderr. +const EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB + /// Limit the number of ExecCommandOutputDelta events emitted per exec call. /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; @@ -290,18 +296,32 @@ async fn exec_windows_sandbox( }; let exit_status = synthetic_exit_status(capture.exit_code); + let mut stdout_text = capture.stdout; + if stdout_text.len() > EXEC_OUTPUT_MAX_BYTES { + stdout_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } + let mut stderr_text = capture.stderr; + if stderr_text.len() > EXEC_OUTPUT_MAX_BYTES { + stderr_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } let stdout = StreamOutput { - text: capture.stdout, + text: stdout_text, truncated_after_lines: None, }; let stderr = StreamOutput { - text: capture.stderr, + text: stderr_text, truncated_after_lines: None, }; - // Best-effort aggregate: stdout then stderr - let mut aggregated = Vec::with_capacity(stdout.text.len() + stderr.text.len()); - append_all(&mut aggregated, &stdout.text); - append_all(&mut aggregated, &stderr.text); + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES); let aggregated_output = StreamOutput { text: aggregated, truncated_after_lines: None, @@ -490,8 +510,13 @@ impl StreamOutput> { } #[inline] -fn append_all(dst: &mut Vec, src: &[u8]) { - dst.extend_from_slice(src); +fn append_capped(dst: &mut Vec, src: &[u8], max_bytes: usize) { + if dst.len() >= max_bytes { + return; + } + let remaining = max_bytes.saturating_sub(dst.len()); + let take = remaining.min(src.len()); + dst.extend_from_slice(&src[..take]); } #[derive(Clone, Debug)] @@ -584,19 +609,15 @@ async fn consume_truncated_output( )) })?; - let (agg_tx, agg_rx) = async_channel::unbounded::>(); - let stdout_handle = tokio::spawn(read_capped( BufReader::new(stdout_reader), stdout_stream.clone(), false, - Some(agg_tx.clone()), )); let stderr_handle = tokio::spawn(read_capped( BufReader::new(stderr_reader), stdout_stream.clone(), true, - Some(agg_tx.clone()), )); let (exit_status, timed_out) = tokio::select! { @@ -662,15 +683,18 @@ async fn consume_truncated_output( Duration::from_millis(IO_DRAIN_TIMEOUT_MS), ) .await?; - - drop(agg_tx); - - let mut combined_buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); - while let Ok(chunk) = agg_rx.recv().await { - append_all(&mut combined_buf, &chunk); - } + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES * 2); let aggregated_output = StreamOutput { - text: combined_buf, + text: aggregated, truncated_after_lines: None, }; @@ -687,14 +711,11 @@ async fn read_capped( mut reader: R, stream: Option, is_stderr: bool, - aggregate_tx: Option>>, ) -> io::Result>> { - let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); + let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY.min(EXEC_OUTPUT_MAX_BYTES)); let mut tmp = [0u8; READ_CHUNK_SIZE]; let mut emitted_deltas: usize = 0; - // No caps: append all bytes - loop { let n = reader.read(&mut tmp).await?; if n == 0 { @@ -723,11 +744,7 @@ async fn read_capped( emitted_deltas += 1; } - if let Some(tx) = &aggregate_tx { - let _ = tx.send(tmp[..n].to_vec()).await; - } - - append_all(&mut buf, &tmp[..n]); + append_capped(&mut buf, &tmp[..n], EXEC_OUTPUT_MAX_BYTES); // Continue reading to EOF to avoid back-pressure } @@ -755,6 +772,7 @@ fn synthetic_exit_status(code: i32) -> ExitStatus { mod tests { use super::*; use std::time::Duration; + use tokio::io::AsyncWriteExt; fn make_exec_output( exit_code: i32, @@ -816,6 +834,18 @@ mod tests { )); } + #[tokio::test] + async fn read_capped_limits_retained_bytes() { + let (mut writer, reader) = tokio::io::duplex(1024); + let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)]; + tokio::spawn(async move { + writer.write_all(&bytes).await.expect("write"); + }); + + let out = read_capped(reader, None, false).await.expect("read"); + assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES); + } + #[cfg(unix)] #[test] fn sandbox_detection_flags_sigsys_exit_code() { From 79c5bf9835a98f527cc6708504708f228fc3d755 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 20 Jan 2026 12:18:00 -0800 Subject: [PATCH 70/78] Fixed config merging issue with profiles (#9509) This PR fixes a small issue with chained (layered) config.toml file merging. The old logic didn't properly handle profiles. In particular, if a lower-layer config overrides a profile defined in a higher-layer config, the override did not take effect. This prevents users from having project-specific profile overrides and contradicts the (soon-to-be) documented behavior of config merging. The change adds a unit test for this case. It also exposes a function from the config crate that is needed by the app server code paths to implement support for layered configs. --- codex-rs/core/src/config/mod.rs | 59 ++++++++++++++++++++++++++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/list.rs | 19 +++++++++ codex-rs/tui/src/lib.rs | 68 +++++++++++++++++++++++-------- codex-rs/tui2/src/lib.rs | 68 +++++++++++++++++++++++-------- 5 files changed, 179 insertions(+), 36 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 20b86e2bf3..7d1426e7a4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -399,6 +399,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + fallback_cwd: Option, } impl ConfigBuilder { @@ -422,21 +423,29 @@ impl ConfigBuilder { self } + pub fn fallback_cwd(mut self, fallback_cwd: Option) -> Self { + self.fallback_cwd = fallback_cwd; + self + } + pub async fn build(self) -> std::io::Result { let Self { codex_home, cli_overrides, harness_overrides, loader_overrides, + fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; let cli_overrides = cli_overrides.unwrap_or_default(); - let harness_overrides = harness_overrides.unwrap_or_default(); + let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); - let cwd = match harness_overrides.cwd.as_deref() { + let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref()); + let cwd = match cwd_override { Some(path) => AbsolutePathBuf::try_from(path)?, None => AbsolutePathBuf::current_dir()?, }; + harness_overrides.cwd = Some(cwd.to_path_buf()); let config_layer_stack = load_config_layers_state(&codex_home, Some(cwd), &cli_overrides, loader_overrides) .await?; @@ -2301,6 +2310,52 @@ trust_level = "trusted" Ok(()) } + #[tokio::test] + async fn project_profile_overrides_user_profile() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +profile = "global" + +[profiles.global] +model = "gpt-global" + +[profiles.project] +model = "gpt-project" + +[projects."{workspace_key}"] +trust_level = "trusted" +"#, + ), + )?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join(CONFIG_TOML_FILE), + r#" +profile = "project" +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(workspace.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + + assert_eq!(config.active_profile.as_deref(), Some("project")); + assert_eq!(config.model.as_deref(), Some("gpt-project")); + + Ok(()) + } + #[test] fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a23ae55aa8..e481f642a3 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -104,6 +104,7 @@ pub use rollout::list::ThreadSortKey; 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; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 3e53882fcf..01e7b1733d 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -20,6 +20,7 @@ use crate::protocol::EventMsg; use codex_file_search as file_search; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; /// Returned page of thread (thread) summaries. @@ -744,6 +745,24 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result io::Result { + let head = read_head_for_summary(path).await?; + let Some(first) = head.first() else { + return Err(io::Error::other(format!( + "rollout at {} is empty", + path.display() + ))); + }; + serde_json::from_value::(first.clone()).map_err(|_| { + io::Error::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + }) +} + async fn file_modified_time(path: &Path) -> io::Result> { let meta = tokio::fs::metadata(path).await?; let modified = meta.modified().ok(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e57497505a..f5c2bc1e75 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -18,6 +18,7 @@ use codex_core::RolloutRecorder; use codex_core::ThreadSortKey; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; @@ -25,6 +26,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; +use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -246,7 +248,6 @@ pub async fn run_main( std::process::exit(1); } - let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -336,16 +337,9 @@ pub async fn run_main( .with(otel_tracing_layer) .try_init(); - run_ratatui_app( - cli, - config, - overrides, - cli_kv_overrides, - active_profile, - feedback, - ) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -353,7 +347,6 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - active_profile: Option, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -404,15 +397,15 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, ); let login_status = get_login_status(&initial_config); - let should_show_trust_screen = should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen); + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), - show_trust_screen: should_show_trust_screen, + show_trust_screen: should_show_trust_screen_flag, login_status, auth_manager: auth_manager.clone(), config: initial_config.clone(), @@ -437,7 +430,7 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides, overrides).await + load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await } else { initial_config } @@ -570,6 +563,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let config = match &session_selection { + resume_picker::SessionSelection::Resume(path) + | resume_picker::SessionSelection::Fork(path) => { + let history_cwd = match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + }; + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + history_cwd, + ) + .await + } + _ => config, + }; + let active_profile = config.active_profile.clone(); + let should_show_trust_screen = should_show_trust_screen(&config); + let Cli { prompt, images, @@ -671,9 +691,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, +) -> Config { + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await +} + +async fn load_config_or_exit_with_fallback_cwd( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { + match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .fallback_cwd(fallback_cwd) + .build() + .await + { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 27d649c4de..bc224610b3 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -18,6 +18,7 @@ use codex_core::RolloutRecorder; use codex_core::ThreadSortKey; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; @@ -25,6 +26,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; +use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -262,7 +264,6 @@ pub async fn run_main( std::process::exit(1); } - let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -355,16 +356,9 @@ pub async fn run_main( let terminal_info = codex_core::terminal::terminal_info(); tracing::info!(terminal = ?terminal_info, "Detected terminal info"); - run_ratatui_app( - cli, - config, - overrides, - cli_kv_overrides, - active_profile, - feedback, - ) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -372,7 +366,6 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - active_profile: Option, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -424,15 +417,15 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, ); let login_status = get_login_status(&initial_config); - let should_show_trust_screen = should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen); + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), - show_trust_screen: should_show_trust_screen, + show_trust_screen: should_show_trust_screen_flag, login_status, auth_manager: auth_manager.clone(), config: initial_config.clone(), @@ -458,7 +451,7 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides, overrides).await + load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await } else { initial_config } @@ -594,6 +587,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let config = match &session_selection { + resume_picker::SessionSelection::Resume(path) + | resume_picker::SessionSelection::Fork(path) => { + let history_cwd = match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + }; + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + history_cwd, + ) + .await + } + _ => config, + }; + let active_profile = config.active_profile.clone(); + let should_show_trust_screen = should_show_trust_screen(&config); + let Cli { prompt, images, @@ -699,9 +719,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, +) -> Config { + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await +} + +async fn load_config_or_exit_with_fallback_cwd( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { + match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .fallback_cwd(fallback_cwd) + .build() + .await + { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); From b236f1c95db1cfcca80998f0b119a2750d4ea4b3 Mon Sep 17 00:00:00 2001 From: Skylar Graika Date: Tue, 20 Jan 2026 13:07:28 -0800 Subject: [PATCH 71/78] fix: prevent repeating interrupted turns (#9043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Record a model-visible `` marker in history when a turn is interrupted, and treat it as a session prefix. ## Why When a turn is interrupted, Codex emits `TurnAborted` but previously did not persist anything model-visible in the conversation history. On the next user turn, the model can’t tell the previous work was aborted and may resume/repeat earlier actions (including duplicated side effects like re-opening PRs). Fixes: https://github.com/openai/codex/issues/9042 ## How On `TurnAbortReason::Interrupted`, append a hidden user message containing a `…` marker and flush. Treat `` like `` for session-prefix filtering. Add a regression test to ensure follow-up turns don’t repeat side effects from an aborted turn. ## Testing `just fmt` `just fix -p codex-core` `cargo test -p codex-core -- --test-threads=1` `cargo test --all-features -- --test-threads=1` --------- Co-authored-by: Skylar Graika Co-authored-by: jif-oai Co-authored-by: Eric Traut --- codex-rs/core/src/codex.rs | 88 ++++++++++++++------ codex-rs/core/src/compact.rs | 61 +++++++++++++- codex-rs/core/src/context_manager/history.rs | 7 +- codex-rs/core/src/event_mapping.rs | 7 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session_prefix.rs | 15 ++++ codex-rs/core/src/tasks/mod.rs | 24 ++++++ codex-rs/core/tests/suite/abort_tasks.rs | 76 +++++++++++++++++ 8 files changed, 239 insertions(+), 40 deletions(-) create mode 100644 codex-rs/core/src/session_prefix.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6344c90e36..b7dbd07969 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4340,6 +4340,8 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + // Interrupts persist a model-visible `` marker into history, but there is no + // separate client-visible event for that marker (only `EventMsg::TurnAborted`). let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("timeout waiting for event") @@ -4348,6 +4350,7 @@ mod tests { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4370,11 +4373,17 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - let evt = rx.recv().await.expect("event"); + // Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted` + // being the only client-visible signal. + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); match evt.msg { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4390,42 +4399,67 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - // Drain events until we observe ExitedReviewMode; earlier - // RawResponseItem entries (e.g., environment context) may arrive first. - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + // Aborting a review task should exit review mode before surfacing the abort to the client. + // We scan for these events (rather than relying on fixed ordering) since unrelated events + // may interleave. + let mut exited_review_mode_idx = None; + let mut turn_aborted_idx = None; + let mut idx = 0usize; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let evt = tokio::time::timeout(remaining, rx.recv()) .await - .expect("timeout waiting for first event") - .expect("first event"); + .expect("timeout waiting for event") + .expect("event"); + let event_idx = idx; + idx = idx.saturating_add(1); match evt.msg { EventMsg::ExitedReviewMode(ev) => { assert!(ev.review_output.is_none()); + exited_review_mode_idx = Some(event_idx); + } + EventMsg::TurnAborted(ev) => { + assert_eq!(TurnAbortReason::Interrupted, ev.reason); + turn_aborted_idx = Some(event_idx); break; } - // Ignore any non-critical events before exit. - _ => continue, - } - } - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for next event") - .expect("event"); - match evt.msg { - EventMsg::RawResponseItem(_) => continue, - EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => continue, - EventMsg::AgentMessage(_) => continue, - EventMsg::TurnAborted(e) => { - assert_eq!(TurnAbortReason::Interrupted, e.reason); - break; - } - other => panic!("unexpected second event: {other:?}"), + _ => {} } } + assert!( + exited_review_mode_idx.is_some(), + "expected ExitedReviewMode after abort" + ); + assert!( + turn_aborted_idx.is_some(), + "expected TurnAborted after abort" + ); + assert!( + exited_review_mode_idx.unwrap() < turn_aborted_idx.unwrap(), + "expected ExitedReviewMode before TurnAborted" + ); - // TODO(jif) investigate what is this? let history = sess.clone_history().await; - let _ = history.raw_items(); + // The `` marker is silent in the event stream, so verify it is still + // recorded in history for the model. + assert!( + history.raw_items().iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "user" { + return false; + } + content.iter().any(|content_item| { + let ContentItem::InputText { text } = content_item else { + return false; + }; + text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG) + }) + }), + "expected a model-visible turn aborted marker in history after interrupt" + ); } #[tokio::test] diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index bde807154d..970eb0054d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -15,6 +15,7 @@ use crate::protocol::EventMsg; use crate::protocol::TurnContextItem; use crate::protocol::TurnStartedEvent; use crate::protocol::WarningEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; @@ -223,11 +224,31 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { Some(user.message()) } } - _ => None, + _ => collect_turn_aborted_marker(item), }) .collect() } +fn collect_turn_aborted_marker(item: &ResponseItem) -> Option { + let ResponseItem::Message { role, content, .. } = item else { + return None; + }; + if role != "user" { + return None; + } + + let text = content_items_to_text(content)?; + if text + .trim_start() + .to_ascii_lowercase() + .starts_with(TURN_ABORTED_OPEN_TAG) + { + Some(text) + } else { + None + } +} + pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } @@ -337,6 +358,7 @@ async fn drain_to_completed( mod tests { use super::*; + use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use pretty_assertions::assert_eq; #[test] @@ -489,4 +511,41 @@ mod tests { }; assert_eq!(summary, summary_text); } + + #[test] + fn build_compacted_history_preserves_turn_aborted_markers() { + let marker = format!( + "{TURN_ABORTED_OPEN_TAG}\n turn-1\n interrupted\n" + ); + let items = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: marker.clone(), + }], + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "real user message".to_string(), + }], + }, + ]; + + let user_messages = collect_user_messages(&items); + let history = build_compacted_history(Vec::new(), &user_messages, "SUMMARY"); + + let found_marker = history.iter().any(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).is_some_and(|text| text == marker) + } + _ => false, + }); + assert!( + found_marker, + "expected compacted history to retain marker" + ); + } } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 4feeddc29f..354b3c2d14 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -2,6 +2,7 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; use crate::instructions::SkillInstructions; use crate::instructions::UserInstructions; +use crate::session_prefix::is_session_prefix; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count; @@ -328,12 +329,6 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize { .saturating_sub(650) } -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { let ResponseItem::Message { role, content, .. } = item else { return false; diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 493a159593..deee79ae70 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -19,14 +19,9 @@ use uuid::Uuid; 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; -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - fn parse_user_message(message: &[ContentItem]) -> Option { if UserInstructions::is_user_instructions(message) || SkillInstructions::is_skill_instructions(message) diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e481f642a3..1fc61a2926 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -46,6 +46,7 @@ pub mod parse_command; pub mod path_utils; pub mod powershell; pub mod sandboxing; +mod session_prefix; mod stream_events_utils; mod text_encoding; pub mod token_data; diff --git a/codex-rs/core/src/session_prefix.rs b/codex-rs/core/src/session_prefix.rs new file mode 100644 index 0000000000..99283082b6 --- /dev/null +++ b/codex-rs/core/src/session_prefix.rs @@ -0,0 +1,15 @@ +/// Helpers for identifying model-visible "session prefix" messages. +/// +/// A session prefix is a user-role message that carries configuration or state needed by +/// follow-up turns (e.g. ``, ``). These items are persisted in +/// history so the model can see them, but they are not user intent and must not create user-turn +/// boundaries. +pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; +pub(crate) const TURN_ABORTED_OPEN_TAG: &str = ""; + +/// Returns true if `text` starts with a session prefix marker (case-insensitive). +pub(crate) fn is_session_prefix(text: &str) -> bool { + let trimmed = text.trim_start(); + let lowered = trimmed.to_ascii_lowercase(); + lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG) +} diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 83326b1f05..91303a3bdc 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -24,9 +24,13 @@ use crate::protocol::EventMsg; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; use crate::protocol::TurnCompleteEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; pub(crate) use compact::CompactTask; @@ -37,6 +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."; /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] @@ -242,6 +247,25 @@ impl Session { .abort(session_ctx, Arc::clone(&task.turn_context)) .await; + if reason == TurnAbortReason::Interrupted { + let marker = ResponseItem::Message { + id: None, + 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" + ), + }], + }; + self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) + .await; + self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) + .await; + // Ensure the marker is durably visible before emitting TurnAborted: some clients + // synchronously re-read the rollout on receipt of the abort event. + self.flush_rollout().await; + } + let event = EventMsg::TurnAborted(TurnAbortedEvent { reason }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c020fa00cb..53b5a26b02 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -163,3 +163,79 @@ async fn interrupt_tool_records_history_entries() { "expected at least one tenth of a second of elapsed time, got {secs}" ); } + +/// After an interrupt we persist a model-visible `` marker in the conversation +/// history. This test asserts that the marker is included in the next `/responses` request. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interrupt_persists_turn_aborted_marker_in_next_request() { + let command = "sleep 60"; + let call_id = "call-turn-aborted-marker"; + + let args = json!({ + "command": command, + "timeout_ms": 60_000 + }) + .to_string(); + let first_body = sse(vec![ + ev_response_created("resp-marker"), + ev_function_call(call_id, "shell_command", &args), + ev_completed("resp-marker"), + ]); + let follow_up_body = sse(vec![ + ev_response_created("resp-followup"), + ev_completed("resp-followup"), + ]); + + let server = start_mock_server().await; + let response_mock = mount_sse_sequence(&server, vec![first_body, follow_up_body]).await; + + let fixture = test_codex() + .with_model("gpt-5.1") + .build(&server) + .await + .unwrap(); + let codex = Arc::clone(&fixture.codex); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "start interrupt marker".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; + + tokio::time::sleep(Duration::from_secs_f32(0.1)).await; + codex.submit(Op::Interrupt).await.unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "follow up".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2, "expected two calls to the responses API"); + + let follow_up_request = &requests[1]; + let user_texts = follow_up_request.message_input_texts("user"); + assert!( + user_texts + .iter() + .any(|text| text.contains("")), + "expected marker in follow-up request" + ); +} From b0049ab644f99eb94529a991e99d8946a70ff01e Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 20 Jan 2026 13:39:31 -0800 Subject: [PATCH 72/78] fix(core): don't update the file's mtime on resume (#9553) Remove `FileTimes::new().set_modified(SystemTime::now())` when resuming a thread. Context: It's awkward in UI built on top of app-server that resuming a thread bumps the `updated_at` timestamp, even if no message is sent. So if you open a thread (perhaps to just view its contents), it automatically reorders it to the top which is almost certainly not what you want. --- codex-rs/core/src/rollout/recorder.rs | 28 ++++++++------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 8c2efcf4e1..c5bd14b3c1 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1,12 +1,10 @@ //! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later. use std::fs::File; -use std::fs::FileTimes; use std::fs::{self}; use std::io::Error as IoError; use std::path::Path; use std::path::PathBuf; -use std::time::SystemTime; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; @@ -197,17 +195,14 @@ impl RolloutRecorder { }), ) } - RolloutRecorderParams::Resume { path } => { - touch_rollout_file(&path)?; - ( - tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await?, - path, - None, - ) - } + RolloutRecorderParams::Resume { path } => ( + tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await?, + path, + None, + ), }; // Clone the cwd for the spawned task to collect git info asynchronously @@ -392,13 +387,6 @@ fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Resul }) } -fn touch_rollout_file(path: &Path) -> std::io::Result<()> { - let file = fs::OpenOptions::new().append(true).open(path)?; - let times = FileTimes::new().set_modified(SystemTime::now()); - file.set_times(times)?; - Ok(()) -} - async fn rollout_writer( file: tokio::fs::File, mut rx: mpsc::Receiver, From 9ea8e3115e3ace2662ad6369665a96db3a83fded Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 20 Jan 2026 13:55:37 -0800 Subject: [PATCH 73/78] lookup system SIDs instead of hardcoding English strings. (#9552) The elevated setup does not work on non-English windows installs where Users/Administrators/etc are in different languages. This PR uses the well-known SIDs instead, which do not vary based on locale --- .../windows-sandbox-rs/src/sandbox_users.rs | 139 ++++++++++++++++-- .../src/setup_orchestrator.rs | 2 +- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs index 41298b8fba..8aa67a41a5 100644 --- a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs @@ -13,6 +13,7 @@ use std::fs::File; use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success; use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAdd; @@ -27,7 +28,10 @@ use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1; use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1003; use windows_sys::Win32::NetworkManagement::NetManagement::USER_PRIV_USER; use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW; +use windows_sys::Win32::Security::CopySid; +use windows_sys::Win32::Security::GetLengthSid; use windows_sys::Win32::Security::LookupAccountNameW; +use windows_sys::Win32::Security::LookupAccountSidW; use windows_sys::Win32::Security::SID_NAME_USE; use codex_windows_sandbox::dpapi_protect; @@ -38,6 +42,11 @@ use codex_windows_sandbox::SETUP_VERSION; pub const SANDBOX_USERS_GROUP: &str = "CodexSandboxUsers"; const SANDBOX_USERS_GROUP_COMMENT: &str = "Codex sandbox internal group (managed)"; +const SID_ADMINISTRATORS: &str = "S-1-5-32-544"; +const SID_USERS: &str = "S-1-5-32-545"; +const SID_AUTHENTICATED_USERS: &str = "S-1-5-11"; +const SID_EVERYONE: &str = "S-1-1-0"; +const SID_SYSTEM: &str = "S-1-5-18"; pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> { ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log) @@ -119,17 +128,24 @@ pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<( } // Ensure the principal is a regular local user account. - let group = to_wide(OsStr::new("Users")); - let member = LOCALGROUP_MEMBERS_INFO_3 { - lgrmi3_domainandname: name_w.as_ptr() as *mut u16, - }; - let _ = NetLocalGroupAddMembers( - std::ptr::null(), - group.as_ptr(), - 3, - &member as *const _ as *mut u8, - 1, - ); + if let Ok(group_name) = lookup_account_name_for_sid(SID_USERS) { + let group = to_wide(OsStr::new(&group_name)); + let member = LOCALGROUP_MEMBERS_INFO_3 { + lgrmi3_domainandname: name_w.as_ptr() as *mut u16, + }; + let _ = NetLocalGroupAddMembers( + std::ptr::null(), + group.as_ptr(), + 3, + &member as *const _ as *mut u8, + 1, + ); + } else { + super::log_line( + log, + "LookupAccountSidW failed for Users SID; skipping Users group membership", + )?; + } } Ok(()) } @@ -184,6 +200,9 @@ pub fn ensure_local_group_member(group_name: &str, member_name: &str) -> Result< } pub fn resolve_sid(name: &str) -> Result> { + if let Some(sid_str) = well_known_sid_str(name) { + return sid_bytes_from_string(sid_str); + } let name_w = to_wide(OsStr::new(name)); let mut sid_buffer = vec![0u8; 68]; let mut sid_len: u32 = sid_buffer.len() as u32; @@ -218,6 +237,104 @@ pub fn resolve_sid(name: &str) -> Result> { } } +fn well_known_sid_str(name: &str) -> Option<&'static str> { + match name { + "Administrators" => Some(SID_ADMINISTRATORS), + "Users" => Some(SID_USERS), + "Authenticated Users" => Some(SID_AUTHENTICATED_USERS), + "Everyone" => Some(SID_EVERYONE), + "SYSTEM" => Some(SID_SYSTEM), + _ => None, + } +} + +fn sid_bytes_from_string(sid_str: &str) -> Result> { + let sid_w = to_wide(OsStr::new(sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let sid_len = unsafe { GetLengthSid(psid) }; + if sid_len == 0 { + unsafe { + LocalFree(psid as _); + } + return Err(anyhow::anyhow!("GetLengthSid failed for {sid_str}")); + } + let mut out = vec![0u8; sid_len as usize]; + let ok = unsafe { CopySid(sid_len, out.as_mut_ptr() as *mut c_void, psid) }; + unsafe { + LocalFree(psid as _); + } + if ok == 0 { + return Err(anyhow::anyhow!("CopySid failed for {sid_str}")); + } + Ok(out) +} + +fn lookup_account_name_for_sid(sid_str: &str) -> Result { + let sid_w = to_wide(OsStr::new(sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let mut name_len: u32 = 0; + let mut domain_len: u32 = 0; + let mut use_type: SID_NAME_USE = 0; + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + psid, + std::ptr::null_mut(), + &mut name_len, + std::ptr::null_mut(), + &mut domain_len, + &mut use_type, + ) + }; + if ok == 0 { + let err = unsafe { GetLastError() }; + if err != ERROR_INSUFFICIENT_BUFFER { + unsafe { + LocalFree(psid as _); + } + return Err(anyhow::anyhow!( + "LookupAccountSidW preflight failed for {sid_str}: {err}" + )); + } + } + let mut name_buf: Vec = vec![0u16; name_len as usize]; + let mut domain_buf: Vec = vec![0u16; domain_len as usize]; + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + psid, + name_buf.as_mut_ptr(), + &mut name_len, + domain_buf.as_mut_ptr(), + &mut domain_len, + &mut use_type, + ) + }; + unsafe { + LocalFree(psid as _); + } + if ok == 0 { + return Err(anyhow::anyhow!( + "LookupAccountSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let name = String::from_utf16_lossy(&name_buf); + Ok(name.trim_end_matches('\0').to_string()) +} + pub fn sid_bytes_to_psid(sid: &[u8]) -> Result<*mut c_void> { let sid_str = string_from_sid_bytes(sid).map_err(anyhow::Error::msg)?; let sid_w = to_wide(OsStr::new(&sid_str)); diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 30907de7c8..7578e494cd 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -26,7 +26,7 @@ use windows_sys::Win32::Security::CheckTokenMembership; use windows_sys::Win32::Security::FreeSid; use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY; -pub const SETUP_VERSION: u32 = 3; +pub const SETUP_VERSION: u32 = 4; pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline"; pub const ONLINE_USERNAME: &str = "CodexSandboxOnline"; const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020; From f2de920185d074e4b4a4dadfdeab5e95b46f437b Mon Sep 17 00:00:00 2001 From: Max Kong Date: Tue, 20 Jan 2026 16:59:59 -0500 Subject: [PATCH 74/78] fix(windows-sandbox): deny .git file entries under writable roots (#9314) ## Summary - Deny `.git` entries under writable roots even when `.git` is a file (worktrees/submodules) - Add a unit test for `.git` file handling ## Testing - `cargo test -p codex-windows-sandbox` (Windows) Fixes #9313 --- codex-rs/windows-sandbox-rs/src/allow.rs | 33 +++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 015555dab0..83d72f7e55 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -52,9 +52,9 @@ pub fn compute_allow_paths( let canonical = canonicalize(&candidate).unwrap_or(candidate); add_allow(canonical.clone()); - let git_dir = canonical.join(".git"); - if git_dir.is_dir() { - add_deny(git_dir); + let git_entry = canonical.join(".git"); + if git_entry.exists() { + add_deny(git_entry); } }; @@ -178,6 +178,33 @@ mod tests { assert_eq!(expected_deny, paths.deny); } + #[test] + fn denies_git_file_inside_writable_root() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + let git_file = command_cwd.join(".git"); + let _ = fs::create_dir_all(&command_cwd); + let _ = fs::write(&git_file, "gitdir: .git/worktrees/example"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + }; + + let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] + .into_iter() + .collect(); + let expected_deny: HashSet = [dunce::canonicalize(&git_file).unwrap()] + .into_iter() + .collect(); + + assert_eq!(expected_allow, paths.allow); + assert_eq!(expected_deny, paths.deny); + } + #[test] fn skips_git_dir_when_missing() { let tmp = TempDir::new().expect("tempdir"); From c73a11d55ee7f684762404a8403629eeec4e6267 Mon Sep 17 00:00:00 2001 From: Max Kong Date: Tue, 20 Jan 2026 17:00:27 -0500 Subject: [PATCH 75/78] fix(windows-sandbox): parse PATH list entries for audit roots (#9319) ## Summary - Use `std::env::split_paths` to parse PATH entries in audit candidate collection - Add a unit test covering multiple PATH entries (including spaces) ## Testing - `cargo test -p codex-windows-sandbox` (Windows) Fixes #9317 --- codex-rs/windows-sandbox-rs/src/audit.rs | 45 ++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 659ea57b6f..2e6059e819 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -10,6 +10,7 @@ use anyhow::anyhow; use anyhow::Result; use std::collections::HashSet; use std::ffi::c_void; +use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use std::time::Duration; @@ -67,9 +68,9 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap .cloned() .or_else(|| std::env::var("PATH").ok()) { - for part in path.split(std::path::MAIN_SEPARATOR) { - if !part.is_empty() { - unique_push(&mut set, &mut out, PathBuf::from(part)); + for part in std::env::split_paths(OsStr::new(&path)) { + if !part.as_os_str().is_empty() { + unique_push(&mut set, &mut out, part); } } } @@ -298,3 +299,41 @@ pub fn apply_capability_denies_for_world_writable( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::gather_candidates; + use std::collections::HashMap; + use std::fs; + + #[test] + fn gathers_path_entries_by_list_separator() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir_a = tmp.path().join("Tools"); + let dir_b = tmp.path().join("Bin"); + let dir_space = tmp.path().join("Program Files"); + fs::create_dir_all(&dir_a).expect("dir a"); + fs::create_dir_all(&dir_b).expect("dir b"); + fs::create_dir_all(&dir_space).expect("dir space"); + + let mut env_map = HashMap::new(); + env_map.insert( + "PATH".to_string(), + format!( + "{};{};{}", + dir_a.display(), + dir_b.display(), + dir_space.display() + ), + ); + + let candidates = gather_candidates(tmp.path(), &env_map); + let canon_a = dir_a.canonicalize().expect("canon a"); + let canon_b = dir_b.canonicalize().expect("canon b"); + let canon_space = dir_space.canonicalize().expect("canon space"); + + assert!(candidates.contains(&canon_a)); + assert!(candidates.contains(&canon_b)); + assert!(candidates.contains(&canon_space)); + } +} From 56fe5e7bea6b18c7b3cbc09cfe6f9e9c2905c30b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 20 Jan 2026 14:02:07 -0800 Subject: [PATCH 76/78] merge remote models (#9547) We have `models.json` and `/models` response Behavior: 1. New models from models endpoint gets added 2. Shared models get replaced by remote ones 3. Existing models in `models.json` but not `/models` are kept 4. Mark highest priority as default --- codex-rs/core/BUILD.bazel | 1 + codex-rs/core/src/models_manager/manager.rs | 70 +++++---- codex-rs/core/tests/suite/remote_models.rs | 166 +++++++++++++++++++- 3 files changed, 203 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index 37a3173f9a..f1649b36c7 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -18,6 +18,7 @@ codex_rust_crate( ), integration_compile_data_extra = [ "//codex-rs/apply-patch:apply_patch_tool_instructions.md", + "models.json", "prompt.md", ], test_data_extra = [ diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index b7cc66159a..7210e1c62d 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -239,7 +239,18 @@ impl ModelsManager { /// Replace the cached remote models and rebuild the derived presets list. async fn apply_remote_models(&self, models: Vec) { - *self.remote_models.write().await = models; + let mut existing_models = Self::load_remote_models_from_file().unwrap_or_default(); + for model in models { + if let Some(existing_index) = existing_models + .iter() + .position(|existing| existing.slug == model.slug) + { + existing_models[existing_index] = model; + } else { + existing_models.push(model); + } + } + *self.remote_models.write().await = existing_models; } fn load_remote_models_from_file() -> Result, std::io::Error> { @@ -272,16 +283,16 @@ impl ModelsManager { let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT); merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode); - let has_default = merged_presets.iter().any(|preset| preset.is_default); - if !has_default { - if let Some(default) = merged_presets - .iter_mut() - .find(|preset| preset.show_in_picker) - { - default.is_default = true; - } else if let Some(default) = merged_presets.first_mut() { - default.is_default = true; - } + for preset in &mut merged_presets { + preset.is_default = false; + } + if let Some(default) = merged_presets + .iter_mut() + .find(|preset| preset.show_in_picker) + { + default.is_default = true; + } else if let Some(default) = merged_presets.first_mut() { + default.is_default = true; } merged_presets @@ -396,6 +407,16 @@ mod tests { .expect("valid model") } + fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) { + for model in expected { + assert!( + actual.iter().any(|candidate| candidate.slug == model.slug), + "expected model {} in cached list", + model.slug + ); + } + } + fn provider_for(base_url: String) -> ModelProviderInfo { ModelProviderInfo { name: "mock".into(), @@ -415,7 +436,7 @@ mod tests { } #[tokio::test] - async fn refresh_available_models_sorts_and_marks_default() { + async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; let remote_models = vec![ remote_model("priority-low", "Low", 1), @@ -447,7 +468,7 @@ mod tests { .await .expect("refresh succeeds"); let cached_remote = manager.get_remote_models(&config).await; - assert_eq!(cached_remote, remote_models); + assert_models_contain(&cached_remote, &remote_models); let available = manager .list_models(&config, RefreshStrategy::OnlineIfUncached) @@ -464,11 +485,6 @@ mod tests { high_idx < low_idx, "higher priority should be listed before lower priority" ); - assert!( - available[high_idx].is_default, - "highest priority should be default" - ); - assert!(!available[low_idx].is_default); assert_eq!( models_mock.requests().len(), 1, @@ -508,22 +524,14 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("first refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "remote cache should store fetched models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); // Second call should read from cache and avoid the network. manager .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("cached refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "cache path should not mutate stored models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); assert_eq!( models_mock.requests().len(), 1, @@ -587,11 +595,7 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - updated_models, - "stale cache should trigger refetch" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &updated_models); assert_eq!( initial_mock.requests().len(), 1, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 8a6beb6e24..3b45989d8a 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -1,4 +1,5 @@ #![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used)] // unified exec is not supported on Windows OS use std::sync::Arc; @@ -434,8 +435,18 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { .find(|model| model.model == "remote-alpha") .expect("remote model should be listed"); let mut expected_remote: ModelPreset = remote_model.into(); - expected_remote.is_default = true; + expected_remote.is_default = remote.is_default; assert_eq!(*remote, expected_remote); + let default_model = available + .iter() + .find(|model| model.show_in_picker) + .expect("default model should be set"); + assert!(default_model.is_default); + assert_eq!( + available.iter().filter(|model| model.is_default).count(), + 1, + "expected a single default model" + ); assert!( available .iter() @@ -451,6 +462,148 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let remote_model = test_remote_model("remote-top", ModelVisibility::List, -10_000); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + assert_eq!( + available.first().map(|model| model.model.as_str()), + Some("remote-top") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let slug = bundled_model_slug(); + let mut remote_model = test_remote_model(&slug, ModelVisibility::List, 0); + remote_model.display_name = "Overridden".to_string(); + remote_model.description = Some("Overridden description".to_string()); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model.clone()], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let overridden = available + .iter() + .find(|model| model.model == slug) + .expect("overlapping model should be listed"); + assert_eq!(overridden.display_name, remote_model.display_name); + assert_eq!( + overridden.description, + remote_model + .description + .expect("remote model should include description") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let models_mock = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let bundled_slug = bundled_model_slug(); + assert!( + available.iter().any(|model| model.model == bundled_slug), + "bundled models should remain available after empty remote response" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_models_request_times_out_after_5s() -> Result<()> { skip_if_no_network!(Ok(())); @@ -588,6 +741,17 @@ async fn wait_for_model_available( } } +fn bundled_model_slug() -> String { + let response: ModelsResponse = serde_json::from_str(include_str!("../../models.json")) + .expect("bundled models.json should deserialize"); + response + .models + .first() + .expect("bundled models.json should include at least one model") + .slug + .clone() +} + fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo { test_remote_model_with_policy( slug, From be9e55c5fc394de6a53d31b19625655a196da4a8 Mon Sep 17 00:00:00 2001 From: charley-oai Date: Tue, 20 Jan 2026 14:04:11 -0800 Subject: [PATCH 77/78] Add total (non-partial) TextElement placeholder accessors (#9545) ## Summary - Make `TextElement` placeholders private and add a text-backed accessor to avoid assuming `Some`. - Since they are optional in the protocol, we want to make sure any accessors properly handle the None case (getting the placeholder using the byte range in the text) - Preserve placeholders during protocol/app-server conversions using the accessor fallback. - Update TUI composer/remap logic and tests to use the new constructor/accessor. --- .../app-server-protocol/src/protocol/v1.rs | 7 +- .../app-server-protocol/src/protocol/v2.rs | 32 +++-- .../tests/suite/v2/thread_resume.rs | 8 +- .../app-server/tests/suite/v2/turn_start.rs | 8 +- codex-rs/core/tests/suite/items.rs | 8 +- codex-rs/core/tests/suite/resume.rs | 8 +- codex-rs/protocol/src/items.rs | 15 ++- codex-rs/protocol/src/user_input.rs | 30 ++++- codex-rs/tui/src/bottom_pane/chat_composer.rs | 52 +++---- codex-rs/tui/src/bottom_pane/textarea.rs | 6 +- codex-rs/tui/src/chatwidget.rs | 10 +- codex-rs/tui/src/chatwidget/tests.rs | 127 +++++++++--------- .../tui2/src/bottom_pane/chat_composer.rs | 52 +++---- codex-rs/tui2/src/bottom_pane/textarea.rs | 6 +- codex-rs/tui2/src/chatwidget.rs | 10 +- codex-rs/tui2/src/chatwidget/tests.rs | 127 +++++++++--------- 16 files changed, 260 insertions(+), 246 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 39090c022f..488862b842 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -503,17 +503,14 @@ impl From for V1TextElement { fn from(value: CoreTextElement) -> Self { Self { byte_range: value.byte_range.into(), - placeholder: value.placeholder, + placeholder: value._placeholder_for_conversion_only().map(str::to_string), } } } impl From for CoreTextElement { fn from(value: V1TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 563a78cc6e..0635b530e8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1665,24 +1665,38 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } } impl From for TextElement { fn from(value: CoreTextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) } } impl From for CoreTextElement { fn from(value: TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index be2971397d..89b127d399 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -74,10 +74,10 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let preview = "Saved user message"; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".into()), + )]; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), "2025-01-05T12-00-00", diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 29a7d6b936..c465444709 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -142,10 +142,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".to_string()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".to_string()), + )]; let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 20739ab0db..60d0dbc75f 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -40,10 +40,10 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { let first_response = sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]); mount_sse_once(&server, first_response).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; let expected_input = UserInput::Text { text: "please inspect sample.txt".into(), text_elements: text_elements.clone(), diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 47b67cfe38..d0718a334d 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -35,10 +35,10 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { ]); mount_sse_once(&server, initial_sse).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; codex .submit(Op::UserInput { diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index cb04d427b9..9276a759cc 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -97,13 +97,14 @@ impl UserMessageItem { // Text element ranges are relative to each text chunk; offset them so they align // with the concatenated message returned by `message()`. for elem in text_elements { - out.push(TextElement { - byte_range: ByteRange { - start: offset + elem.byte_range.start, - end: offset + elem.byte_range.end, - }, - placeholder: elem.placeholder.clone(), - }); + let byte_range = ByteRange { + start: offset + elem.byte_range.start, + end: offset + elem.byte_range.end, + }; + out.push(TextElement::new( + byte_range, + elem.placeholder(text).map(str::to_string), + )); } offset += text.len(); } diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index b9ee693d85..0f32f5a33d 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -36,7 +36,35 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + /// Returns the stored placeholder without falling back to the text buffer. + /// + /// This is intended only for protocol conversions where the full text is not + /// available; prefer `placeholder(text)` for UI logic. + #[doc(hidden)] + pub fn _placeholder_for_conversion_only(&self) -> Option<&str> { + self.placeholder.as_deref() + } + + pub fn placeholder<'a>(&'a self, text: &'a str) -> Option<&'a str> { + self.placeholder + .as_deref() + .or_else(|| text.get(self.byte_range.start..self.byte_range.end)) + } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 55b40a9a06..d54e7005eb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -557,12 +557,7 @@ impl ChatComposer { let image_placeholders: HashSet = text_elements .iter() - .filter_map(|elem| { - elem.placeholder.as_ref().cloned().or_else(|| { - text.get(elem.byte_range.start..elem.byte_range.end) - .map(str::to_string) - }) - }) + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) .collect(); for (idx, path) in local_image_paths.into_iter().enumerate() { let placeholder = local_image_label_text(idx + 1); @@ -1240,13 +1235,13 @@ impl ChatComposer { return None; } let placeholder = trimmed.get(new_start..new_end).map(str::to_string); - Some(TextElement { - byte_range: ByteRange { + Some(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }) + )) }) .collect() } @@ -1287,7 +1282,7 @@ impl ChatComposer { rebuilt.push_str(&text[cursor..start]); } let elem_text = &text[start..end]; - let placeholder = elem.placeholder; + let placeholder = elem.placeholder(text).map(str::to_string); let replacement = placeholder .as_deref() .and_then(|ph| pending_by_placeholder.get_mut(ph)) @@ -1301,13 +1296,13 @@ impl ChatComposer { rebuilt.push_str(elem_text); let new_end = rebuilt.len(); let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); - rebuilt_elements.push(TextElement { - byte_range: ByteRange { + rebuilt_elements.push(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }); + )); } cursor = end; } @@ -4232,7 +4227,7 @@ mod tests { } => { assert_eq!(text, "[Image #1] hi"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4261,10 +4256,7 @@ mod tests { let placeholder = local_image_label_text(1); let text = format!("{placeholder} restored"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: None, - }]; + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; let path = PathBuf::from("/tmp/image1.png"); composer.set_text_content(text, text_elements, vec![path.clone()]); @@ -4301,7 +4293,7 @@ mod tests { let expected = format!("{large_content} [Image #1]"); assert_eq!(text, expected); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4345,7 +4337,7 @@ mod tests { let trimmed = large_content.trim().to_string(); assert_eq!(text, format!("{trimmed} [Image #1]")); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4389,7 +4381,7 @@ mod tests { assert_eq!(text, "line1\nline2\n [Image #1]"); assert!(!text.contains('\r')); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4474,7 +4466,7 @@ mod tests { } => { assert_eq!(text, "[Image #1]"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4664,20 +4656,20 @@ mod tests { let start2 = text.find(&placeholder2).expect("placeholder2 present"); let start1 = text.find(&placeholder1).expect("placeholder1 present"); let text_elements = vec![ - TextElement { - byte_range: ByteRange { + TextElement::new( + ByteRange { start: start2, end: start2 + placeholder2.len(), }, - placeholder: Some(placeholder2), - }, - TextElement { - byte_range: ByteRange { + Some(placeholder2), + ), + TextElement::new( + ByteRange { start: start1, end: start1 + placeholder1.len(), }, - placeholder: Some(placeholder1.clone()), - }, + Some(placeholder1.clone()), + ), ]; composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 926c53601a..3a849822b1 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -755,13 +755,13 @@ impl TextArea { .iter() .map(|e| { let placeholder = self.text.get(e.range.clone()).map(str::to_string); - UserTextElement { - byte_range: ByteRange { + UserTextElement::new( + ByteRange { start: e.range.start, end: e.range.end, }, placeholder, - } + ) }) .collect() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 48ed806d3a..8fb3dbacbe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -614,9 +614,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } let original = text.get(start..end).unwrap_or(""); - let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); - let replacement = mapping - .get(placeholder_key) + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) .map(String::as_str) .unwrap_or(original); @@ -624,8 +624,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) rebuilt.push_str(replacement); let elem_end = rebuilt.len(); - if let Some(remapped) = mapping.get(placeholder_key) { - elem.placeholder = Some(remapped.clone()); + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); } elem.byte_range = (elem_start..elem_end).into(); rebuilt_elements.push(elem); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 46f5c3be9e..0610a640c7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -192,10 +192,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; let conversation_id = ThreadId::new(); @@ -274,10 +274,10 @@ async fn submission_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let text = format!("{placeholder} submit"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/submitted.png")]; chat.bottom_pane @@ -330,26 +330,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let first_placeholder = "[Image #1]"; let first_text = format!("{first_placeholder} first"); - let first_elements = vec![TextElement { - byte_range: (0..first_placeholder.len()).into(), - placeholder: Some(first_placeholder.to_string()), - }]; + let first_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + Some(first_placeholder.to_string()), + )]; let first_images = [PathBuf::from("/tmp/first.png")]; let second_placeholder = "[Image #1]"; let second_text = format!("{second_placeholder} second"); - let second_elements = vec![TextElement { - byte_range: (0..second_placeholder.len()).into(), - placeholder: Some(second_placeholder.to_string()), - }]; + let second_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + Some(second_placeholder.to_string()), + )]; let second_images = [PathBuf::from("/tmp/second.png")]; let existing_placeholder = "[Image #1]"; let existing_text = format!("{existing_placeholder} existing"); - let existing_elements = vec![TextElement { - byte_range: (0..existing_placeholder.len()).into(), - placeholder: Some(existing_placeholder.to_string()), - }]; + let existing_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; chat.queued_user_messages.push_back(UserMessage { @@ -392,18 +392,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let second_start = first.len() + 1; let third_start = second_start + second.len() + 1; let expected_elements = vec![ - TextElement { - byte_range: (first_start..first_start + "[Image #1]".len()).into(), - placeholder: Some("[Image #1]".to_string()), - }, - TextElement { - byte_range: (second_start..second_start + "[Image #2]".len()).into(), - placeholder: Some("[Image #2]".to_string()), - }, - TextElement { - byte_range: (third_start..third_start + "[Image #3]".len()).into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ]; assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); assert_eq!( @@ -422,14 +422,14 @@ async fn remap_placeholders_uses_attachment_labels() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: Some(placeholder_two.to_string()), - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: Some(placeholder_one.to_string()), - }, + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), ]; let attachments = vec![ @@ -454,15 +454,14 @@ async fn remap_placeholders_uses_attachment_labels() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -486,14 +485,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: None, - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: None, - }, + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), ]; let attachments = vec![ @@ -518,15 +514,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 3592b25737..34a4795de9 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -489,12 +489,7 @@ impl ChatComposer { let image_placeholders: HashSet = text_elements .iter() - .filter_map(|elem| { - elem.placeholder.as_ref().cloned().or_else(|| { - text.get(elem.byte_range.start..elem.byte_range.end) - .map(str::to_string) - }) - }) + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) .collect(); for (idx, path) in local_image_paths.into_iter().enumerate() { let placeholder = local_image_label_text(idx + 1); @@ -1174,13 +1169,13 @@ impl ChatComposer { return None; } let placeholder = trimmed.get(new_start..new_end).map(str::to_string); - Some(TextElement { - byte_range: ByteRange { + Some(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }) + )) }) .collect() } @@ -1219,7 +1214,7 @@ impl ChatComposer { rebuilt.push_str(&text[cursor..start]); } let elem_text = &text[start..end]; - let placeholder = elem.placeholder; + let placeholder = elem.placeholder(text).map(str::to_string); let replacement = placeholder .as_deref() .and_then(|ph| pending_by_placeholder.get_mut(ph)) @@ -1231,13 +1226,13 @@ impl ChatComposer { rebuilt.push_str(elem_text); let new_end = rebuilt.len(); let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); - rebuilt_elements.push(TextElement { - byte_range: ByteRange { + rebuilt_elements.push(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }); + )); } cursor = end; } @@ -4223,7 +4218,7 @@ mod tests { } => { assert_eq!(text, "[Image #1] hi"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4252,10 +4247,7 @@ mod tests { let placeholder = local_image_label_text(1); let text = format!("{placeholder} restored"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: None, - }]; + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; let path = PathBuf::from("/tmp/image1.png"); composer.set_text_content(text, text_elements, vec![path.clone()]); @@ -4292,7 +4284,7 @@ mod tests { let expected = format!("{large_content} [Image #1]"); assert_eq!(text, expected); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4336,7 +4328,7 @@ mod tests { let trimmed = large_content.trim().to_string(); assert_eq!(text, format!("{trimmed} [Image #1]")); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4380,7 +4372,7 @@ mod tests { assert_eq!(text, "line1\nline2\n [Image #1]"); assert!(!text.contains('\r')); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4465,7 +4457,7 @@ mod tests { } => { assert_eq!(text, "[Image #1]"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4627,20 +4619,20 @@ mod tests { let start2 = text.find(&placeholder2).expect("placeholder2 present"); let start1 = text.find(&placeholder1).expect("placeholder1 present"); let text_elements = vec![ - TextElement { - byte_range: ByteRange { + TextElement::new( + ByteRange { start: start2, end: start2 + placeholder2.len(), }, - placeholder: Some(placeholder2), - }, - TextElement { - byte_range: ByteRange { + Some(placeholder2), + ), + TextElement::new( + ByteRange { start: start1, end: start1 + placeholder1.len(), }, - placeholder: Some(placeholder1.clone()), - }, + Some(placeholder1.clone()), + ), ]; composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs index 37f2d54c7b..7c39379c9f 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -752,13 +752,13 @@ impl TextArea { .iter() .map(|e| { let placeholder = self.text.get(e.range.clone()).map(str::to_string); - UserTextElement { - byte_range: ByteRange { + UserTextElement::new( + ByteRange { start: e.range.start, end: e.range.end, }, placeholder, - } + ) }) .collect() } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index c4cdf76695..23c6195aca 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -556,9 +556,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } let original = text.get(start..end).unwrap_or(""); - let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); - let replacement = mapping - .get(placeholder_key) + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) .map(String::as_str) .unwrap_or(original); @@ -566,8 +566,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) rebuilt.push_str(replacement); let elem_end = rebuilt.len(); - if let Some(remapped) = mapping.get(placeholder_key) { - elem.placeholder = Some(remapped.clone()); + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); } elem.byte_range = (elem_start..elem_end).into(); rebuilt_elements.push(elem); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index f1cf4c32e0..005b015a67 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -181,10 +181,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; let conversation_id = ThreadId::new(); @@ -263,10 +263,10 @@ async fn submission_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let text = format!("{placeholder} submit"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/submitted.png")]; chat.bottom_pane @@ -319,26 +319,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let first_placeholder = "[Image #1]"; let first_text = format!("{first_placeholder} first"); - let first_elements = vec![TextElement { - byte_range: (0..first_placeholder.len()).into(), - placeholder: Some(first_placeholder.to_string()), - }]; + let first_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + Some(first_placeholder.to_string()), + )]; let first_images = [PathBuf::from("/tmp/first.png")]; let second_placeholder = "[Image #1]"; let second_text = format!("{second_placeholder} second"); - let second_elements = vec![TextElement { - byte_range: (0..second_placeholder.len()).into(), - placeholder: Some(second_placeholder.to_string()), - }]; + let second_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + Some(second_placeholder.to_string()), + )]; let second_images = [PathBuf::from("/tmp/second.png")]; let existing_placeholder = "[Image #1]"; let existing_text = format!("{existing_placeholder} existing"); - let existing_elements = vec![TextElement { - byte_range: (0..existing_placeholder.len()).into(), - placeholder: Some(existing_placeholder.to_string()), - }]; + let existing_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; chat.queued_user_messages.push_back(UserMessage { @@ -381,18 +381,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let second_start = first.len() + 1; let third_start = second_start + second.len() + 1; let expected_elements = vec![ - TextElement { - byte_range: (first_start..first_start + "[Image #1]".len()).into(), - placeholder: Some("[Image #1]".to_string()), - }, - TextElement { - byte_range: (second_start..second_start + "[Image #2]".len()).into(), - placeholder: Some("[Image #2]".to_string()), - }, - TextElement { - byte_range: (third_start..third_start + "[Image #3]".len()).into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ]; assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); assert_eq!( @@ -411,14 +411,14 @@ async fn remap_placeholders_uses_attachment_labels() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: Some(placeholder_two.to_string()), - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: Some(placeholder_one.to_string()), - }, + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), ]; let attachments = vec![ @@ -443,15 +443,14 @@ async fn remap_placeholders_uses_attachment_labels() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -475,14 +474,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: None, - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: None, - }, + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), ]; let attachments = vec![ @@ -507,15 +503,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( From c20aa036a2f316afdaf0b57846d3f95eab38fad8 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 20 Jan 2026 14:31:09 -0800 Subject: [PATCH 78/78] feat: rename experimental_instructions_file to model_instructions --- MODULE.bazel.lock | 204 ++++++++---------- codex-rs/core/config.schema.json | 27 ++- codex-rs/core/src/codex.rs | 13 ++ codex-rs/core/src/config/mod.rs | 44 +++- codex-rs/core/src/config/profile.rs | 4 + codex-rs/core/src/config_loader/mod.rs | 4 +- codex-rs/core/src/config_loader/tests.rs | 6 +- codex-rs/core/tests/suite/cli_stream.rs | 10 +- .../core/tests/suite/deprecation_notice.rs | 43 ++++ 9 files changed, 207 insertions(+), 148 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7a7f9ce498..93afa2c2e2 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1,5 +1,5 @@ { - "lockFileVersion": 24, + "lockFileVersion": 26, "registryFileHashes": { "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", @@ -9,11 +9,19 @@ "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", - "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", + "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", + "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c", + "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", + "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", + "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", + "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1", "https://bcr.bazel.build/modules/apple_support/1.23.0/MODULE.bazel": "317d47e3f65b580e7fb4221c160797fda48e32f07d2dfff63d754ef2316dcd25", - "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", "https://bcr.bazel.build/modules/apple_support/1.24.1/MODULE.bazel": "f46e8ddad60aef170ee92b2f3d00ef66c147ceafea68b6877cb45bd91737f5f8", - "https://bcr.bazel.build/modules/apple_support/1.24.1/source.json": "cf725267cbacc5f028ef13bb77e7f2c2e0066923a4dab1025e4a0511b1ed258a", + "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", + "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456", @@ -21,16 +29,21 @@ "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.2/MODULE.bazel": "598e7fe3b54f5fa64fdbeead1027653963a359cc23561d43680006f3b463d5a4", "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.2/source.json": "c6f5c39e6f32eb395f8fdaea63031a233bbe96d49a3bfb9f75f6fce9b74bec6c", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", + "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a", "https://bcr.bazel.build/modules/bazel_features/1.24.0/MODULE.bazel": "4796b4c25b47053e9bbffa792b3792d07e228ff66cd0405faef56a978708acd4", "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", "https://bcr.bazel.build/modules/bazel_features/1.32.0/MODULE.bazel": "095d67022a58cb20f7e20e1aefecfa65257a222c18a938e2914fd257b5f1ccdc", + "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", "https://bcr.bazel.build/modules/bazel_features/1.34.0/source.json": "dfa5c4b01110313153b484a735764d247fee5624bbab63d25289e43b151a657a", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", @@ -52,20 +65,25 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", - "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", - "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/buildozer/8.2.1/MODULE.bazel": "61e9433c574c2bd9519cad7fa66b9c1d2b8e8d5f3ae5d6528a2c2d26e68d874d", + "https://bcr.bazel.build/modules/buildozer/8.2.1/source.json": "7c33f6a26ee0216f85544b4bca5e9044579e0219b6898dd653f5fb449cf2e484", "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8", "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", - "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4", "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", + "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", + "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", - "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/source.json": "543ed7627cc18e6460b9c1ae4a1b6b1debc5a5e0aca878b00f7531c7186b73da", "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92", @@ -83,21 +101,28 @@ "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", - "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", - "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", - "https://bcr.bazel.build/modules/protobuf/29.0/source.json": "b857f93c796750eef95f0d61ee378f3420d00ee1dd38627b27193aa482f4f981", + "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", + "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", + "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", + "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", - "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", - "https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4", + "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", + "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715", "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", - "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", @@ -106,35 +131,37 @@ "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", + "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", + "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", + "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", - "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", - "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963", - "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", - "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", - "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", - "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", + "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", + "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", "https://bcr.bazel.build/modules/rules_java/8.6.0/MODULE.bazel": "9c064c434606d75a086f15ade5edb514308cccd1544c2b2a89bbac4310e41c71", + "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", + "https://bcr.bazel.build/modules/rules_java/9.0.3/MODULE.bazel": "1f98ed015f7e744a745e0df6e898a7c5e83562d6b759dfd475c76456dda5ccea", + "https://bcr.bazel.build/modules/rules_java/9.0.3/source.json": "b038c0c07e12e658135bbc32cc1a2ded6e33785105c9d41958014c592de4593e", "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", - "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", - "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", - "https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197", - "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", @@ -150,34 +177,47 @@ "https://bcr.bazel.build/modules/rules_platform/0.1.0/source.json": "98becf9569572719b65f639133510633eb3527fb37d347d7ef08447f3ebcf1c9", "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", + "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", - "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", - "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", - "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", - "https://bcr.bazel.build/modules/rules_python/0.40.0/source.json": "939d4bd2e3110f27bfb360292986bb79fd8dcefb874358ccd6cdaa7bda029320", + "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", + "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", + "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", "https://bcr.bazel.build/modules/rules_rs/0.0.23/MODULE.bazel": "2e7ae2044105b1873a451c628713329d6746493f677b371f9d8063fd06a00937", "https://bcr.bazel.build/modules/rules_rs/0.0.23/source.json": "1149e7f599f2e41e9e9de457f9c4deb3d219a4fec967cea30557d02ede88037e", "https://bcr.bazel.build/modules/rules_rust/0.66.0/MODULE.bazel": "86ef763a582f4739a27029bdcc6c562258ed0ea6f8d58294b049e215ceb251b3", "https://bcr.bazel.build/modules/rules_rust/0.68.1/MODULE.bazel": "8d3332ef4079673385eb81f8bd68b012decc04ac00c9d5a01a40eff90301732c", "https://bcr.bazel.build/modules/rules_rust/0.68.1/source.json": "3378e746f81b62457fdfd37391244fa8ff075ba85c05931ee4f3a20ac1efe963", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", + "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", "https://bcr.bazel.build/modules/rules_shell/0.4.0/MODULE.bazel": "0f8f11bb3cd11755f0b48c1de0bbcf62b4b34421023aa41a2fc74ef68d9584f0", "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", + "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", + "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", + "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66", "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", - "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd", "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", - "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", - "https://bcr.bazel.build/modules/stardoc/0.7.1/source.json": "b6500ffcd7b48cd72c29bb67bcac781e12701cc0d6d55d266a652583cfcdab01", + "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", + "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", "https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c", "https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658", @@ -197,9 +237,10 @@ "general": { "bzlTransitiveDigest": "dnnhvKMf9MIXMulhbhHBblZdDAfAkiSVjApIXpUz9Y8=", "usagesDigest": "dPuxg6asjUidjHZi+xFfMiW+r9RawVYGjTZnOeP+fLI=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" + ], "generatedRepoSpecs": { "aspect_tools_telemetry_report": { "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", @@ -246,83 +287,7 @@ } } } - }, - "recordedRepoMappingEntries": [ - [ - "aspect_tools_telemetry+", - "bazel_lib", - "bazel_lib+" - ], - [ - "aspect_tools_telemetry+", - "bazel_skylib", - "bazel_skylib+" - ] - ] - } - }, - "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { - "general": { - "bzlTransitiveDigest": "rL/34P1aFDq2GqVC2zCFgQ8nTuOC6ziogocpvG50Qz8=", - "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, - "generatedRepoSpecs": { - "com_github_jetbrains_kotlin_git": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", - "attributes": { - "urls": [ - "https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip" - ], - "sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88" - } - }, - "com_github_jetbrains_kotlin": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", - "attributes": { - "git_repository_name": "com_github_jetbrains_kotlin_git", - "compiler_version": "1.9.23" - } - }, - "com_github_google_ksp": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", - "attributes": { - "urls": [ - "https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip" - ], - "sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d", - "strip_version": "1.9.23-1.0.20" - } - }, - "com_github_pinterest_ktlint": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", - "attributes": { - "sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985", - "urls": [ - "https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint" - ], - "executable": true - } - }, - "rules_android": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806", - "strip_prefix": "rules_android-0.1.1", - "urls": [ - "https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip" - ] - } - } - }, - "recordedRepoMappingEntries": [ - [ - "rules_kotlin+", - "bazel_tools", - "bazel_tools" - ] - ] + } } } }, @@ -355,7 +320,7 @@ "anstyle_1.0.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "anyhow_1.0.100": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.51\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "arboard_3.6.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"clipboard-win\",\"req\":\"^5.3.1\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"png\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"default_features\":false,\"features\":[\"tiff\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"png\",\"bmp\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(windows)\"},{\"name\":\"objc2\",\"req\":\"^0.6.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"objc2-core-graphics\",\"NSPasteboard\",\"NSPasteboardItem\",\"NSImage\"],\"name\":\"objc2-app-kit\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CFCGTypes\"],\"name\":\"objc2-core-foundation\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CGImage\",\"CGColorSpace\",\"CGDataProvider\"],\"name\":\"objc2-core-graphics\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"NSArray\",\"NSString\",\"NSEnumerator\",\"NSGeometry\",\"NSValue\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_DataExchange\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.61.0\",\"target\":\"cfg(windows)\"},{\"name\":\"wl-clipboard-rs\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"x11rb\",\"req\":\"^0.13\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"}],\"features\":{\"core-graphics\":[\"dep:objc2-core-graphics\"],\"default\":[\"image-data\"],\"image\":[\"dep:image\"],\"image-data\":[\"dep:objc2-core-graphics\",\"dep:objc2-core-foundation\",\"image\",\"windows-sys\",\"core-graphics\"],\"wayland-data-control\":[\"wl-clipboard-rs\"],\"windows-sys\":[\"windows-sys/Win32_Graphics_Gdi\"],\"wl-clipboard-rs\":[\"dep:wl-clipboard-rs\"]}}", - "arc-swap_1.7.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.130\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", + "arc-swap_1.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -406,7 +371,7 @@ "cfg_aliases_0.1.1": "{\"dependencies\":[],\"features\":{}}", "cfg_aliases_0.2.1": "{\"dependencies\":[],\"features\":{}}", "chardetng_0.1.17": "{\"dependencies\":[{\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"encoding_rs\",\"req\":\"^0.8.29\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.2.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"multithreading\":[\"rayon\",\"arrayvec\"],\"testing-only-no-semver-guarantees-do-not-use\":[]}}", - "chrono_0.4.42": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.45\",\"target\":\"cfg(unix)\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pure-rust-locales\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.43\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.63\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-link\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_bench\":[],\"alloc\":[],\"clock\":[\"winapi\",\"iana-time-zone\",\"now\"],\"core-error\":[],\"default\":[\"clock\",\"std\",\"oldtime\",\"wasmbind\"],\"libc\":[],\"now\":[\"std\"],\"oldtime\":[],\"rkyv\":[\"dep:rkyv\",\"rkyv/size_32\"],\"rkyv-16\":[\"dep:rkyv\",\"rkyv?/size_16\"],\"rkyv-32\":[\"dep:rkyv\",\"rkyv?/size_32\"],\"rkyv-64\":[\"dep:rkyv\",\"rkyv?/size_64\"],\"rkyv-validation\":[\"rkyv?/validation\"],\"std\":[\"alloc\"],\"unstable-locales\":[\"pure-rust-locales\"],\"wasmbind\":[\"wasm-bindgen\",\"js-sys\"],\"winapi\":[\"windows-link\"]}}", + "chrono_0.4.43": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\"^1.0.1\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.45\",\"target\":\"cfg(unix)\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pure-rust-locales\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.43\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.66\"},{\"name\":\"windows-link\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_bench\":[],\"alloc\":[],\"clock\":[\"winapi\",\"iana-time-zone\",\"now\"],\"core-error\":[],\"default\":[\"clock\",\"std\",\"oldtime\",\"wasmbind\"],\"defmt\":[\"dep:defmt\",\"pure-rust-locales?/defmt\"],\"libc\":[],\"now\":[\"std\"],\"oldtime\":[],\"rkyv\":[\"dep:rkyv\",\"rkyv/size_32\"],\"rkyv-16\":[\"dep:rkyv\",\"rkyv?/size_16\"],\"rkyv-32\":[\"dep:rkyv\",\"rkyv?/size_32\"],\"rkyv-64\":[\"dep:rkyv\",\"rkyv?/size_64\"],\"rkyv-validation\":[\"rkyv?/validation\"],\"std\":[\"alloc\"],\"unstable-locales\":[\"pure-rust-locales\"],\"wasmbind\":[\"wasm-bindgen\",\"js-sys\"],\"winapi\":[\"windows-link\"]}}", "chunked_transfer_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{}}", "cipher_0.4.4": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.6\"},{\"name\":\"inout\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[],\"block-padding\":[\"inout/block-padding\"],\"dev\":[\"blobby\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\",\"inout/std\"]}}", "clap_4.5.54": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.5.54\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.5.49\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.3\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}", @@ -439,9 +404,9 @@ "crossterm_winapi_0.9.1": "{\"dependencies\":[{\"features\":[\"winbase\",\"consoleapi\",\"processenv\",\"handleapi\",\"synchapi\",\"impl-default\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "crunchy_0.2.4": "{\"dependencies\":[],\"features\":{\"default\":[\"limit_128\"],\"limit_1024\":[],\"limit_128\":[],\"limit_2048\":[],\"limit_256\":[],\"limit_512\":[],\"limit_64\":[],\"std\":[]}}", "crypto-common_0.1.6": "{\"dependencies\":[{\"features\":[\"more_lengths\"],\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"typenum\",\"req\":\"^1.14\"}],\"features\":{\"getrandom\":[\"rand_core/getrandom\"],\"std\":[]}}", - "ctor-proc-macro_0.0.6": "{\"dependencies\":[],\"features\":{\"default\":[]}}", + "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "ctor_0.1.26": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^1.0.98\"}],\"features\":{}}", - "ctor_0.5.0": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.6\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", + "ctor_0.6.3": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.7\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", "darling_0.20.11": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.20.11\"},{\"name\":\"darling_macro\",\"req\":\"=0.20.11\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"suggestions\":[\"darling_core/suggestions\"]}}", "darling_0.21.3": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.21.3\"},{\"name\":\"darling_macro\",\"req\":\"=0.21.3\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"serde\":[\"darling_core/serde\"],\"suggestions\":[\"darling_core/suggestions\"]}}", "darling_0.23.0": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.23.0\"},{\"name\":\"darling_macro\",\"req\":\"=0.23.0\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"serde\":[\"darling_core/serde\"],\"suggestions\":[\"darling_core/suggestions\"]}}", @@ -541,6 +506,8 @@ "getrandom_0.2.16": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"wasi\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.18\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"}],\"features\":{\"custom\":[],\"js\":[\"wasm-bindgen\",\"js-sys\"],\"linux_disable_fallback\":[],\"rdrand\":[],\"rustc-dep-of-std\":[\"compiler_builtins\",\"core\",\"libc/rustc-dep-of-std\",\"wasi/rustc-dep-of-std\"],\"std\":[],\"test-in-browser\":[]}}", "getrandom_0.3.3": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^5.1\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"default_features\":false,\"name\":\"wasi\",\"req\":\"^0.14\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"rustc-dep-of-std\":[\"dep:compiler_builtins\",\"dep:core\"],\"std\":[],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", "gimli_0.31.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"}],\"features\":{\"default\":[\"read-all\",\"write\"],\"endian-reader\":[\"read\",\"dep:stable_deref_trait\"],\"fallible-iterator\":[\"dep:fallible-iterator\"],\"read\":[\"read-core\"],\"read-all\":[\"read\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"read-core\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\",\"dep:compiler_builtins\"],\"std\":[\"fallible-iterator?/std\",\"stable_deref_trait?/std\"],\"write\":[\"dep:indexmap\"]}}", + "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", "globset_0.4.16": "{\"dependencies\":[{\"name\":\"aho-corasick\",\"req\":\"^1.1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"features\":[\"std\",\"perf\",\"syntax\",\"meta\",\"nfa\",\"hybrid\"],\"name\":\"regex-automata\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-syntax\",\"req\":\"^0.8.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.188\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.107\"}],\"features\":{\"default\":[\"log\"],\"serde1\":[\"serde\"],\"simd-accel\":[]}}", @@ -633,7 +600,7 @@ "litrs_1.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"optional\":true,\"req\":\"^1.0.63\"},{\"name\":\"unicode-xid\",\"optional\":true,\"req\":\"^0.2.4\"}],\"features\":{\"check_suffix\":[\"unicode-xid\"]}}", "local-waker_0.1.4": "{\"dependencies\":[],\"features\":{}}", "lock_api_0.4.13": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1.1.0\"},{\"name\":\"owning_ref\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"scopeguard\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.126\"}],\"features\":{\"arc_lock\":[],\"atomic_usize\":[],\"default\":[\"atomic_usize\"],\"nightly\":[]}}", - "log_0.4.28": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.63\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2.14.1\"},{\"kind\":\"dev\",\"name\":\"sval\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"sval_derive\",\"req\":\"^2.1\"},{\"default_features\":false,\"name\":\"sval_ref\",\"optional\":true,\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"inline-i128\"],\"name\":\"value-bag\",\"optional\":true,\"req\":\"^1.7\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"value-bag\",\"req\":\"^1.7\"}],\"features\":{\"kv\":[],\"kv_serde\":[\"kv_std\",\"value-bag/serde\",\"serde\"],\"kv_std\":[\"std\",\"kv\",\"value-bag/error\"],\"kv_sval\":[\"kv\",\"value-bag/sval\",\"sval\",\"sval_ref\"],\"kv_unstable\":[\"kv\",\"value-bag\"],\"kv_unstable_serde\":[\"kv_serde\",\"kv_unstable_std\"],\"kv_unstable_std\":[\"kv_std\",\"kv_unstable\"],\"kv_unstable_sval\":[\"kv_sval\",\"kv_unstable\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"std\":[]}}", + "log_0.4.29": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.63\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval\",\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval_derive\",\"req\":\"^2.16\"},{\"default_features\":false,\"name\":\"sval_ref\",\"optional\":true,\"req\":\"^2.16\"},{\"default_features\":false,\"features\":[\"inline-i128\"],\"name\":\"value-bag\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"value-bag\",\"req\":\"^1.12\"}],\"features\":{\"kv\":[],\"kv_serde\":[\"kv_std\",\"value-bag/serde\",\"serde\"],\"kv_std\":[\"std\",\"kv\",\"value-bag/error\"],\"kv_sval\":[\"kv\",\"value-bag/sval\",\"sval\",\"sval_ref\"],\"kv_unstable\":[\"kv\",\"value-bag\"],\"kv_unstable_serde\":[\"kv_serde\",\"kv_unstable_std\"],\"kv_unstable_std\":[\"kv_std\",\"kv_unstable\"],\"kv_unstable_sval\":[\"kv_sval\",\"kv_unstable\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "logos-derive_0.12.1": "{\"dependencies\":[{\"name\":\"beef\",\"req\":\"^0.5.0\"},{\"name\":\"fnv\",\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.9\"},{\"name\":\"quote\",\"req\":\"^1.0.3\"},{\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^1.0.17\"}],\"features\":{}}", "logos_0.12.1": "{\"dependencies\":[{\"name\":\"logos-derive\",\"optional\":true,\"req\":\"^0.12.1\"}],\"features\":{\"default\":[\"export_derive\",\"std\"],\"export_derive\":[\"logos-derive\"],\"std\":[]}}", "lru-slab_0.1.2": "{\"dependencies\":[],\"features\":{}}", @@ -907,9 +874,8 @@ "tokio-rustls_0.26.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.1\"},{\"features\":[\"pem\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.22\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.26\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"logging\",\"tls12\",\"aws_lc_rs\"],\"early-data\":[],\"fips\":[\"rustls/fips\"],\"logging\":[\"rustls/logging\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"rustls/tls12\"]}}", "tokio-stream_0.1.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"}],\"features\":{\"default\":[\"time\"],\"fs\":[\"tokio/fs\"],\"full\":[\"time\",\"net\",\"io-util\",\"fs\",\"sync\",\"signal\"],\"io-util\":[\"tokio/io-util\"],\"net\":[\"tokio/net\"],\"signal\":[\"tokio/signal\"],\"sync\":[\"tokio/sync\",\"tokio-util\"],\"time\":[\"tokio/time\"]}}", "tokio-test_0.4.4": "{\"dependencies\":[{\"name\":\"async-stream\",\"req\":\"^0.3.3\"},{\"name\":\"bytes\",\"req\":\"^1.0.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"features\":[\"rt\",\"sync\",\"time\",\"test-util\"],\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"}],\"features\":{}}", - "tokio-tungstenite_0.21.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"http1\",\"server\",\"tcp\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14.25\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.11\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27.0\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.1\"},{\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.25.0\"},{\"default_features\":false,\"name\":\"tungstenite\",\"req\":\"^0.21.0\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.3.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26.0\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[]}}", "tokio-util_0.7.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.44.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", - "tokio_1.48.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", + "tokio_1.49.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", "toml_0.5.11": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.97\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[],\"preserve_order\":[\"indexmap\"]}}", "toml_0.9.5": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.1.5\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.199\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.7\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.116\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_spanned\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.0\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_datetime\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.10\"}],\"features\":{\"debug\":[\"std\",\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\",\"serde\",\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"fast_hash\":[\"preserve_order\",\"dep:foldhash\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"preserve_order\":[\"dep:indexmap\",\"std\"],\"serde\":[\"dep:serde\",\"toml_datetime/serde\",\"serde_spanned/serde\"],\"std\":[\"indexmap?/std\",\"serde?/std\",\"toml_parser?/std\",\"toml_writer?/std\",\"toml_datetime/std\",\"serde_spanned/std\"],\"unbounded\":[]}}", "toml_datetime_0.7.5+spec-1.1.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"alloc\",\"serde_core?/std\"]}}", @@ -942,7 +908,6 @@ "ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}", "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", "tui-scrollbar_0.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"color-eyre\",\"req\":\"^0.6\"},{\"name\":\"crossterm_0_28\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.28\"},{\"name\":\"crossterm_0_29\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.29\"},{\"name\":\"document-features\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"ratatui\",\"req\":\"^0.30.0\"},{\"name\":\"ratatui-core\",\"req\":\"^0.1\"}],\"features\":{\"crossterm\":[\"crossterm_0_29\"],\"crossterm_0_28\":[\"dep:crossterm_0_28\"],\"crossterm_0_29\":[\"dep:crossterm_0_29\"],\"default\":[]}}", - "tungstenite_0.21.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.3.2\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.3.4\"},{\"kind\":\"dev\",\"name\":\"input_buffer\",\"req\":\"^0.5.0\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.3\"},{\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.5.5\"},{\"name\":\"thiserror\",\"req\":\"^1.0.23\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"utf-8\",\"req\":\"^0.7.5\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\",\"url\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"]}}", "typenum_1.18.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", "uds_windows_1.1.0": "{\"dependencies\":[{\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"winsock2\",\"ws2def\",\"minwinbase\",\"ntdef\",\"processthreadsapi\",\"handleapi\",\"ws2tcpip\",\"winbase\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "uname_0.1.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", @@ -993,6 +958,7 @@ "web-time_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.20\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"macro\"],\"kind\":\"dev\",\"name\":\"pollster\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"^0.2.70\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"features\":[\"WorkerGlobalScope\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"CssStyleDeclaration\",\"Document\",\"Element\",\"HtmlTableElement\",\"HtmlTableRowElement\",\"Performance\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", "webbrowser_1.0.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-files\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"actix-web\",\"req\":\"^4\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.0\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\">=0.3, <=0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"objc2\",\"req\":\"^0.6\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"NSDictionary\",\"NSString\",\"NSURL\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.10\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"Window\"],\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"disable-wsl\":[],\"hardened\":[],\"wasm-console\":[\"web-sys/console\"]}}", "webpki-root-certs_1.0.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"}],\"features\":{}}", + "webpki-roots_0.26.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"parent\",\"package\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "webpki-roots_1.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "weezl_0.1.10": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.12\"},{\"default_features\":false,\"features\":[\"macros\",\"io-util\",\"net\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"compat\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.6.2\"}],\"features\":{\"alloc\":[],\"async\":[\"futures\",\"std\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "which_8.0.0": "{\"dependencies\":[{\"name\":\"env_home\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"optional\":true,\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"optional\":true,\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"real-sys\"],\"real-sys\":[\"dep:env_home\",\"dep:rustix\",\"dep:winsafe\"],\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 32318cd62d..ed701bf016 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -53,14 +53,6 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, - "experimental_instructions_file": { - "description": "Legacy, now use features", - "allOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - } - ] - }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, @@ -240,6 +232,14 @@ "type": "integer", "format": "int64" }, + "model_instructions": { + "description": "Optional path to a file containing model instructions.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, "model_personality": { "description": "EXPERIMENTAL Optionally specify a personality for the model", "allOf": [ @@ -545,9 +545,6 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, - "experimental_instructions_file": { - "$ref": "#/definitions/AbsolutePathBuf" - }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, @@ -640,6 +637,14 @@ "model": { "type": "string" }, + "model_instructions": { + "description": "Optional path to a file containing model instructions.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, "model_personality": { "$ref": "#/definitions/Personality" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b7dbd07969..557d7ebd19 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -666,6 +666,19 @@ impl Session { msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }), }); } + if crate::config::uses_deprecated_instructions_file(&config.config_layer_stack) { + post_session_configured_events.push(Event { + id: INITIAL_SUBMIT_ID.to_owned(), + msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { + summary: "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions` instead." + .to_string(), + details: Some( + "Move the setting to `model_instructions` in config.toml (or under a profile) to load instructions from a file." + .to_string(), + ), + }), + }); + } maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events); let auth = auth_manager.auth().await; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7d1426e7a4..d3add11096 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -820,6 +820,12 @@ pub struct ConfigToml { #[serde(default)] pub developer_instructions: Option, + /// Optional path to a file containing model instructions that will override + /// the built-in instructions for the selected model. Users are STRONGLY + /// DISCOURAGED from using this field, as deviating from the instructions + /// sanctioned by Codex will likely degrade model performance. + pub model_instructions: Option, + /// Compact prompt used for history compaction. pub compact_prompt: Option, @@ -965,6 +971,8 @@ pub struct ConfigToml { pub notice: Option, /// Legacy, now use features + /// Deprecated: ignored. Use `model_instructions`. + #[schemars(skip)] pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, @@ -1432,14 +1440,12 @@ impl Config { // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. - let experimental_instructions_path = config_profile - .experimental_instructions_file + let model_instructions_path = config_profile + .model_instructions .as_ref() - .or(cfg.experimental_instructions_file.as_ref()); - let file_base_instructions = Self::try_read_non_empty_file( - experimental_instructions_path, - "experimental instructions file", - )?; + .or(cfg.model_instructions.as_ref()); + let file_base_instructions = + Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?; let base_instructions = base_instructions.or(file_base_instructions); let developer_instructions = developer_instructions.or(cfg.developer_instructions); @@ -1682,6 +1688,30 @@ impl Config { } } +pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { + config_layer_stack + .layers_high_to_low() + .into_iter() + .any(|layer| toml_uses_deprecated_instructions_file(&layer.config)) +} + +fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool { + let Some(table) = value.as_table() else { + return false; + }; + if table.contains_key("experimental_instructions_file") { + return true; + } + let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else { + return false; + }; + profiles.values().any(|profile| { + profile.as_table().is_some_and(|profile_table| { + profile_table.contains_key("experimental_instructions_file") + }) + }) +} + /// Returns the path to the Codex configuration directory, which can be /// specified by the `CODEX_HOME` environment variable. If not set, defaults to /// `~/.codex`. diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 4f1a964ebc..80ae772d0b 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -27,6 +27,10 @@ pub struct ConfigProfile { pub model_verbosity: Option, pub model_personality: Option, pub chatgpt_base_url: Option, + /// Optional path to a file containing model instructions. + pub model_instructions: Option, + /// Deprecated: ignored. Use `model_instructions`. + #[schemars(skip)] pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub include_apply_patch_tool: Option, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 0460e01572..5afdcc9b18 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -656,7 +656,7 @@ mod unit_tests { let contents = r#" # This is a field recognized by config.toml that is an AbsolutePathBuf in # the ConfigToml struct. -experimental_instructions_file = "./some_file.md" +model_instructions = "./some_file.md" # This is a field recognized by config.toml. model = "gpt-1000" @@ -669,7 +669,7 @@ foo = "xyzzy" let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?; let mut expected_toml_value = toml::map::Map::new(); expected_toml_value.insert( - "experimental_instructions_file".to_string(), + "model_instructions".to_string(), TomlValue::String( AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)? .as_path() diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 773d9951d1..ff531dacb1 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -438,10 +438,10 @@ async fn project_paths_resolve_relative_to_dot_codex_and_override_in_order() -> tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; let root_cfg = r#" -experimental_instructions_file = "root.txt" +model_instructions = "root.txt" "#; let nested_cfg = r#" -experimental_instructions_file = "child.txt" +model_instructions = "child.txt" "#; tokio::fs::write(project_root.join(".codex").join(CONFIG_TOML_FILE), root_cfg).await?; tokio::fs::write(nested.join(".codex").join(CONFIG_TOML_FILE), nested_cfg).await?; @@ -591,7 +591,7 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let cli_overrides = vec![( - "experimental_instructions_file".to_string(), + "model_instructions".to_string(), TomlValue::String("relative.md".to_string()), )]; diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 9108f263f9..cebd978536 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -108,11 +108,11 @@ async fn chat_mode_stream_cli() { ); } -/// Verify that passing `-c experimental_instructions_file=...` to the CLI +/// Verify that passing `-c model_instructions=...` to the CLI /// overrides the built-in base instructions by inspecting the request body /// received by a mock OpenAI Responses endpoint. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exec_cli_applies_experimental_instructions_file() { +async fn exec_cli_applies_model_instructions() { skip_if_no_network!(); // Start mock server which will capture the request and return a minimal @@ -127,7 +127,7 @@ async fn exec_cli_applies_experimental_instructions_file() { // Create a temporary instructions file with a unique marker we can assert // appears in the outbound request payload. let custom = TempDir::new().unwrap(); - let marker = "cli-experimental-instructions-marker"; + let marker = "cli-model-instructions-marker"; let custom_path = custom.path().join("instr.md"); std::fs::write(&custom_path, marker).unwrap(); let custom_path_str = custom_path.to_string_lossy().replace('\\', "/"); @@ -150,9 +150,7 @@ async fn exec_cli_applies_experimental_instructions_file() { .arg("-c") .arg("model_provider=\"mock\"") .arg("-c") - .arg(format!( - "experimental_instructions_file=\"{custom_path_str}\"" - )) + .arg(format!("model_instructions=\"{custom_path_str}\"")) .arg("-C") .arg(&repo_root) .arg("hello?\n"); diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index bab715ebd8..17592e6179 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -48,3 +48,46 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_pre_build_hook(|home| { + let config_path = home.join("config.toml"); + std::fs::write( + config_path, + "experimental_instructions_file = \"legacy.md\"\n", + ) + .expect("write config.toml"); + }); + + let TestCodex { codex, .. } = builder.build(&server).await?; + + let notice = wait_for_event_match(&codex, |event| match event { + EventMsg::DeprecationNotice(ev) + if ev.summary.contains("experimental_instructions_file") => + { + Some(ev.clone()) + } + _ => None, + }) + .await; + + let DeprecationNoticeEvent { summary, details } = notice; + assert_eq!( + summary, + "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions` instead." + .to_string(), + ); + assert_eq!( + details.as_deref(), + Some( + "Move the setting to `model_instructions` in config.toml (or under a profile) to load instructions from a file." + ), + ); + + Ok(()) +}
Release notes

Sourced from log's releases.

0.4.29

MSRV

This release increases log's MSRV from 1.61.0 to 1.68.0.

What's Changed

New Contributors

Full Changelog: https://github.com/rust-lang/log/compare/0.4.28...0.4.29