//! Thread routing, buffering, and app-server operation submission for the TUI app. //! //! This module manages active thread channels, routes server requests and notifications into those //! channels, submits thread-scoped operations through the app server, and replays buffered events //! when the visible thread changes. use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_event::ThreadTitleDestination; use crate::chatwidget::ThreadInputStateRestoreMode; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::WarningNotification; // Leave time for side-thread cleanup and unsubscribe inside the two-second exit budget. const REALTIME_STOP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1); impl App { pub(super) async fn stop_realtime_conversation(&mut self, app_server: &mut AppServerSession) { let thread_id = self .background_voice .as_mut() .and_then(|owner| owner.reset_realtime_conversation()) .or_else(|| self.chat_widget.reset_realtime_conversation()); self.retire_background_voice(); let Some(thread_id) = thread_id else { return; }; match tokio::time::timeout( REALTIME_STOP_TIMEOUT, app_server.thread_realtime_stop(thread_id), ) .await { Ok(Ok(())) => {} Ok(Err(error)) => { tracing::warn!(%thread_id, %error, "failed to stop voice conversation"); } Err(_) => { tracing::warn!(%thread_id, "timed out stopping voice conversation"); } } } pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) { self.stop_realtime_conversation(app_server).await; self.detach_current_thread_for_navigation(app_server, /*destination*/ None) .await; } pub(super) async fn shutdown_side_threads( &mut self, app_server: &mut AppServerSession, root: Option, ) -> bool { if self.side_threads.is_empty() { return true; } let subtree = root.map(|id| self.known_thread_subtree(id)); let ids: Vec<_> = self .side_threads .iter() .filter(|(_, side)| { subtree .as_ref() .is_none_or(|ids| ids.contains(&side.parent_thread_id)) }) .map(|(id, _)| *id) .collect(); let mut closed = true; for id in ids { closed &= self.discard_side_thread(app_server, id).await; } closed } pub(super) fn abort_thread_event_listener(&mut self, thread_id: ThreadId) { if let Some(handle) = self.thread_event_listener_tasks.remove(&thread_id) { handle.abort(); } } pub(super) fn abort_all_thread_event_listeners(&mut self) { for handle in self .thread_event_listener_tasks .drain() .map(|(_, handle)| handle) { handle.abort(); } } pub(super) fn ensure_thread_channel(&mut self, thread_id: ThreadId) -> &mut ThreadEventChannel { self.thread_event_channels .entry(thread_id) .or_insert_with(|| ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY)) } pub(super) async fn set_thread_active(&mut self, thread_id: ThreadId, active: bool) { if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) { let mut store = channel.store.lock().await; store.active = active; } } pub(super) async fn activate_thread_channel(&mut self, thread_id: ThreadId) { if self.active_thread_id.is_some() { return; } self.set_thread_active(thread_id, /*active*/ true).await; let receiver = if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) { channel.receiver.take() } else { None }; if let Some(startup_pane) = self.sidebars.remove(&None) && let std::collections::hash_map::Entry::Vacant(entry) = self.sidebars.entry(Some(thread_id)) { entry.insert(startup_pane); if let Some(mode) = &mut self.pane_resize && mode.parent.is_none() { mode.parent = Some(thread_id); } } self.active_thread_id = Some(thread_id); self.active_thread_rx = receiver; self.refresh_pending_thread_approvals().await; } pub(super) async fn store_active_thread_receiver(&mut self) { let Some(active_id) = self.active_thread_id else { return; }; let input_state = self.chat_widget.capture_thread_input_state(); let recap_progress = self.recap.progress(); if let Some(channel) = self.thread_event_channels.get_mut(&active_id) { let receiver = self.active_thread_rx.take(); let mut store = channel.store.lock().await; if store.active { store.replay_may_have_output = false; } store.active = false; store.input_state = input_state; store.merge_recap_progress(recap_progress); if let Some(receiver) = receiver { channel.receiver = Some(receiver); } } } pub(super) fn retain_subscriptions_for_fork(&mut self) { // Tasks can start while the fork RPC is pending. for id in self .thread_event_channels .keys() .copied() .chain(self.agent_navigation.tracked_thread_ids()) .filter(|id| !self.side_threads.contains_key(id)) { self.agents_overview .dispatched_requests .entry(id) .or_default(); } } pub(super) async fn activate_thread_for_replay( &mut self, thread_id: ThreadId, ) -> Option<(mpsc::Receiver, ThreadEventSnapshot)> { let channel = self.thread_event_channels.get_mut(&thread_id)?; let receiver = channel.receiver.take()?; let mut store = channel.store.lock().await; store.active = true; let snapshot = store.snapshot(); Some((receiver, snapshot)) } pub(super) async fn clear_active_thread(&mut self) { if let Some(active_id) = self.active_thread_id.take() { self.set_thread_active(active_id, /*active*/ false).await; } self.active_thread_rx = None; self.refresh_pending_thread_approvals().await; } pub(super) async fn note_thread_outbound_op(&mut self, thread_id: ThreadId, op: &AppCommand) { let Some(channel) = self.thread_event_channels.get(&thread_id) else { return; }; let mut store = channel.store.lock().await; store.note_outbound_op(op); } pub(super) async fn note_active_thread_outbound_op(&mut self, op: &AppCommand) { if !ThreadEventStore::op_can_change_pending_replay_state(op) { return; } let Some(thread_id) = self.active_thread_id else { return; }; self.note_thread_outbound_op(thread_id, op).await; } pub(super) async fn active_turn_id_for_thread(&self, thread_id: ThreadId) -> Option { let channel = self.thread_event_channels.get(&thread_id)?; let store = channel.store.lock().await; store.active_turn_id().map(ToOwned::to_owned) } pub(super) fn thread_label(&self, thread_id: ThreadId) -> String { let is_primary = self.primary_thread_id == Some(thread_id); let anonymous = |label: &str| { // Agents spawned together share the timestamp prefix of their UUIDv7 IDs. let id = thread_id.to_string(); let suffix = id.rsplit('-').next().unwrap_or(&id); format!("{label} ({suffix})") }; if let Some(entry) = self.agent_navigation.get(&thread_id) { if let Some(path) = entry.agent_path.as_deref().map(str::trim) && !is_primary && !path.is_empty() { return path.to_string(); } let label = format_agent_picker_item_name( entry.agent_nickname.as_deref(), entry.agent_role.as_deref(), is_primary, ); if !is_primary && entry .agent_nickname .as_deref() .is_none_or(|name| name.trim().is_empty()) { anonymous(&label) } else { label } } else if is_primary { "Main [default]".to_string() } else { anonymous("Agent") } } /// Returns the thread whose transcript is currently on screen. /// /// `active_thread_id` is the source of truth during steady state, but the widget can briefly /// lag behind thread bookkeeping during transitions. The footer label and adjacent-thread /// navigation both follow what the user is actually looking at, not whichever thread most /// recently began switching. pub(super) fn current_displayed_thread_id(&self) -> Option { self.active_thread_id.or(self.chat_widget.thread_id()) } pub(super) fn ignore_same_thread_resume( &mut self, target_session: &crate::resume_picker::SessionTarget, ) -> bool { if self.chat_widget.has_provider_error() || self.active_thread_id != Some(target_session.thread_id) || self .thread_event_channels .get(&target_session.thread_id) .is_some_and(|channel| channel.attachment() != ThreadEventAttachment::Live) { return false; }; self.chat_widget.add_info_message( format!("Already viewing {}.", target_session.display_label()), /*hint*/ None, ); true } /// Mirrors the visible thread into the contextual footer row. /// /// The footer sometimes shows ambient context instead of an instructional hint. In multi-agent /// sessions, that contextual row includes the currently viewed agent label. The label is /// intentionally hidden until there is more than one known thread so single-thread sessions do /// not spend footer space restating that the user is already on the main conversation. pub(super) fn sync_active_agent_label(&mut self) { let label = self .agent_navigation .active_agent_label(self.current_displayed_thread_id(), self.primary_thread_id); self.chat_widget.set_active_agent_label(label); self.sync_side_thread_ui(); } pub(super) async fn thread_cwd(&self, thread_id: ThreadId) -> Option { let channel = self.thread_event_channels.get(&thread_id)?; let store = channel.store.lock().await; store.session.as_ref().map(|session| session.cwd.clone()) } pub(super) async fn interactive_request_for_thread_request( &self, thread_id: ThreadId, request: &ServerRequest, ) -> std::io::Result> { let thread_label = Some(self.thread_label(thread_id)); Ok(match request { ServerRequest::CommandExecutionRequestApproval { params, .. } => { let network_approval_context = params.network_approval_context.clone(); let additional_permissions = params.additional_permissions.clone(); let proposed_execpolicy_amendment = params.proposed_execpolicy_amendment.clone(); let proposed_network_policy_amendments = params.proposed_network_policy_amendments.clone(); let approval = ExecApprovalRequest { kind: params.kind, thread_id, thread_label, id: params .approval_id .clone() .unwrap_or_else(|| params.item_id.clone()), environment_id: params.environment_id.clone(), command: params .command .as_deref() .map(split_command_string) .unwrap_or_default(), reason: params.reason.clone(), available_decisions: params.available_decisions.clone().unwrap_or_else(|| { default_exec_approval_decisions( network_approval_context.as_ref(), proposed_execpolicy_amendment.as_ref(), proposed_network_policy_amendments.as_deref(), additional_permissions.as_ref(), ) }), network_approval_context, additional_permissions, }; Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec( approval, ))) } ServerRequest::FileChangeRequestApproval { params, .. } => Some( ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch( ApplyPatchApprovalRequest { thread_id, thread_label, id: params.item_id.clone(), reason: params.reason.clone(), cwd: self .thread_cwd(thread_id) .await .unwrap_or_else(|| self.config.cwd.clone()), changes: self .thread_file_change_changes(thread_id, ¶ms.turn_id, ¶ms.item_id) .await .map(crate::app_server_approval_conversions::file_update_changes_to_display) .unwrap_or_default(), }, )), ), ServerRequest::McpServerElicitationRequest { request_id, params } => { if let Some(params) = AppLinkViewParams::from_url_app_server_request( thread_id, ¶ms.server_name, request_id.clone(), ¶ms.request, ) { Some(ThreadInteractiveRequest::AppLink(params)) } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( thread_id, request_id.clone(), params, ) { Some(ThreadInteractiveRequest::McpServerElicitation(request)) } else { match ¶ms.request { codex_app_server_protocol::McpServerElicitationRequest::Form { message, .. } => Some(ThreadInteractiveRequest::Approval( ApprovalRequest::McpElicitation(McpElicitationApprovalRequest { thread_id, thread_label, server_name: params.server_name.clone(), request_id: request_id.clone(), message: message.clone(), }), )), codex_app_server_protocol::McpServerElicitationRequest::UserVerification { title, description, .. } => Some(ThreadInteractiveRequest::UserVerification { thread_id, request: crate::bottom_pane::user_verification::UserVerificationRequest { thread_label, server_name: params.server_name.clone(), request_id: request_id.clone(), title: title.clone(), description: description.clone(), }, }), codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { .. } | codex_app_server_protocol::McpServerElicitationRequest::OpenAiElicitationForm { .. } | codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { self.app_event_tx.resolve_elicitation( thread_id, params.server_name.clone(), request_id.clone(), codex_app_server_protocol::McpServerElicitationAction::Decline, /*content*/ None, /*meta*/ None, ); None } } } } ServerRequest::PermissionsRequestApproval { params, .. } => { // TODO(anp): Remove this native-path localization error path once core permission // paths remain PathUri after crossing the app-server boundary. let permissions = params.permissions.clone().try_into().map_err(|err| { std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("failed to localize requested filesystem paths: {err}"), ) })?; Some(ThreadInteractiveRequest::Approval( ApprovalRequest::Permissions(PermissionsApprovalRequest { thread_id, thread_label, call_id: params.item_id.clone(), environment_id: params.environment_id.clone(), reason: params.reason.clone(), permissions, }), )) } _ => None, }) } pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) { if self.chat_widget.has_misalignment_policy_violation() { return; } match request { ThreadInteractiveRequest::AppLink(params) => { self.chat_widget.open_app_link_view(params); } ThreadInteractiveRequest::Approval(request) => { self.render_inactive_patch_preview(&request); self.chat_widget.push_approval_request(request); if self.startup_protected_input_boundary && !self.chat_widget.has_active_modal() { self.startup_pending_protected_request = true; } } ThreadInteractiveRequest::UserVerification { thread_id, request } => { self.chat_widget .push_user_verification_request(thread_id, request); } ThreadInteractiveRequest::McpServerElicitation(request) => { self.chat_widget .push_mcp_server_elicitation_request(request); } } } fn render_inactive_patch_preview(&mut self, request: &ApprovalRequest) { let ApprovalRequest::ApplyPatch(request) = request else { return; }; if request.thread_label.is_none() || request.changes.is_empty() { return; } self.chat_widget .add_to_history(history_cell::new_patch_event( request.changes.clone(), &request.cwd, )); } pub(super) async fn pending_inactive_thread_requests(&self) -> Vec<(ThreadId, ServerRequest)> { let channels: Vec<(ThreadId, Arc>)> = self .thread_event_channels .iter() .map(|(thread_id, channel)| (*thread_id, Arc::clone(&channel.store))) .collect(); let mut requests = Vec::new(); for (thread_id, store) in channels { if Some(thread_id) == self.active_thread_id { continue; } let store = store.lock().await; if store.side_parent_pending_status().is_none() { continue; } requests.extend( store .pending_replay_requests() .into_iter() .map(|request| (thread_id, request)), ); } requests } pub(super) async fn surface_pending_inactive_thread_interactive_requests( &mut self, ) -> Result<()> { if self.active_side_parent_thread_id().is_some() { return Ok(()); } let requests = self.pending_inactive_thread_requests().await; for (thread_id, request) in requests { if let Some(request) = self .interactive_request_for_thread_request(thread_id, &request) .await? { self.push_thread_interactive_request(request); } } Ok(()) } pub(super) async fn submit_active_thread_op( &mut self, app_server: &mut AppServerSession, op: AppCommand, ) -> Result<()> { let Some(thread_id) = self.active_thread_id else { if let AppCommand::RealtimeConversationSpeech { delivery_id, .. } = &op { self.chat_widget .restore_undelivered_realtime_speech(*delivery_id); } if let AppCommand::RealtimeConversationStart { thread_id, .. } | AppCommand::RealtimeConversationStop { thread_id } = &op && self.chat_widget.thread_id() == Some(*thread_id) { self.chat_widget .record_realtime_failure(crate::chatwidget::RealtimeFailureCause::LocalRequest); self.chat_widget.reset_realtime_conversation(); } self.chat_widget .add_error_message("No active thread is available.".to_string()); return Ok(()); }; self.submit_thread_op(app_server, thread_id, op).await } pub(super) async fn submit_thread_op( &mut self, app_server: &mut AppServerSession, thread_id: ThreadId, op: AppCommand, ) -> Result<()> { if self.thread_unavailable(thread_id) { if let AppCommand::RealtimeConversationSpeech { delivery_id, .. } = &op { self.chat_widget .restore_undelivered_realtime_speech(*delivery_id); } if let AppCommand::RealtimeConversationStart { thread_id, .. } | AppCommand::RealtimeConversationStop { thread_id } = &op && self.chat_widget.thread_id() == Some(*thread_id) { self.chat_widget .record_realtime_failure(crate::chatwidget::RealtimeFailureCause::LocalRequest); self.chat_widget.reset_realtime_conversation(); } self.chat_widget.add_error_message( "This conversation is read-only or unavailable; no operation was sent.".into(), ); return Ok(()); } if self.chat_widget.rejects_misalignment_policy_op(&op) { if let AppCommand::RealtimeConversationSpeech { delivery_id, .. } = &op { self.chat_widget .restore_undelivered_realtime_speech(*delivery_id); } return Ok(()); } crate::session_log::log_outbound_op(&op); if self.try_resolve_app_server_request(thread_id, &op).await? { return Ok(()); } let submitted = match self .try_submit_active_thread_op_via_app_server(app_server, thread_id, &op) .await { Ok(submitted) => submitted, Err(error) => { if let Some(provider_error) = crate::app_server_session::model_provider_error(&error) && self.current_displayed_thread_id() == Some(thread_id) { self.chat_widget.show_provider_error( provider_error, match &op { AppCommand::UserTurn { client_user_message_id, .. } => Some(client_user_message_id.as_str()), _ => None, }, ); return Ok(()); } return Err(error); } }; if submitted { if ThreadEventStore::op_can_change_pending_replay_state(&op) { self.note_thread_outbound_op(thread_id, &op).await; self.refresh_pending_thread_approvals().await; self.refresh_side_parent_status_from_store(thread_id).await; } return Ok(()); } self.chat_widget .add_error_message(format!("Not available in TUI yet for thread {thread_id}.")); Ok(()) } /// Persist prompt text in the local cross-session message history. pub(super) fn append_message_history_entry(&self, thread_id: ThreadId, text: String) { let history_config = codex_message_history::HistoryConfig::new( self.local_settings.codex_home.clone(), &self.local_settings.history, ); tokio::spawn(async move { if let Err(err) = codex_message_history::append_entry(&text, thread_id, &history_config).await { tracing::warn!( thread_id = %thread_id, error = %err, "failed to append to message history" ); } }); } /// Fetch one local cross-session message history entry for the requesting thread. pub(super) async fn lookup_message_history_entry( &mut self, thread_id: ThreadId, offset: usize, log_id: u64, ) -> Result<()> { let history_config = codex_message_history::HistoryConfig::new( self.local_settings.codex_home.clone(), &self.local_settings.history, ); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let entry_opt = tokio::task::spawn_blocking(move || { codex_message_history::lookup(log_id, offset, &history_config) }) .await .unwrap_or_else(|err| { tracing::warn!(error = %err, "history lookup task failed"); None }); app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { thread_id, event: HistoryLookupResponse::Entry { offset, log_id, entry: entry_opt.map(|entry| entry.text), }, }); }); Ok(()) } /// Fetch one bounded local cross-session history batch for the requesting thread. pub(super) async fn lookup_message_history_batch( &mut self, thread_id: ThreadId, cursor: codex_message_history::HistoryBatchCursor, log_id: u64, ) -> Result<()> { let history_config = codex_message_history::HistoryConfig::new( self.local_settings.codex_home.clone(), &self.local_settings.history, ); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let event = match tokio::task::spawn_blocking(move || { codex_message_history::lookup_batch(log_id, cursor, &history_config) }) .await { Ok(Ok(batch)) => { let entries = batch .entries .into_iter() .map(|entry| crate::app_event::HistoryBatchEntryResponse { offset: entry.offset, entry: entry.entry.map(|entry| entry.text), }) .collect(); HistoryLookupResponse::Batch { cursor, log_id, entries, next_older_cursor: batch.next_older_cursor, } } Ok(Err(err)) => { tracing::warn!(error = %err, "history batch lookup failed"); HistoryLookupResponse::BatchError { cursor, log_id } } Err(err) => { tracing::warn!(error = %err, "history batch lookup task failed"); HistoryLookupResponse::BatchError { cursor, log_id } } }; app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { thread_id, event }); }); Ok(()) } pub(super) async fn try_submit_active_thread_op_via_app_server( &mut self, app_server: &mut AppServerSession, thread_id: ThreadId, op: &AppCommand, ) -> Result { if user_turn_submission::try_submit_user_turn(self, app_server, thread_id, op).await? { return Ok(true); } match op { AppCommand::Interrupt => { let mut turn_id = self .active_turn_id_for_thread(thread_id) .await .unwrap_or_default(); let (thread_event_tx, thread_event_store) = { let channel = self.ensure_thread_channel(thread_id); (channel.sender.clone(), Arc::clone(&channel.store)) }; self.reset_backtrack_state(); if !turn_id.is_empty() { let mut store = thread_event_store.lock().await; if store.pending_interrupt_turn_id.as_deref() == Some(turn_id.as_str()) { return Ok(true); } store.pending_interrupt_turn_id = Some(turn_id.clone()); } let request_handle = app_server.request_handle(); let request_ids = [app_server.next_request_id(), app_server.next_request_id()]; tokio::spawn(async move { for (attempt, request_id) in request_ids.into_iter().enumerate() { let result = request_handle .request_typed::(ClientRequest::TurnInterrupt { request_id, params: TurnInterruptParams { thread_id: thread_id.to_string(), turn_id: turn_id.clone(), }, }) .await; match result { Ok(_) => break, Err(error) => { if attempt == 0 && let Some(actual_turn_id) = active_turn_interrupt_race(&error) && actual_turn_id != turn_id { thread_event_store.lock().await.pending_interrupt_turn_id = Some(actual_turn_id.clone()); turn_id = actual_turn_id; continue; } tracing::warn!(error = %error, "turn/interrupt failed in TUI"); let notification = ServerNotification::Warning(WarningNotification { thread_id: Some(thread_id.to_string()), message: format!("Failed to interrupt turn: {error}"), }); let should_send = { let mut store = thread_event_store.lock().await; store.push_notification_ref(¬ification); store.active }; if should_send && let Err(error) = thread_event_tx .send(ThreadBufferedEvent::Notification(Box::new( notification, ))) .await { tracing::warn!(error = %error, "thread event channel closed"); } break; } } } }); Ok(true) } AppCommand::ListSkills { cwds, force_reload } => { // A queued refresh can outlive the workspace it was requested for. if !cwds .iter() .any(|cwd| cwd == self.chat_widget.config_ref().cwd.as_path()) { return Ok(true); } self.handle_skills_list_result( app_server .skills_list(codex_app_server_protocol::SkillsListParams { cwds: cwds.clone(), force_reload: *force_reload, }) .await, "failed to refresh skills", ); Ok(true) } AppCommand::Compact => { app_server.thread_compact_start(thread_id).await?; Ok(true) } AppCommand::SetThreadName { name } => { let name = name.to_string(); app_server.thread_set_name(thread_id, name.clone()).await?; self.chat_widget.expect_manual_thread_name(thread_id, name); self.cancel_thread_title_generation(thread_id); Ok(true) } AppCommand::Review { target } => { let response = app_server.review_start(thread_id, target.clone()).await?; let review_thread_id = ThreadId::from_string(&response.review_thread_id) .wrap_err("review/start returned invalid review thread id")?; let store = Arc::clone(&self.ensure_thread_channel(review_thread_id).store); let mut store = store.lock().await; store.set_active_turn_id(response.turn.id); self.chat_widget.on_review_started(); Ok(true) } AppCommand::CleanBackgroundTerminals => { app_server .thread_background_terminals_clean(thread_id) .await?; Ok(true) } AppCommand::RealtimeConversationStart { thread_id: realtime_thread_id, offer_sdp, } => { if *realtime_thread_id != thread_id { return Ok(true); } let model = self .chat_widget .config_ref() .experimental_realtime_ws_model .clone(); let voices = self.realtime_voices(app_server).await?; let voice = self.effective_realtime_voice(app_server, &voices).await?; app_server .thread_realtime_start( *realtime_thread_id, String::from(offer_sdp.clone()), model, voice, ) .await?; Ok(true) } AppCommand::RealtimeConversationStop { thread_id: realtime_thread_id, } => { app_server.thread_realtime_stop(*realtime_thread_id).await?; Ok(true) } AppCommand::RealtimeConversationSpeech { thread_id: realtime_thread_id, attempt_id, input_generation, delivery_id, text, } => { if !self.chat_widget.has_pending_realtime_speech(*delivery_id) { return Ok(true); } if *realtime_thread_id != thread_id || !self.chat_widget.is_current_realtime_attempt( *realtime_thread_id, *attempt_id, *input_generation, ) { self.chat_widget .restore_undelivered_realtime_speech(*delivery_id); return Ok(true); } app_server .thread_realtime_append_speech(*realtime_thread_id, text.as_str().to_owned()) .await?; self.chat_widget.accept_realtime_speech(*delivery_id); Ok(true) } AppCommand::RunUserShellCommand { command } => { app_server .thread_shell_command(thread_id, command.to_string()) .await?; Ok(true) } AppCommand::ReloadUserConfig => { app_server.reload_user_config().await?; Ok(true) } AppCommand::OverrideTurnContext { .. } => { self.sync_override_turn_context_settings(app_server, thread_id, op) .await; Ok(true) } AppCommand::ApproveGuardianDeniedAction { event } => { app_server .thread_approve_guardian_denied_action(thread_id, event) .await?; Ok(true) } _ => Ok(false), } } pub(super) fn turn_permissions_override_from_config( config: &Config, active_permission_profile: Option<&ActivePermissionProfile>, runtime_permission_profile_override: Option<&PermissionProfile>, ) -> TurnPermissionsOverride { if let Some(active_permission_profile) = active_permission_profile { return TurnPermissionsOverride::ActiveProfile(active_permission_profile.clone()); } let effective_permission_profile = config.permissions.effective_permission_profile(); let runtime_permission_profile_override = runtime_permission_profile_override.map(|profile| { profile .clone() .materialize_project_roots_with_path_uris(&config.effective_workspace_roots()) }); if runtime_permission_profile_override .as_ref() .is_some_and(|profile| profile == &effective_permission_profile) { return TurnPermissionsOverride::LegacySandbox(effective_permission_profile); } TurnPermissionsOverride::Preserve } pub(super) fn handle_skills_list_result( &mut self, result: Result, failure_message: &str, ) { match result { Ok(response) => self.handle_skills_list_response(response), Err(err) => { tracing::warn!("{failure_message}: {err:#}"); self.chat_widget .add_error_message(format!("{failure_message}: {err:#}")); } } } pub(super) async fn try_resolve_app_server_request( &mut self, thread_id: ThreadId, op: &AppCommand, ) -> Result { let Some(resolution) = self .pending_app_server_requests .take_resolution_record(&thread_id.to_string(), op) .map_err(|err| color_eyre::eyre::eyre!(err))? else { return Ok(false); }; let Some(reply) = resolution.request.reply else { return Err(color_eyre::eyre::eyre!( "Missing original app-server connection for pending request" )); }; if let Err(err) = reply.resolve(resolution.result).await { self.chat_widget.add_error_message(format!( "Failed to resolve app-server request for thread {thread_id}: {err}" )); } if ThreadEventStore::op_can_change_pending_replay_state(op) { self.note_thread_outbound_op(thread_id, op).await; self.refresh_pending_thread_approvals().await; self.refresh_side_parent_status_from_store(thread_id).await; } Ok(true) } pub(super) async fn refresh_pending_thread_approvals(&mut self) { let side_parent_thread_id = self.active_side_parent_thread_id(); let channels: Vec<(ThreadId, Arc>)> = self .thread_event_channels .iter() .map(|(thread_id, channel)| (*thread_id, Arc::clone(&channel.store))) .collect(); let mut pending_thread_ids = HashSet::new(); for (thread_id, store) in channels { if Some(thread_id) == self.active_thread_id || Some(thread_id) == side_parent_thread_id { continue; } if store.lock().await.has_pending_thread_approvals() { pending_thread_ids.insert(thread_id); // Approval requests can arrive before the thread's metadata. if self.agent_navigation.get(&thread_id).is_none() { self.upsert_agent_picker_thread( thread_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, ); } } } if self.agent_navigation.pending_approvals != pending_thread_ids { self.agent_navigation.pending_approvals = pending_thread_ids; self.refresh_agent_picker(); } } pub(super) async fn refresh_side_parent_status_from_store(&mut self, thread_id: ThreadId) { let Some(channel) = self.thread_event_channels.get(&thread_id) else { return; }; let status = { let store = channel.store.lock().await; store.side_parent_pending_status() }; if let Some(status) = status { self.set_side_parent_status(thread_id, Some(status)); } else { self.clear_side_parent_action_status(thread_id); } } pub(super) async fn enqueue_thread_notification( &mut self, thread_id: ThreadId, notification: ServerNotification, ) -> Result<()> { self.deliver_background_voice_notification(thread_id, ¬ification); if self.abandoned_side_threads.contains(&thread_id) { return Ok(()); } self.track_side_chat_title(thread_id, ¬ification); if self.current_displayed_thread_id() == Some(thread_id) && let ServerNotification::TurnCompleted(notification) = ¬ification { let now = Instant::now(); self.recap .note_turn_finished(¬ification.turn.status, now); self.schedule_recap_check(thread_id, now); } let misalignment_policy_violation = match ¬ification { ServerNotification::Error(notification) if !notification.will_retry => { notification.error.codex_error_info.as_ref() } ServerNotification::TurnCompleted(notification) => notification .turn .error .as_ref() .and_then(|error| error.codex_error_info.as_ref()), _ => None, } == Some(&AppServerCodexErrorInfo::MisalignmentPolicyViolation); if misalignment_policy_violation && self.active_side_parent_thread_id() == Some(thread_id) { if self.chat_widget.is_user_turn_pending_or_running() { self.chat_widget.submit_op(AppCommand::Interrupt); } self.chat_widget.on_misalignment_policy_violation(); } if matches!( notification, ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ThreadArchived(_) | ServerNotification::ThreadAttachmentUpdated(_) ) && self.primary_thread_id.is_some() && self.primary_thread_id != Some(thread_id) && !self.thread_event_channels.contains_key(&thread_id) { return Ok(()); } let mut confirmed_profile = None; if let ServerNotification::ThreadSettingsUpdated(notification) = ¬ification { self.apply_thread_settings_to_cached_session(thread_id, ¬ification.thread_settings) .await; if self .pending_server_profiles .get(&thread_id) .or_else(|| { self.agents_overview .requested_permission_profiles .get(&thread_id) }) .is_some_and(|selected| { notification .thread_settings .active_permission_profile .as_ref() .is_some_and(|active| active.id == selected.profile_id) && selected.approval_policy.is_none_or(|policy| { notification.thread_settings.approval_policy == policy }) && selected.approvals_reviewer.is_none_or(|reviewer| { notification.thread_settings.approvals_reviewer.to_core() == reviewer }) }) { confirmed_profile = self.pending_server_profiles.remove(&thread_id).or_else(|| { self.agents_overview .requested_permission_profiles .remove(&thread_id) }); } } let inferred_session = if let ServerNotification::ThreadStarted(started) = ¬ification && self.primary_session_configured.is_some() { self.upsert_agent_picker_thread( thread_id, started.thread.agent_nickname.clone(), started.thread.agent_role.clone(), /*is_closed*/ false, ); self.agent_navigation.set_agent_path( thread_id, crate::app_server_session::source_agent_path(&started.thread.source), ); self.sync_agent_picker_thread(thread_id); // Lifecycle responses already contain authoritative session state. Their rollout may // not exist until the first turn, so inferring it again can wait through reader retries. let already_has_session = match self.thread_event_channels.get(&thread_id) { Some(channel) => channel.store.lock().await.session.is_some(), None => false, }; if already_has_session { None } else { self.infer_session_for_started_thread(thread_id, started) .await } } else { None }; let is_turn_started = matches!(notification, ServerNotification::TurnStarted(_)); let is_thread_closed = matches!(notification, ServerNotification::ThreadClosed(_)); let notification_status_change = SideParentStatusChange::for_notification(¬ification); let (sender, store) = { let channel = self.ensure_thread_channel(thread_id); (channel.sender.clone(), Arc::clone(&channel.store)) }; let (mut notification, previous_pending_status, pending_status, turn_stopped) = { let mut guard = store.lock().await; if guard.session.is_none() && let Some(session) = inferred_session { guard.session = Some(session); } let previous_pending_status = guard.side_parent_pending_status(); let turn_stopped = match ¬ification { ServerNotification::TurnCompleted(notification) => { guard.active_turn_id() == Some(notification.turn.id.as_str()) } _ => false, }; let notification = if guard.active { guard.push_notification_ref(¬ification); Some(notification) } else { if self .background_voice .as_ref() .is_none_or(|owner| owner.thread_id() != Some(thread_id)) { self.retain_inactive_realtime_transcript(thread_id, ¬ification); } if let Some(companion) = self.side_companions.get_mut(&thread_id) && companion.apply_notification(¬ification, &guard, || { self.chat_widget.new_status_indicator() }) { self.chat_widget.request_redraw(); } // Status traffic alone does not change the displayed transcript. guard.replay_may_have_output |= !matches!( ¬ification, ServerNotification::ThreadStarted(_) | ServerNotification::ThreadStatusChanged(_) | ServerNotification::ThreadReadStateChanged(_) | ServerNotification::ThreadTokenUsageUpdated(_) | ServerNotification::ThreadNameUpdated(_) | ServerNotification::ThreadProjectUpdated(_) | ServerNotification::ThreadQueueChanged(_) | ServerNotification::ThreadPredictionUpdated(_) | ServerNotification::ThreadAttachmentUpdated(_) | ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::McpToolCallProgress(_) | ServerNotification::FileChangePatchUpdated(_) | ServerNotification::ServerRequestResolved(_) | ServerNotification::ThreadRealtimeOutputAudioDelta(_) | ServerNotification::ThreadRealtimeSdp(_) | ServerNotification::ThreadRealtimeItemAdded(_) | ServerNotification::RawResponseItemCompleted(_) | ServerNotification::RawResponseCompleted(_) ); guard.push_notification(notification); None }; ( notification, previous_pending_status, guard.side_parent_pending_status(), turn_stopped, ) }; if is_turn_started { self.agent_navigation.mark_running(thread_id); } else if is_thread_closed { self.mark_agent_picker_thread_closed(thread_id); } else if turn_stopped { self.agent_navigation.mark_stopped(thread_id); } // Settings snapshots do not belong in the transcript queue: apply them in receive order. if let Some(ServerNotification::ThreadSettingsUpdated(settings)) = notification.as_ref() && self.active_thread_id == Some(thread_id) && self.chat_widget.thread_id() == Some(thread_id) { self.chat_widget .on_thread_settings_updated(settings.clone()); if confirmed_profile.is_some() || settings .thread_settings .active_permission_profile .as_ref() .is_some_and(|profile| { self.agents_overview .selected_permission_profiles .get(&thread_id) == Some(&profile.id) }) { // Also adopt changed custom definitions and settings from another client // after a no-op re-selection that needed no pending request. self.adopt_server_permissions(); } notification = None; } if confirmed_profile.is_some() { self.app_event_tx.send(AppEvent::SettingsSelectionSettled); } if let Some(notification) = notification { match sender.try_send(ThreadBufferedEvent::Notification(Box::new(notification))) { Ok(()) => {} Err(TrySendError::Full(event)) => { tokio::spawn(async move { if let Err(err) = sender.send(event).await { tracing::warn!("thread {thread_id} event channel closed: {err}"); } }); } Err(TrySendError::Closed(_)) => { tracing::warn!("thread {thread_id} event channel closed"); } } } if let Some(status) = pending_status { self.set_side_parent_status(thread_id, Some(status)); } else if let Some(change) = notification_status_change { self.apply_side_parent_status_change(thread_id, change); } else if previous_pending_status.is_some() { self.clear_side_parent_action_status(thread_id); } self.refresh_pending_thread_approvals().await; Ok(()) } /// Locally remembers receiver threads referenced by a collab notification. /// /// This intentionally avoids app-server reads on the active-thread rendering path. During large /// fan-outs the app-server can be saturated with spawn work, and blocking here would freeze the /// TUI event loop. Metadata from `ThreadStarted` or explicit picker refreshes still fills in /// names and roles later; until then, rendering falls back to the thread id. pub(super) fn cache_collab_receiver_threads_for_notification( &mut self, notification: &ServerNotification, ) { if let Some(activity) = sub_agent_activity_item(notification).and_then(sub_agent_activity_display) { let thread_id = activity.thread_id; self.agent_navigation.record_sub_agent_activity(activity); self.sync_agent_picker_thread(thread_id); return; } let Some(receiver_thread_ids) = collab_receiver_thread_ids(notification) else { return; }; for receiver_thread_id in receiver_thread_ids { if collab_receiver_is_not_found(notification, receiver_thread_id) { continue; } let Ok(thread_id) = ThreadId::from_string(receiver_thread_id) else { tracing::warn!( thread_id = receiver_thread_id, "ignoring collab receiver with invalid thread id during local caching" ); continue; }; if self.agent_navigation.get(&thread_id).is_some() { continue; } self.upsert_agent_picker_thread( thread_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, ); self.sync_agent_picker_thread(thread_id); } } async fn infer_session_for_started_thread( &self, thread_id: ThreadId, notification: &ThreadStartedNotification, ) -> Option { let mut session = self.primary_session_configured.clone()?; session.thread_id = thread_id; session.daybreak_enabled = notification.thread.daybreak_enabled.unwrap_or(false); session.windows_sandbox_host = crate::windows_sandbox::host_from_environments( notification.thread.environments.as_deref(), ); session.thread_name = notification.thread.name.clone(); session.model_provider_id = notification.thread.model_provider.clone(); session .set_cwd_retargeting_implicit_runtime_workspace_root(notification.thread.cwd.clone()); let rollout_path = notification.thread.path.clone(); if let Some(model) = ¬ification.thread.model { session.model = model.clone(); } else if rollout_path.is_some() { session.model.clear(); } session.message_history = None; session.rollout_path = rollout_path; Some(session) } pub(super) async fn enqueue_thread_request( &mut self, thread_id: ThreadId, request: ServerRequest, ) -> Result<()> { let inactive_interactive_request = if self.active_thread_id != Some(thread_id) { self.interactive_request_for_thread_request(thread_id, &request) .await? } else { None }; let (sender, store) = { let channel = self.ensure_thread_channel(thread_id); (channel.sender.clone(), Arc::clone(&channel.store)) }; let (should_send, pending_status) = { let mut guard = store.lock().await; guard.push_request(request.clone()); (guard.active, guard.side_parent_pending_status()) }; let request_status = SideParentStatus::for_request(&request); if should_send { match sender.try_send(ThreadBufferedEvent::Request(Box::new(request))) { Ok(()) => {} Err(TrySendError::Full(event)) => { tokio::spawn(async move { if let Err(err) = sender.send(event).await { tracing::warn!("thread {thread_id} event channel closed: {err}"); } }); } Err(TrySendError::Closed(_)) => { tracing::warn!("thread {thread_id} event channel closed"); } } } else if self.active_side_parent_thread_id().is_none() && let Some(request) = inactive_interactive_request { self.push_thread_interactive_request(request); } if let Some(status) = pending_status.or(request_status) { self.set_side_parent_status(thread_id, Some(status)); } self.refresh_pending_thread_approvals().await; Ok(()) } pub(super) async fn enqueue_thread_history_entry_response( &mut self, thread_id: ThreadId, event: HistoryLookupResponse, ) -> Result<()> { let (sender, store) = { let channel = self.ensure_thread_channel(thread_id); (channel.sender.clone(), Arc::clone(&channel.store)) }; let should_send = { let mut guard = store.lock().await; let should_send = guard.active; // Active batch responses remain queued in the receiver across a concurrent detach, so // retaining another deep copy for thread replay only accumulates already-delivered // history data. Inactive responses still need the buffer because they are not sent. if !should_send || !matches!(&event, HistoryLookupResponse::Batch { .. }) { guard.push_buffered_event(ThreadBufferedEvent::HistoryEntryResponse(event.clone())); } should_send }; if should_send { match sender.try_send(ThreadBufferedEvent::HistoryEntryResponse(event)) { Ok(()) => {} Err(TrySendError::Full(event)) => { tokio::spawn(async move { if let Err(err) = sender.send(event).await { tracing::warn!("thread {thread_id} event channel closed: {err}"); } }); } Err(TrySendError::Closed(_)) => { tracing::warn!("thread {thread_id} event channel closed"); } } } Ok(()) } pub(super) async fn enqueue_primary_thread_session( &mut self, session: ThreadSessionState, turns: Vec, ) -> Result<()> { self.enqueue_primary_thread_session_with_presentation( session, turns, ThreadAttachPresentation::SessionLineage, ) .await } pub(super) async fn enqueue_primary_thread_session_with_presentation( &mut self, session: ThreadSessionState, turns: Vec, presentation: ThreadAttachPresentation, ) -> Result<()> { if let Err(err) = self .config .permissions .approval_policy .set(session.approval_policy.to_core()) { tracing::warn!(%err, "failed to sync app approval policy from thread session"); } if let Err(err) = self .config .permissions .set_permission_profile_from_session_snapshot( PermissionProfileSnapshot::from_session_snapshot( session.permission_profile.clone(), session.active_permission_profile.clone(), ), ) { tracing::warn!(%err, "failed to sync app permissions from thread session"); } self.config.approvals_reviewer = session.approvals_reviewer; if !self.app_server_target.uses_remote_workspace() { self.config .workspace_roots .clone_from(&session.runtime_workspace_roots); self.config .permissions .set_workspace_roots(session.runtime_workspace_roots.clone()); } let thread_id = session.thread_id; self.pending_server_profiles.remove(&thread_id); self.agents_overview .requested_permission_profiles .remove(&thread_id); if self.primary_thread_id != Some(thread_id) { self.recap.reset_for_new_thread(Instant::now()); } self.primary_thread_id = Some(thread_id); self.agents_overview.hidden_threads.remove(&thread_id); self.agents_overview.threads.entry(thread_id).or_default(); self.primary_session_configured = Some(session.clone()); self.upsert_agent_picker_thread( thread_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, ); self.sync_agent_picker_thread(thread_id); let channel = self.ensure_thread_channel(thread_id); { let mut store = channel.store.lock().await; store.set_session(session.clone(), turns.clone()); } self.activate_thread_channel(thread_id).await; self.chat_widget .set_initial_user_message_submit_suppressed(/*suppressed*/ true); if !turns.is_empty() { self.chat_widget.set_token_info(/*info*/ None); } match presentation { ThreadAttachPresentation::Fresh | ThreadAttachPresentation::FreshAfterClear | ThreadAttachPresentation::FreshWithDraft | ThreadAttachPresentation::SessionLineage => { self.chat_widget.handle_thread_session(session); } } let should_buffer_initial_replay = !turns.is_empty(); let replayed_final_items = realtime_delivery::completed_agent_items_from_turns(&turns); let replayed_voice_texts = realtime_delivery::replayed_voice_texts_from_turns(&turns); let retained_assistant_captions = self.prepare_realtime_transcript_replay(replayed_voice_texts); if should_buffer_initial_replay { self.app_event_tx .send(AppEvent::BeginInitialHistoryReplayBuffer); } let now = Instant::now(); self.recap.seed_from_turns(&turns, now); self.schedule_recap_check(thread_id, now); self.chat_widget .replay_thread_turns(turns, ReplayKind::ResumeInitialMessages); if should_buffer_initial_replay { self.app_event_tx .send(AppEvent::EndInitialHistoryReplayBuffer); self.app_event_tx .send(AppEvent::RestoreBackgroundTerminals(thread_id)); } self.restore_realtime_replay_state_after_replay( &replayed_final_items, retained_assistant_captions, ); self.restore_voice_owner_after_replay(); let pending = std::mem::take(&mut self.pending_primary_events); for pending_event in pending { match pending_event { ThreadBufferedEvent::Notification(notification) => { self.enqueue_thread_notification(thread_id, *notification) .await?; } ThreadBufferedEvent::Request(request) => { self.enqueue_thread_request(thread_id, *request).await?; } ThreadBufferedEvent::HistoryEntryResponse(event) => { self.enqueue_thread_history_entry_response(thread_id, event) .await?; } ThreadBufferedEvent::FeedbackSubmission(event) => { self.enqueue_thread_feedback_event(thread_id, event).await; } } } self.chat_widget .set_initial_user_message_submit_suppressed(/*suppressed*/ false); self.chat_widget.submit_initial_user_message_if_pending(); Ok(()) } pub(super) async fn enqueue_primary_thread_notification( &mut self, notification: ServerNotification, ) -> Result<()> { if let Some(thread_id) = self.primary_thread_id { return self .enqueue_thread_notification(thread_id, notification) .await; } self.pending_primary_events .push_back(ThreadBufferedEvent::Notification(Box::new(notification))); Ok(()) } pub(super) async fn enqueue_primary_thread_request( &mut self, request: ServerRequest, ) -> Result<()> { if let Some(thread_id) = self.primary_thread_id { return self.enqueue_thread_request(thread_id, request).await; } self.pending_primary_events .push_back(ThreadBufferedEvent::Request(Box::new(request))); Ok(()) } pub(super) async fn refresh_snapshot_session_if_needed( &mut self, app_server: &mut AppServerSession, thread_id: ThreadId, is_replay_only: bool, snapshot: &mut ThreadEventSnapshot, ) -> bool { if !self.should_refresh_snapshot_session(thread_id, is_replay_only, snapshot) { return true; } match app_server .resume_thread( &self.local_settings, self.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, ) .await { Ok(started) => { self.apply_refreshed_snapshot_thread(thread_id, started, snapshot) .await; true } Err(err) => { tracing::warn!( thread_id = %thread_id, error = %err, "failed to refresh inferred thread session before replay" ); false } } } pub(super) fn should_refresh_snapshot_session( &self, thread_id: ThreadId, is_replay_only: bool, snapshot: &ThreadEventSnapshot, ) -> bool { !is_replay_only && !self.side_threads.contains_key(&thread_id) && snapshot.session.as_ref().is_none_or(|session| { session.model.trim().is_empty() || session.rollout_path.is_none() || (self.primary_thread_id != Some(thread_id) && session .active_permission_profile .as_ref() .is_some_and(|profile| !profile.id.starts_with(':'))) }) } pub(super) async fn apply_refreshed_snapshot_thread( &mut self, thread_id: ThreadId, started: AppServerStartedThread, snapshot: &mut ThreadEventSnapshot, ) { if started.blocks_direct_input { self.agent_navigation.mark_parent_owned(thread_id); } let AppServerStartedThread { session, turns, .. } = started; if let Some(channel) = self.thread_event_channels.get(&thread_id) { let mut store = channel.store.lock().await; store.replay_may_have_output |= snapshot.turns != turns; store.set_session(session.clone(), turns.clone()); store.rebase_buffer_after_session_refresh(); snapshot.active_reasoning_item = store.active_reasoning_item.clone(); } snapshot.session = Some(session); snapshot.turns = turns; snapshot .events .retain_mut(ThreadEventStore::event_survives_session_refresh); } /// Opens the `/subagents` picker after refreshing cached labels for known threads. /// /// The picker state is derived from long-lived thread channels plus best-effort metadata /// refreshes from the backend. Refresh failures are treated as "thread is only inspectable by /// historical id now" and converted into closed picker entries instead of deleting them, so /// the stable traversal order remains intact for review and keyboard navigation. pub(super) async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { let frame_deadline = Instant::now() + tui::TARGET_FRAME_INTERVAL; self.drain_active_thread_events_until(tui, frame_deadline) .await } pub(super) async fn drain_active_thread_events_until( &mut self, tui: &mut tui::Tui, frame_deadline: Instant, ) -> Result<()> { let Some(mut rx) = self.active_thread_rx.take() else { return Ok(()); }; let mut disconnected = false; loop { match rx.try_recv() { Ok(event) => { self.handle_thread_event_now_recovering_file_changes(event) .await } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { disconnected = true; break; } } if Instant::now() >= frame_deadline { break; } } if !disconnected { self.active_thread_rx = Some(rx); } else { self.clear_active_thread().await; } if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); } Ok(()) } /// Returns `(closed_thread_id, primary_thread_id)` when a non-primary active /// thread has died and we should fail over to the primary thread. /// /// A user-requested shutdown (`ExitMode::ShutdownFirst`) sets /// `pending_shutdown_exit_thread_id`; matching shutdown completions are ignored /// here so Ctrl+C-like exits don't accidentally resurrect the main thread. /// /// Failover is only eligible when all of these are true: /// 1. the event is `thread/closed`; /// 2. the active thread differs from the primary thread; /// 3. the active thread is not the pending shutdown-exit thread. pub(super) fn active_non_primary_shutdown_target( &self, notification: &ServerNotification, ) -> Option<(ThreadId, ThreadId)> { if !matches!(notification, ServerNotification::ThreadClosed(_)) { return None; } let active_thread_id = self.active_thread_id?; let primary_thread_id = self.primary_thread_id?; if self.pending_shutdown_exit_thread_id == Some(active_thread_id) { return None; } (active_thread_id != primary_thread_id).then_some((active_thread_id, primary_thread_id)) } pub(super) fn replay_thread_snapshot( &mut self, mut snapshot: ThreadEventSnapshot, resume_restored_queue: bool, ) { let mut reasoning_replay = reasoning_replay::ReasoningReplay::new(&mut snapshot); let replayed_final_items = realtime_delivery::completed_agent_items(&snapshot); let replayed_voice_texts = realtime_delivery::replayed_voice_texts(&snapshot); replay_filter::omit_completed_agent_deltas(&mut snapshot.events); let request_changes = snapshot .events .iter() .map(|event| { let ThreadBufferedEvent::Request(request) = event else { return None; }; let ServerRequest::FileChangeRequestApproval { params, .. } = request.as_ref() else { return None; }; file_change_changes( snapshot.events.iter(), &snapshot.turns, ¶ms.turn_id, ¶ms.item_id, ) }) .collect::>(); self.refresh_mcp_startup_expected_servers_from_config(); let should_buffer_replay = !snapshot.turns.is_empty() || !snapshot.events.is_empty(); if should_buffer_replay { self.app_event_tx .send(AppEvent::BeginThreadSwitchHistoryReplayBuffer); } let suppress_replay_notices = replay_filter::snapshot_has_pending_interactive_request(&snapshot); self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ true); let has_resumed_collaboration_mode = snapshot .session .as_ref() .is_some_and(|session| session.collaboration_mode.is_some()); if let Some(session) = snapshot.session { if session.reasoning_effort != Some(ReasoningEffortConfig::Ultra) { self.chat_widget .set_plan_mode_reasoning_effort(self.config.plan_mode_reasoning_effort.clone()); } if self.side_threads.contains_key(&session.thread_id) { self.chat_widget.handle_side_thread_session(session); } else if suppress_replay_notices { self.chat_widget.handle_thread_session_quiet(session); } else { self.chat_widget.handle_thread_session(session); } } let retained_assistant_captions = self.prepare_realtime_transcript_replay(replayed_voice_texts); for turn_id in &snapshot.delegated_turns { self.chat_widget .remember_realtime_delegated_reasoning_turn(turn_id); } let confirmed_message_ids = super::thread_events::confirmed_user_message_ids( &snapshot.turns, snapshot.events.iter(), ); let recovered_input = snapshot .input_state .as_ref() .is_some_and(|input| { input.recovered_queue || input.reconnect_pending || input.has_unconfirmed_messages() }) .then(|| snapshot.input_state.take()) .flatten(); self.chat_widget.restore_thread_input_state( snapshot.input_state, ThreadInputStateRestoreMode { preserve_in_flight_turn: true, }, ); if !snapshot.turns.is_empty() { self.chat_widget .replay_thread_turns(snapshot.turns, ReplayKind::ThreadSnapshot); } for (event, changes) in snapshot.events.into_iter().zip(request_changes) { reasoning_replay.before_event(&event, &mut self.chat_widget); match (event, changes) { (ThreadBufferedEvent::Request(request), Some(changes)) => { self.handle_file_change_request(*request, changes) } (event, _) => self.handle_thread_event_replay(event), } } reasoning_replay.restore(&mut self.chat_widget); if should_buffer_replay { self.app_event_tx .send(AppEvent::EndInitialHistoryReplayBuffer); } let recovered = recovered_input.is_some(); if recovered { let mode = has_resumed_collaboration_mode .then(|| self.chat_widget.effective_collaboration_mode()); self.chat_widget .restore_reconnected_input(recovered_input, &confirmed_message_ids); if let Some(mode) = mode { self.chat_widget.set_effective_collaboration_mode(mode); } } self.restore_realtime_replay_state_after_replay( &replayed_final_items, retained_assistant_captions, ); self.restore_voice_owner_after_replay(); self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); self.chat_widget .set_initial_user_message_submit_suppressed(/*suppressed*/ false); self.chat_widget.submit_initial_user_message_if_pending(); if resume_restored_queue { self.chat_widget.maybe_send_next_queued_input(); } self.refresh_status_line(); } pub(super) fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool { matches!( session_selection, SessionSelection::StartFresh | SessionSelection::Exit ) } pub(super) fn should_prompt_for_paused_goal_after_startup_resume( session_selection: &SessionSelection, initial_prompt: &Option, initial_images: &[PathBuf], ) -> bool { matches!(session_selection, SessionSelection::Resume(_)) && initial_prompt.is_none() && initial_images.is_empty() } pub(super) fn should_handle_active_thread_events( waiting_for_initial_session_configured: bool, has_active_thread_receiver: bool, ) -> bool { has_active_thread_receiver && !waiting_for_initial_session_configured } pub(super) fn should_stop_waiting_for_initial_session( waiting_for_initial_session_configured: bool, primary_thread_id: Option, ) -> bool { waiting_for_initial_session_configured && primary_thread_id.is_some() } #[allow(clippy::too_many_arguments)] pub(super) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { let cwd = self.chat_widget.config_ref().cwd.clone(); let errors = errors_for_cwd(&cwd, &response); let errors = self.skill_load_warnings.newly_active_errors(&errors); let warnings = skill_load_warning_messages(&errors); if self.skill_load_warnings.startup_complete { for warning in warnings { self.chat_widget.add_warning_message(warning); } } else { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( // The per-file diagnostics already identify every affected skill. history_cell::StartupWarningsCell::new( warnings.into_iter().skip(/*n*/ 1).collect(), ), ))); } self.chat_widget.handle_skills_list_response(response); } fn startup_request_may_open_protected_view(&self, request: &ServerRequest) -> bool { let ServerRequest::McpServerElicitationRequest { request_id, params } = request else { return true; }; match ¶ms.request { codex_app_server_protocol::McpServerElicitationRequest::UserVerification { .. } => true, codex_app_server_protocol::McpServerElicitationRequest::Form { .. } => true, codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { .. } | codex_app_server_protocol::McpServerElicitationRequest::OpenAiElicitationForm { .. } => false, request @ codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { let thread_id = ThreadId::from_string(¶ms.thread_id) .unwrap_or_else(|_| self.chat_widget.thread_id().unwrap_or_default()); AppLinkViewParams::from_url_app_server_request( thread_id, ¶ms.server_name, request_id.clone(), request, ) .is_some() } } } pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) { // Replay has its own handler; suppress repeated notifications for the current turn. if crate::terminal_size_telemetry::enabled(&self.config) && !self.terminal_size_tags.is_empty() && let ThreadBufferedEvent::Notification(notification) = &event && let ServerNotification::TurnStarted(started) = notification.as_ref() && self .active_thread_id .is_some_and(|id| id.to_string() == started.thread_id) && self.chat_widget.last_turn_id() != Some(started.turn.id.as_str()) { crate::terminal_size_telemetry::record( &self.terminal_size_tags, &self.config, "turn_start", crate::terminal_size_telemetry::read_size(), ); } let needs_refresh = matches!( &event, ThreadBufferedEvent::Notification(notification) if matches!( notification.as_ref(), ServerNotification::TurnStarted(_) | ServerNotification::ThreadTokenUsageUpdated(_) ) ); match event { ThreadBufferedEvent::Notification(notification) => { self.cache_collab_receiver_threads_for_notification(notification.as_ref()); let tip_ready = self.turn_tips.observe(¬ification, Instant::now()); self.chat_widget .handle_server_notification(*notification, /*replay_kind*/ None); // History cells queued by completion must be applied before anchoring its tip. if let Some(event) = tip_ready { self.app_event_tx.send(event); } } ThreadBufferedEvent::Request(request) => { if self .pending_app_server_requests .contains_server_request(request.as_ref()) { let may_open_protected_view = self.startup_request_may_open_protected_view(request.as_ref()); self.chat_widget .handle_server_request(*request, /*replay_kind*/ None); if may_open_protected_view && self.startup_protected_input_boundary && !self.chat_widget.has_active_modal() { self.startup_pending_protected_request = true; } } } ThreadBufferedEvent::HistoryEntryResponse(event) => { self.chat_widget.handle_history_entry_response(event); } ThreadBufferedEvent::FeedbackSubmission(event) => { self.handle_feedback_thread_event(event); } } if needs_refresh { self.refresh_status_line(); } } pub(super) fn handle_thread_event_replay(&mut self, event: ThreadBufferedEvent) { self.turn_tips.dismiss(); match event { ThreadBufferedEvent::Notification(notification) => self .chat_widget .handle_server_notification(*notification, Some(ReplayKind::ThreadSnapshot)), ThreadBufferedEvent::Request(request) => { let may_open_protected_view = self.startup_request_may_open_protected_view(request.as_ref()); self.chat_widget .handle_server_request(*request, Some(ReplayKind::ThreadSnapshot)); if may_open_protected_view && self.startup_protected_input_boundary && !self.chat_widget.has_active_modal() { self.startup_pending_protected_request = true; } } ThreadBufferedEvent::HistoryEntryResponse(event) => { self.chat_widget.handle_history_entry_response(event) } ThreadBufferedEvent::FeedbackSubmission(event) => { self.handle_feedback_thread_event(event); } } } /// Handles an event emitted by the currently active thread. /// /// This function enforces shutdown intent routing: unexpected non-primary /// thread shutdowns fail over to the primary thread, while user-requested /// app exits consume only the tracked shutdown completion and then proceed. pub(super) async fn handle_active_thread_event( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, event: ThreadBufferedEvent, ) -> Result<()> { // Capture this before any potential thread switch: we only want to clear // the exit marker when the currently active thread acknowledges shutdown. let pending_shutdown_exit_completed = matches!( &event, ThreadBufferedEvent::Notification(notification) if matches!(notification.as_ref(), ServerNotification::ThreadClosed(_)) ) && self.pending_shutdown_exit_thread_id == self.active_thread_id; // Processing order matters: // // 1. handle unexpected non-primary shutdown failover first; // 2. clear pending exit marker for matching shutdown; // 3. forward the event through normal handling. // // This preserves the mental model that user-requested exits do not trigger // failover, while true sub-agent deaths still do. if let ThreadBufferedEvent::Notification(notification) = &event && let Some((closed_thread_id, primary_thread_id)) = self.active_non_primary_shutdown_target(notification.as_ref()) { self.mark_agent_picker_thread_closed(closed_thread_id); if self.side_threads.contains_key(&closed_thread_id) { self.discard_closed_side_thread(closed_thread_id).await; } self.select_agent_thread(tui, app_server, primary_thread_id) .await?; if self.active_thread_id == Some(primary_thread_id) { self.chat_widget.add_info_message( format!( "Agent thread {closed_thread_id} closed. Switched back to main thread." ), /*hint*/ None, ); } else { self.clear_active_thread().await; self.chat_widget.add_error_message(format!( "Agent thread {closed_thread_id} closed. Failed to switch back to main thread {primary_thread_id}.", )); } return Ok(()); } if pending_shutdown_exit_completed { // Clear only after seeing the shutdown completion for the tracked // thread, so unrelated shutdowns cannot consume this marker. self.pending_shutdown_exit_thread_id = None; } // Sidebar chats use their first prompt until they receive an explicit name. let automatic_title_user_message = if self.chat_widget.thread_name().is_none() && !(self.side_retention_enabled(tui) && self.active_side_parent_thread_id().is_some()) && let ThreadBufferedEvent::Notification(notification) = &event && let ServerNotification::ItemCompleted(notification) = notification.as_ref() && let ThreadItemPayload::UserMessage { content, .. } = ¬ification.item.payload { Some( content .iter() .filter_map(|item| match item { codex_app_server_protocol::UserInput::Text { text, .. } => { Some(crate::user_prompt::extract_prompt_request_with_offset(text).0) } _ => None, }) .collect::>() .join(" "), ) } else { None }; let had_active_modal = self.chat_widget.has_active_modal(); self.handle_thread_event_now_recovering_file_changes(event) .await; if let Some(user_message) = automatic_title_user_message && !user_message.trim().is_empty() && let Some(thread_id) = self.active_thread_id { self.generate_thread_title( app_server, thread_id, ThreadTitleDestination::Automatic, super::thread_title::thread_title_prompt(&user_message), ); } if !had_active_modal && self.chat_widget.has_active_modal() && self.startup_protected_input_boundary { self.chat_widget.pre_draw_tick(); self.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?; self.discard_startup_modal_input(tui)?; } if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); } Ok(()) } } #[cfg(test)] mod tests { use super::*; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; async fn config_with_workspace_profile() -> Config { let temp_dir = tempfile::tempdir().expect("tempdir"); ConfigBuilder::default() .codex_home(temp_dir.path().to_path_buf()) .harness_overrides(ConfigOverrides { default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), ..ConfigOverrides::default() }) .build() .await .expect("config should build") } #[tokio::test] async fn turn_permissions_use_active_profile_when_available() { let config = config_with_workspace_profile().await; let active_permission_profile = config.permissions.active_permission_profile(); assert_eq!( App::turn_permissions_override_from_config( &config, active_permission_profile.as_ref(), /*runtime_permission_profile_override*/ None, ), TurnPermissionsOverride::ActiveProfile(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_WORKSPACE )) ); } #[tokio::test] async fn turn_permissions_preserve_server_snapshot_without_local_override() { let mut config = config_with_workspace_profile().await; config .permissions .set_permission_profile(PermissionProfile::read_only()) .expect("read-only profile should be allowed"); assert_eq!( App::turn_permissions_override_from_config( &config, /*active_permission_profile*/ None, /*runtime_permission_profile_override*/ None, ), TurnPermissionsOverride::Preserve ); } #[tokio::test] async fn turn_permissions_send_legacy_sandbox_for_local_override() { let mut config = config_with_workspace_profile().await; let permission_profile = PermissionProfile::workspace_write(); config .permissions .set_permission_profile(permission_profile.clone()) .expect("workspace profile should be allowed"); let effective_permission_profile = config.permissions.effective_permission_profile(); assert_eq!( App::turn_permissions_override_from_config( &config, /*active_permission_profile*/ None, Some(&permission_profile), ), TurnPermissionsOverride::LegacySandbox(effective_permission_profile) ); } }