From 3e4aea3097fb800d783fa436bc955bb9e14cf9e4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 29 Jan 2026 17:07:42 -0800 Subject: [PATCH] feat: migrate TUI to use app-server v2 --- codex-rs/Cargo.lock | 1 + .../src/protocol/common.rs | 20 + .../app-server-protocol/src/protocol/v2.rs | 51 + .../app-server/src/codex_message_processor.rs | 100 ++ codex-rs/app-server/src/lib.rs | 247 +++ codex-rs/app-server/src/message_processor.rs | 45 +- codex-rs/core/src/context_manager/history.rs | 2 +- codex-rs/core/src/lib.rs | 4 +- codex-rs/core/src/message_history.rs | 10 +- codex-rs/core/src/truncate.rs | 27 +- codex-rs/tui/Cargo.toml | 2 + codex-rs/tui/src/app.rs | 1342 +++++++++++++---- codex-rs/tui/src/app_backtrack.rs | 5 +- codex-rs/tui/src/app_event.rs | 86 +- codex-rs/tui/src/app_event_sender.rs | 6 +- codex-rs/tui/src/app_server_client.rs | 613 ++++++++ .../tui/src/bottom_pane/approval_overlay.rs | 53 +- .../src/bottom_pane/chat_composer_history.rs | 31 +- codex-rs/tui/src/bottom_pane/mod.rs | 19 +- .../src/bottom_pane/request_user_input/mod.rs | 77 +- .../tui/src/bottom_pane/skills_toggle_view.rs | 11 +- codex-rs/tui/src/chatwidget.rs | 417 +---- codex-rs/tui/src/chatwidget/agent.rs | 122 -- codex-rs/tui/src/chatwidget/tests.rs | 577 ++++--- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/session_log.rs | 16 +- codex-rs/tui/src/status_indicator_widget.rs | 5 +- .../tui/tests/suite/no_panic_on_startup.rs | 10 +- 28 files changed, 2690 insertions(+), 1210 deletions(-) create mode 100644 codex-rs/tui/src/app_server_client.rs delete mode 100644 codex-rs/tui/src/chatwidget/agent.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 35d3129106..69a6cce857 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1890,6 +1890,7 @@ dependencies = [ "chrono", "clap", "codex-ansi-escape", + "codex-app-server", "codex-app-server-protocol", "codex-arg0", "codex-backend-client", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b9c58a5883..639b0c2457 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -75,6 +75,14 @@ macro_rules! client_request_definitions { )* } + impl ClientRequest { + pub fn request_id(&self) -> &RequestId { + match self { + $(Self::$variant { request_id, .. } => request_id,)* + } + } + } + pub fn export_client_responses( out_dir: &::std::path::Path, ) -> ::std::result::Result<(), ::ts_rs::ExportError> { @@ -140,6 +148,14 @@ client_request_definitions! { params: v2::ThreadRollbackParams, response: v2::ThreadRollbackResponse, }, + ThreadCompact => "thread/compact" { + params: v2::ThreadCompactParams, + response: v2::ThreadCompactResponse, + }, + ThreadShutdown => "thread/shutdown" { + params: v2::ThreadShutdownParams, + response: v2::ThreadShutdownResponse, + }, ThreadList => "thread/list" { params: v2::ThreadListParams, response: v2::ThreadListResponse, @@ -201,6 +217,10 @@ client_request_definitions! { params: v2::ListMcpServerStatusParams, response: v2::ListMcpServerStatusResponse, }, + McpElicitationResolve => "mcp/elicitation/resolve" { + params: v2::McpElicitationResolveParams, + response: v2::McpElicitationResolveResponse, + }, LoginAccount => "account/login/start" { params: v2::LoginAccountParams, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ac3df37bd0..3f9e72c64f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; +use codex_protocol::approvals::ElicitationAction as CoreElicitationAction; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; @@ -41,6 +42,7 @@ use codex_protocol::user_input::TextElement as CoreTextElement; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::AbsolutePathBuf; use mcp_types::ContentBlock as McpContentBlock; +use mcp_types::RequestId as McpRequestId; use mcp_types::Resource as McpResource; use mcp_types::ResourceTemplate as McpResourceTemplate; use mcp_types::Tool as McpTool; @@ -233,6 +235,14 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum ElicitationAction from CoreElicitationAction { + Accept, + Decline, + Cancel + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -1053,6 +1063,21 @@ pub struct ListMcpServerStatusResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpElicitationResolveParams { + pub thread_id: String, + pub server_name: String, + pub request_id: McpRequestId, + pub decision: ElicitationAction, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpElicitationResolveResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1333,6 +1358,32 @@ pub struct ThreadRollbackResponse { pub thread: Thread, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactResponse { + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShutdownParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShutdownResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 660b4b87d0..c036d51839 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -64,6 +64,8 @@ use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::LogoutChatGptResponse; +use codex_app_server_protocol::McpElicitationResolveParams; +use codex_app_server_protocol::McpElicitationResolveResponse; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; use codex_app_server_protocol::McpServerOauthLoginParams; use codex_app_server_protocol::McpServerOauthLoginResponse; @@ -98,6 +100,8 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadCompactParams; +use codex_app_server_protocol::ThreadCompactResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadItem; @@ -110,6 +114,8 @@ use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadShutdownParams; +use codex_app_server_protocol::ThreadShutdownResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; @@ -422,6 +428,12 @@ impl CodexMessageProcessor { ClientRequest::ThreadRollback { request_id, params } => { self.thread_rollback(request_id, params).await; } + ClientRequest::ThreadCompact { request_id, params } => { + self.thread_compact(request_id, params).await; + } + ClientRequest::ThreadShutdown { request_id, params } => { + self.thread_shutdown(request_id, params).await; + } ClientRequest::ThreadList { request_id, params } => { self.thread_list(request_id, params).await; } @@ -488,6 +500,9 @@ impl CodexMessageProcessor { ClientRequest::McpServerStatusList { request_id, params } => { self.list_mcp_server_status(request_id, params).await; } + ClientRequest::McpElicitationResolve { request_id, params } => { + self.mcp_elicitation_resolve(request_id, params).await; + } ClientRequest::LoginAccount { request_id, params } => { self.login_v2(request_id, params).await; } @@ -1976,6 +1991,63 @@ impl CodexMessageProcessor { } } + async fn thread_compact(&mut self, request_id: RequestId, params: ThreadCompactParams) { + let ThreadCompactParams { thread_id } = params; + let (thread_uuid, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match thread.submit(Op::Compact).await { + Ok(turn_id) => { + let turn = Turn { + id: turn_id.clone(), + items: Vec::new(), + error: None, + status: TurnStatus::InProgress, + }; + let response = ThreadCompactResponse { turn: turn.clone() }; + self.outgoing.send_response(request_id, response).await; + + let notif = TurnStartedNotification { + thread_id: thread_uuid.to_string(), + turn, + }; + self.outgoing + .send_server_notification(ServerNotification::TurnStarted(notif)) + .await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start compact turn: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn thread_shutdown(&mut self, request_id: RequestId, params: ThreadShutdownParams) { + let ThreadShutdownParams { thread_id } = params; + let (thread_uuid, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let _ = thread.submit(Op::Shutdown).await; + self.thread_manager.remove_thread(&thread_uuid).await; + + let response = ThreadShutdownResponse {}; + self.outgoing.send_response(request_id, response).await; + } + async fn thread_list(&self, request_id: RequestId, params: ThreadListParams) { let ThreadListParams { cursor, @@ -3066,6 +3138,34 @@ impl CodexMessageProcessor { }); } + async fn mcp_elicitation_resolve( + &self, + request_id: RequestId, + params: McpElicitationResolveParams, + ) { + let McpElicitationResolveParams { + thread_id, + server_name, + request_id: elicitation_id, + decision, + } = params; + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let op = Op::ResolveElicitation { + server_name, + request_id: elicitation_id, + decision: decision.to_core(), + }; + let _ = thread.submit(op).await; + let response = McpElicitationResolveResponse {}; + self.outgoing.send_response(request_id, response).await; + } + async fn list_mcp_server_status_task( outgoing: Arc, request_id: RequestId, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 5b3d39704c..75c521bdbd 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -8,13 +8,21 @@ use codex_core::config_loader::LoaderOverrides; use std::io::ErrorKind; use std::io::Result as IoResult; use std::path::PathBuf; +use std::sync::Arc; use crate::message_processor::MessageProcessor; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::TextPosition as AppTextPosition; use codex_app_server_protocol::TextRange as AppTextRange; use codex_core::ExecPolicyError; @@ -298,6 +306,7 @@ pub async fn run_main( loader_overrides, feedback.clone(), config_warnings, + codex_protocol::protocol::SessionSource::VSCode, ); let mut thread_created_rx = processor.thread_created_receiver(); async move { @@ -369,3 +378,241 @@ pub async fn run_main( Ok(()) } + +#[derive(Debug, Clone)] +pub enum AppServerClientMessage { + Request(ClientRequest), + Response { + id: RequestId, + result: Result, + }, + Error { + id: RequestId, + error: JSONRPCErrorError, + }, + Notification(ClientNotification), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AppServerEventNotification { + pub method: String, + pub params: Option, +} + +#[derive(Debug, Clone)] +pub enum AppServerMessage { + Request(ServerRequest), + Notification(ServerNotification), + EventNotification(AppServerEventNotification), + Response { + id: RequestId, + result: Result, + }, + Error { + id: RequestId, + error: JSONRPCErrorError, + }, +} + +pub struct InMemoryAppServer { + pub incoming: mpsc::Sender, + pub outgoing: mpsc::Receiver, +} + +pub struct InProcessAppServer { + pub incoming: mpsc::Sender, + pub outgoing: mpsc::Receiver, +} + +pub fn spawn_in_memory( + codex_linux_sandbox_exe: Option, + config: Arc, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + feedback: CodexFeedback, + config_warnings: Vec, + session_source: codex_protocol::protocol::SessionSource, +) -> InMemoryAppServer { + let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (client_outgoing_tx, client_outgoing_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + + tokio::spawn({ + let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); + let mut processor = MessageProcessor::new( + outgoing_message_sender, + codex_linux_sandbox_exe, + Arc::clone(&config), + cli_overrides, + loader_overrides, + feedback, + config_warnings, + session_source, + ); + let mut thread_created_rx = processor.thread_created_receiver(); + async move { + let mut listen_for_threads = true; + loop { + tokio::select! { + msg = incoming_rx.recv() => { + let Some(msg) = msg else { + break; + }; + match msg { + JSONRPCMessage::Request(r) => processor.process_request(r).await, + JSONRPCMessage::Response(r) => processor.process_response(r).await, + JSONRPCMessage::Notification(n) => processor.process_notification(n).await, + JSONRPCMessage::Error(e) => processor.process_error(e).await, + } + } + created = thread_created_rx.recv(), if listen_for_threads => { + match created { + Ok(thread_id) => { + processor.try_attach_thread_listener(thread_id).await; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + warn!("thread_created receiver lagged; skipping resync"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + listen_for_threads = false; + } + } + } + } + } + + info!("in-memory processor task exited (channel closed)"); + } + }); + + tokio::spawn(async move { + while let Some(outgoing_message) = outgoing_rx.recv().await { + let Ok(value) = serde_json::to_value(outgoing_message) else { + error!("Failed to convert OutgoingMessage to JSON value"); + continue; + }; + let jsonrpc = match serde_json::from_value::(value) { + Ok(message) => message, + Err(err) => { + error!("Failed to deserialize OutgoingMessage as JSONRPCMessage: {err}"); + continue; + } + }; + if client_outgoing_tx.send(jsonrpc).await.is_err() { + break; + } + } + info!("in-memory outgoing task exited (channel closed)"); + }); + + InMemoryAppServer { + incoming: incoming_tx, + outgoing: client_outgoing_rx, + } +} + +pub fn spawn_in_memory_typed( + codex_linux_sandbox_exe: Option, + config: Arc, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + feedback: CodexFeedback, + config_warnings: Vec, + session_source: codex_protocol::protocol::SessionSource, +) -> InProcessAppServer { + let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (client_outgoing_tx, client_outgoing_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + + tokio::spawn({ + let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); + let mut processor = MessageProcessor::new( + outgoing_message_sender, + codex_linux_sandbox_exe, + Arc::clone(&config), + cli_overrides, + loader_overrides, + feedback, + config_warnings, + session_source, + ); + let mut thread_created_rx = processor.thread_created_receiver(); + async move { + let mut listen_for_threads = true; + loop { + tokio::select! { + msg = incoming_rx.recv() => { + let Some(msg) = msg else { + break; + }; + match msg { + AppServerClientMessage::Request(request) => { + processor.process_client_request(request).await; + } + AppServerClientMessage::Response { id, result } => { + processor.process_client_response(id, result).await; + } + AppServerClientMessage::Error { id, error } => { + processor.process_client_error(id, error); + } + AppServerClientMessage::Notification(notification) => { + processor.process_client_notification(notification).await; + } + } + } + created = thread_created_rx.recv(), if listen_for_threads => { + match created { + Ok(thread_id) => { + processor.try_attach_thread_listener(thread_id).await; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + warn!("thread_created receiver lagged; skipping resync"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + listen_for_threads = false; + } + } + } + } + } + + info!("in-process processor task exited (channel closed)"); + } + }); + + tokio::spawn(async move { + while let Some(outgoing_message) = outgoing_rx.recv().await { + let mapped = match outgoing_message { + OutgoingMessage::Request(request) => AppServerMessage::Request(request), + OutgoingMessage::AppServerNotification(notification) => { + AppServerMessage::Notification(notification) + } + OutgoingMessage::Notification(notification) => { + AppServerMessage::EventNotification(AppServerEventNotification { + method: notification.method, + params: notification.params, + }) + } + OutgoingMessage::Response(response) => AppServerMessage::Response { + id: response.id, + result: response.result, + }, + OutgoingMessage::Error(error) => AppServerMessage::Error { + id: error.id, + error: error.error, + }, + }; + if client_outgoing_tx.send(mapped).await.is_err() { + break; + } + } + info!("in-process outgoing task exited (channel closed)"); + }); + + InProcessAppServer { + incoming: incoming_tx, + outgoing: client_outgoing_rx, + } +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index ced93c3bdd..5425fb5e74 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -10,6 +10,7 @@ use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; @@ -22,6 +23,7 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result as JsonResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_core::AuthManager; @@ -109,6 +111,7 @@ pub(crate) struct MessageProcessor { impl MessageProcessor { /// Create a new `MessageProcessor`, retaining a handle to the outgoing /// `Sender` so handlers can enqueue messages to be written to stdout. + #[allow(clippy::too_many_arguments)] pub(crate) fn new( outgoing: OutgoingMessageSender, codex_linux_sandbox_exe: Option, @@ -117,6 +120,7 @@ impl MessageProcessor { loader_overrides: LoaderOverrides, feedback: CodexFeedback, config_warnings: Vec, + session_source: SessionSource, ) -> Self { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared( @@ -131,7 +135,7 @@ impl MessageProcessor { let thread_manager = Arc::new(ThreadManager::new( config.codex_home.clone(), auth_manager.clone(), - SessionSource::VSCode, + session_source, )); let codex_message_processor = CodexMessageProcessor::new( auth_manager, @@ -181,24 +185,33 @@ impl MessageProcessor { } }; - match codex_request { + self.process_client_request(codex_request).await; + } + + pub(crate) async fn process_client_request(&mut self, codex_request: ClientRequest) { + let request_id = codex_request.request_id().clone(); + match &codex_request { // Handle Initialize internally so CodexMessageProcessor does not have to concern // itself with the `initialized` bool. - ClientRequest::Initialize { request_id, params } => { + ClientRequest::Initialize { + request_id: _, + params, + } => { if self.initialized { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: "Already initialized".to_string(), data: None, }; - self.outgoing.send_error(request_id, error).await; + self.outgoing.send_error(request_id.clone(), error).await; return; } else { + let client_info = params.client_info.clone(); let ClientInfo { name, title: _title, version, - } = params.client_info; + } = client_info; if let Err(error) = set_default_originator(name.clone()) { match error { SetOriginatorError::InvalidHeaderValue => { @@ -209,7 +222,7 @@ impl MessageProcessor { ), data: None, }; - self.outgoing.send_error(request_id, error).await; + self.outgoing.send_error(request_id.clone(), error).await; return; } SetOriginatorError::AlreadyInitialized => { @@ -227,7 +240,9 @@ impl MessageProcessor { let user_agent = get_codex_user_agent(); let response = InitializeResponse { user_agent }; - self.outgoing.send_response(request_id, response).await; + self.outgoing + .send_response(request_id.clone(), response) + .await; self.initialized = true; if !self.config_warnings.is_empty() { @@ -250,7 +265,7 @@ impl MessageProcessor { message: "Not initialized".to_string(), data: None, }; - self.outgoing.send_error(request_id, error).await; + self.outgoing.send_error(request_id.clone(), error).await; return; } } @@ -284,6 +299,10 @@ impl MessageProcessor { tracing::info!("<- notification: {:?}", notification); } + pub(crate) async fn process_client_notification(&self, notification: ClientNotification) { + tracing::info!("<- notification: {:?}", notification); + } + pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { self.codex_message_processor.thread_created_receiver() } @@ -301,7 +320,11 @@ impl MessageProcessor { pub(crate) async fn process_response(&mut self, response: JSONRPCResponse) { tracing::info!("<- response: {:?}", response); let JSONRPCResponse { id, result, .. } = response; - self.outgoing.notify_client_response(id, result).await + self.process_client_response(id, result).await; + } + + pub(crate) async fn process_client_response(&mut self, id: RequestId, result: JsonResult) { + self.outgoing.notify_client_response(id, result).await; } /// Handle an error object received from the peer. @@ -310,6 +333,10 @@ impl MessageProcessor { self.outgoing.notify_client_error(err.id, err.error).await; } + pub(crate) fn process_client_error(&mut self, id: RequestId, error: JSONRPCErrorError) { + tracing::error!("<- error: {:?}", JSONRPCError { id, error }); + } + async fn handle_config_read(&self, request_id: RequestId, params: ConfigReadParams) { match self.config_api.read(params).await { Ok(response) => self.outgoing.send_response(request_id, response).await, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 080b701d42..1638a243cc 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -266,7 +266,7 @@ impl ContextManager { } fn process_item(&self, item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem { - let policy_with_serialization_budget = policy.mul(1.2); + let policy_with_serialization_budget = policy * 1.2; match item { ResponseItem::FunctionCallOutput { call_id, output } => { let truncated = diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1b1fb65ecd..b0b88235fb 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -44,7 +44,7 @@ pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; mod mcp_tool_call; mod mentions; -mod message_history; +pub mod message_history; mod model_provider_info; pub mod parse_command; pub mod path_utils; @@ -96,6 +96,7 @@ pub mod spawn; pub mod state_db; pub mod terminal; mod tools; +pub use tools::format_exec_output_str; pub mod turn_diff_tracker; pub use rollout::ARCHIVED_SESSIONS_SUBDIR; pub use rollout::INTERACTIVE_SESSION_SOURCES; @@ -115,6 +116,7 @@ pub use rollout::list::read_head_for_summary; pub use rollout::list::read_session_meta_line; pub use rollout::rollout_date_parts; pub use transport_manager::TransportManager; +pub use truncate::TruncationPolicy; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index cb3b10098c..802acaf7e8 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -69,11 +69,7 @@ fn history_filepath(config: &Config) -> PathBuf { /// Append a `text` entry associated with `conversation_id` to the history file. Uses /// advisory file locking to ensure that concurrent writes do not interleave, /// which entails a small amount of blocking I/O internally. -pub(crate) async fn append_entry( - text: &str, - conversation_id: &ThreadId, - config: &Config, -) -> Result<()> { +pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Config) -> Result<()> { match config.history.persistence { HistoryPersistence::SaveAll => { // Save everything: proceed. @@ -245,7 +241,7 @@ fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 { /// Asynchronously fetch the history file's *identifier* (inode on Unix) and /// the current number of entries by counting newline characters. -pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) { +pub async fn history_metadata(config: &Config) -> (u64, usize) { let path = history_filepath(config); history_metadata_for_file(&path).await } @@ -258,7 +254,7 @@ pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) { /// /// Note this function is not async because it uses a sync advisory file /// locking API. -pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option { +pub fn lookup(log_id: u64, offset: usize, config: &Config) -> Option { let path = history_filepath(config); lookup_history_entry(&path, log_id, offset) } diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs index 8150b994d0..441a157375 100644 --- a/codex-rs/core/src/truncate.rs +++ b/codex-rs/core/src/truncate.rs @@ -34,18 +34,6 @@ impl From for TruncationPolicy { } impl TruncationPolicy { - /// Scale the underlying budget by `multiplier`, rounding up to avoid under-budgeting. - pub fn mul(self, multiplier: f64) -> Self { - match self { - TruncationPolicy::Bytes(bytes) => { - TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize) - } - TruncationPolicy::Tokens(tokens) => { - TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize) - } - } - } - /// Returns a token budget derived from this policy. /// /// - For `Tokens`, this is the explicit token limit. @@ -73,6 +61,21 @@ impl TruncationPolicy { } } +impl std::ops::Mul for TruncationPolicy { + type Output = Self; + + fn mul(self, multiplier: f64) -> Self::Output { + match self { + TruncationPolicy::Bytes(bytes) => { + TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize) + } + TruncationPolicy::Tokens(tokens) => { + TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize) + } + } + } +} + pub(crate) fn formatted_truncate_text(content: &str, policy: TruncationPolicy) -> String { if content.len() <= policy.byte_budget() { return content.to_string(); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 6664e7f9c5..9c7265fc34 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -27,6 +27,7 @@ base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-ansi-escape = { workspace = true } +codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-backend-client = { workspace = true } @@ -94,6 +95,7 @@ tree-sitter-highlight = { workspace = true } unicode-segmentation = { workspace = true } unicode-width = { workspace = true } url = { workspace = true } +uuid = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ae1dc4b322..aaa76eca46 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,11 +1,13 @@ use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; +use crate::app_event::AppServerAction; use crate::app_event::ExitMode; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; +use crate::app_server_client::AppServerClient; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; @@ -33,15 +35,19 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ThreadItem as V2ThreadItem; +use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::AuthManager; use codex_core::CodexAuth; -use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config_loader::ConfigLayerStackOrdering; +use codex_core::config_loader::LoaderOverrides; +#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; @@ -50,9 +56,13 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExecCommandOutputDeltaEvent; +use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::ExecOutputStream; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; -use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; @@ -64,7 +74,12 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::ContextCompactionItem; +use codex_protocol::items::ReasoningItem; use codex_protocol::items::TurnItem; +use codex_protocol::items::UserMessageItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -93,7 +108,6 @@ use std::time::Duration; use std::time::Instant; use tokio::select; use tokio::sync::Mutex; -use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::error::TrySendError; @@ -102,6 +116,7 @@ use toml::Value as TomlValue; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; +const SHUTDOWN_FALLBACK_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -229,6 +244,98 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } +fn app_server_error(err: JSONRPCErrorError) -> color_eyre::Report { + let details = err + .data + .as_ref() + .map(|data| format!(" (data: {data})")) + .unwrap_or_default(); + color_eyre::eyre::eyre!("app server error {}: {}{details}", err.code, err.message) +} + +fn map_skills_list_entry( + entry: codex_app_server_protocol::SkillsListEntry, +) -> codex_core::protocol::SkillsListEntry { + codex_core::protocol::SkillsListEntry { + cwd: entry.cwd, + skills: entry.skills.into_iter().map(map_skill_metadata).collect(), + errors: entry.errors.into_iter().map(map_skill_error).collect(), + } +} + +fn map_skill_metadata( + value: codex_app_server_protocol::SkillMetadata, +) -> codex_core::protocol::SkillMetadata { + codex_core::protocol::SkillMetadata { + name: value.name, + description: value.description, + short_description: value.short_description, + interface: value.interface.map(map_skill_interface), + dependencies: value.dependencies.map(map_skill_dependencies), + path: value.path, + scope: map_skill_scope(value.scope), + enabled: value.enabled, + } +} + +fn map_skill_interface( + value: codex_app_server_protocol::SkillInterface, +) -> codex_core::protocol::SkillInterface { + codex_core::protocol::SkillInterface { + display_name: value.display_name, + short_description: value.short_description, + icon_small: value.icon_small, + icon_large: value.icon_large, + brand_color: value.brand_color, + default_prompt: value.default_prompt, + } +} + +fn map_skill_dependencies( + value: codex_app_server_protocol::SkillDependencies, +) -> codex_core::protocol::SkillDependencies { + codex_core::protocol::SkillDependencies { + tools: value + .tools + .into_iter() + .map(map_skill_tool_dependency) + .collect(), + } +} + +fn map_skill_tool_dependency( + value: codex_app_server_protocol::SkillToolDependency, +) -> codex_core::protocol::SkillToolDependency { + codex_core::protocol::SkillToolDependency { + r#type: value.r#type, + value: value.value, + description: value.description, + transport: value.transport, + command: value.command, + url: value.url, + } +} + +fn map_skill_error( + value: codex_app_server_protocol::SkillErrorInfo, +) -> codex_core::protocol::SkillErrorInfo { + codex_core::protocol::SkillErrorInfo { + path: value.path, + message: value.message, + } +} + +fn map_skill_scope( + scope: codex_app_server_protocol::SkillScope, +) -> codex_core::protocol::SkillScope { + match scope { + codex_app_server_protocol::SkillScope::User => codex_core::protocol::SkillScope::User, + codex_app_server_protocol::SkillScope::Repo => codex_core::protocol::SkillScope::Repo, + codex_app_server_protocol::SkillScope::System => codex_core::protocol::SkillScope::System, + codex_app_server_protocol::SkillScope::Admin => codex_core::protocol::SkillScope::Admin, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -241,6 +348,12 @@ struct ThreadEventSnapshot { events: Vec, } +#[derive(Debug, Clone)] +struct ThreadBootstrap { + thread_id: ThreadId, + session_event: Event, +} + #[derive(Debug)] struct ThreadEventStore { session_configured: Option, @@ -261,6 +374,7 @@ impl ThreadEventStore { } } + #[allow(dead_code)] fn new_with_session_configured(capacity: usize, event: Event) -> Self { let mut store = Self::new(capacity); store.session_configured = Some(event); @@ -334,6 +448,7 @@ impl ThreadEventChannel { } } + #[allow(dead_code)] fn new_with_session_configured(capacity: usize, event: Event) -> Self { let (sender, receiver) = mpsc::channel(capacity); Self { @@ -506,7 +621,8 @@ async fn handle_model_migration_prompt_if_needed( } pub(crate) struct App { - pub(crate) server: Arc, + pub(crate) app_server: AppServerClient, + pub(crate) models_manager: Arc, pub(crate) otel_manager: OtelManager, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, @@ -583,6 +699,14 @@ fn normalize_harness_overrides_for_cwd( } impl App { + fn arm_shutdown_exit_fallback(&self) { + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(SHUTDOWN_FALLBACK_TIMEOUT).await; + tx.send(AppEvent::Exit(ExitMode::Immediate)); + }); + } + pub fn chatwidget_init_for_forked_or_resumed_thread( &self, tui: &mut tui::Tui, @@ -596,7 +720,7 @@ impl App { initial_user_message: None, enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), + models_manager: self.models_manager.clone(), feedback: self.feedback.clone(), is_first_run: false, model: Some(self.chat_widget.current_model().to_string()), @@ -641,11 +765,229 @@ impl App { // 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; + let params = codex_app_server_protocol::ThreadShutdownParams { + thread_id: thread_id.to_string(), + }; + if let Ok(pending) = self + .app_server + .request( + |id| codex_app_server_protocol::ClientRequest::ThreadShutdown { + request_id: id, + params, + }, + ) + .await + { + let _ = pending.discard().await; + } } } + fn sandbox_mode_override( + policy: &codex_core::protocol::SandboxPolicy, + ) -> Option { + match policy { + codex_core::protocol::SandboxPolicy::DangerFullAccess => { + Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) + } + codex_core::protocol::SandboxPolicy::ReadOnly => { + Some(codex_app_server_protocol::SandboxMode::ReadOnly) + } + codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } => { + Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite) + } + codex_core::protocol::SandboxPolicy::ExternalSandbox { .. } => None, + } + } + + fn build_thread_start_params( + &self, + config: &Config, + ) -> codex_app_server_protocol::ThreadStartParams { + codex_app_server_protocol::ThreadStartParams { + model: config.model.clone(), + model_provider: Some(config.model_provider_id.clone()), + cwd: Some(config.cwd.display().to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::from( + *config.approval_policy.get(), + )), + sandbox: Self::sandbox_mode_override(config.sandbox_policy.get()), + config: None, + base_instructions: None, + developer_instructions: None, + dynamic_tools: None, + experimental_raw_events: false, + personality: config.model_personality, + ephemeral: None, + } + } + + fn build_thread_resume_params( + &self, + config: &Config, + path: &Path, + ) -> codex_app_server_protocol::ThreadResumeParams { + codex_app_server_protocol::ThreadResumeParams { + thread_id: String::new(), + history: None, + path: Some(path.to_path_buf()), + model: config.model.clone(), + model_provider: Some(config.model_provider_id.clone()), + cwd: Some(config.cwd.display().to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::from( + *config.approval_policy.get(), + )), + sandbox: Self::sandbox_mode_override(config.sandbox_policy.get()), + config: None, + base_instructions: None, + developer_instructions: None, + personality: config.model_personality, + } + } + + fn build_thread_fork_params( + &self, + config: &Config, + path: &Path, + ) -> codex_app_server_protocol::ThreadForkParams { + codex_app_server_protocol::ThreadForkParams { + thread_id: String::new(), + path: Some(path.to_path_buf()), + model: config.model.clone(), + model_provider: Some(config.model_provider_id.clone()), + cwd: Some(config.cwd.display().to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::from( + *config.approval_policy.get(), + )), + sandbox: Self::sandbox_mode_override(config.sandbox_policy.get()), + config: None, + base_instructions: None, + developer_instructions: None, + } + } + + async fn set_primary_thread(&mut self, thread_id: ThreadId) { + self.primary_thread_id = Some(thread_id); + self.ensure_thread_channel(thread_id); + self.activate_thread_channel(thread_id).await; + } + + async fn start_thread_for_config(&mut self, config: &Config) -> Result { + let params = self.build_thread_start_params(config); + let pending = self + .app_server + .request( + |request_id| codex_app_server_protocol::ClientRequest::ThreadStart { + request_id, + params, + }, + ) + .await + .map_err(app_server_error)?; + let response: codex_app_server_protocol::ThreadStartResponse = + pending.into_typed().await.map_err(app_server_error)?; + let thread_id = ThreadId::from_string(&response.thread.id) + .map_err(|err| color_eyre::eyre::eyre!("invalid thread id: {err}"))?; + self.set_primary_thread(thread_id).await; + if self.chat_widget.thread_id().is_none() { + let event = session_configured_from_thread_response( + thread_id, + response.model, + response.model_provider, + response.approval_policy, + response.sandbox, + response.cwd, + response.reasoning_effort, + response.thread.path.clone(), + None, + ); + self.enqueue_thread_event(thread_id, event).await?; + } + Ok(thread_id) + } + + async fn resume_thread_from_path( + &mut self, + config: &Config, + path: &Path, + ) -> Result { + let params = self.build_thread_resume_params(config, path); + let pending = self + .app_server + .request( + |request_id| codex_app_server_protocol::ClientRequest::ThreadResume { + request_id, + params, + }, + ) + .await + .map_err(app_server_error)?; + let response: codex_app_server_protocol::ThreadResumeResponse = + pending.into_typed().await.map_err(app_server_error)?; + let thread_id = ThreadId::from_string(&response.thread.id) + .map_err(|err| color_eyre::eyre::eyre!("invalid thread id: {err}"))?; + let initial_messages = thread_turns_to_initial_messages( + &response.thread.turns, + self.config.show_raw_agent_reasoning, + ); + let session_event = session_configured_from_thread_response( + thread_id, + response.model, + response.model_provider, + response.approval_policy, + response.sandbox, + response.cwd, + response.reasoning_effort, + response.thread.path, + initial_messages, + ); + Ok(ThreadBootstrap { + thread_id, + session_event, + }) + } + + async fn fork_thread_from_path( + &mut self, + config: &Config, + path: &Path, + ) -> Result { + let params = self.build_thread_fork_params(config, path); + let pending = self + .app_server + .request( + |request_id| codex_app_server_protocol::ClientRequest::ThreadFork { + request_id, + params, + }, + ) + .await + .map_err(app_server_error)?; + let response: codex_app_server_protocol::ThreadForkResponse = + pending.into_typed().await.map_err(app_server_error)?; + let thread_id = ThreadId::from_string(&response.thread.id) + .map_err(|err| color_eyre::eyre::eyre!("invalid thread id: {err}"))?; + let initial_messages = thread_turns_to_initial_messages( + &response.thread.turns, + self.config.show_raw_agent_reasoning, + ); + let session_event = session_configured_from_thread_response( + thread_id, + response.model, + response.model_provider, + response.approval_policy, + response.sandbox, + response.cwd, + response.reasoning_effort, + response.thread.path, + initial_messages, + ); + Ok(ThreadBootstrap { + thread_id, + session_event, + }) + } + fn ensure_thread_channel(&mut self, thread_id: ThreadId) -> &mut ThreadEventChannel { self.thread_event_channels .entry(thread_id) @@ -762,6 +1104,32 @@ impl App { Ok(()) } + async fn enqueue_thread_event_with_primary( + &mut self, + thread_id: ThreadId, + event: Event, + ) -> Result<()> { + if self.primary_thread_id.is_some() { + return self.enqueue_thread_event(thread_id, event).await; + } + + if let EventMsg::SessionConfigured(session) = &event.msg { + self.primary_thread_id = Some(thread_id); + self.primary_session_configured = Some(session.clone()); + self.ensure_thread_channel(thread_id); + self.activate_thread_channel(thread_id).await; + + let pending = std::mem::take(&mut self.pending_primary_events); + for pending_event in pending { + self.enqueue_thread_event(thread_id, pending_event).await?; + } + self.enqueue_thread_event(thread_id, event).await?; + } else { + self.pending_primary_events.push_back(event); + } + Ok(()) + } + fn open_agent_picker(&mut self) { if self.thread_event_channels.is_empty() { self.chat_widget @@ -809,16 +1177,6 @@ impl App { return Ok(()); } - let thread = match self.server.get_thread(thread_id).await { - Ok(thread) => thread, - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to attach to agent thread {thread_id}: {err}" - )); - return Ok(()); - } - }; - let previous_thread_id = self.active_thread_id; self.store_active_thread_receiver().await; self.active_thread_id = None; @@ -835,8 +1193,7 @@ impl App { self.active_thread_rx = Some(receiver); let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - let codex_op_tx = crate::chatwidget::spawn_op_forwarder(thread); - self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); + self.chat_widget = ChatWidget::new(init); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot); @@ -927,17 +1284,14 @@ impl App { let harness_overrides = normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; - let thread_manager = Arc::new(ThreadManager::new( + let models_manager = Arc::new(codex_core::models_manager::manager::ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - SessionSource::Cli, )); - let mut model = thread_manager - .get_models_manager() + let mut model = models_manager .get_default_model(&config.model, &config, RefreshStrategy::Offline) .await; - let available_models = thread_manager - .get_models_manager() + let available_models = models_manager .list_models(&config, RefreshStrategy::Offline) .await; let exit_info = handle_model_migration_prompt_if_needed( @@ -970,85 +1324,64 @@ impl App { ); let enhanced_keys_supported = tui.enhanced_keys_supported(); - let mut chat_widget = match session_selection { - SessionSelection::StartFresh | SessionSelection::Exit => { - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.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(), - feedback: feedback.clone(), - is_first_run, - model: Some(model.clone()), - otel_manager: otel_manager.clone(), - }; - ChatWidget::new(init, thread_manager.clone()) - } - SessionSelection::Resume(path) => { - let resumed = thread_manager - .resume_thread_from_rollout(config.clone(), path.clone(), auth_manager.clone()) - .await - .wrap_err_with(|| { - let path_display = path.display(); - format!("Failed to resume session from {path_display}") - })?; - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.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(), - feedback: feedback.clone(), - is_first_run, - model: config.model.clone(), - otel_manager: otel_manager.clone(), - }; - ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) - } - SessionSelection::Fork(path) => { - let forked = thread_manager - .fork_thread(usize::MAX, config.clone(), path.clone()) - .await - .wrap_err_with(|| { - let path_display = path.display(); - format!("Failed to fork session from {path_display}") - })?; - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.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(), - feedback: feedback.clone(), - is_first_run, - model: config.model.clone(), - otel_manager: otel_manager.clone(), - }; - ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) - } + let app_server = AppServerClient::spawn( + app_event_tx.clone(), + Arc::new(config.clone()), + cli_kv_overrides.clone(), + LoaderOverrides::default(), + feedback.clone(), + Vec::new(), + SessionSource::Cli, + ); + let init_params = codex_app_server_protocol::InitializeParams { + client_info: codex_app_server_protocol::ClientInfo { + name: "codex_tui".to_string(), + title: Some("Codex TUI".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + }, }; + match app_server + .request( + |request_id| codex_app_server_protocol::ClientRequest::Initialize { + request_id, + params: init_params, + }, + ) + .await + { + Ok(pending) => { + pending.discard().await.map_err(app_server_error)?; + app_server + .send_notification(codex_app_server_protocol::ClientNotification::Initialized) + .await + .map_err(app_server_error)?; + } + Err(err) => { + return Ok(AppExitInfo::fatal(format!( + "Failed to initialize app server: {err:?}" + ))); + } + } + + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.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: models_manager.clone(), + feedback: feedback.clone(), + is_first_run, + model: Some(model.clone()), + otel_manager: otel_manager.clone(), + }; + let mut chat_widget = ChatWidget::new(init); chat_widget.maybe_prompt_windows_sandbox_enable(); @@ -1057,7 +1390,8 @@ impl App { let upgrade_version = crate::updates::get_upgrade_version(&config); let mut app = Self { - server: thread_manager.clone(), + app_server, + models_manager, otel_manager: otel_manager.clone(), app_event_tx, chat_widget, @@ -1089,6 +1423,39 @@ impl App { pending_primary_events: VecDeque::new(), }; + match session_selection { + SessionSelection::Resume(path) => { + let config = app.config.clone(); + let bootstrap = app + .resume_thread_from_path(&config, &path) + .await + .wrap_err_with(|| { + let path_display = path.display(); + format!("Failed to resume session from {path_display}") + })?; + app.set_primary_thread(bootstrap.thread_id).await; + app.enqueue_thread_event(bootstrap.thread_id, bootstrap.session_event) + .await?; + } + SessionSelection::Fork(path) => { + let config = app.config.clone(); + let bootstrap = app + .fork_thread_from_path(&config, &path) + .await + .wrap_err_with(|| { + let path_display = path.display(); + format!("Failed to fork session from {path_display}") + })?; + app.set_primary_thread(bootstrap.thread_id).await; + app.enqueue_thread_event(bootstrap.thread_id, bootstrap.session_event) + .await?; + } + SessionSelection::StartFresh | SessionSelection::Exit => { + let config = app.config.clone(); + app.start_thread_for_config(&config).await?; + } + } + // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] { @@ -1140,9 +1507,6 @@ 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() => { @@ -1165,21 +1529,6 @@ 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 => {} @@ -1254,9 +1603,7 @@ 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"); - } + self.reset_thread_event_state(); let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), frame_requester: tui.frame_requester(), @@ -1265,14 +1612,18 @@ impl App { initial_user_message: None, enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), + models_manager: self.models_manager.clone(), feedback: self.feedback.clone(), is_first_run: false, model: Some(model), otel_manager: self.otel_manager.clone(), }; - self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.reset_thread_event_state(); + self.chat_widget = ChatWidget::new(init); + let config = self.config.clone(); + if let Err(err) = self.start_thread_for_config(&config).await { + self.chat_widget + .add_error_message(format!("Failed to start new session: {err}")); + } if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -1325,16 +1676,8 @@ impl App { self.chat_widget.token_usage(), self.chat_widget.thread_id(), ); - match self - .server - .resume_thread_from_rollout( - resume_config.clone(), - path.clone(), - self.auth_manager.clone(), - ) - .await - { - Ok(resumed) => { + match self.resume_thread_from_path(&resume_config, &path).await { + Ok(bootstrap) => { self.shutdown_current_thread().await; self.config = resume_config; tui.set_notification_method(self.config.tui_notification_method); @@ -1346,12 +1689,14 @@ impl App { tui, self.config.clone(), ); - self.chat_widget = ChatWidget::new_from_existing( - init, - resumed.thread, - resumed.session_configured, - ); + self.chat_widget = ChatWidget::new(init); self.reset_thread_event_state(); + self.set_primary_thread(bootstrap.thread_id).await; + self.enqueue_thread_event( + bootstrap.thread_id, + bootstrap.session_event, + ) + .await?; if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -1385,23 +1730,19 @@ impl App { 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) => { + let config = self.config.clone(); + match self.fork_thread_from_path(&config, &path).await { + Ok(bootstrap) => { self.shutdown_current_thread().await; let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), ); - self.chat_widget = ChatWidget::new_from_existing( - init, - forked.thread, - forked.session_configured, - ); + self.chat_widget = ChatWidget::new(init); self.reset_thread_event_state(); + self.set_primary_thread(bootstrap.thread_id).await; + self.enqueue_thread_event(bootstrap.thread_id, bootstrap.session_event) + .await?; if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -1480,8 +1821,20 @@ impl App { AppEvent::CodexEvent(event) => { self.enqueue_primary_event(event).await?; } + AppEvent::CodexThreadEvent { thread_id, event } => { + self.enqueue_thread_event_with_primary(thread_id, event) + .await?; + } AppEvent::Exit(mode) => match mode { - ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), + ExitMode::ShutdownFirst => { + self.suppress_shutdown_complete = false; + if self.chat_widget.thread_id().is_none() { + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } + self.handle_app_server_action(AppServerAction::Shutdown) + .await?; + self.arm_shutdown_exit_fallback(); + } ExitMode::Immediate => { return Ok(AppRunControl::Exit(ExitReason::UserRequested)); } @@ -1489,8 +1842,8 @@ impl App { AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } - AppEvent::CodexOp(op) => { - self.chat_widget.submit_op(op); + AppEvent::AppServerAction(action) => { + self.handle_app_server_action(action).await?; } AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane @@ -1734,19 +2087,6 @@ impl App { if let Some((sample_paths, extra_count, failed_scan)) = self.chat_widget.world_writable_warning_details() { - self.app_event_tx.send(AppEvent::CodexOp( - Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - windows_sandbox_level: Some(windows_sandbox_level), - model: None, - effort: None, - summary: None, - collaboration_mode: None, - personality: None, - }, - )); self.app_event_tx.send( AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset.clone()), @@ -1756,19 +2096,6 @@ impl App { }, ); } else { - self.app_event_tx.send(AppEvent::CodexOp( - Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(preset.approval), - sandbox_policy: Some(preset.sandbox.clone()), - windows_sandbox_level: Some(windows_sandbox_level), - model: None, - effort: None, - summary: None, - collaboration_mode: None, - personality: None, - }, - )); self.app_event_tx .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); self.app_event_tx @@ -1948,12 +2275,6 @@ impl App { if updates.is_empty() { return Ok(AppRunControl::Continue); } - let windows_sandbox_changed = updates.iter().any(|(feature, _)| { - matches!( - feature, - Feature::WindowsSandbox | Feature::WindowsSandboxElevated - ) - }); let mut builder = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(self.active_profile.as_deref()); for (feature, enabled) in &updates { @@ -1979,24 +2300,6 @@ impl App { } } } - if windows_sandbox_changed { - #[cfg(target_os = "windows")] - { - let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); - self.app_event_tx - .send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - windows_sandbox_level: Some(windows_sandbox_level), - model: None, - effort: None, - summary: None, - collaboration_mode: None, - personality: None, - })); - } - } if let Err(err) = builder.apply().await { tracing::error!(error = %err, "failed to persist feature flags"); self.chat_widget.add_error_message(format!( @@ -2178,6 +2481,410 @@ impl App { Ok(AppRunControl::Continue) } + async fn handle_app_server_action(&mut self, action: AppServerAction) -> Result<()> { + match action { + AppServerAction::TurnStart(request) => { + let Some(thread_id) = self.chat_widget.thread_id() else { + return Ok(()); + }; + let params = codex_app_server_protocol::TurnStartParams { + thread_id: thread_id.to_string(), + input: request.items.into_iter().map(Into::into).collect(), + cwd: Some(request.cwd), + approval_policy: Some(codex_app_server_protocol::AskForApproval::from( + request.approval_policy, + )), + sandbox_policy: Some(request.sandbox_policy.into()), + model: Some(request.model), + effort: request.effort, + summary: request.summary, + personality: request.personality, + output_schema: request.output_schema, + collaboration_mode: request.collaboration_mode, + }; + let pending = self + .app_server + .request( + |request_id| codex_app_server_protocol::ClientRequest::TurnStart { + request_id, + params, + }, + ) + .await + .map_err(app_server_error)?; + pending.discard().await.map_err(app_server_error)?; + } + AppServerAction::ReviewStart { review_request } => { + let Some(thread_id) = self.chat_widget.thread_id() else { + return Ok(()); + }; + let target = match review_request.target { + codex_protocol::protocol::ReviewTarget::UncommittedChanges => { + codex_app_server_protocol::ReviewTarget::UncommittedChanges + } + codex_protocol::protocol::ReviewTarget::BaseBranch { branch } => { + codex_app_server_protocol::ReviewTarget::BaseBranch { branch } + } + codex_protocol::protocol::ReviewTarget::Commit { sha, title } => { + codex_app_server_protocol::ReviewTarget::Commit { sha, title } + } + codex_protocol::protocol::ReviewTarget::Custom { instructions } => { + codex_app_server_protocol::ReviewTarget::Custom { instructions } + } + }; + let params = codex_app_server_protocol::ReviewStartParams { + thread_id: thread_id.to_string(), + target, + delivery: None, + }; + let pending = self + .app_server + .request( + |request_id| codex_app_server_protocol::ClientRequest::ReviewStart { + request_id, + params, + }, + ) + .await + .map_err(app_server_error)?; + pending.discard().await.map_err(app_server_error)?; + } + AppServerAction::Interrupt => { + if let Some(thread_id) = self.chat_widget.thread_id() { + self.app_server + .interrupt_current_turn(thread_id) + .await + .map_err(app_server_error)?; + } + } + AppServerAction::Shutdown => { + if let Some(thread_id) = self.chat_widget.thread_id() { + let params = codex_app_server_protocol::ThreadShutdownParams { + thread_id: thread_id.to_string(), + }; + let pending = self + .app_server + .request(|request_id| { + codex_app_server_protocol::ClientRequest::ThreadShutdown { + request_id, + params, + } + }) + .await + .map_err(app_server_error)?; + pending.discard().await.map_err(app_server_error)?; + } + } + AppServerAction::Compact => { + if let Some(thread_id) = self.chat_widget.thread_id() { + let params = codex_app_server_protocol::ThreadCompactParams { + thread_id: thread_id.to_string(), + }; + let pending = self + .app_server + .request(|request_id| { + codex_app_server_protocol::ClientRequest::ThreadCompact { + request_id, + params, + } + }) + .await + .map_err(app_server_error)?; + pending.discard().await.map_err(app_server_error)?; + } + } + AppServerAction::ThreadRollback { num_turns } => { + if let Some(thread_id) = self.chat_widget.thread_id() { + let params = codex_app_server_protocol::ThreadRollbackParams { + thread_id: thread_id.to_string(), + num_turns, + }; + let pending = self + .app_server + .request(|request_id| { + codex_app_server_protocol::ClientRequest::ThreadRollback { + request_id, + params, + } + }) + .await + .map_err(app_server_error)?; + pending.discard().await.map_err(app_server_error)?; + } + } + AppServerAction::ListSkills { cwds, force_reload } => { + let params = codex_app_server_protocol::SkillsListParams { cwds, force_reload }; + let pending = self + .app_server + .request( + |request_id| codex_app_server_protocol::ClientRequest::SkillsList { + request_id, + params, + }, + ) + .await + .map_err(app_server_error)?; + let response: codex_app_server_protocol::SkillsListResponse = + pending.into_typed().await.map_err(app_server_error)?; + let skills = response + .data + .into_iter() + .map(map_skills_list_entry) + .collect(); + let event = Event { + id: String::new(), + msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { skills }), + }; + if let Some(thread_id) = self.chat_widget.thread_id() { + self.enqueue_thread_event(thread_id, event).await?; + } else { + self.enqueue_primary_event(event).await?; + } + } + AppServerAction::RefreshMcpServers { config: _ } => { + let pending = self + .app_server + .request(|request_id| { + codex_app_server_protocol::ClientRequest::McpServerRefresh { + request_id, + params: None, + } + }) + .await + .map_err(app_server_error)?; + pending.discard().await.map_err(app_server_error)?; + } + AppServerAction::ListMcpTools => { + let params = codex_app_server_protocol::ListMcpServerStatusParams { + cursor: None, + limit: None, + }; + let pending = self + .app_server + .request(|request_id| { + codex_app_server_protocol::ClientRequest::McpServerStatusList { + request_id, + params, + } + }) + .await + .map_err(app_server_error)?; + let response: codex_app_server_protocol::ListMcpServerStatusResponse = + pending.into_typed().await.map_err(app_server_error)?; + let mut tools = HashMap::new(); + let mut resources = HashMap::new(); + let mut resource_templates = HashMap::new(); + let mut auth_statuses = HashMap::new(); + for server in response.data { + let server_name = server.name.clone(); + auth_statuses.insert(server_name.clone(), server.auth_status.to_core()); + resources.insert(server_name.clone(), server.resources); + resource_templates.insert(server_name.clone(), server.resource_templates); + for (tool_name, tool) in server.tools { + let qualified = format!("mcp__{server_name}__{tool_name}"); + tools.insert(qualified, tool); + } + } + let event = Event { + id: String::new(), + msg: EventMsg::McpListToolsResponse( + codex_core::protocol::McpListToolsResponseEvent { + tools, + resources, + resource_templates, + auth_statuses, + }, + ), + }; + if let Some(thread_id) = self.chat_widget.thread_id() { + self.enqueue_thread_event(thread_id, event).await?; + } else { + self.enqueue_primary_event(event).await?; + } + } + AppServerAction::ListCustomPrompts => { + let custom_prompts = + if let Some(dir) = codex_core::custom_prompts::default_prompts_dir() { + codex_core::custom_prompts::discover_prompts_in(&dir).await + } else { + Vec::new() + }; + let event = Event { + id: String::new(), + msg: EventMsg::ListCustomPromptsResponse( + codex_core::protocol::ListCustomPromptsResponseEvent { custom_prompts }, + ), + }; + if let Some(thread_id) = self.chat_widget.thread_id() { + self.enqueue_thread_event(thread_id, event).await?; + } else { + self.enqueue_primary_event(event).await?; + } + } + AppServerAction::RunUserShellCommand { command } => { + let Some(thread_id) = self.chat_widget.thread_id() else { + return Ok(()); + }; + let shell = codex_core::shell::default_user_shell(); + let command_args = shell.derive_exec_args(&command, true); + let parsed_cmd = codex_core::parse_command::parse_command(&command_args); + let call_id = uuid::Uuid::new_v4().to_string(); + let turn_id = uuid::Uuid::new_v4().to_string(); + let cwd = self.config.cwd.clone(); + let begin_event = Event { + id: String::new(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: call_id.clone(), + process_id: None, + turn_id: turn_id.clone(), + command: command_args.clone(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::UserShell, + interaction_input: None, + }), + }; + self.enqueue_thread_event(thread_id, begin_event).await?; + + let started_at = Instant::now(); + let params = codex_app_server_protocol::CommandExecParams { + command: command_args.clone(), + timeout_ms: None, + cwd: Some(cwd.clone()), + sandbox_policy: Some(self.config.sandbox_policy.get().clone().into()), + }; + let pending = self + .app_server + .request(|request_id| { + codex_app_server_protocol::ClientRequest::OneOffCommandExec { + request_id, + params, + } + }) + .await + .map_err(app_server_error)?; + let response: codex_app_server_protocol::CommandExecResponse = + pending.into_typed().await.map_err(app_server_error)?; + let duration = started_at.elapsed(); + if !response.stdout.is_empty() { + let delta = Event { + id: String::new(), + msg: EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { + call_id: call_id.clone(), + stream: ExecOutputStream::Stdout, + chunk: response.stdout.as_bytes().to_vec(), + }), + }; + self.enqueue_thread_event(thread_id, delta).await?; + } + if !response.stderr.is_empty() { + let delta = Event { + id: String::new(), + msg: EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { + call_id: call_id.clone(), + stream: ExecOutputStream::Stderr, + chunk: response.stderr.as_bytes().to_vec(), + }), + }; + self.enqueue_thread_event(thread_id, delta).await?; + } + let aggregated_output = format!("{}{}", response.stdout, response.stderr); + let exec_output = codex_core::exec::ExecToolCallOutput { + exit_code: response.exit_code, + stdout: codex_core::exec::StreamOutput::new(response.stdout.clone()), + stderr: codex_core::exec::StreamOutput::new(response.stderr.clone()), + aggregated_output: codex_core::exec::StreamOutput::new( + aggregated_output.clone(), + ), + duration, + timed_out: false, + }; + let formatted_output = codex_core::format_exec_output_str( + &exec_output, + codex_core::TruncationPolicy::Bytes(10_000), + ); + let end_event = Event { + id: String::new(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id, + process_id: None, + turn_id, + command: command_args, + cwd, + parsed_cmd, + source: ExecCommandSource::UserShell, + interaction_input: None, + stdout: response.stdout, + stderr: response.stderr, + aggregated_output, + exit_code: response.exit_code, + duration, + formatted_output, + }), + }; + self.enqueue_thread_event(thread_id, end_event).await?; + } + AppServerAction::AddToHistory { text } => { + if let Some(thread_id) = self.chat_widget.thread_id() + && let Err(err) = + codex_core::message_history::append_entry(&text, &thread_id, &self.config) + .await + { + tracing::warn!(error = %err, "failed to append history entry"); + } + } + AppServerAction::GetHistoryEntry { log_id, offset } => { + let entry = codex_core::message_history::lookup(log_id, offset, &self.config).map( + |entry| codex_protocol::message_history::HistoryEntry { + conversation_id: entry.session_id, + ts: entry.ts, + text: entry.text, + }, + ); + let event = Event { + id: String::new(), + msg: EventMsg::GetHistoryEntryResponse( + codex_core::protocol::GetHistoryEntryResponseEvent { + offset, + log_id, + entry, + }, + ), + }; + self.enqueue_primary_event(event).await?; + } + AppServerAction::ExecApproval { call_id, decision } => { + self.app_server + .respond_exec_approval(call_id, decision) + .await + .map_err(app_server_error)?; + } + AppServerAction::PatchApproval { call_id, decision } => { + self.app_server + .respond_patch_approval(call_id, decision) + .await + .map_err(app_server_error)?; + } + AppServerAction::UserInputAnswer { call_id, response } => { + self.app_server + .respond_user_input(call_id, response) + .await + .map_err(app_server_error)?; + } + AppServerAction::ResolveElicitation { + server_name, + request_id, + decision, + } => { + self.app_server + .respond_elicitation(server_name, request_id, decision) + .await + .map_err(app_server_error)?; + } + } + Ok(()) + } + fn handle_codex_event_now(&mut self, event: Event) { if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) { self.suppress_shutdown_complete = false; @@ -2205,63 +2912,6 @@ impl App { Ok(()) } - async fn handle_thread_created(&mut self, thread_id: ThreadId) -> Result<()> { - if self.thread_event_channels.contains_key(&thread_id) { - return Ok(()); - } - 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 config_snapshot = thread.config_snapshot().await; - let event = Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - model: config_snapshot.model, - model_provider_id: config_snapshot.model_provider_id, - approval_policy: config_snapshot.approval_policy, - sandbox_policy: config_snapshot.sandbox_policy, - cwd: config_snapshot.cwd, - reasoning_effort: config_snapshot.reasoning_effort, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - rollout_path: thread.rollout_path(), - }), - }; - let channel = - ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event); - let sender = channel.sender.clone(); - let store = Arc::clone(&channel.store); - self.thread_event_channels.insert(thread_id, channel); - 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; - } - }; - let should_send = { - let mut guard = store.lock().await; - guard.push_event(event.clone()); - guard.active - }; - if should_send && let Err(err) = sender.send(event).await { - tracing::debug!("external thread {thread_id} channel closed: {err}"); - break; - } - } - }); - Ok(()) - } - fn reasoning_label(reasoning_effort: Option) -> &'static str { match reasoning_effort { Some(ReasoningEffortConfig::Minimal) => "minimal", @@ -2471,6 +3121,87 @@ impl App { } } +#[allow(clippy::too_many_arguments)] +fn session_configured_from_thread_response( + thread_id: ThreadId, + model: String, + model_provider: String, + approval_policy: codex_app_server_protocol::AskForApproval, + sandbox: codex_app_server_protocol::SandboxPolicy, + cwd: PathBuf, + reasoning_effort: Option, + rollout_path: Option, + initial_messages: Option>, +) -> Event { + Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + model, + model_provider_id: model_provider, + approval_policy: approval_policy.to_core(), + sandbox_policy: sandbox.to_core(), + cwd, + reasoning_effort, + history_log_id: 0, + history_entry_count: 0, + initial_messages, + rollout_path, + }), + } +} + +fn thread_turns_to_initial_messages( + turns: &[codex_app_server_protocol::Turn], + show_raw_agent_reasoning: bool, +) -> Option> { + let mut events = Vec::new(); + for turn in turns { + for item in &turn.items { + if let Some(turn_item) = thread_item_to_turn_item(item) { + events.extend(turn_item.as_legacy_events(show_raw_agent_reasoning)); + } + } + } + (!events.is_empty()).then_some(events) +} + +fn thread_item_to_turn_item(item: &V2ThreadItem) -> Option { + match item { + V2ThreadItem::UserMessage { id, content } => { + let content = content + .iter() + .cloned() + .map(V2UserInput::into_core) + .collect(); + Some(TurnItem::UserMessage(UserMessageItem { + id: id.clone(), + content, + })) + } + V2ThreadItem::AgentMessage { id, text } => Some(TurnItem::AgentMessage(AgentMessageItem { + id: id.clone(), + content: vec![AgentMessageContent::Text { text: text.clone() }], + })), + V2ThreadItem::Reasoning { + id, + summary, + content, + } => Some(TurnItem::Reasoning(ReasoningItem { + id: id.clone(), + summary_text: summary.clone(), + raw_content: content.clone(), + })), + V2ThreadItem::ContextCompaction { id } => { + Some(TurnItem::ContextCompaction(ContextCompactionItem { + id: id.clone(), + })) + } + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -2484,9 +3215,9 @@ mod tests { use crate::history_cell::new_session_info; use codex_core::AuthManager; use codex_core::CodexAuth; - use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; + use codex_core::config_loader::LoaderOverrides; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; @@ -2567,20 +3298,31 @@ mod tests { } async fn make_test_app() -> App { - let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; + let (chat_widget, app_event_tx, _rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let server = Arc::new(ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new( + config.codex_home.clone(), + auth_manager.clone(), + )); + let feedback = codex_feedback::CodexFeedback::new(); + let app_server = AppServerClient::spawn( + app_event_tx.clone(), + Arc::new(config.clone()), + Vec::new(), + LoaderOverrides::default(), + feedback.clone(), + Vec::new(), + SessionSource::Cli, + ); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); let model = ModelsManager::get_model_offline(config.model.as_deref()); let otel_manager = test_otel_manager(&config, model.as_str()); App { - server, + app_server, + models_manager, otel_manager, app_event_tx, chat_widget, @@ -2600,7 +3342,7 @@ mod tests { commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, - feedback: codex_feedback::CodexFeedback::new(), + feedback, pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), @@ -2613,26 +3355,34 @@ mod tests { } } - async fn make_test_app_with_channels() -> ( - App, - tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, - ) { - let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; + async fn make_test_app_with_channels() -> (App, tokio::sync::mpsc::UnboundedReceiver) + { + let (chat_widget, app_event_tx, rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let server = Arc::new(ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new( + config.codex_home.clone(), + auth_manager.clone(), + )); + let feedback = codex_feedback::CodexFeedback::new(); + let app_server = AppServerClient::spawn( + app_event_tx.clone(), + Arc::new(config.clone()), + Vec::new(), + LoaderOverrides::default(), + feedback.clone(), + Vec::new(), + SessionSource::Cli, + ); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); let model = ModelsManager::get_model_offline(config.model.as_deref()); let otel_manager = test_otel_manager(&config, model.as_str()); ( App { - server, + app_server, + models_manager, otel_manager, app_event_tx, chat_widget, @@ -2652,7 +3402,7 @@ mod tests { commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, - feedback: codex_feedback::CodexFeedback::new(), + feedback, pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), @@ -2664,7 +3414,6 @@ mod tests { pending_primary_events: VecDeque::new(), }, rx, - op_rx, ) } @@ -2864,7 +3613,7 @@ mod tests { #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { - let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let (mut app, mut app_event_rx) = make_test_app_with_channels().await; let user_cell = |text: &str, text_elements: Vec, @@ -2967,8 +3716,9 @@ mod tests { app.apply_backtrack_rollback(selection); let mut rollback_turns = None; - while let Ok(op) = op_rx.try_recv() { - if let Op::ThreadRollback { num_turns } = op { + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::AppServerAction(AppServerAction::ThreadRollback { num_turns }) = event + { rollback_turns = Some(num_turns); } } @@ -2977,8 +3727,8 @@ mod tests { } #[tokio::test] - async fn new_session_requests_shutdown_for_previous_conversation() { - let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + async fn shutdown_current_thread_sets_suppress_flag() { + let (mut app, _app_event_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let event = SessionConfiguredEvent { @@ -3001,16 +3751,12 @@ mod tests { msg: EventMsg::SessionConfigured(event), }); - while app_event_rx.try_recv().is_ok() {} - while op_rx.try_recv().is_ok() {} - app.shutdown_current_thread().await; - match op_rx.try_recv() { - Ok(Op::Shutdown) => {} - Ok(other) => panic!("expected Op::Shutdown, got {other:?}"), - Err(_) => panic!("expected shutdown op to be sent"), - } + assert!( + app.suppress_shutdown_complete, + "shutdown should set suppress_shutdown_complete" + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 256355dd05..e7689af29c 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -28,6 +28,7 @@ use std::path::PathBuf; use std::sync::Arc; use crate::app::App; +use crate::app_event::AppServerAction; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; @@ -36,7 +37,6 @@ 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 codex_protocol::user_input::TextElement; use color_eyre::eyre::Result; @@ -210,7 +210,8 @@ impl App { selection, thread_id: self.chat_widget.thread_id(), }); - self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); + self.chat_widget + .submit_action(AppServerAction::ThreadRollback { num_turns }); if !prefill.is_empty() || !text_elements.is_empty() || !local_image_paths.is_empty() { self.chat_widget .set_composer_text(prefill, text_elements, local_image_paths); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bbd228e114..919c2b40d0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -14,19 +14,26 @@ use codex_chatgpt::connectors::AppInfo; use codex_common::approval_presets::ApprovalPreset; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; +use codex_core::protocol::ReviewDecision; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; use crate::bottom_pane::ApprovalRequest; use crate::history_cell::HistoryCell; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; +use codex_core::protocol::ElicitationAction; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; +use serde::Serialize; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] @@ -46,10 +53,79 @@ pub(crate) struct ConnectorsSnapshot { pub(crate) connectors: Vec, } +#[derive(Debug, Clone, Serialize, PartialEq)] +pub(crate) struct TurnStartRequest { + pub(crate) items: Vec, + pub(crate) cwd: PathBuf, + pub(crate) approval_policy: AskForApproval, + pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) model: String, + pub(crate) effort: Option, + pub(crate) summary: Option, + pub(crate) collaboration_mode: Option, + pub(crate) personality: Option, + pub(crate) output_schema: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub(crate) enum AppServerAction { + TurnStart(TurnStartRequest), + ReviewStart { + review_request: codex_core::protocol::ReviewRequest, + }, + Interrupt, + Shutdown, + Compact, + ThreadRollback { + num_turns: u32, + }, + ListSkills { + cwds: Vec, + force_reload: bool, + }, + #[allow(dead_code)] + RefreshMcpServers { + config: codex_protocol::protocol::McpServerRefreshConfig, + }, + ListMcpTools, + ListCustomPrompts, + RunUserShellCommand { + command: String, + }, + AddToHistory { + text: String, + }, + GetHistoryEntry { + log_id: u64, + offset: usize, + }, + ExecApproval { + call_id: String, + decision: ReviewDecision, + }, + PatchApproval { + call_id: String, + decision: ReviewDecision, + }, + UserInputAnswer { + call_id: String, + response: RequestUserInputResponse, + }, + ResolveElicitation { + server_name: String, + request_id: mcp_types::RequestId, + decision: ElicitationAction, + }, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { CodexEvent(Event), + CodexThreadEvent { + thread_id: ThreadId, + event: Event, + }, /// Open the agent picker for switching active threads. OpenAgentPicker, /// Switch the active thread to the selected agent. @@ -73,11 +149,12 @@ pub(crate) enum AppEvent { Exit(ExitMode), /// Request to exit the application due to a fatal error. + #[allow(dead_code)] FatalExitRequest(String), - /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids + /// Forward a command to the app server. Using an `AppEvent` for this avoids /// bubbling channels through layers of widgets. - CodexOp(codex_core::protocol::Op), + AppServerAction(AppServerAction), /// Kick off an asynchronous file search for the given query (text after /// the `@`). Previous searches may be cancelled by the app layer so there @@ -305,8 +382,9 @@ pub(crate) enum ExitMode { ShutdownFirst, /// Exit the UI loop immediately without waiting for shutdown. /// - /// This skips `Op::Shutdown`, so any in-flight work may be dropped and - /// cleanup that normally runs before `ShutdownComplete` can be missed. + /// This skips the app-server shutdown request, so any in-flight work may be + /// dropped and cleanup that normally runs before `ShutdownComplete` can be + /// missed. Immediate, } diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index c1427b3ff0..682caf4bcb 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -17,8 +17,10 @@ impl AppEventSender { /// error and log it. pub(crate) fn send(&self, event: AppEvent) { // Record inbound events for high-fidelity session replay. - // Avoid double-logging Ops; those are logged at the point of submission. - if !matches!(event, AppEvent::CodexOp(_)) { + // Avoid double-logging app-server actions; those are logged at the point of submission. + if let AppEvent::AppServerAction(action) = &event { + session_log::log_outbound_app_action(action); + } else { session_log::log_inbound_app_event(&event); } if let Err(e) = self.app_event_tx.send(event) { diff --git a/codex-rs/tui/src/app_server_client.rs b/codex-rs/tui/src/app_server_client.rs new file mode 100644 index 0000000000..58c5bff68e --- /dev/null +++ b/codex-rs/tui/src/app_server_client.rs @@ -0,0 +1,613 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; + +use codex_app_server::AppServerClientMessage; +use codex_app_server::AppServerEventNotification; +use codex_app_server::AppServerMessage; +use codex_app_server::spawn_in_memory_typed; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::DynamicToolCallResponse; +use codex_app_server_protocol::ElicitationAction as V2ElicitationAction; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::FileChangeRequestApprovalResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result as JsonResult; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputResponse; +use codex_core::config::Config; +use codex_core::config_loader::LoaderOverrides; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ReviewDecision; +use codex_feedback::CodexFeedback; +use codex_protocol::ThreadId; +use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; +use mcp_types::RequestId as McpRequestId; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +type PendingResponseMap = + HashMap>>; + +pub(crate) struct PendingResponse { + receiver: oneshot::Receiver>, +} + +impl PendingResponse { + pub async fn into_typed( + self, + ) -> std::result::Result { + let value = self + .receiver + .await + .map_err(|_| internal_error("response channel closed"))??; + serde_json::from_value(value).map_err(|err| internal_error(err.to_string())) + } + + pub async fn discard(self) -> std::result::Result<(), JSONRPCErrorError> { + let _ = self + .receiver + .await + .map_err(|_| internal_error("response channel closed"))??; + Ok(()) + } +} + +#[derive(Default)] +struct PendingServerRequests { + exec: HashMap, + patch: HashMap, + user_input: HashMap, +} + +#[derive(Default)] +struct QueuedResponses { + exec: HashMap, + patch: HashMap, + user_input: HashMap, +} + +#[derive(Default)] +struct ElicitationState { + thread_by_request: HashMap, + queued: HashMap, +} + +#[derive(Default)] +struct TurnState { + current_turn_by_thread: HashMap, +} + +pub(crate) struct AppServerClient { + sender: mpsc::Sender, + pending: Arc>, + pending_server_requests: Arc>, + queued_responses: Arc>, + elicitation_state: Arc>, + turn_state: Arc>, + next_request_id: Arc, +} + +impl AppServerClient { + pub(crate) fn spawn( + app_event_tx: AppEventSender, + config: Arc, + cli_overrides: Vec<(String, toml::Value)>, + loader_overrides: LoaderOverrides, + feedback: CodexFeedback, + config_warnings: Vec, + session_source: codex_protocol::protocol::SessionSource, + ) -> Self { + let in_process = spawn_in_memory_typed( + config.codex_linux_sandbox_exe.clone(), + config, + cli_overrides, + loader_overrides, + feedback, + config_warnings, + session_source, + ); + + let client = Self { + sender: in_process.incoming, + pending: Arc::new(Mutex::new(HashMap::new())), + pending_server_requests: Arc::new(Mutex::new(PendingServerRequests::default())), + queued_responses: Arc::new(Mutex::new(QueuedResponses::default())), + elicitation_state: Arc::new(Mutex::new(ElicitationState::default())), + turn_state: Arc::new(Mutex::new(TurnState::default())), + next_request_id: Arc::new(AtomicI64::new(1)), + }; + + client.spawn_outgoing_handler(app_event_tx, in_process.outgoing); + client + } + + pub(crate) async fn request( + &self, + build: impl FnOnce(RequestId) -> ClientRequest, + ) -> std::result::Result { + let request_id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed)); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(request_id.clone(), tx); + if self + .sender + .send(AppServerClientMessage::Request(build(request_id.clone()))) + .await + .is_err() + { + self.pending.lock().await.remove(&request_id); + return Err(internal_error("app-server request channel closed")); + } + Ok(PendingResponse { receiver: rx }) + } + + pub(crate) async fn send_notification( + &self, + notification: ClientNotification, + ) -> std::result::Result<(), JSONRPCErrorError> { + self.sender + .send(AppServerClientMessage::Notification(notification)) + .await + .map_err(|_| internal_error("app-server notification channel closed")) + } + + pub(crate) async fn respond_exec_approval( + &self, + call_id: String, + decision: ReviewDecision, + ) -> std::result::Result<(), JSONRPCErrorError> { + let request_id = { + let mut pending = self.pending_server_requests.lock().await; + pending.exec.remove(&call_id) + }; + + let Some(request_id) = request_id else { + self.queued_responses + .lock() + .await + .exec + .insert(call_id, decision); + return Ok(()); + }; + + let response = CommandExecutionRequestApprovalResponse { + decision: map_exec_decision(decision), + }; + self.send_response(request_id, response).await + } + + pub(crate) async fn respond_patch_approval( + &self, + call_id: String, + decision: ReviewDecision, + ) -> std::result::Result<(), JSONRPCErrorError> { + let request_id = { + let mut pending = self.pending_server_requests.lock().await; + pending.patch.remove(&call_id) + }; + + let Some(request_id) = request_id else { + self.queued_responses + .lock() + .await + .patch + .insert(call_id, decision); + return Ok(()); + }; + + let response = FileChangeRequestApprovalResponse { + decision: map_patch_decision(decision), + }; + self.send_response(request_id, response).await + } + + pub(crate) async fn respond_user_input( + &self, + call_id: String, + response: CoreRequestUserInputResponse, + ) -> std::result::Result<(), JSONRPCErrorError> { + let request_id = { + let mut pending = self.pending_server_requests.lock().await; + pending.user_input.remove(&call_id) + }; + + let Some(request_id) = request_id else { + self.queued_responses + .lock() + .await + .user_input + .insert(call_id, response); + return Ok(()); + }; + + let response = ToolRequestUserInputResponse { + answers: response + .answers + .into_iter() + .map(|(id, answer)| { + ( + id, + codex_app_server_protocol::ToolRequestUserInputAnswer { + answers: answer.answers, + }, + ) + }) + .collect(), + }; + + self.send_response(request_id, response).await + } + + pub(crate) async fn respond_elicitation( + &self, + server_name: String, + request_id: McpRequestId, + decision: codex_core::protocol::ElicitationAction, + ) -> std::result::Result<(), JSONRPCErrorError> { + let thread_id = { + let mut state = self.elicitation_state.lock().await; + state.thread_by_request.remove(&request_id) + }; + + let Some(thread_id) = thread_id else { + self.elicitation_state + .lock() + .await + .queued + .insert(request_id, (server_name, decision)); + return Ok(()); + }; + + let params = codex_app_server_protocol::McpElicitationResolveParams { + thread_id: thread_id.to_string(), + server_name, + request_id: request_id.clone(), + decision: V2ElicitationAction::from(decision), + }; + + self.request(|id| ClientRequest::McpElicitationResolve { + request_id: id, + params, + }) + .await? + .discard() + .await + } + + pub(crate) async fn interrupt_current_turn( + &self, + thread_id: ThreadId, + ) -> std::result::Result<(), JSONRPCErrorError> { + let turn_id = { + let state = self.turn_state.lock().await; + state.current_turn_by_thread.get(&thread_id).cloned() + }; + let Some(turn_id) = turn_id else { + return Ok(()); + }; + + let params = codex_app_server_protocol::TurnInterruptParams { + thread_id: thread_id.to_string(), + turn_id, + }; + self.request(|id| ClientRequest::TurnInterrupt { + request_id: id, + params, + }) + .await? + .discard() + .await + } + + async fn send_response( + &self, + request_id: RequestId, + response: T, + ) -> std::result::Result<(), JSONRPCErrorError> { + let result = + serde_json::to_value(response).map_err(|err| internal_error(err.to_string()))?; + self.sender + .send(AppServerClientMessage::Response { + id: request_id, + result, + }) + .await + .map_err(|_| internal_error("app-server response channel closed")) + } + + fn spawn_outgoing_handler( + &self, + app_event_tx: AppEventSender, + mut outgoing: mpsc::Receiver, + ) { + let pending = Arc::clone(&self.pending); + let pending_server_requests = Arc::clone(&self.pending_server_requests); + let queued_responses = Arc::clone(&self.queued_responses); + let elicitation_state = Arc::clone(&self.elicitation_state); + let turn_state = Arc::clone(&self.turn_state); + let sender = self.sender.clone(); + let next_request_id = Arc::clone(&self.next_request_id); + + tokio::spawn(async move { + while let Some(message) = outgoing.recv().await { + match message { + AppServerMessage::EventNotification(notification) => { + handle_event_notification( + notification, + &app_event_tx, + &turn_state, + &elicitation_state, + &sender, + next_request_id.as_ref(), + ) + .await; + } + AppServerMessage::Request(request) => { + handle_server_request( + request, + &pending_server_requests, + &queued_responses, + &sender, + ) + .await; + } + AppServerMessage::Response { id, result } => { + if let Some(tx) = pending.lock().await.remove(&id) { + let _ = tx.send(Ok(result)); + } + } + AppServerMessage::Error { id, error } => { + if let Some(tx) = pending.lock().await.remove(&id) { + let _ = tx.send(Err(error)); + } + } + AppServerMessage::Notification(_notification) => { + // v2 notifications are currently surfaced through codex/event + // for the TUI, so ignore explicit server notifications here. + } + } + } + }); + } +} + +async fn handle_event_notification( + notification: AppServerEventNotification, + app_event_tx: &AppEventSender, + turn_state: &Mutex, + elicitation_state: &Mutex, + sender: &mpsc::Sender, + next_request_id: &AtomicI64, +) { + if !notification.method.starts_with("codex/event/") { + return; + } + let Some(params) = notification.params else { + return; + }; + let serde_json::Value::Object(mut map) = params else { + return; + }; + let Some(conversation_id) = map.remove("conversationId") else { + return; + }; + let thread_id = match conversation_id.as_str() { + Some(value) => match ThreadId::from_string(value) { + Ok(thread_id) => thread_id, + Err(err) => { + tracing::warn!("invalid thread id in event: {err}"); + return; + } + }, + None => return, + }; + + let event_value = serde_json::Value::Object(map); + let event: Event = match serde_json::from_value(event_value) { + Ok(event) => event, + Err(err) => { + tracing::warn!("failed to parse codex event: {err}"); + return; + } + }; + + match &event.msg { + EventMsg::TurnStarted(_) => { + if !event.id.is_empty() { + turn_state + .lock() + .await + .current_turn_by_thread + .insert(thread_id, event.id.clone()); + } + } + EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_) => { + if !event.id.is_empty() { + let mut state = turn_state.lock().await; + if state + .current_turn_by_thread + .get(&thread_id) + .is_some_and(|id| id == &event.id) + { + state.current_turn_by_thread.remove(&thread_id); + } + } + } + EventMsg::ElicitationRequest(ev) => { + let mut state = elicitation_state.lock().await; + state.thread_by_request.insert(ev.id.clone(), thread_id); + if let Some((server_name, decision)) = state.queued.remove(&ev.id) { + let params = codex_app_server_protocol::McpElicitationResolveParams { + thread_id: thread_id.to_string(), + server_name, + request_id: ev.id.clone(), + decision: V2ElicitationAction::from(decision), + }; + let request_id = + RequestId::Integer(next_request_id.fetch_add(1, Ordering::Relaxed)); + let request = ClientRequest::McpElicitationResolve { request_id, params }; + let _ = sender.send(AppServerClientMessage::Request(request)).await; + } + } + _ => {} + } + + app_event_tx.send(AppEvent::CodexThreadEvent { thread_id, event }); +} + +async fn handle_server_request( + request: ServerRequest, + pending: &Mutex, + queued: &Mutex, + sender: &mpsc::Sender, +) { + match request { + ServerRequest::CommandExecutionRequestApproval { request_id, params } => { + record_server_request( + request_id, + params.item_id, + &mut pending.lock().await.exec, + &mut queued.lock().await.exec, + |decision| CommandExecutionRequestApprovalResponse { + decision: map_exec_decision(decision), + }, + sender, + ) + .await; + } + ServerRequest::FileChangeRequestApproval { request_id, params } => { + record_server_request( + request_id, + params.item_id, + &mut pending.lock().await.patch, + &mut queued.lock().await.patch, + |decision| FileChangeRequestApprovalResponse { + decision: map_patch_decision(decision), + }, + sender, + ) + .await; + } + ServerRequest::ToolRequestUserInput { request_id, params } => { + record_user_input_request(request_id, params, pending, queued, sender).await; + } + ServerRequest::DynamicToolCall { request_id, params } => { + let response = DynamicToolCallResponse { + output: "Dynamic tools are not supported in the TUI yet.".to_string(), + success: false, + }; + let _ = send_response(sender, request_id, response).await; + tracing::warn!( + "dynamic tool call {} for tool {} ignored", + params.call_id, + params.tool + ); + } + _ => {} + } +} + +async fn record_user_input_request( + request_id: RequestId, + params: ToolRequestUserInputParams, + pending: &Mutex, + queued: &Mutex, + sender: &mpsc::Sender, +) { + let call_id = params.item_id; + let mut pending_guard = pending.lock().await; + if let Some(response) = queued.lock().await.user_input.remove(&call_id) { + drop(pending_guard); + let response = ToolRequestUserInputResponse { + answers: response + .answers + .into_iter() + .map(|(id, answer)| { + ( + id, + codex_app_server_protocol::ToolRequestUserInputAnswer { + answers: answer.answers, + }, + ) + }) + .collect(), + }; + let _ = send_response(sender, request_id, response).await; + } else { + pending_guard.user_input.insert(call_id, request_id); + } +} + +async fn record_server_request( + request_id: RequestId, + call_id: String, + pending: &mut HashMap, + queued: &mut HashMap, + build_response: impl FnOnce(ReviewDecision) -> T, + sender: &mpsc::Sender, +) { + if let Some(decision) = queued.remove(&call_id) { + let response = build_response(decision); + let _ = send_response(sender, request_id, response).await; + } else { + pending.insert(call_id, request_id); + } +} + +async fn send_response( + sender: &mpsc::Sender, + request_id: RequestId, + response: T, +) -> std::result::Result<(), JSONRPCErrorError> { + let result = serde_json::to_value(response).map_err(|err| internal_error(err.to_string()))?; + sender + .send(AppServerClientMessage::Response { + id: request_id, + result, + }) + .await + .map_err(|_| internal_error("app-server response channel closed")) +} + +fn internal_error(message: impl Into) -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32603, + message: message.into(), + data: None, + } +} + +fn map_exec_decision(decision: ReviewDecision) -> CommandExecutionApprovalDecision { + match decision { + ReviewDecision::Approved => CommandExecutionApprovalDecision::Accept, + ReviewDecision::ApprovedForSession => CommandExecutionApprovalDecision::AcceptForSession, + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment, + } => CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: proposed_execpolicy_amendment.into(), + }, + ReviewDecision::Denied => CommandExecutionApprovalDecision::Decline, + ReviewDecision::Abort => CommandExecutionApprovalDecision::Cancel, + } +} + +fn map_patch_decision(decision: ReviewDecision) -> FileChangeApprovalDecision { + match decision { + ReviewDecision::Approved => FileChangeApprovalDecision::Accept, + ReviewDecision::ApprovedForSession => FileChangeApprovalDecision::AcceptForSession, + ReviewDecision::ApprovedExecpolicyAmendment { .. } => FileChangeApprovalDecision::Accept, + ReviewDecision::Denied => FileChangeApprovalDecision::Decline, + ReviewDecision::Abort => FileChangeApprovalDecision::Cancel, + } +} diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 0f0445fee8..a6b4a28b57 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::app_event::AppEvent; +use crate::app_event::AppServerAction; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::CancellationEvent; @@ -21,7 +22,6 @@ use codex_core::features::Features; use codex_core::protocol::ElicitationAction; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; -use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -194,17 +194,19 @@ impl ApprovalOverlay { fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone()); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); - self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { - id: id.to_string(), - decision, - })); + self.app_event_tx + .send(AppEvent::AppServerAction(AppServerAction::ExecApproval { + call_id: id.to_string(), + decision, + })); } fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) { - self.app_event_tx.send(AppEvent::CodexOp(Op::PatchApproval { - id: id.to_string(), - decision, - })); + self.app_event_tx + .send(AppEvent::AppServerAction(AppServerAction::PatchApproval { + call_id: id.to_string(), + decision, + })); } fn handle_elicitation_decision( @@ -213,12 +215,13 @@ impl ApprovalOverlay { request_id: &RequestId, decision: ElicitationAction, ) { - self.app_event_tx - .send(AppEvent::CodexOp(Op::ResolveElicitation { + self.app_event_tx.send(AppEvent::AppServerAction( + AppServerAction::ResolveElicitation { server_name: server_name.to_string(), request_id: request_id.clone(), decision, - })); + }, + )); } fn advance_queue(&mut self) { @@ -540,6 +543,7 @@ fn elicitation_options() -> Vec { mod tests { use super::*; use crate::app_event::AppEvent; + use crate::app_event::AppServerAction; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; @@ -570,15 +574,15 @@ mod tests { let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); assert!(!view.is_complete()); view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - // We expect at least one CodexOp message in the queue. - let mut saw_op = false; + // We expect at least one app-server action message in the queue. + let mut saw_action = false; while let Ok(ev) = rx.try_recv() { - if matches!(ev, AppEvent::CodexOp(_)) { - saw_op = true; + if matches!(ev, AppEvent::AppServerAction(_)) { + saw_action = true; break; } } - assert!(saw_op, "expected approval decision to emit an op"); + assert!(saw_action, "expected approval decision to emit an action"); } #[test] @@ -598,9 +602,9 @@ mod tests { Features::with_defaults(), ); view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); - let mut saw_op = false; + let mut saw_action = false; while let Ok(ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { + if let AppEvent::AppServerAction(AppServerAction::ExecApproval { decision, .. }) = ev { assert_eq!( decision, ReviewDecision::ApprovedExecpolicyAmendment { @@ -609,13 +613,13 @@ mod tests { ]) } ); - saw_op = true; + saw_action = true; break; } } assert!( - saw_op, - "expected approval decision to emit an op with command prefix" + saw_action, + "expected approval decision to emit an action with command prefix" ); } @@ -717,7 +721,10 @@ mod tests { let mut decision = None; while let Ok(ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::ExecApproval { decision: d, .. }) = ev { + if let AppEvent::AppServerAction(AppServerAction::ExecApproval { + decision: d, .. + }) = ev + { decision = Some(d); break; } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index da9f46ae4b..b6cfb2c49e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::app_event::AppEvent; +use crate::app_event::AppServerAction; use crate::app_event_sender::AppEventSender; -use codex_core::protocol::Op; use codex_protocol::user_input::TextElement; #[derive(Debug, Clone, PartialEq)] @@ -213,11 +213,12 @@ impl ChatComposerHistory { self.last_history_text = Some(entry.text.clone()); return Some(entry); } else if let Some(log_id) = self.history_log_id { - let op = Op::GetHistoryEntryRequest { - offset: global_idx, - log_id, - }; - app_event_tx.send(AppEvent::CodexOp(op)); + app_event_tx.send(AppEvent::AppServerAction( + AppServerAction::GetHistoryEntry { + log_id, + offset: global_idx, + }, + )); } None } @@ -227,7 +228,7 @@ impl ChatComposerHistory { mod tests { use super::*; use crate::app_event::AppEvent; - use codex_core::protocol::Op; + use crate::app_event::AppServerAction; use tokio::sync::mpsc::unbounded_channel; #[test] @@ -272,15 +273,15 @@ mod tests { assert!(history.should_handle_navigation("", 0)); assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet - // Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent. + // Verify that an AppServerAction with the correct GetHistoryEntry request was sent. let event = rx.try_recv().expect("expected AppEvent to be sent"); - let AppEvent::CodexOp(history_request1) = event else { + let AppEvent::AppServerAction(history_request1) = event else { panic!("unexpected event variant"); }; assert_eq!( - Op::GetHistoryEntryRequest { + AppServerAction::GetHistoryEntry { log_id: 1, - offset: 2 + offset: 2, }, history_request1 ); @@ -294,15 +295,15 @@ mod tests { // Next Up should move to offset 1. assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet - // Verify second CodexOp event for offset 1. + // Verify second AppServerAction event for offset 1. let event2 = rx.try_recv().expect("expected second event"); - let AppEvent::CodexOp(history_request_2) = event2 else { + let AppEvent::AppServerAction(history_request_2) = event2 else { panic!("unexpected event variant"); }; assert_eq!( - Op::GetHistoryEntryRequest { + AppServerAction::GetHistoryEntry { log_id: 1, - offset: 1 + offset: 1, }, history_request_2 ); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d08ccf5068..907a4dc11e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -325,7 +325,7 @@ impl BottomPane { && !self.composer.popup_active() && let Some(status) = &self.status { - // Send Op::Interrupt + // Send interrupt request status.interrupt(); self.request_redraw(); return InputResult::None; @@ -831,7 +831,7 @@ impl Renderable for BottomPane { mod tests { use super::*; use crate::app_event::AppEvent; - use codex_core::protocol::Op; + use crate::app_event::AppServerAction; use codex_protocol::protocol::SkillScope; use crossterm::event::KeyModifiers; use insta::assert_snapshot; @@ -1197,8 +1197,8 @@ mod tests { 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" + !matches!(ev, AppEvent::AppServerAction(AppServerAction::Interrupt)), + "expected Esc to not send interrupt when dismissing skill popup" ); } assert!( @@ -1235,8 +1235,8 @@ mod tests { 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" + !matches!(ev, AppEvent::AppServerAction(AppServerAction::Interrupt)), + "expected Esc to not send interrupt while command popup is active" ); } assert_eq!(pane.composer_text(), "/"); @@ -1262,8 +1262,11 @@ mod tests { 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" + matches!( + rx.try_recv(), + Ok(AppEvent::AppServerAction(AppServerAction::Interrupt)) + ), + "expected Esc to send interrupt while a task is running" ); } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 8fe3401693..764219f741 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -18,6 +18,7 @@ mod layout; mod render; use crate::app_event::AppEvent; +use crate::app_event::AppServerAction; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::ChatComposer; @@ -29,7 +30,6 @@ use crate::bottom_pane::selection_popup_common::GenericDisplayRow; use crate::bottom_pane::selection_popup_common::measure_rows_height; use crate::render::renderable::Renderable; -use codex_core::protocol::Op; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputResponse; @@ -719,11 +719,12 @@ impl RequestUserInputOverlay { }, ); } - self.app_event_tx - .send(AppEvent::CodexOp(Op::UserInputAnswer { - id: self.request.turn_id.clone(), + self.app_event_tx.send(AppEvent::AppServerAction( + AppServerAction::UserInputAnswer { + call_id: self.request.call_id.clone(), response: RequestUserInputResponse { answers }, - })); + }, + )); if let Some(next) = self.queue.pop_front() { self.request = next; self.reset_for_request(); @@ -966,7 +967,8 @@ impl BottomPaneView for RequestUserInputOverlay { } if matches!(key_event.code, KeyCode::Esc) { - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); + self.app_event_tx + .send(AppEvent::AppServerAction(AppServerAction::Interrupt)); self.done = true; return; } @@ -1173,7 +1175,8 @@ impl BottomPaneView for RequestUserInputOverlay { fn on_ctrl_c(&mut self) -> CancellationEvent { if self.confirm_unanswered_active() { self.close_unanswered_confirmation(); - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); + self.app_event_tx + .send(AppEvent::AppServerAction(AppServerAction::Interrupt)); self.done = true; return CancellationEvent::Handled; } @@ -1182,7 +1185,8 @@ impl BottomPaneView for RequestUserInputOverlay { return CancellationEvent::Handled; } - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); + self.app_event_tx + .send(AppEvent::AppServerAction(AppServerAction::Interrupt)); self.done = true; CancellationEvent::Handled } @@ -1403,10 +1407,12 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { call_id, response }) = + event + else { panic!("expected UserInputAnswer"); }; - assert_eq!(id, "turn-1"); + assert_eq!(call_id, "call-1"); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!(answer.answers, Vec::::new()); } @@ -1425,7 +1431,8 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { response, .. }) = event + else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -1458,7 +1465,8 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { response, .. }) = event + else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -1479,7 +1487,8 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { response, .. }) = event + else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -1634,7 +1643,8 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { response, .. }) = event + else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -1658,10 +1668,10 @@ mod tests { assert_eq!(overlay.done, true); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(op) = event else { - panic!("expected CodexOp"); + let AppEvent::AppServerAction(action) = event else { + panic!("expected AppServerAction"); }; - assert_eq!(op, Op::Interrupt); + assert_eq!(action, AppServerAction::Interrupt); } #[test] @@ -1679,10 +1689,10 @@ mod tests { assert_eq!(overlay.done, true); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(op) = event else { - panic!("expected CodexOp"); + let AppEvent::AppServerAction(action) = event else { + panic!("expected AppServerAction"); }; - assert_eq!(op, Op::Interrupt); + assert_eq!(action, AppServerAction::Interrupt); } #[test] @@ -1703,10 +1713,10 @@ mod tests { assert_eq!(overlay.done, true); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(op) = event else { - panic!("expected CodexOp"); + let AppEvent::AppServerAction(action) = event else { + panic!("expected AppServerAction"); }; - assert_eq!(op, Op::Interrupt); + assert_eq!(action, AppServerAction::Interrupt); } #[test] @@ -1728,10 +1738,10 @@ mod tests { assert_eq!(overlay.done, true); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(op) = event else { - panic!("expected CodexOp"); + let AppEvent::AppServerAction(action) = event else { + panic!("expected AppServerAction"); }; - assert_eq!(op, Op::Interrupt); + assert_eq!(action, AppServerAction::Interrupt); } #[test] @@ -1906,7 +1916,8 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { response, .. }) = event + else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -1931,7 +1942,8 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { response, .. }) = event + else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -1973,7 +1985,8 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { response, .. }) = event + else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -2009,7 +2022,8 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { response, .. }) = event + else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -2094,7 +2108,8 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::AppServerAction(AppServerAction::UserInputAnswer { response, .. }) = event + else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); diff --git a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs index 7b25b9841e..4626dc451c 100644 --- a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs +++ b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs @@ -13,6 +13,7 @@ use ratatui::widgets::Block; use ratatui::widgets::Widget; use crate::app_event::AppEvent; +use crate::app_event::AppServerAction; use crate::app_event_sender::AppEventSender; use crate::key_hint; use crate::render::Insets; @@ -22,7 +23,6 @@ use crate::render::renderable::Renderable; use crate::skills_helpers::match_skill; use crate::skills_helpers::truncate_skill_name; use crate::style::user_message_style; -use codex_core::protocol::Op; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -187,10 +187,11 @@ impl SkillsToggleView { } self.complete = true; self.app_event_tx.send(AppEvent::ManageSkillsClosed); - self.app_event_tx.send(AppEvent::CodexOp(Op::ListSkills { - cwds: Vec::new(), - force_reload: true, - })); + self.app_event_tx + .send(AppEvent::AppServerAction(AppServerAction::ListSkills { + cwds: Vec::new(), + force_reload: true, + })); } fn rows_width(total_width: u16) -> u16 { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 74200e470a..0e216bcac7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -69,7 +69,6 @@ use codex_core::protocol::McpStartupStatus; use codex_core::protocol::McpStartupUpdateEvent; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; @@ -122,7 +121,6 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; -use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; use tracing::debug; @@ -133,8 +131,10 @@ const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; use crate::app_event::AppEvent; +use crate::app_event::AppServerAction; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; +use crate::app_event::TurnStartRequest; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; @@ -185,10 +185,7 @@ use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; use self::interrupts::InterruptManager; -mod agent; -use self::agent::spawn_agent; -use self::agent::spawn_agent_from_existing; -pub(crate) use self::agent::spawn_op_forwarder; +// Agent execution now lives behind the app-server client. mod session_header; use self::session_header::SessionHeader; mod skills; @@ -203,7 +200,6 @@ use codex_common::approval_presets::ApprovalPreset; use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; -use codex_core::ThreadManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_file_search::FileMatch; @@ -439,7 +435,7 @@ pub(crate) enum ExternalEditorState { /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming /// buffers, bottom-pane overlays, and transient status text) and turns key presses into user -/// intent (`Op` submissions and `AppEvent` requests). +/// intent (app-server requests and `AppEvent` requests). /// /// It is not responsible for running the agent itself; it reflects progress by updating UI state /// and by sending requests back to codex-core. @@ -449,7 +445,6 @@ pub(crate) enum ExternalEditorState { /// active work, arming the double-press quit shortcut, and requesting shutdown-first exit. pub(crate) struct ChatWidget { app_event_tx: AppEventSender, - codex_op_tx: UnboundedSender, bottom_pane: BottomPane, active_cell: Option>, /// Monotonic-ish counter used to invalidate transcript overlay caching. @@ -806,9 +801,9 @@ impl ChatWidget { if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } - // Ask codex-core to enumerate custom prompts for this session. - self.submit_op(Op::ListCustomPrompts); - self.submit_op(Op::ListSkills { + // Ask the app server to enumerate custom prompts and skills for this session. + self.submit_action(AppServerAction::ListCustomPrompts); + self.submit_action(AppServerAction::ListSkills { cwds: Vec::new(), force_reload: true, }); @@ -2036,7 +2031,7 @@ impl ChatWidget { self.had_work_activity = true; } - pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc) -> Self { + pub(crate) fn new(common: ChatWidgetInit) -> Self { let ChatWidgetInit { config, frame_requester, @@ -2055,8 +2050,6 @@ impl ChatWidget { 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_override = model.as_deref(); let model_for_header = model .clone() @@ -2083,7 +2076,6 @@ impl ChatWidget { let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), - codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, app_event_tx, @@ -2172,267 +2164,6 @@ impl ChatWidget { widget } - pub(crate) fn new_with_op_sender( - common: ChatWidgetInit, - codex_op_tx: UnboundedSender, - ) -> Self { - let ChatWidgetInit { - config, - frame_requester, - app_event_tx, - initial_user_message, - enhanced_keys_supported, - auth_manager, - models_manager, - feedback, - is_first_run, - model, - otel_manager, - } = common; - 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 model_override = model.as_deref(); - let model_for_header = model - .clone() - .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); - let active_collaboration_mask = - Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); - let header_model = active_collaboration_mask - .as_ref() - .and_then(|mask| mask.model.clone()) - .unwrap_or_else(|| model_for_header.clone()); - let fallback_custom = Settings { - model: header_model.clone(), - reasoning_effort: None, - developer_instructions: None, - }; - // Collaboration modes start in Custom mode (not activated). - let current_collaboration_mode = CollaborationMode { - mode: ModeKind::Custom, - settings: fallback_custom, - }; - - let active_cell = Some(Self::placeholder_session_header_cell(&config)); - - let mut widget = Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), - codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - animations_enabled: config.animations, - skills: None, - }), - active_cell, - active_cell_revision: 0, - config, - skills_all: Vec::new(), - skills_initial_state: None, - current_collaboration_mode, - active_collaboration_mask, - auth_manager, - models_manager, - otel_manager, - session_header: SessionHeader::new(header_model), - initial_user_message, - token_info: None, - rate_limit_snapshot: None, - plan_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - rate_limit_poller: None, - stream_controller: None, - running_commands: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - unified_exec_wait_streak: None, - task_complete_pending: false, - unified_exec_processes: Vec::new(), - agent_turn_running: false, - mcp_startup_status: None, - connectors_cache: ConnectorsCacheState::default(), - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, - thread_id: None, - forked_from: None, - saw_plan_update_this_turn: false, - queued_user_messages: VecDeque::new(), - show_welcome_banner: is_first_run, - suppress_session_configured_redraw: false, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - last_separator_elapsed_secs: None, - last_rendered_width: std::cell::Cell::new(None), - feedback, - current_rollout_path: None, - external_editor_state: ExternalEditorState::Closed, - }; - - widget.prefetch_rate_limits(); - 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.sync_personality_command_enabled(); - - widget - } - - /// Create a ChatWidget attached to an existing conversation (e.g., a fork). - pub(crate) fn new_from_existing( - common: ChatWidgetInit, - conversation: std::sync::Arc, - session_configured: codex_core::protocol::SessionConfiguredEvent, - ) -> Self { - let ChatWidgetInit { - config, - frame_requester, - app_event_tx, - initial_user_message, - enhanced_keys_supported, - auth_manager, - models_manager, - feedback, - model, - otel_manager, - .. - } = common; - let model = model.filter(|m| !m.trim().is_empty()); - let mut rng = rand::rng(); - let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - - let model_override = model.as_deref(); - let header_model = model - .clone() - .unwrap_or_else(|| session_configured.model.clone()); - let active_collaboration_mask = - Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); - let header_model = active_collaboration_mask - .as_ref() - .and_then(|mask| mask.model.clone()) - .unwrap_or(header_model); - - let codex_op_tx = - spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); - - let fallback_custom = Settings { - model: header_model.clone(), - reasoning_effort: None, - developer_instructions: None, - }; - // Collaboration modes start in Custom mode (not activated). - let current_collaboration_mode = CollaborationMode { - mode: ModeKind::Custom, - settings: fallback_custom, - }; - - let mut widget = Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), - codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - animations_enabled: config.animations, - skills: None, - }), - active_cell: None, - active_cell_revision: 0, - config, - skills_all: Vec::new(), - skills_initial_state: None, - current_collaboration_mode, - active_collaboration_mask, - auth_manager, - models_manager, - otel_manager, - session_header: SessionHeader::new(header_model), - initial_user_message, - token_info: None, - rate_limit_snapshot: None, - plan_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - rate_limit_poller: None, - stream_controller: None, - running_commands: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - unified_exec_wait_streak: None, - task_complete_pending: false, - unified_exec_processes: Vec::new(), - agent_turn_running: false, - mcp_startup_status: None, - connectors_cache: ConnectorsCacheState::default(), - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - 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, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - saw_plan_update_this_turn: false, - last_separator_elapsed_secs: None, - last_rendered_width: std::cell::Cell::new(None), - feedback, - current_rollout_path: None, - external_editor_state: ExternalEditorState::Closed, - }; - - widget.prefetch_rate_limits(); - 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.sync_personality_command_enabled(); - #[cfg(target_os = "windows")] - widget.bottom_pane.set_windows_degraded_sandbox_active( - codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && matches!( - WindowsSandboxLevel::from_config(&widget.config), - WindowsSandboxLevel::RestrictedToken - ), - ); - widget.update_collaboration_mode_indicator(); - - widget - } - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { @@ -2457,6 +2188,19 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::SUPER) && c.eq_ignore_ascii_case(&'d') => { + if self.on_ctrl_d() { + return; + } + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -2657,7 +2401,8 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); - self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + self.app_event_tx + .send(AppEvent::AppServerAction(AppServerAction::Compact)); } SlashCommand::Review => { self.open_review_popup(); @@ -2762,7 +2507,7 @@ impl ChatWidget { self.request_quit_without_confirmation(); } // SlashCommand::Undo => { - // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); + // Undo shortcut pending: would dispatch an app-server action. // } SlashCommand::Diff => { self.add_diff_in_progress(); @@ -2868,7 +2613,7 @@ impl ChatWidget { self.dispatch_command(cmd); } SlashCommand::Review if !trimmed.is_empty() => { - self.submit_op(Op::Review { + self.submit_action(AppServerAction::ReviewStart { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: trimmed.to_string(), @@ -2975,7 +2720,7 @@ impl ChatWidget { ))); return; } - self.submit_op(Op::RunUserShellCommand { + self.submit_action(AppServerAction::RunUserShellCommand { command: cmd.to_string(), }); return; @@ -3035,30 +2780,23 @@ impl ChatWidget { .model_personality .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); - let op = Op::UserTurn { + let request = TurnStartRequest { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), model: effective_mode.model().to_string(), effort: effective_mode.reasoning_effort(), - summary: self.config.model_reasoning_summary, - final_output_json_schema: None, + summary: Some(self.config.model_reasoning_summary), collaboration_mode, personality, + output_schema: None, }; - - self.codex_op_tx.send(op).unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); + self.submit_action(AppServerAction::TurnStart(request)); // Persist the text to cross-session message history. if !text.is_empty() { - self.codex_op_tx - .send(Op::AddToHistory { text: text.clone() }) - .unwrap_or_else(|e| { - tracing::error!("failed to send AddHistory op: {e}"); - }); + self.submit_action(AppServerAction::AddToHistory { text: text.clone() }); } // Only show the text portion in conversation history. @@ -3211,7 +2949,7 @@ impl ChatWidget { EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), EventMsg::SkillsUpdateAvailable => { - self.submit_op(Op::ListSkills { + self.submit_action(AppServerAction::ListSkills { cwds: Vec::new(), force_reload: true, }); @@ -3542,17 +3280,6 @@ impl ChatWidget { let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; let switch_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - windows_sandbox_level: None, - model: Some(switch_model.clone()), - effort: Some(Some(default_effort)), - summary: None, - collaboration_mode: None, - personality: None, - })); tx.send(AppEvent::UpdateModel(switch_model.clone())); tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); })]; @@ -3665,17 +3392,6 @@ impl ChatWidget { let name = Self::personality_label(personality).to_string(); let description = Some(Self::personality_description(personality).to_string()); let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - model: None, - effort: None, - summary: None, - collaboration_mode: None, - windows_sandbox_level: None, - personality: Some(personality), - })); tx.send(AppEvent::UpdatePersonality(personality)); tx.send(AppEvent::PersistPersonalitySelection { personality }); })]; @@ -3936,17 +3652,6 @@ impl ChatWidget { let effort_label = effort_for_action .map(|effort| effort.to_string()) .unwrap_or_else(|| "default".to_string()); - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - windows_sandbox_level: None, - model: Some(model_for_action.clone()), - effort: Some(effort_for_action), - summary: None, - collaboration_mode: None, - personality: None, - })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); tx.send(AppEvent::PersistModelSelection { @@ -4109,18 +3814,6 @@ impl ChatWidget { } fn apply_model_and_effort(&self, model: String, effort: Option) { - self.app_event_tx - .send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - windows_sandbox_level: None, - model: Some(model.clone()), - effort: Some(effort), - summary: None, - collaboration_mode: None, - personality: None, - })); self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); self.app_event_tx .send(AppEvent::UpdateReasoningEffort(effort)); @@ -4299,17 +3992,6 @@ impl ChatWidget { ) -> Vec { vec![Box::new(move |tx| { let sandbox_clone = sandbox.clone(); - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(approval), - sandbox_policy: Some(sandbox_clone.clone()), - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - collaboration_mode: None, - personality: None, - })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); })] @@ -5147,7 +4829,8 @@ impl ChatWidget { /// Update the active collaboration mask. /// /// When collaboration modes are enabled and a preset is selected (not Custom), - /// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`. + /// the current mode is attached to submissions as `turn/start` with + /// `collaboration_mode` set. pub(crate) fn set_collaboration_mask(&mut self, mask: CollaborationModeMask) { if !self.collaboration_modes_enabled() { return; @@ -5232,7 +4915,7 @@ impl ChatWidget { if self.config.mcp_servers.is_empty() { self.add_to_history(history_cell::empty_mcp_output()); } else { - self.submit_op(Op::ListMcpTools); + self.submit_action(AppServerAction::ListMcpTools); } } @@ -5390,7 +5073,7 @@ impl ChatWidget { /// Handles a Ctrl+C press at the chat-widget layer. /// /// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom - /// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut + /// pane. If cancellable work is active, Ctrl+C also submits an interrupt after the shortcut /// is armed. /// /// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first @@ -5413,7 +5096,7 @@ impl ChatWidget { if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if self.is_cancellable_work_active() { - self.submit_op(Op::Interrupt); + self.submit_action(AppServerAction::Interrupt); } else { self.request_quit_without_confirmation(); } @@ -5430,7 +5113,7 @@ impl ChatWidget { self.arm_quit_shortcut(key); if self.is_cancellable_work_active() { - self.submit_op(Op::Interrupt); + self.submit_action(AppServerAction::Interrupt); } } @@ -5533,16 +5216,14 @@ impl ChatWidget { pub(crate) fn clear_esc_backtrack_hint(&mut self) { self.bottom_pane.clear_esc_backtrack_hint(); } - /// Forward an `Op` directly to codex. - pub(crate) fn submit_op(&mut self, op: Op) { - // Record outbound operation for session replay fidelity. - crate::session_log::log_outbound_op(&op); - if matches!(&op, Op::Review { .. }) && !self.bottom_pane.is_task_running() { + /// Forward an app-server action via the app event loop. + pub(crate) fn submit_action(&mut self, action: AppServerAction) { + if matches!(&action, AppServerAction::ReviewStart { .. }) + && !self.bottom_pane.is_task_running() + { self.bottom_pane.set_task_running(true); } - if let Err(e) = self.codex_op_tx.send(op) { - tracing::error!("failed to submit op: {e}"); - } + self.app_event_tx.send(AppEvent::AppServerAction(action)); } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { @@ -5598,7 +5279,7 @@ impl ChatWidget { items.push(SelectionItem { name: "Review uncommitted changes".to_string(), actions: vec![Box::new(move |tx: &AppEventSender| { - tx.send(AppEvent::CodexOp(Op::Review { + tx.send(AppEvent::AppServerAction(AppServerAction::ReviewStart { review_request: ReviewRequest { target: ReviewTarget::UncommittedChanges, user_facing_hint: None, @@ -5651,7 +5332,7 @@ impl ChatWidget { items.push(SelectionItem { name: format!("{current_branch} -> {branch}"), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { + tx3.send(AppEvent::AppServerAction(AppServerAction::ReviewStart { review_request: ReviewRequest { target: ReviewTarget::BaseBranch { branch: branch.clone(), @@ -5688,7 +5369,7 @@ impl ChatWidget { items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { + tx3.send(AppEvent::AppServerAction(AppServerAction::ReviewStart { review_request: ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), @@ -5725,7 +5406,7 @@ impl ChatWidget { if trimmed.is_empty() { return; } - tx.send(AppEvent::CodexOp(Op::Review { + tx.send(AppEvent::AppServerAction(AppServerAction::ReviewStart { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: trimmed, @@ -5970,7 +5651,7 @@ pub(crate) fn show_review_commit_picker_with_entries( items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { + tx3.send(AppEvent::AppServerAction(AppServerAction::ReviewStart { review_request: ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs deleted file mode 100644 index c902e5c638..0000000000 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::sync::Arc; - -use codex_core::CodexThread; -use codex_core::NewThread; -use codex_core::ThreadManager; -use codex_core::config::Config; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use tokio::sync::mpsc::UnboundedSender; -use tokio::sync::mpsc::unbounded_channel; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; - -/// Spawn the agent bootstrapper and op forwarding loop, returning the -/// `UnboundedSender` used by the UI to submit operations. -pub(crate) fn spawn_agent( - config: Config, - app_event_tx: AppEventSender, - server: Arc, -) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - let app_event_tx_clone = app_event_tx; - tokio::spawn(async move { - let NewThread { - thread, - session_configured, - .. - } = match server.start_thread(config).await { - Ok(v) => v, - Err(err) => { - let message = format!("Failed to initialize codex: {err}"); - tracing::error!("{message}"); - app_event_tx_clone.send(AppEvent::CodexEvent(Event { - id: "".to_string(), - msg: EventMsg::Error(err.to_error_event(None)), - })); - app_event_tx_clone.send(AppEvent::FatalExitRequest(message)); - tracing::error!("failed to initialize codex: {err}"); - return; - } - }; - - // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_core::protocol::Event { - // The `id` does not matter for rendering, so we can use a fake value. - id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), - }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); - - let thread_clone = thread.clone(); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - let id = thread_clone.submit(op).await; - if let Err(e) = id { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - while let Ok(event) = thread.next_event().await { - app_event_tx_clone.send(AppEvent::CodexEvent(event)); - } - }); - - codex_op_tx -} - -/// Spawn agent loops for an existing thread (e.g., a forked thread). -/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent -/// events and accepts Ops for submission. -pub(crate) fn spawn_agent_from_existing( - thread: std::sync::Arc, - session_configured: codex_core::protocol::SessionConfiguredEvent, - app_event_tx: AppEventSender, -) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - let app_event_tx_clone = app_event_tx; - tokio::spawn(async move { - // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_core::protocol::Event { - id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), - }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); - - let thread_clone = thread.clone(); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - let id = thread_clone.submit(op).await; - if let Err(e) = id { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - while let Ok(event) = thread.next_event().await { - app_event_tx_clone.send(AppEvent::CodexEvent(event)); - } - }); - - codex_op_tx -} - -/// Spawn an op-forwarding loop for an existing thread without subscribing to events. -pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - if let Err(e) = thread.submit(op).await { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - codex_op_tx -} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d8f22dc8e3..4db2b1bb7a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6,6 +6,7 @@ use super::*; use crate::app_event::AppEvent; +use crate::app_event::AppServerAction; use crate::app_event::ExitMode; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::LocalImageAttachment; @@ -42,7 +43,6 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpStartupCompleteEvent; use codex_core::protocol::McpStartupStatus; use codex_core::protocol::McpStartupUpdateEvent; -use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::RateLimitWindow; @@ -126,7 +126,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { #[tokio::test] async fn resumed_initial_messages_render_history() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); @@ -184,7 +184,7 @@ 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 (mut chat, mut rx) = make_chatwidget_manual(None).await; let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); @@ -244,7 +244,7 @@ async fn replayed_user_message_preserves_text_elements_and_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 (mut chat, mut rx) = make_chatwidget_manual(None).await; let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); @@ -280,10 +280,26 @@ async fn submission_preserves_text_elements_and_local_images() { .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:?}"), - }; + let mut items = None; + let mut user_cell = None; + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::AppServerAction(AppServerAction::TurnStart(request)) => { + items = Some(request.items); + } + AppEvent::InsertHistoryCell(cell) => { + if let Some(cell) = cell.as_any().downcast_ref::() { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + } + } + _ => {} + } + } + let items = items.expect("expected TurnStart request"); assert_eq!(items.len(), 2); assert_eq!( items[0], @@ -299,20 +315,6 @@ async fn submission_preserves_text_elements_and_local_images() { } ); - 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); @@ -322,7 +324,7 @@ async fn submission_preserves_text_elements_and_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 (mut chat, _rx) = make_chatwidget_manual(None).await; let first_placeholder = "[Image #1]"; let first_text = format!("{first_placeholder} first"); @@ -542,7 +544,7 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { /// Entering review mode uses the hint provided by the review request. #[tokio::test] async fn entered_review_mode_uses_request_hint() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "review-start".into(), @@ -563,7 +565,7 @@ async fn entered_review_mode_uses_request_hint() { /// Entering review mode renders the current changes banner when requested. #[tokio::test] async fn entered_review_mode_defaults_to_current_changes_banner() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "review-start".into(), @@ -582,7 +584,7 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { /// Exiting review restores the pre-review context window indicator. #[tokio::test] async fn review_restores_context_window_indicator() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let context_window = 13_000; let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline. @@ -631,7 +633,7 @@ async fn review_restores_context_window_indicator() { /// Receiving a TokenCount event without usage clears the context indicator. #[tokio::test] async fn token_count_none_resets_context_indicator() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; let context_window = 13_000; let pre_compact_tokens = 12_700; @@ -657,7 +659,7 @@ async fn token_count_none_resets_context_indicator() { #[tokio::test] async fn context_indicator_shows_used_tokens_when_window_unknown() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("unknown-model")).await; chat.config.model_context_window = None; let auto_compact_limit = 200_000; @@ -701,11 +703,11 @@ async fn helpers_are_available_and_do_not_panic() { let cfg = test_config().await; let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); - 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 models_manager = Arc::new(ModelsManager::new( + cfg.codex_home.clone(), + auth_manager.clone(), + )); let init = ChatWidgetInit { config: cfg, frame_requester: FrameRequester::test_dummy(), @@ -713,13 +715,13 @@ async fn helpers_are_available_and_do_not_panic() { initial_user_message: None, enhanced_keys_supported: false, auth_manager, - models_manager: thread_manager.get_models_manager(), + models_manager, feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, model: Some(resolved_model), otel_manager, }; - let mut w = ChatWidget::new(init, thread_manager); + let mut w = ChatWidget::new(init); // Basic construction sanity. let _ = &mut w; } @@ -742,14 +744,9 @@ fn test_otel_manager(config: &Config, model: &str) -> OtelManager { // --- Helpers for tests that need direct construction and event draining --- async fn make_chatwidget_manual( model_override: Option<&str>, -) -> ( - ChatWidget, - tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, -) { +) -> (ChatWidget, tokio::sync::mpsc::UnboundedReceiver) { let (tx_raw, rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); - let (op_tx, op_rx) = unbounded_channel::(); let mut cfg = test_config().await; let resolved_model = model_override .map(str::to_owned) @@ -785,7 +782,6 @@ async fn make_chatwidget_manual( let current_collaboration_mode = base_mode; let mut widget = ChatWidget { app_event_tx, - codex_op_tx: op_tx, bottom_pane: bottom, active_cell: None, active_cell_revision: 0, @@ -841,18 +837,20 @@ async fn make_chatwidget_manual( external_editor_state: ExternalEditorState::Closed, }; widget.set_model(&resolved_model); - (widget, rx, op_rx) + (widget, 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 { +// ChatWidget may emit other `AppEvent`s on the same channel; this helper filters until a submit +// action appears. +fn next_submit_action(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> AppServerAction { loop { - match op_rx.try_recv() { - Ok(op @ Op::UserTurn { .. }) => return op, + match rx.try_recv() { + Ok(AppEvent::AppServerAction(action @ AppServerAction::TurnStart(_))) => { + return action; + } Ok(_) => continue, - Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), - Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), + Err(TryRecvError::Empty) => panic!("expected a submit action but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected submit action but channel closed"), } } } @@ -868,7 +866,7 @@ fn set_chatgpt_auth(chat: &mut ChatWidget) { #[tokio::test] async fn worked_elapsed_from_resets_when_timer_restarts() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; assert_eq!(chat.worked_elapsed_from(5), 5); assert_eq!(chat.worked_elapsed_from(9), 4); // Simulate status timer resetting (e.g., status indicator recreated for a new task). @@ -880,11 +878,10 @@ pub(crate) async fn make_chatwidget_manual_with_sender() -> ( ChatWidget, AppEventSender, tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, ) { - let (widget, rx, op_rx) = make_chatwidget_manual(None).await; + let (widget, rx) = make_chatwidget_manual(None).await; let app_event_tx = widget.app_event_tx.clone(); - (widget, app_event_tx, rx, op_rx) + (widget, app_event_tx, rx) } fn drain_insert_history( @@ -978,7 +975,7 @@ async fn test_rate_limit_warnings_monthly() { #[tokio::test] async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { primary: None, @@ -1027,7 +1024,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { #[tokio::test] async fn rate_limit_snapshot_updates_and_retains_plan_type() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { primary: Some(RateLimitWindow { @@ -1080,7 +1077,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { #[tokio::test] async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { - let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await; + let (mut chat, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await; chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); @@ -1095,7 +1092,7 @@ async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { #[tokio::test] async fn rate_limit_switch_prompt_shows_once_per_session() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _) = make_chatwidget_manual(Some("gpt-5")).await; chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.on_rate_limit_snapshot(Some(snapshot(90.0))); @@ -1119,7 +1116,7 @@ async fn rate_limit_switch_prompt_shows_once_per_session() { #[tokio::test] async fn rate_limit_switch_prompt_respects_hidden_notice() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _) = make_chatwidget_manual(Some("gpt-5")).await; chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.config.notices.hide_rate_limit_model_nudge = Some(true); @@ -1134,7 +1131,7 @@ async fn rate_limit_switch_prompt_respects_hidden_notice() { #[tokio::test] async fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _) = make_chatwidget_manual(Some("gpt-5")).await; chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.bottom_pane.set_task_running(true); @@ -1154,7 +1151,7 @@ async fn rate_limit_switch_prompt_defers_until_task_complete() { #[tokio::test] async fn rate_limit_switch_prompt_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); @@ -1167,7 +1164,7 @@ async fn rate_limit_switch_prompt_popup_snapshot() { #[tokio::test] async fn plan_implementation_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.open_plan_implementation_prompt(); let popup = render_bottom_popup(&chat, 80); @@ -1176,7 +1173,7 @@ async fn plan_implementation_popup_snapshot() { #[tokio::test] async fn plan_implementation_popup_no_selected_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.open_plan_implementation_prompt(); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); @@ -1186,7 +1183,7 @@ async fn plan_implementation_popup_no_selected_snapshot() { #[tokio::test] async fn plan_implementation_popup_yes_emits_submit_message_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, mut rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.open_plan_implementation_prompt(); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -1205,7 +1202,7 @@ async fn plan_implementation_popup_yes_emits_submit_message_event() { #[tokio::test] async fn submit_user_message_with_mode_sets_coding_collaboration_mode() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, mut rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); @@ -1213,25 +1210,26 @@ async fn submit_user_message_with_mode_sets_coding_collaboration_mode() { .expect("expected code collaboration mode"); chat.submit_user_message_with_mode("Implement the plan.".to_string(), code_mode); - match next_submit_op(&mut op_rx) { - Op::UserTurn { - collaboration_mode: + match next_submit_action(&mut rx) { + AppServerAction::TurnStart(request) => { + assert!(matches!( + request.collaboration_mode, Some(CollaborationMode { mode: ModeKind::Code, .. - }), - personality: None, - .. - } => {} + }) + )); + assert_eq!(request.personality, None); + } other => { - panic!("expected Op::UserTurn with code collab mode, got {other:?}") + panic!("expected TurnStart with code collab mode, got {other:?}") } } } #[tokio::test] async fn plan_implementation_popup_skips_replayed_turn_complete() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); let plan_mask = collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) @@ -1251,7 +1249,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { #[tokio::test] async fn plan_implementation_popup_skips_when_messages_queued() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); let plan_mask = collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) @@ -1271,7 +1269,7 @@ async fn plan_implementation_popup_skips_when_messages_queued() { #[tokio::test] async fn plan_implementation_popup_shows_on_plan_update_without_message() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); let plan_mask = collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) @@ -1297,7 +1295,7 @@ async fn plan_implementation_popup_shows_on_plan_update_without_message() { #[tokio::test] async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.set_feature_enabled(Feature::CollaborationModes, true); @@ -1332,7 +1330,7 @@ async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() { #[tokio::test] async fn exec_approval_emits_proposed_command_and_decision_history() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Trigger an exec approval request with a short, single-line command let ev = ExecApprovalRequestEvent { @@ -1376,7 +1374,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { #[tokio::test] async fn exec_approval_decision_truncates_multiline_and_long_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Multiline command: modal should show full command, history records decision only let ev_multi = ExecApprovalRequestEvent { @@ -1595,7 +1593,7 @@ fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { #[tokio::test] async fn empty_enter_during_task_does_not_queue() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Simulate running task so submissions would normally be queued. chat.bottom_pane.set_task_running(true); @@ -1609,7 +1607,7 @@ async fn empty_enter_during_task_does_not_queue() { #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(true); @@ -1642,7 +1640,7 @@ async fn alt_up_edits_most_recent_queued_message() { /// is queued repeatedly. #[tokio::test] async fn enqueueing_history_prompt_multiple_times_is_stable() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); // Submit an initial prompt to seed history. @@ -1670,7 +1668,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { #[tokio::test] async fn streaming_final_answer_keeps_task_running_state() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); @@ -1689,19 +1687,23 @@ async fn streaming_final_answer_keeps_task_running_state() { chat.queued_user_messages.front().unwrap().text, "queued submission" ); - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + match rx.try_recv() { + Err(TryRecvError::Empty) | Ok(AppEvent::StartCommitAnimation) => {} + other => panic!("expected no app event, got {other:?}"), + } + while rx.try_recv().is_ok() {} chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - match op_rx.try_recv() { - Ok(Op::Interrupt) => {} - other => panic!("expected Op::Interrupt, got {other:?}"), + match rx.try_recv() { + Ok(AppEvent::AppServerAction(AppServerAction::Interrupt)) => {} + other => panic!("expected Interrupt action, got {other:?}"), } assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); } #[tokio::test] async fn ctrl_c_shutdown_works_with_caps_lock() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); @@ -1710,7 +1712,7 @@ async fn ctrl_c_shutdown_works_with_caps_lock() { #[tokio::test] async fn ctrl_d_quits_without_prompt() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); @@ -1718,7 +1720,7 @@ async fn ctrl_d_quits_without_prompt() { #[tokio::test] async fn ctrl_d_with_modal_open_does_not_quit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.open_approvals_popup(); chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); @@ -1728,7 +1730,7 @@ async fn ctrl_d_with_modal_open_does_not_quit() { #[tokio::test] async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.bottom_pane.insert_str("draft message "); chat.bottom_pane @@ -1741,7 +1743,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); assert!(chat.bottom_pane.composer_text().is_empty()); - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); @@ -1759,7 +1761,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { #[tokio::test] async fn exec_history_cell_shows_working_then_completed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Begin command let begin = begin_exec(&mut chat, "call-1", "echo done"); @@ -1789,7 +1791,7 @@ async fn exec_history_cell_shows_working_then_completed() { #[tokio::test] async fn exec_history_cell_shows_working_then_failed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Begin command let begin = begin_exec(&mut chat, "call-2", "false"); @@ -1813,7 +1815,7 @@ async fn exec_history_cell_shows_working_then_failed() { #[tokio::test] async fn exec_end_without_begin_uses_event_command() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let command = vec![ "bash".to_string(), "-lc".to_string(), @@ -1856,7 +1858,7 @@ async fn exec_end_without_begin_uses_event_command() { #[tokio::test] async fn exec_history_shows_unified_exec_startup_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.on_task_started(); let begin = begin_exec_with_source( @@ -1883,7 +1885,7 @@ async fn exec_history_shows_unified_exec_startup_commands() { #[tokio::test] async fn exec_history_shows_unified_exec_tool_calls() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.on_task_started(); let begin = begin_exec_with_source( @@ -1900,7 +1902,7 @@ async fn exec_history_shows_unified_exec_tool_calls() { #[tokio::test] async fn unified_exec_end_after_task_complete_is_suppressed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.on_task_started(); let begin = begin_exec_with_source( @@ -1923,7 +1925,7 @@ 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; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.on_task_started(); chat.on_task_complete(None, false); @@ -1945,7 +1947,7 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() { #[tokio::test] async fn unified_exec_wait_after_final_agent_message_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { @@ -1979,7 +1981,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { #[tokio::test] async fn unified_exec_wait_before_streamed_agent_message_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { @@ -2018,7 +2020,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { #[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; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.on_task_started(); chat.unified_exec_processes.push(UnifiedExecProcessSummary { key: "proc-1".to_string(), @@ -2040,7 +2042,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; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-1", "proc-1", "just fix"); @@ -2068,7 +2070,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; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-2", "proc-2", "just fix"); @@ -2085,7 +2087,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; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-3", "proc-3", "just fix"); @@ -2129,7 +2131,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { /// OpenReviewCustomPrompt to the app event channel. #[tokio::test] async fn review_popup_custom_prompt_action_sends_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Open the preset selection popup chat.open_review_popup(); @@ -2154,7 +2156,7 @@ async fn review_popup_custom_prompt_action_sends_event() { #[tokio::test] async fn slash_init_skips_when_project_doc_exists() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let tempdir = tempdir().unwrap(); let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); std::fs::write(&existing_path, "existing instructions").unwrap(); @@ -2162,12 +2164,22 @@ async fn slash_init_skips_when_project_doc_exists() { chat.dispatch_command(SlashCommand::Init); - match op_rx.try_recv() { - Err(TryRecvError::Empty) => {} - other => panic!("expected no Codex op to be sent, got {other:?}"), + let mut cells = Vec::new(); + while let Ok(ev) = rx.try_recv() { + match ev { + AppEvent::InsertHistoryCell(cell) => { + let mut lines = cell.display_lines(80); + if !cell.is_stream_continuation() && !cells.is_empty() && !lines.is_empty() { + lines.insert(0, "".into()); + } + cells.push(lines); + } + AppEvent::AppServerAction(action) => { + panic!("expected no app-server action, got {action:?}"); + } + _ => {} + } } - - let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one info message"); let rendered = lines_to_single_string(&cells[0]); assert!( @@ -2186,7 +2198,7 @@ async fn slash_init_skips_when_project_doc_exists() { #[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; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); let initial = chat.current_collaboration_mode().clone(); @@ -2212,7 +2224,7 @@ async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { #[tokio::test] async fn collab_slash_command_opens_picker_and_updates_mode() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); @@ -2233,43 +2245,45 @@ async fn collab_slash_command_opens_picker_and_updates_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: + match next_submit_action(&mut rx) { + AppServerAction::TurnStart(request) => { + assert!(matches!( + request.collaboration_mode, Some(CollaborationMode { mode: ModeKind::Code, .. - }), - personality: None, - .. - } => {} + }) + )); + assert_eq!(request.personality, None); + } other => { - panic!("expected Op::UserTurn with code collab mode, got {other:?}") + panic!("expected TurnStart with code collab mode, got {other:?}") } } 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 { - collaboration_mode: + match next_submit_action(&mut rx) { + AppServerAction::TurnStart(request) => { + assert!(matches!( + request.collaboration_mode, Some(CollaborationMode { mode: ModeKind::Code, .. - }), - personality: None, - .. - } => {} + }) + )); + assert_eq!(request.personality, None); + } other => { - panic!("expected Op::UserTurn with code collab mode, got {other:?}") + panic!("expected TurnStart with code collab mode, got {other:?}") } } } #[tokio::test] async fn plan_slash_command_switches_to_plan_mode() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, true); let initial = chat.current_collaboration_mode().clone(); @@ -2294,11 +2308,11 @@ async fn collaboration_modes_defaults_to_code_on_startup() { .expect("config"); let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); - 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 models_manager = Arc::new(ModelsManager::new( + cfg.codex_home.clone(), + auth_manager.clone(), + )); let init = ChatWidgetInit { config: cfg, frame_requester: FrameRequester::test_dummy(), @@ -2306,14 +2320,14 @@ async fn collaboration_modes_defaults_to_code_on_startup() { initial_user_message: None, enhanced_keys_supported: false, auth_manager, - models_manager: thread_manager.get_models_manager(), + models_manager, feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, model: Some(resolved_model.clone()), otel_manager, }; - let chat = ChatWidget::new(init, thread_manager); + let chat = ChatWidget::new(init); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Code); assert_eq!(chat.current_model(), resolved_model); } @@ -2338,11 +2352,11 @@ async fn experimental_mode_plan_applies_on_startup() { .expect("config"); let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); - 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 models_manager = Arc::new(ModelsManager::new( + cfg.codex_home.clone(), + auth_manager.clone(), + )); let init = ChatWidgetInit { config: cfg, frame_requester: FrameRequester::test_dummy(), @@ -2350,21 +2364,21 @@ async fn experimental_mode_plan_applies_on_startup() { initial_user_message: None, enhanced_keys_supported: false, auth_manager, - models_manager: thread_manager.get_models_manager(), + models_manager, feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, model: Some(resolved_model.clone()), otel_manager, }; - let chat = ChatWidget::new(init, thread_manager); + let chat = ChatWidget::new(init); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); assert_eq!(chat.current_model(), resolved_model); } #[tokio::test] async fn set_model_updates_active_collaboration_mask() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5.1")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); let plan_mask = collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) @@ -2379,7 +2393,7 @@ async fn set_model_updates_active_collaboration_mask() { #[tokio::test] async fn set_reasoning_effort_updates_active_collaboration_mask() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5.1")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); let plan_mask = collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) @@ -2394,30 +2408,27 @@ async fn set_reasoning_effort_updates_active_collaboration_mask() { #[tokio::test] async fn collab_mode_is_not_sent_until_selected() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut 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(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - match next_submit_op(&mut op_rx) { - Op::UserTurn { - collaboration_mode, - personality: None, - .. - } => { - assert_eq!(collaboration_mode, None); + match next_submit_action(&mut rx) { + AppServerAction::TurnStart(request) => { + assert_eq!(request.collaboration_mode, None); + assert_eq!(request.personality, None); } other => { - panic!("expected Op::UserTurn, got {other:?}") + panic!("expected TurnStart, got {other:?}") } } } #[tokio::test] async fn collab_mode_enabling_keeps_custom_until_selected() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, true); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Custom); assert_eq!(chat.current_collaboration_mode().mode, ModeKind::Custom); @@ -2425,7 +2436,7 @@ async fn collab_mode_enabling_keeps_custom_until_selected() { #[tokio::test] async fn user_turn_includes_personality_from_config() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("bengalfox")).await; + let (mut chat, mut rx) = make_chatwidget_manual(Some("bengalfox")).await; chat.set_feature_enabled(Feature::Personality, true); chat.thread_id = Some(ThreadId::new()); chat.set_model("bengalfox"); @@ -2434,18 +2445,17 @@ async fn user_turn_includes_personality_from_config() { 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 { - personality: Some(Personality::Friendly), - .. - } => {} - other => panic!("expected Op::UserTurn with friendly personality, got {other:?}"), + match next_submit_action(&mut rx) { + AppServerAction::TurnStart(request) => { + assert_eq!(request.personality, Some(Personality::Friendly)); + } + other => panic!("expected TurnStart with friendly personality, got {other:?}"), } } #[tokio::test] async fn slash_quit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::Quit); @@ -2454,7 +2464,7 @@ async fn slash_quit_requests_exit() { #[tokio::test] async fn slash_exit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::Exit); @@ -2463,7 +2473,7 @@ async fn slash_exit_requests_exit() { #[tokio::test] async fn slash_resume_opens_picker() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::Resume); @@ -2472,7 +2482,7 @@ async fn slash_resume_opens_picker() { #[tokio::test] async fn slash_fork_requests_current_fork() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::Fork); @@ -2481,7 +2491,7 @@ async fn slash_fork_requests_current_fork() { #[tokio::test] async fn slash_rollout_displays_current_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl"); chat.current_rollout_path = Some(rollout_path.clone()); @@ -2498,7 +2508,7 @@ async fn slash_rollout_displays_current_path() { #[tokio::test] async fn slash_rollout_handles_missing_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::Rollout); @@ -2517,7 +2527,7 @@ async fn slash_rollout_handles_missing_path() { #[tokio::test] async fn undo_success_events_render_info_messages() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "turn-1".to_string(), @@ -2554,7 +2564,7 @@ async fn undo_success_events_render_info_messages() { #[tokio::test] async fn undo_failure_events_render_error_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "turn-2".to_string(), @@ -2589,7 +2599,7 @@ async fn undo_failure_events_render_error_message() { #[tokio::test] async fn undo_started_hides_interrupt_hint() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "turn-hint".to_string(), @@ -2609,7 +2619,7 @@ async fn undo_started_hides_interrupt_hint() { /// The commit picker shows only commit subjects (no timestamps). #[tokio::test] async fn review_commit_picker_shows_subjects_without_timestamps() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Open the Review presets parent popup. chat.open_review_popup(); @@ -2667,21 +2677,21 @@ async fn review_commit_picker_shows_subjects_without_timestamps() { ); } -/// Submitting the custom prompt view sends Op::Review with the typed prompt +/// Submitting the custom prompt view sends a review action with the typed prompt /// and uses the same text for the user-facing hint. #[tokio::test] -async fn custom_prompt_submit_sends_review_op() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; +async fn custom_prompt_submit_sends_review_action() { + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.show_review_custom_prompt(); // Paste prompt text via ChatWidget handler, then submit chat.handle_paste(" please audit dependencies ".to_string()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt + // Expect AppServerAction::ReviewStart with trimmed prompt let evt = rx.try_recv().expect("expected one app event"); match evt { - AppEvent::CodexOp(Op::Review { review_request }) => { + AppEvent::AppServerAction(AppServerAction::ReviewStart { review_request }) => { assert_eq!( review_request, ReviewRequest { @@ -2699,19 +2709,19 @@ async fn custom_prompt_submit_sends_review_op() { /// Hitting Enter on an empty custom prompt view does not submit. #[tokio::test] async fn custom_prompt_enter_empty_does_not_send() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.show_review_custom_prompt(); // Enter without any text chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - // No AppEvent::CodexOp should be sent + // No app event should be sent assert!(rx.try_recv().is_err(), "no app event should be sent"); } #[tokio::test] async fn view_image_tool_call_adds_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let image_path = chat.config.cwd.join("example.png"); chat.handle_codex_event(Event { @@ -2732,7 +2742,7 @@ async fn view_image_tool_call_adds_history_cell() { // marker (replacing the spinner) and flushes it into history. #[tokio::test] async fn interrupt_exec_marks_failed_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Begin a long-running command so we have an active exec cell with a spinner. begin_exec(&mut chat, "call-int", "sleep 1"); @@ -2761,7 +2771,7 @@ async fn interrupt_exec_marks_failed_snapshot() { // suggesting the user to tell the model what to do differently and to use /feedback. #[tokio::test] async fn interrupted_turn_error_message_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Simulate an in-progress task so the widget is in a running state. chat.handle_codex_event(Event { @@ -2792,7 +2802,7 @@ async fn interrupted_turn_error_message_snapshot() { /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[tokio::test] async fn review_custom_prompt_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Open the Review presets parent popup. chat.open_review_popup(); @@ -2827,7 +2837,7 @@ async fn review_custom_prompt_escape_navigates_back_then_dismisses() { /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[tokio::test] async fn review_branch_picker_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Open the Review presets parent popup. chat.open_review_popup(); @@ -2914,7 +2924,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { #[tokio::test] async fn experimental_features_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; let features = vec![ ExperimentalFeatureItem { @@ -2939,7 +2949,7 @@ async fn experimental_features_popup_snapshot() { #[tokio::test] async fn experimental_features_toggle_saves_on_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let expected_feature = Feature::GhostCommit; let view = ExperimentalFeaturesView::new( @@ -2979,7 +2989,7 @@ async fn experimental_features_toggle_saves_on_exit() { #[tokio::test] async fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; chat.thread_id = Some(ThreadId::new()); chat.open_model_popup(); @@ -2989,7 +2999,7 @@ async fn model_selection_popup_snapshot() { #[tokio::test] async fn personality_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("bengalfox")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("bengalfox")).await; chat.thread_id = Some(ThreadId::new()); chat.open_personality_popup(); @@ -2999,7 +3009,7 @@ async fn personality_selection_popup_snapshot() { #[tokio::test] async fn model_picker_hides_show_in_picker_false_models_from_cache() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("test-visible-model")).await; chat.thread_id = Some(ThreadId::new()); let preset = |slug: &str, show_in_picker: bool| ModelPreset { id: slug.to_string(), @@ -3036,10 +3046,9 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { #[tokio::test] async fn model_cap_error_does_not_switch_models() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("boomslang")).await; + let (mut chat, mut rx) = make_chatwidget_manual(Some("boomslang")).await; chat.set_model("boomslang"); while rx.try_recv().is_ok() {} - while op_rx.try_recv().is_ok() {} chat.handle_codex_event(Event { id: "err-1".to_string(), @@ -3060,20 +3069,11 @@ async fn model_cap_error_does_not_switch_models() { ); } } - - while let Ok(event) = op_rx.try_recv() { - if let Op::OverrideTurnContext { model, .. } = event { - assert!( - model.is_none(), - "did not expect OverrideTurnContext model update on model-cap error" - ); - } - } } #[tokio::test] async fn approvals_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.config.notices.hide_full_access_warning = None; chat.open_approvals_popup(); @@ -3091,7 +3091,7 @@ async fn approvals_selection_popup_snapshot() { #[tokio::test] #[serial] async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.config.notices.hide_full_access_warning = None; chat.set_feature_enabled(Feature::WindowsSandbox, true); @@ -3130,7 +3130,7 @@ async fn preset_matching_ignores_extra_writable_roots() { #[tokio::test] async fn full_access_confirmation_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; let preset = builtin_approval_presets() .into_iter() @@ -3145,7 +3145,7 @@ async fn full_access_confirmation_popup_snapshot() { #[cfg(target_os = "windows")] #[tokio::test] async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; let preset = builtin_approval_presets() .into_iter() @@ -3163,7 +3163,7 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { #[cfg(target_os = "windows")] #[tokio::test] async fn startup_prompts_for_windows_sandbox_when_agent_requested() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::WindowsSandbox, false); chat.set_feature_enabled(Feature::WindowsSandboxElevated, false); @@ -3188,7 +3188,7 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { #[tokio::test] async fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); @@ -3202,7 +3202,7 @@ async fn model_reasoning_selection_popup_snapshot() { #[tokio::test] 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; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); @@ -3216,7 +3216,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { #[tokio::test] async fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); @@ -3236,7 +3236,7 @@ async fn reasoning_popup_shows_extra_high_with_space() { #[tokio::test] async fn single_reasoning_option_skips_selection() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let single_effort = vec![ReasoningEffortPreset { effort: ReasoningEffortConfig::High, @@ -3278,7 +3278,7 @@ async fn single_reasoning_option_skips_selection() { #[tokio::test] async fn feedback_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Open the feedback category selection popup via slash command. chat.dispatch_command(SlashCommand::Feedback); @@ -3289,7 +3289,7 @@ async fn feedback_selection_popup_snapshot() { #[tokio::test] async fn feedback_upload_consent_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Open the consent popup directly for a chosen category. chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); @@ -3300,7 +3300,7 @@ async fn feedback_upload_consent_popup_snapshot() { #[tokio::test] async fn reasoning_popup_escape_returns_to_model_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; chat.thread_id = Some(ThreadId::new()); chat.open_model_popup(); @@ -3319,7 +3319,7 @@ async fn reasoning_popup_escape_returns_to_model_popup() { #[tokio::test] async fn exec_history_extends_previous_when_consecutive() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // 1) Start "ls -la" (List) let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la"); @@ -3350,7 +3350,7 @@ async fn exec_history_extends_previous_when_consecutive() { #[tokio::test] async fn user_shell_command_renders_output_not_exploring() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let begin_ls = begin_exec_with_source( &mut chat, @@ -3373,7 +3373,7 @@ async fn user_shell_command_renders_output_not_exploring() { #[tokio::test] async fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.bottom_pane.set_task_running(true); // Dispatch a command that is unavailable while a task runs (e.g., /model) @@ -3391,7 +3391,7 @@ async fn disabled_slash_command_while_task_running_snapshot() { #[tokio::test] async fn approvals_popup_shows_disabled_presets() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.config.approval_policy = Constrained::new(AskForApproval::OnRequest, |candidate| match candidate { @@ -3427,7 +3427,7 @@ async fn approvals_popup_shows_disabled_presets() { #[tokio::test] async fn approvals_popup_navigation_skips_disabled() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.config.approval_policy = Constrained::new(AskForApproval::OnRequest, |candidate| match candidate { @@ -3461,7 +3461,7 @@ async fn approvals_popup_navigation_skips_disabled() { "popup should remain open after selecting a disabled entry" ); assert!( - op_rx.try_recv().is_err(), + rx.try_recv().is_err(), "no actions should be dispatched yet" ); assert!(rx.try_recv().is_err(), "no history should be emitted"); @@ -3475,22 +3475,14 @@ async fn approvals_popup_navigation_skips_disabled() { assert!( app_events.iter().any(|ev| matches!( ev, - AppEvent::CodexOp(Op::OverrideTurnContext { - approval_policy: Some(AskForApproval::OnRequest), - personality: None, - .. - }) + AppEvent::UpdateAskForApprovalPolicy(AskForApproval::OnRequest) )), "enter should select an enabled preset" ); assert!( !app_events.iter().any(|ev| matches!( ev, - AppEvent::CodexOp(Op::OverrideTurnContext { - approval_policy: Some(AskForApproval::Never), - personality: None, - .. - }) + AppEvent::UpdateAskForApprovalPolicy(AskForApproval::Never) )), "disabled preset should not be selected" ); @@ -3504,7 +3496,7 @@ async fn approvals_popup_navigation_skips_disabled() { #[tokio::test] async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { // Build a chat widget with manual channels to avoid spawning the agent. - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). chat.config.approval_policy.set(AskForApproval::OnRequest)?; // Inject an exec approval request to display the approval modal. @@ -3560,7 +3552,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { // Ensures spacing looks correct when no reason text is provided. #[tokio::test] async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.config.approval_policy.set(AskForApproval::OnRequest)?; let ev = ExecApprovalRequestEvent { @@ -3602,7 +3594,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { #[tokio::test] async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() -> anyhow::Result<()> { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.config.approval_policy.set(AskForApproval::OnRequest)?; let script = "python - <<'PY'\nprint('hello')\nPY".to_string(); @@ -3642,7 +3634,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() // Snapshot test: patch approval modal #[tokio::test] async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.config.approval_policy.set(AskForApproval::OnRequest)?; // Build a small changeset and a reason/grant_root to exercise the prompt text. @@ -3683,7 +3675,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { #[tokio::test] async fn interrupt_restores_queued_messages_into_composer() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Simulate a running task to enable queuing of user inputs. chat.bottom_pane.set_task_running(true); @@ -3711,18 +3703,18 @@ async fn interrupt_restores_queued_messages_into_composer() { // Queue should be cleared and no new user input should have been auto-submitted. assert!(chat.queued_user_messages.is_empty()); - assert!( - op_rx.try_recv().is_err(), - "unexpected outbound op after interrupt" - ); - - // Drain rx to avoid unused warnings. - let _ = drain_insert_history(&mut rx); + let mut saw_action = false; + while let Ok(event) = rx.try_recv() { + if matches!(event, AppEvent::AppServerAction(_)) { + saw_action = true; + } + } + assert!(!saw_action, "unexpected app-server action after interrupt"); } #[tokio::test] async fn interrupt_prepends_queued_messages_before_existing_composer_text() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.bottom_pane.set_task_running(true); chat.bottom_pane @@ -3746,17 +3738,18 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { "first queued\nsecond queued\ncurrent draft" ); assert!(chat.queued_user_messages.is_empty()); - assert!( - op_rx.try_recv().is_err(), - "unexpected outbound op after interrupt" - ); - - let _ = drain_insert_history(&mut rx); + let mut saw_action = false; + while let Ok(event) = rx.try_recv() { + if matches!(event, AppEvent::AppServerAction(_)) { + saw_action = true; + } + } + assert!(!saw_action, "unexpected app-server action after interrupt"); } #[tokio::test] async fn interrupt_clears_unified_exec_processes() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); @@ -3776,7 +3769,7 @@ async fn interrupt_clears_unified_exec_processes() { #[tokio::test] async fn interrupt_clears_unified_exec_wait_streak_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "turn-1".into(), @@ -3808,7 +3801,7 @@ async fn interrupt_clears_unified_exec_wait_streak_snapshot() { #[tokio::test] async fn turn_complete_clears_unified_exec_processes() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); @@ -3832,7 +3825,7 @@ async fn turn_complete_clears_unified_exec_processes() { async fn ui_snapshots_small_heights_idle() { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (chat, _rx) = make_chatwidget_manual(None).await; for h in [1u16, 2, 3] { let name = format!("chat_small_idle_h{h}"); let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); @@ -3849,7 +3842,7 @@ async fn ui_snapshots_small_heights_idle() { async fn ui_snapshots_small_heights_task_running() { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Activate status line chat.handle_codex_event(Event { id: "task-1".into(), @@ -3880,7 +3873,7 @@ async fn ui_snapshots_small_heights_task_running() { async fn status_widget_and_approval_modal_snapshot() { use codex_core::protocol::ExecApprovalRequestEvent; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Begin a running task so the status indicator would be active. chat.handle_codex_event(Event { id: "task-1".into(), @@ -3932,7 +3925,7 @@ async fn status_widget_and_approval_modal_snapshot() { // Ensures the VT100 rendering of the status indicator is stable when active. #[tokio::test] async fn status_widget_active_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Activate the status indicator by simulating a task start. chat.handle_codex_event(Event { id: "task-1".into(), @@ -3959,7 +3952,7 @@ async fn status_widget_active_snapshot() { #[tokio::test] async fn mcp_startup_header_booting_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.show_welcome_banner = false; chat.handle_codex_event(Event { @@ -3981,7 +3974,7 @@ async fn mcp_startup_header_booting_snapshot() { #[tokio::test] async fn mcp_startup_complete_does_not_clear_running_task() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "task-1".into(), @@ -4007,7 +4000,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { #[tokio::test] async fn background_event_updates_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "bg-1".into(), @@ -4023,7 +4016,7 @@ async fn background_event_updates_status_header() { #[tokio::test] async fn apply_patch_events_emit_history_cells() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // 1) Approval request -> proposed patch summary cell let mut changes = HashMap::new(); @@ -4121,7 +4114,7 @@ async fn apply_patch_events_emit_history_cells() { #[tokio::test] async fn apply_patch_manual_approval_adjusts_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -4170,7 +4163,7 @@ async fn apply_patch_manual_approval_adjusts_header() { #[tokio::test] async fn apply_patch_manual_flow_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -4222,8 +4215,8 @@ async fn apply_patch_manual_flow_snapshot() { } #[tokio::test] -async fn apply_patch_approval_sends_op_with_submission_id() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; +async fn apply_patch_approval_sends_action_with_submission_id() { + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Simulate receiving an approval request with a distinct submission id and call id let mut changes = HashMap::new(); changes.insert( @@ -4247,11 +4240,13 @@ async fn apply_patch_approval_sends_op_with_submission_id() { // Approve via key press 'y' chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - // Expect a CodexOp with PatchApproval carrying the submission id, not call id + // Expect a PatchApproval action carrying the submission id, not call id let mut found = false; while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev { - assert_eq!(id, "sub-123"); + if let AppEvent::AppServerAction(AppServerAction::PatchApproval { call_id, decision }) = + app_ev + { + assert_eq!(call_id, "sub-123"); assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); found = true; break; @@ -4262,7 +4257,7 @@ async fn apply_patch_approval_sends_op_with_submission_id() { #[tokio::test] async fn apply_patch_full_flow_integration_like() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // 1) Backend requests approval let mut changes = HashMap::new(); @@ -4281,31 +4276,25 @@ async fn apply_patch_full_flow_integration_like() { }), }); - // 2) User approves via 'y' and App receives a CodexOp + // 2) User approves via 'y' and App receives a PatchApproval action chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - let mut maybe_op: Option = None; + let mut maybe_action: Option = None; while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::CodexOp(op) = app_ev { - maybe_op = Some(op); + if let AppEvent::AppServerAction(action) = app_ev { + maybe_action = Some(action); break; } } - let op = maybe_op.expect("expected CodexOp after key press"); - - // 3) App forwards to widget.submit_op, which pushes onto codex_op_tx - chat.submit_op(op); - let forwarded = op_rx - .try_recv() - .expect("expected op forwarded to codex channel"); - match forwarded { - Op::PatchApproval { id, decision } => { - assert_eq!(id, "sub-xyz"); + let action = maybe_action.expect("expected PatchApproval action after key press"); + match action { + AppServerAction::PatchApproval { call_id, decision } => { + assert_eq!(call_id, "sub-xyz"); assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); } - other => panic!("unexpected op forwarded: {other:?}"), + other => panic!("unexpected action forwarded: {other:?}"), } - // 4) Simulate patch begin/end events from backend; ensure history cells are emitted + // 3) Simulate patch begin/end events from backend; ensure history cells are emitted let mut changes2 = HashMap::new(); changes2.insert( PathBuf::from("pkg.rs"), @@ -4340,7 +4329,7 @@ async fn apply_patch_full_flow_integration_like() { #[tokio::test] async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; // Ensure approval policy is untrusted (OnRequest) chat.config.approval_policy.set(AskForApproval::OnRequest)?; @@ -4387,7 +4376,7 @@ async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> { #[tokio::test] async fn apply_patch_request_shows_diff_summary() -> anyhow::Result<()> { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Ensure we are in OnRequest so an approval is surfaced chat.config.approval_policy.set(AskForApproval::OnRequest)?; @@ -4455,7 +4444,7 @@ async fn apply_patch_request_shows_diff_summary() -> anyhow::Result<()> { #[tokio::test] async fn plan_update_renders_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; let update = UpdatePlanArgs { explanation: Some("Adapting plan".to_string()), plan: vec![ @@ -4491,7 +4480,7 @@ async fn plan_update_renders_history_cell() { #[tokio::test] async fn stream_error_updates_status_indicator() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.bottom_pane.set_task_running(true); let msg = "Reconnecting... 2/5"; let details = "Idle timeout waiting for SSE"; @@ -4519,7 +4508,7 @@ async fn stream_error_updates_status_indicator() { #[tokio::test] async fn warning_event_adds_warning_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::Warning(WarningEvent { @@ -4538,7 +4527,7 @@ async fn warning_event_adds_warning_history_cell() { #[tokio::test] async fn stream_recovery_restores_previous_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { @@ -4573,7 +4562,7 @@ async fn stream_recovery_restores_previous_status_header() { #[tokio::test] async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Begin turn chat.handle_codex_event(Event { @@ -4627,7 +4616,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { #[tokio::test] async fn final_reasoning_then_message_without_deltas_are_rendered() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // No deltas; only final reasoning followed by final message. chat.handle_codex_event(Event { @@ -4654,7 +4643,7 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { #[tokio::test] async fn deltas_then_same_final_message_are_rendered_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Stream some reasoning deltas first. chat.handle_codex_event(Event { @@ -4718,7 +4707,7 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { // then the exec block, another blank line, the status line, a blank line, and the composer. #[tokio::test] async fn chatwidget_exec_and_status_layout_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "t1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }), @@ -4813,7 +4802,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { // E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks #[tokio::test] async fn chatwidget_markdown_code_blocks_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; // Simulate a final agent message via streaming deltas instead of a single message @@ -4904,7 +4893,7 @@ printf 'fenced within fenced\n' #[tokio::test] async fn chatwidget_tall() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, _rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.handle_codex_event(Event { id: "t1".into(), @@ -4930,7 +4919,7 @@ async fn chatwidget_tall() { #[tokio::test] async fn review_queues_user_messages_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 010f8c83e5..e48f2b255a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -53,6 +53,7 @@ mod app; mod app_backtrack; mod app_event; mod app_event_sender; +mod app_server_client; mod ascii_animation; mod bottom_pane; mod chatwidget; diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index b2858e8f21..ea3501bdf1 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -7,11 +7,11 @@ use std::sync::Mutex; use std::sync::OnceLock; use codex_core::config::Config; -use codex_core::protocol::Op; use serde::Serialize; use serde_json::json; use crate::app_event::AppEvent; +use crate::app_event::AppServerAction; static LOGGER: LazyLock = LazyLock::new(SessionLogger::new); @@ -128,6 +128,16 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { AppEvent::CodexEvent(ev) => { write_record("to_tui", "codex_event", ev); } + AppEvent::CodexThreadEvent { thread_id, event } => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "codex_event", + "thread_id": thread_id.to_string(), + "payload": event, + }); + LOGGER.write_json_line(value); + } AppEvent::NewSession => { let value = json!({ "ts": now_ts(), @@ -177,11 +187,11 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { } } -pub(crate) fn log_outbound_op(op: &Op) { +pub(crate) fn log_outbound_app_action(action: &AppServerAction) { if !LOGGER.is_enabled() { return; } - write_record("from_tui", "op", op); + write_record("from_tui", "app_server_action", action); } pub(crate) fn log_session_end() { diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index bef0d0328d..e262680d78 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -4,7 +4,6 @@ use std::time::Duration; use std::time::Instant; -use codex_core::protocol::Op; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -17,6 +16,7 @@ use ratatui::widgets::WidgetRef; use unicode_width::UnicodeWidthStr; use crate::app_event::AppEvent; +use crate::app_event::AppServerAction; use crate::app_event_sender::AppEventSender; use crate::exec_cell::spinner; use crate::key_hint; @@ -82,7 +82,8 @@ impl StatusIndicatorWidget { } pub(crate) fn interrupt(&self) { - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); + self.app_event_tx + .send(AppEvent::AppServerAction(AppServerAction::Interrupt)); } /// Update the animated header label (left of the brackets). diff --git a/codex-rs/tui/tests/suite/no_panic_on_startup.rs b/codex-rs/tui/tests/suite/no_panic_on_startup.rs index eade57d08e..34c970f3c2 100644 --- a/codex-rs/tui/tests/suite/no_panic_on_startup.rs +++ b/codex-rs/tui/tests/suite/no_panic_on_startup.rs @@ -37,12 +37,16 @@ model_provider = "ollama" let CodexCliOutput { exit_code, output } = run_codex_cli(codex_home, cwd).await?; assert_ne!(0, exit_code, "Codex CLI should exit nonzero."); assert!( - output.contains("ERROR: Failed to initialize codex:"), + output.contains("Error: app server error -32603"), "expected startup error in output, got: {output}" ); + let expected_error = format!( + "Fatal error: failed to load rules: failed to read rules files from {}", + codex_home.join("rules").canonicalize()?.display() + ); assert!( - output.contains("failed to read rules files"), - "expected rules read error in output, got: {output}" + output.contains(&expected_error), + "expected:\n{expected_error}\nin output, got:\n{output}" ); Ok(()) }