mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
## Why Losing an external app-server connection can leave a submission's outcome uncertain. The TUI must preserve local work without retrying it or allowing stale server events to trigger more actions. ## What changed - Enter an offline state when an external app-server transport disconnects during startup, event streaming, or submission. - Keep conversation drafts, queued input, expanded pastes, attachments, and agent-overview input editable while blocking submissions, remote actions, and automatic queue replay. - Show a connection-lost notice that directs users to copy their work and relaunch, while retaining `Ctrl-C` and an empty-composer `Ctrl-D` as quit shortcuts. - Cancel pending views and asynchronous work, and ignore stale completions after the disconnect. Embedded app-server behavior is unchanged. ## Testing Added unit, snapshot, and pseudo-terminal coverage for lost startup and mutation replies, offline draft editing, queued-input recovery, stale-event quarantine, agent-overview behavior, and end-to-end preservation without reconnection or resubmission. GitOrigin-RevId: 7219a95fad676dda447d625db9e465345b469d58
565 lines
24 KiB
Rust
565 lines
24 KiB
Rust
//! App-server event stream handling for the TUI app.
|
|
|
|
use super::App;
|
|
use super::ThreadBufferedEvent;
|
|
use super::app_server_event_targets::ServerNotificationThreadTarget;
|
|
use super::app_server_event_targets::server_notification_thread_target;
|
|
use super::app_server_event_targets::server_request_thread_id;
|
|
use crate::app_command::AppCommand;
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event::RateLimitRefreshOrigin;
|
|
use crate::app_info::app_info_from_api;
|
|
use crate::app_server_session::AppServerSession;
|
|
use crate::app_server_session::status_account_display_from_auth_mode;
|
|
use codex_app_server_client::AppServerEvent;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::RateLimitReachedType;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ServerRequest;
|
|
use codex_app_server_protocol::SessionSource;
|
|
use codex_app_server_protocol::ThreadReadParams;
|
|
use codex_app_server_protocol::ThreadReadResponse;
|
|
use codex_app_server_protocol::ThreadSource;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
|
|
impl App {
|
|
pub(super) fn refresh_mcp_startup_expected_servers_from_config(&mut self) {
|
|
if self
|
|
.current_displayed_thread_id()
|
|
.zip(self.primary_thread_id)
|
|
.is_some_and(|(thread_id, primary_thread_id)| {
|
|
self.agent_navigation.is_parent_owned(thread_id)
|
|
|| (thread_id != primary_thread_id
|
|
&& !self.side_threads.contains_key(&thread_id))
|
|
})
|
|
{
|
|
// Subagents can defer cached servers indefinitely, so only servers
|
|
// that actually report startup should keep their status running.
|
|
self.chat_widget
|
|
.set_mcp_startup_expected_servers(std::iter::empty());
|
|
return;
|
|
}
|
|
|
|
let enabled_config_mcp_servers: Vec<String> = self
|
|
.config
|
|
.mcp_servers
|
|
.get()
|
|
.iter()
|
|
.filter_map(|(name, server)| server.enabled.then_some(name.clone()))
|
|
.collect();
|
|
self.chat_widget
|
|
.set_mcp_startup_expected_servers(enabled_config_mcp_servers);
|
|
}
|
|
|
|
pub(super) async fn handle_app_server_event(
|
|
&mut self,
|
|
app_server_client: &AppServerSession,
|
|
event: AppServerEvent,
|
|
) {
|
|
match event {
|
|
AppServerEvent::Lagged { skipped } => {
|
|
tracing::warn!(
|
|
skipped,
|
|
"app-server event consumer lagged; dropping ignored events"
|
|
);
|
|
self.refresh_mcp_startup_expected_servers_from_config();
|
|
self.chat_widget.finish_mcp_startup_after_lag();
|
|
self.refresh_agents_overview_threads(app_server_client);
|
|
}
|
|
AppServerEvent::ServerNotification(notification) => {
|
|
self.handle_server_notification_event(app_server_client, *notification)
|
|
.await;
|
|
}
|
|
AppServerEvent::ServerRequest(request) => {
|
|
self.handle_server_request_event(app_server_client, *request)
|
|
.await;
|
|
}
|
|
AppServerEvent::Disconnected { message } => {
|
|
if self.begin_reconnect() {
|
|
return;
|
|
}
|
|
tracing::warn!("app-server event stream disconnected: {message}");
|
|
self.chat_widget.add_error_message(message.clone());
|
|
self.app_event_tx.send(AppEvent::FatalExitRequest(message));
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_server_notification_event(
|
|
&mut self,
|
|
app_server_client: &AppServerSession,
|
|
notification: ServerNotification,
|
|
) {
|
|
if let ServerNotification::ThreadStatusChanged(status) = ¬ification {
|
|
let _ = self.dynamic_tool_status_updates.send(status.clone());
|
|
}
|
|
|
|
if let ServerNotification::ThreadStarted(started) = ¬ification
|
|
&& started.thread.ephemeral
|
|
&& matches!(
|
|
started.thread.thread_source.as_ref(),
|
|
Some(ThreadSource::Feature(feature)) if feature == "system"
|
|
)
|
|
{
|
|
return;
|
|
}
|
|
// Hidden helper threads must not enter visible thread routing or overview refreshes.
|
|
if let ServerNotificationThreadTarget::Thread(thread_id) =
|
|
server_notification_thread_target(¬ification)
|
|
&& let Some(sender) = self.temporary_structured_requests.get(&thread_id)
|
|
{
|
|
if matches!(
|
|
¬ification,
|
|
ServerNotification::ItemCompleted(_) | ServerNotification::TurnCompleted(_)
|
|
) && sender.send(notification).is_err()
|
|
{
|
|
self.temporary_structured_requests.remove(&thread_id);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if let ServerNotification::ThreadStarted(started) = ¬ification
|
|
&& let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id, ..
|
|
}) = &started.thread.source
|
|
&& self
|
|
.agents_overview
|
|
.dispatched_requests
|
|
.contains_key(parent_thread_id)
|
|
&& let Ok(thread_id) = codex_protocol::ThreadId::from_string(&started.thread.id)
|
|
{
|
|
self.agents_overview
|
|
.dispatched_requests
|
|
.entry(thread_id)
|
|
.or_default();
|
|
}
|
|
if matches!(
|
|
¬ification,
|
|
ServerNotification::ThreadStarted(_)
|
|
| ServerNotification::ThreadStatusChanged(_)
|
|
| ServerNotification::ThreadSettingsUpdated(_)
|
|
| ServerNotification::ThreadNameUpdated(_)
|
|
| ServerNotification::ThreadArchived(_)
|
|
| ServerNotification::ThreadDeleted(_)
|
|
| ServerNotification::ThreadClosed(_)
|
|
) {
|
|
self.refresh_agents_overview_threads(app_server_client);
|
|
}
|
|
match ¬ification {
|
|
ServerNotification::ServerRequestResolved(notification) => {
|
|
if let Some((_, task)) = self.dynamic_tool_tasks.remove(¬ification.request_id) {
|
|
task.abort();
|
|
}
|
|
let notification_thread_id =
|
|
codex_protocol::ThreadId::from_string(¬ification.thread_id).ok();
|
|
self.pending_primary_events.retain(|event| {
|
|
!matches!(event, ThreadBufferedEvent::Request(request)
|
|
if request.id() == ¬ification.request_id
|
|
&& server_request_thread_id(request) == notification_thread_id)
|
|
});
|
|
if let Some(thread_id) = notification_thread_id
|
|
&& let Some(requests) =
|
|
self.agents_overview.dispatched_requests.get_mut(&thread_id)
|
|
{
|
|
requests.retain(|request| request.id() != ¬ification.request_id);
|
|
}
|
|
if let Some(request) = self
|
|
.pending_app_server_requests
|
|
.resolve_notification(¬ification.thread_id, ¬ification.request_id)
|
|
{
|
|
self.chat_widget.dismiss_app_server_request(&request);
|
|
if self.startup_pending_protected_request {
|
|
self.startup_pending_protected_request =
|
|
self.chat_widget.has_pending_protected_request();
|
|
}
|
|
}
|
|
}
|
|
ServerNotification::McpServerStatusUpdated(_) => {
|
|
self.refresh_mcp_startup_expected_servers_from_config();
|
|
}
|
|
ServerNotification::AccountRateLimitsUpdated(notification) => {
|
|
let workspace_hard_stop = matches!(
|
|
notification.rate_limits.rate_limit_reached_type,
|
|
Some(
|
|
RateLimitReachedType::WorkspaceOwnerCreditsDepleted
|
|
| RateLimitReachedType::WorkspaceMemberCreditsDepleted
|
|
| RateLimitReachedType::WorkspaceOwnerUsageLimitReached
|
|
| RateLimitReachedType::WorkspaceMemberUsageLimitReached
|
|
)
|
|
) || notification.rate_limits.spend_control_reached
|
|
== Some(true);
|
|
if workspace_hard_stop {
|
|
self.rate_limit_hard_stop_generation =
|
|
self.rate_limit_hard_stop_generation.wrapping_add(1);
|
|
}
|
|
self.chat_widget
|
|
.on_rolling_rate_limit_snapshot(notification.rate_limits.clone());
|
|
if workspace_hard_stop && self.chat_widget.has_chatgpt_account() {
|
|
// Background inference may publish a hard stop without a foreground Error.
|
|
self.refresh_rate_limits(app_server_client, RateLimitRefreshOrigin::Recovery);
|
|
}
|
|
return;
|
|
}
|
|
ServerNotification::AccountUpdated(notification) => {
|
|
self.rate_limit_hard_stop_generation =
|
|
self.rate_limit_hard_stop_generation.wrapping_add(1);
|
|
self.rate_limit_refresh_state.invalidate_recovery();
|
|
// Deferred terminal writes must never carry the previous account's billing into
|
|
// the newly authenticated identity, even when both accounts share one thread.
|
|
self.last_thread_usage_status_cell = None;
|
|
self.pending_thread_usage_history_refresh = false;
|
|
let has_codex_backend_auth = matches!(
|
|
notification.auth_mode,
|
|
Some(
|
|
AuthMode::Chatgpt
|
|
| AuthMode::ChatgptAuthTokens
|
|
| AuthMode::AgentIdentity
|
|
| AuthMode::PersonalAccessToken
|
|
)
|
|
);
|
|
self.chat_widget.update_account_state(
|
|
status_account_display_from_auth_mode(
|
|
notification.auth_mode,
|
|
notification.plan_type,
|
|
),
|
|
notification.plan_type,
|
|
notification
|
|
.auth_mode
|
|
.is_some_and(AuthMode::has_chatgpt_account),
|
|
has_codex_backend_auth,
|
|
);
|
|
return;
|
|
}
|
|
ServerNotification::ExternalAgentConfigImportCompleted(notification) => {
|
|
let should_report_completion =
|
|
app_server_client.consume_external_agent_config_import_completion();
|
|
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
|
tracing::warn!(
|
|
error = %err,
|
|
"failed to refresh config after external agent config import"
|
|
);
|
|
}
|
|
let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
|
|
self.chat_widget.refresh_plugin_mentions();
|
|
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
|
self.fetch_plugins_list(app_server_client, cwd);
|
|
if should_report_completion {
|
|
self.chat_widget.add_plain_history_lines(
|
|
crate::external_agent_config_migration::flow::external_agent_config_migration_finished_lines(notification),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
ServerNotification::AppListUpdated(notification) => {
|
|
if self.current_displayed_thread_id().is_some() {
|
|
self.chat_widget
|
|
.refresh_connector_directory_after_notification(
|
|
notification
|
|
.data
|
|
.iter()
|
|
.cloned()
|
|
.map(app_info_from_api)
|
|
.collect(),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
match server_notification_thread_target(¬ification) {
|
|
ServerNotificationThreadTarget::Thread(thread_id) => {
|
|
if self.primary_thread_id.is_none() && !self.pending_startup_thread_start {
|
|
return;
|
|
}
|
|
if self.primary_thread_id.is_some()
|
|
&& self.primary_thread_id != Some(thread_id)
|
|
&& !self.thread_event_channels.contains_key(&thread_id)
|
|
&& self.agent_navigation.get(&thread_id).is_none()
|
|
&& !self.side_threads.contains_key(&thread_id)
|
|
&& !matches!(¬ification, ServerNotification::McpServerStatusUpdated(_))
|
|
&& !matches!(
|
|
¬ification,
|
|
ServerNotification::ThreadStarted(started)
|
|
if matches!(
|
|
&started.thread.source,
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id,
|
|
..
|
|
}) if self.primary_thread_id == Some(*parent_thread_id)
|
|
|| self.thread_event_channels.contains_key(parent_thread_id)
|
|
|| self.agent_navigation.get(parent_thread_id).is_some()
|
|
)
|
|
)
|
|
{
|
|
return;
|
|
}
|
|
let result = if self.primary_thread_id == Some(thread_id)
|
|
|| self.primary_thread_id.is_none()
|
|
{
|
|
self.enqueue_primary_thread_notification(notification).await
|
|
} else {
|
|
self.enqueue_thread_notification(thread_id, notification)
|
|
.await
|
|
};
|
|
|
|
if let Err(err) = result {
|
|
tracing::warn!("failed to enqueue app-server notification: {err}");
|
|
}
|
|
return;
|
|
}
|
|
ServerNotificationThreadTarget::InvalidThreadId(thread_id) => {
|
|
tracing::warn!(
|
|
thread_id,
|
|
"ignoring app-server notification with invalid thread_id"
|
|
);
|
|
return;
|
|
}
|
|
ServerNotificationThreadTarget::AppScoped => {
|
|
tracing::debug!(
|
|
"ignoring app-scoped MCP startup notification without a TUI app-level target"
|
|
);
|
|
return;
|
|
}
|
|
ServerNotificationThreadTarget::Global => {}
|
|
}
|
|
|
|
self.chat_widget
|
|
.handle_server_notification(notification, /*replay_kind*/ None);
|
|
}
|
|
|
|
async fn handle_server_request_event(
|
|
&mut self,
|
|
app_server_client: &AppServerSession,
|
|
request: ServerRequest,
|
|
) {
|
|
if let ServerRequest::DynamicToolCall { request_id, params } = &request {
|
|
if self.dynamic_tool_tasks.contains_key(request_id)
|
|
|| (params.namespace.as_deref() != Some(crate::dynamic_tools::NAMESPACE)
|
|
&& !app_server_client.uses_embedded_app_server())
|
|
{
|
|
return;
|
|
}
|
|
|
|
let requires_mcp = crate::dynamic_tools::DELEGATION_TOOLS
|
|
.contains(¶ms.tool.as_str())
|
|
|| matches!(
|
|
app_server_client.thread_tool_transport(),
|
|
crate::dynamic_tools_mcp::ThreadToolTransport::Mcp(_)
|
|
);
|
|
if app_server_client.uses_embedded_app_server()
|
|
|| requires_mcp
|
|
|| codex_protocol::ThreadId::from_string(¶ms.thread_id)
|
|
.is_ok_and(|thread_id| self.abandoned_side_threads.contains(&thread_id))
|
|
{
|
|
let response = crate::dynamic_tools::failure_response(if requires_mcp {
|
|
"TUI task tools require the approval-gated MCP server"
|
|
} else {
|
|
"TUI dynamic tools require an active external task"
|
|
});
|
|
self.app_event_tx.send(AppEvent::DynamicToolCallCompleted {
|
|
request_id: request_id.clone(),
|
|
response,
|
|
});
|
|
return;
|
|
}
|
|
|
|
let request_handle = app_server_client.request_handle();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
let status_updates = self.dynamic_tool_status_updates.subscribe();
|
|
let request_id = request_id.clone();
|
|
let task_request_id = request_id.clone();
|
|
let source_thread_id = params.thread_id.clone();
|
|
let inherits_task_tools = params.tool == "fork_thread"
|
|
&& params
|
|
.arguments
|
|
.get("threadId")
|
|
.and_then(serde_json::Value::as_str)
|
|
.and_then(|thread_id| ThreadId::from_string(thread_id).ok())
|
|
.is_none_or(|thread_id| app_server_client.task_tools_available(thread_id));
|
|
let params = params.clone();
|
|
let mut thread_start_params =
|
|
crate::app_server_session::thread_start_params_from_config(
|
|
&self.config,
|
|
app_server_client.thread_params_mode(),
|
|
app_server_client.remote_cwd_override(),
|
|
/*session_start_source*/ None,
|
|
);
|
|
app_server_client
|
|
.thread_tool_transport()
|
|
.configure(&mut thread_start_params);
|
|
let task = tokio::spawn(async move {
|
|
let response = crate::dynamic_tools::execute(
|
|
request_handle,
|
|
params,
|
|
thread_start_params,
|
|
status_updates,
|
|
Some(&app_event_tx),
|
|
)
|
|
.await;
|
|
if inherits_task_tools
|
|
&& response.success
|
|
&& let [
|
|
codex_app_server_protocol::DynamicToolCallOutputContentItem::InputText {
|
|
text,
|
|
},
|
|
] = response.content_items.as_slice()
|
|
&& let Ok(result) = serde_json::from_str::<serde_json::Value>(text)
|
|
&& let Some(thread_id) =
|
|
result.get("threadId").and_then(serde_json::Value::as_str)
|
|
&& let Ok(thread_id) = ThreadId::from_string(thread_id)
|
|
{
|
|
app_event_tx.send(AppEvent::TaskToolsAvailable { thread_id });
|
|
}
|
|
app_event_tx.send(AppEvent::DynamicToolCallCompleted {
|
|
request_id,
|
|
response,
|
|
});
|
|
});
|
|
self.dynamic_tool_tasks
|
|
.insert(task_request_id, (source_thread_id, task));
|
|
return;
|
|
}
|
|
|
|
let thread_id = server_request_thread_id(&request);
|
|
if thread_id.is_some_and(|thread_id| self.abandoned_side_threads.contains(&thread_id)) {
|
|
if let Err(err) = self
|
|
.reject_app_server_request(
|
|
app_server_client,
|
|
request.id().clone(),
|
|
"side conversation was closed".to_string(),
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!("{err}");
|
|
}
|
|
return;
|
|
}
|
|
if thread_id.is_some()
|
|
&& self.primary_thread_id.is_none()
|
|
&& self.pending_startup_thread_start
|
|
{
|
|
self.pending_primary_events
|
|
.push_back(ThreadBufferedEvent::Request(Box::new(request)));
|
|
return;
|
|
}
|
|
let unsupported_request = matches!(
|
|
&request,
|
|
ServerRequest::DynamicToolCall { .. }
|
|
| ServerRequest::AttestationGenerate { .. }
|
|
| ServerRequest::CurrentTimeRead { .. }
|
|
| ServerRequest::ApplyPatchApproval { .. }
|
|
| ServerRequest::ExecCommandApproval { .. }
|
|
);
|
|
if self
|
|
.pending_app_server_requests
|
|
.contains_server_request(&request)
|
|
{
|
|
return;
|
|
}
|
|
if let Some(thread_id) = thread_id
|
|
&& self.primary_thread_id != Some(thread_id)
|
|
&& !unsupported_request
|
|
&& let Some(requests) = self.agents_overview.dispatched_requests.get_mut(&thread_id)
|
|
{
|
|
requests.push(request);
|
|
return;
|
|
}
|
|
if thread_id.is_some()
|
|
&& self.primary_thread_id.is_none()
|
|
&& !self.pending_startup_thread_start
|
|
&& !unsupported_request
|
|
{
|
|
return;
|
|
}
|
|
if let Some(thread_id) = thread_id
|
|
&& self.primary_thread_id.is_some()
|
|
&& self.primary_thread_id != Some(thread_id)
|
|
&& !self.thread_event_channels.contains_key(&thread_id)
|
|
&& self.agent_navigation.get(&thread_id).is_none()
|
|
&& !self.side_threads.contains_key(&thread_id)
|
|
&& !unsupported_request
|
|
{
|
|
let thread = app_server_client
|
|
.request_handle()
|
|
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
|
|
request_id: RequestId::String(format!("subagent-approval-{thread_id}")),
|
|
params: ThreadReadParams {
|
|
thread_id: thread_id.to_string(),
|
|
include_turns: false,
|
|
},
|
|
})
|
|
.await;
|
|
let Ok(ThreadReadResponse { thread }) = thread else {
|
|
return;
|
|
};
|
|
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id, ..
|
|
}) = thread.source
|
|
else {
|
|
return;
|
|
};
|
|
if self.primary_thread_id != Some(parent_thread_id)
|
|
&& !self.thread_event_channels.contains_key(&parent_thread_id)
|
|
&& self.agent_navigation.get(&parent_thread_id).is_none()
|
|
{
|
|
if self
|
|
.agents_overview
|
|
.dispatched_requests
|
|
.contains_key(&parent_thread_id)
|
|
{
|
|
self.agents_overview
|
|
.dispatched_requests
|
|
.entry(thread_id)
|
|
.or_default()
|
|
.push(request);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if let Some(unsupported) = self
|
|
.pending_app_server_requests
|
|
.note_server_request(&request)
|
|
{
|
|
tracing::warn!(
|
|
request_id = ?unsupported.request_id,
|
|
message = unsupported.message,
|
|
"rejecting unsupported app-server request"
|
|
);
|
|
self.chat_widget
|
|
.add_error_message(unsupported.message.clone());
|
|
if let Err(err) = self
|
|
.reject_app_server_request(
|
|
app_server_client,
|
|
unsupported.request_id,
|
|
unsupported.message,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!("{err}");
|
|
}
|
|
return;
|
|
}
|
|
|
|
let Some(thread_id) = thread_id else {
|
|
tracing::warn!("ignoring threadless app-server request");
|
|
return;
|
|
};
|
|
|
|
let result =
|
|
if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() {
|
|
self.enqueue_primary_thread_request(request).await
|
|
} else {
|
|
self.enqueue_thread_request(thread_id, request).await
|
|
};
|
|
if let Err(err) = result {
|
|
tracing::warn!("failed to enqueue app-server request: {err}");
|
|
}
|
|
}
|
|
}
|