Preserve TUI drafts after app-server disconnects (#41911)

## 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
This commit is contained in:
Eric Traut
2026-08-31 21:00:25 +00:00
committed by copyberry
parent b51b07785b
commit a7913390f7
35 changed files with 987 additions and 38 deletions
+34
View File
@@ -229,6 +229,7 @@ mod platform_actions;
mod plugin_mentions;
mod rate_limit_refresh;
mod recap;
mod reconnect;
mod replay_filter;
mod resize_reflow;
mod safety_buffering;
@@ -590,6 +591,7 @@ pub(crate) struct App {
feedback_audience: FeedbackAudience,
environment_manager: Arc<EnvironmentManager>,
app_server_target: AppServerTarget,
reconnect: reconnect::ReconnectState,
/// Set when the user confirms an update; propagated on exit.
pub(crate) pending_update_action: Option<UpdateAction>,
@@ -787,6 +789,18 @@ impl App {
app_server: &mut AppServerSession,
event: TuiEvent,
) -> Result<AppRunControl> {
if self.reconnect.offline
&& let TuiEvent::Key(key) = &event
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& let KeyCode::Char(character) = key.code
&& (character.eq_ignore_ascii_case(&'c')
|| (character.eq_ignore_ascii_case(&'d')
&& self.chat_widget.composer_is_empty()
&& self.chat_widget.no_modal_or_popup_active()))
{
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
let screen_size = tui.screen_size_for_event(&event)?;
if !matches!(
&event,
@@ -805,6 +819,17 @@ impl App {
event
};
if self.reconnect.offline
&& let TuiEvent::Key(key) = &event
{
if self.reconnect.presentation == reconnect::ReconnectPresentation::Overview {
self.chat_widget.handle_disconnected_view_key(*key);
} else {
self.chat_widget.handle_disconnected_key(*key);
}
return Ok(AppRunControl::Continue);
}
match &event {
TuiEvent::FocusLost => {
let now = Instant::now();
@@ -840,6 +865,15 @@ impl App {
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
self.chat_widget.handle_paste(pasted);
if self.reconnect.offline
&& self.reconnect.presentation
== reconnect::ReconnectPresentation::Conversation
{
self.chat_widget.handle_disconnected_key(KeyEvent::new(
KeyCode::Null,
KeyModifiers::NONE,
));
}
}
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => {
if self.backtrack_render_pending {
+1 -1
View File
@@ -257,7 +257,7 @@ impl App {
}
}
fn agents_overview_view(
pub(super) fn agents_overview_view(
&self,
mut threads: Vec<Thread>,
selected_thread_id: Option<ThreadId>,
+33 -8
View File
@@ -85,6 +85,7 @@ pub(super) struct AgentsOverviewRow {
#[derive(Clone, Default)]
pub(super) struct AgentsOverviewViewState {
pub(super) input: String,
pub(super) connection_notice: Option<&'static str>,
search: String,
searching: bool,
pub(super) status_grouping: bool,
@@ -461,12 +462,12 @@ impl BottomPaneView for AgentsOverviewView {
return;
}
if self.agents_keymap.toggle_grouping.is_pressed(key) {
let mut state = self.state();
state.status_grouping = !state.status_grouping;
return;
}
if self.agents_keymap.search.is_pressed(key) {
if self.agents_keymap.search.is_pressed(key) || {
let state = self.state();
state.connection_notice.is_some()
&& state.searching
&& self.keymap.action_for(key) == Some(ListAction::Cancel)
} {
let mut state = self.state();
if !state.renaming {
state.searching = !state.searching;
@@ -476,6 +477,21 @@ impl BottomPaneView for AgentsOverviewView {
}
return;
}
if self.state().connection_notice.is_some() {
match self.keymap.action_for(key) {
Some(ListAction::MoveUp) => self.move_selection(/*forward*/ false),
Some(ListAction::MoveDown) => self.move_selection(/*forward*/ true),
_ => {}
}
return;
}
if self.agents_keymap.toggle_grouping.is_pressed(key) {
let mut state = self.state();
state.status_grouping = !state.status_grouping;
return;
}
if self.agents_keymap.new_task.is_pressed(key) {
let mut state = self.state();
state.search.clear();
@@ -603,8 +619,12 @@ impl Renderable for AgentsOverviewView {
}
});
let attention = format!("{needs_you} need input");
Line::from(format!("{attention} {working} working {ready} ready").dim())
.render(inset(summary), buf);
if let Some(notice) = self.state().connection_notice {
Line::from(notice.cyan()).render(inset(summary), buf);
} else {
Line::from(format!("{attention} {working} working {ready} ready").dim())
.render(inset(summary), buf);
}
Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim())
.render(inset(divider), buf);
let body = inset(body);
@@ -654,6 +674,11 @@ impl Renderable for AgentsOverviewView {
let input = &input[visible_start..];
Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()])
.render(inset(prompt), buf);
if state.connection_notice.is_some() {
Line::from("ctrl+c quit · actions paused until the list is refreshed".dim())
.render(inset(footer), buf);
return;
}
let list_hint = |action| {
self.keymap.primary_hint(action).filter(|hint| {
!matches!(hint, ShortcutHint::Single(binding)
@@ -78,6 +78,9 @@ impl App {
.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));
+17
View File
@@ -30,6 +30,19 @@ impl App {
app_server: &mut AppServerSession,
event: AppEvent,
) -> Result<AppRunControl> {
if self.reconnect.offline
&& !matches!(
&event,
AppEvent::InsertHistoryCell(_)
| AppEvent::AppendMessageHistoryEntry { .. }
| AppEvent::BeginInitialHistoryReplayBuffer
| AppEvent::BeginThreadSwitchHistoryReplayBuffer
| AppEvent::EndInitialHistoryReplayBuffer
| AppEvent::FatalExitRequest(_)
)
{
return Ok(AppRunControl::Continue);
}
if self.chat_widget.has_misalignment_policy_violation()
&& matches!(
event,
@@ -722,6 +735,10 @@ impl App {
}
self.chat_widget.prepare_local_op_submission(&op);
if let Err(err) = self.submit_active_thread_op(app_server, op).await {
if self.recover_transport_error(&err)
{
return Ok(AppRunControl::Continue);
}
let unsupported_permissions = err
.downcast_ref::<UnsupportedLegacyPermissionProfile>()
.is_some();
+1 -1
View File
@@ -176,7 +176,7 @@ pub(super) struct RecapRequest {
}
impl App {
fn clear_recap_request(&mut self, trigger: RecapTrigger) {
pub(super) fn clear_recap_request(&mut self, trigger: RecapTrigger) {
self.recap.clear_in_flight_request();
if !matches!(trigger, RecapTrigger::Manual) {
+66
View File
@@ -0,0 +1,66 @@
//! Preserve local work after an external connection is lost. Offline input and stale
//! async completions are quarantined; recovery requires relaunching the TUI.
use super::*;
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub(super) enum ReconnectPresentation {
#[default]
Conversation,
Overview,
}
#[derive(Default)]
pub(super) struct ReconnectState {
pub(super) offline: bool,
pub(super) presentation: ReconnectPresentation,
}
impl App {
pub(super) fn recover_transport_error(&mut self, error: &color_eyre::Report) -> bool {
let disconnected = matches!(
error.downcast_ref::<TypedRequestError>(),
Some(TypedRequestError::Transport { .. })
);
disconnected && self.begin_reconnect()
}
pub(super) fn begin_reconnect(&mut self) -> bool {
if matches!(self.app_server_target, AppServerTarget::Embedded) {
return false;
}
if !self.reconnect.offline {
self.reconnect.offline = true;
self.cancel_pending_key_chord();
self.overlay = None;
self.commit_animation = None;
self.clear_recap_request(crate::app_event::RecapTrigger::Manual);
self.reconnect.presentation = if self
.chat_widget
.selected_index_for_active_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID)
.is_some()
{
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.connection_notice = Some("Disconnected — agent list is stale");
if let Some(task) = state.refresh_task.take() {
task.abort();
}
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
ReconnectPresentation::Overview
} else {
self.chat_widget
.handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE));
ReconnectPresentation::Conversation
};
self.chat_widget.pause_for_disconnect();
self.startup_pending_protected_request = false;
self.abort_all_thread_event_listeners();
for (_, (_, task)) in self.dynamic_tool_tasks.drain() {
task.abort();
}
}
true
}
}
+2 -1
View File
@@ -639,7 +639,7 @@ impl App {
pub(super) async fn handle_startup_thread_started(
&mut self,
app_server: &mut AppServerSession,
result: Result<AppServerStartedThread, String>,
result: Result<AppServerStartedThread>,
) -> Result<()> {
if !self.pending_startup_thread_start {
if let Ok(started) = result {
@@ -718,6 +718,7 @@ impl App {
}
self.chat_widget.maybe_send_next_queued_input();
}
Err(err) if self.recover_transport_error(&err) => {}
Err(err) => {
return Err(color_eyre::eyre::eyre!(
"Failed to start a fresh session through the app server: {err}"
+20 -11
View File
@@ -37,8 +37,7 @@ fn spawn_startup_thread_start(
remote_cwd_override,
thread_tool_transport,
)
.await
.map_err(|err| format!("{err:#}"));
.await;
app_event_tx.send(AppEvent::StartupThreadStarted { result });
});
}
@@ -514,6 +513,7 @@ See the Codex keymap documentation for supported actions and examples."
feedback_audience,
environment_manager,
app_server_target,
reconnect: Default::default(),
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
@@ -717,11 +717,15 @@ See the Codex keymap documentation for supported actions and examples."
let initial_session_header_pending = waiting_for_initial_session_header
&& app.primary_session_configured.is_some()
&& has_pending_app_events;
let block_terminal_input_for_pending_startup_events = initial_session_header_pending
|| (pending_startup_draft.is_some() || app.startup_protected_input_boundary)
&& has_pending_app_events
|| (!waiting_for_initial_session_configured
&& app.has_queued_startup_protected_request());
let block_terminal_input_for_pending_startup_events =
(!matches!(app.app_server_target, AppServerTarget::Embedded)
&& has_pending_app_events)
|| initial_session_header_pending
|| (pending_startup_draft.is_some()
|| app.startup_protected_input_boundary)
&& has_pending_app_events
|| (!waiting_for_initial_session_configured
&& app.has_queued_startup_protected_request());
let control = select! {
Some(event) = app_event_rx.recv() => {
let is_initial_session_header = matches!(
@@ -744,6 +748,7 @@ See the Codex keymap documentation for supported actions and examples."
AppRunControl::Continue
}
Ok(AppRunControl::Exit(reason)) => AppRunControl::Exit(reason),
Err(err) if app.recover_transport_error(&err) => AppRunControl::Continue,
Err(err) => break Err(err),
}
}
@@ -756,7 +761,7 @@ See the Codex keymap documentation for supported actions and examples."
}, if App::should_handle_active_thread_events(
waiting_for_initial_session_configured,
app.active_thread_rx.is_some()
) && !has_pending_app_events => {
) && !has_pending_app_events && !app.reconnect.offline => {
if let Some(event) = active {
if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await {
break Err(err);
@@ -766,13 +771,14 @@ See the Codex keymap documentation for supported actions and examples."
}
AppRunControl::Continue
}
event = tui_events.next(), if !block_terminal_input_for_pending_startup_events => {
event = tui_events.next(), if app.reconnect.offline || !block_terminal_input_for_pending_startup_events => {
if let Some(event) = event {
if (matches!(
&event,
TuiEvent::Key(key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
) || matches!(&event, TuiEvent::Paste(_)))
&& !app.reconnect.offline
&& pending_startup_draft.is_none()
&& !waiting_for_initial_session_configured
&& app_event_rx.is_empty()
@@ -789,18 +795,21 @@ See the Codex keymap documentation for supported actions and examples."
}
match app.handle_tui_event(tui, &mut app_server, event).await {
Ok(control) => control,
Err(err) => break Err(err),
Err(err) if app.recover_transport_error(&err) => AppRunControl::Continue,
Err(err) => break Err(err),
}
} else {
tracing::warn!("terminal input stream closed; shutting down active thread");
app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await
}
}
app_server_event = app_server.next_event(), if listen_for_app_server_events => {
app_server_event = app_server.next_event(), if listen_for_app_server_events && !app.reconnect.offline
&& (matches!(app.app_server_target, AppServerTarget::Embedded) || !has_pending_app_events) => {
match app_server_event {
Some(event) => app.handle_app_server_event(&app_server, event).await,
None => {
listen_for_app_server_events = false;
app.begin_reconnect();
tracing::warn!("app-server event stream closed");
}
}
+1
View File
@@ -56,6 +56,7 @@ pub(super) async fn make_test_app() -> App {
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
app_server_target: crate::AppServerTarget::Embedded,
reconnect: Default::default(),
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
+4
View File
@@ -12,6 +12,8 @@ mod backend_banner_startup_tests;
mod background_exit_tests;
#[path = "tests/connector_policy.rs"]
mod connector_policy;
#[path = "tests/disconnect_tests.rs"]
mod disconnect;
#[path = "tests/key_chords.rs"]
mod key_chords;
#[path = "tests/mcp_startup.rs"]
@@ -5453,6 +5455,7 @@ async fn make_test_app() -> App {
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
app_server_target: crate::AppServerTarget::Embedded,
reconnect: Default::default(),
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
@@ -5535,6 +5538,7 @@ async fn make_test_app_with_channels() -> (
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
app_server_target: crate::AppServerTarget::Embedded,
reconnect: Default::default(),
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
@@ -0,0 +1,346 @@
//! Offline editing, uncertain submissions, and stale events never consume local work.
use super::*;
use crate::app_server_session::ThreadParamsMode;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCRequest;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message;
#[tokio::test]
async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()> {
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
let id = ThreadId::new();
app.active_thread_id = Some(id);
app.primary_thread_id = Some(id);
app.chat_widget
.handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf()));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
app.app_server_target = AppServerTarget::Remote {
endpoint: endpoint.clone(),
};
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await?;
serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| {
assert_eq!(request.method, "turn/start");
std::future::ready(None)
})
.await
});
let mut session = AppServerSession::new(
crate::connect_remote_app_server(endpoint).await?,
ThreadParamsMode::Remote,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.chat_widget
.restore_user_message_to_composer("only once".into());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let op = next_user_turn_op(&mut ops);
app.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ true);
app.chat_widget
.restore_user_message_to_composer("queued before disconnect".into());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
app.chat_widget
.restore_user_message_to_composer("keep this draft".into());
let overview = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(overview));
app.chat_widget.show_selection_view(SelectionViewParams {
items: vec![SelectionItem {
name: "Stale server action".into(),
actions: vec![Box::new(|tx| tx.send(AppEvent::CodexOp(Op::Interrupt)))],
..Default::default()
}],
..Default::default()
});
assert!(matches!(
app.handle_event(&mut tui, &mut session, AppEvent::CodexOp(op))
.await?,
AppRunControl::Continue
));
assert!(app.reconnect.offline);
assert!(!app.chat_widget.has_active_view());
for key in [
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('D'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE),
] {
assert!(matches!(
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
.await?,
AppRunControl::Continue
));
}
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"keep this draft!"
);
assert!(app.chat_widget.has_queued_follow_up_messages());
app.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ false);
assert!(!app.chat_widget.maybe_send_next_queued_input());
app.handle_event(&mut tui, &mut session, AppEvent::NewSession { name: None })
.await?;
assert_eq!(app.current_displayed_thread_id(), Some(id));
while let Ok(event) = events.try_recv() {
app.handle_event(&mut tui, &mut session, event).await?;
}
let history = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async {
loop {
if let Ok(text) =
tokio::fs::read_to_string(app.config.codex_home.join("history.jsonl")).await
&& let Ok(entry) =
serde_json::from_str::<codex_message_history::HistoryEntry>(&text)
{
break entry;
}
tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await;
}
})
.await?;
assert_eq!(
(history.session_id, history.text),
(id.to_string(), "only once".into())
);
assert_snapshot!(
"offline_draft",
format!(
"{}\n{}",
lines_to_single_string(
&app.transcript_cells
.last()
.unwrap()
.display_lines(/*width*/ 80)
),
render_bottom_popup(&app.chat_widget, /*width*/ 80)
)
);
let mut keymap = codex_config::types::TuiKeymap::default();
keymap.chat.edit_queued_message = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("ctrl-x up".into()),
));
let runtime =
RuntimeKeymap::from_config(&keymap).map_err(|error| color_eyre::eyre::eyre!(error))?;
app.chat_widget.apply_keymap_update(keymap, &runtime);
app.keymap = runtime;
for key in [
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
] {
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
.await?;
}
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"queued before disconnect"
);
assert!(!app.chat_widget.has_queued_follow_up_messages());
let paste = "copied text ".repeat(/*n*/ 92);
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste(paste.clone()))
.await?;
assert_eq!(
app.chat_widget.composer_text_with_pending(),
format!("queued before disconnect{paste}")
);
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
let draft = rendered
.split_once("› ")
.unwrap()
.1
.split("\n\n")
.next()
.unwrap();
assert_snapshot!("offline_expanded_paste", draft);
assert!(ops.try_recv().is_err());
for character in ['c', 'C', 'd', 'D'] {
if character.eq_ignore_ascii_case(&'d') {
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)),
)
.await?;
}
assert!(matches!(
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(
KeyCode::Char(character),
KeyModifiers::CONTROL
))
)
.await?,
AppRunControl::Exit(ExitReason::UserRequested)
));
}
session.shutdown().await?;
assert_eq!(server.await??, vec!["initialize", "turn/start"]);
Ok(())
}
#[tokio::test]
async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<()> {
let (mut app, mut events, _) = make_test_app_with_channels().await;
let mut session = crate::start_embedded_app_server_for_picker(&app.config).await?;
assert!(!app.begin_reconnect(), "embedded behavior stays unchanged");
app.app_server_target = AppServerTarget::Remote {
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?,
};
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.agents_overview.view_state.lock().unwrap().input = "task draft".into();
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)),
)
.await?;
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Paste("search query".into()),
)
.await?;
assert!(app.begin_reconnect());
for key in [
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
] {
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
.await?;
}
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into()))
.await?;
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)
.await?;
assert_eq!(
app.agents_overview.view_state.lock().unwrap().input,
"task draft!"
);
assert!(
!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. }))
);
assert_snapshot!(
"offline_command_center",
render_bottom_popup(&app.chat_widget, /*width*/ 100)
);
session.shutdown().await?;
Ok(())
}
// Common framing/bootstrap for both transports. Returning None deliberately loses a reply.
pub(super) async fn serve_reconnect_requests<S, F>(
mut socket: tokio_tungstenite::WebSocketStream<S>,
mut respond: impl FnMut(JSONRPCRequest) -> F,
) -> Result<Vec<String>>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
F: std::future::Future<Output = Option<serde_json::Value>>,
{
let mut methods = Vec::new();
while let Some(Ok(frame)) = socket.next().await {
let Message::Text(text) = frame else { continue };
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
continue;
};
methods.push(request.method.clone());
let request_id = request.id.clone();
let response = match request.method.as_str() {
"initialize" => Some(json!({"result": {"userAgent": "reconnect-test"}})),
"account/read" => {
Some(json!({"result": {"account": null, "requiresOpenaiAuth": false}}))
}
"model/list" => Some(json!({"result": {"data": [], "nextCursor": null}})),
"configRequirements/read" => Some(json!({"result": {"requirements": null}})),
_ => respond(request).await,
};
let Some(mut response) = response else {
socket.close(None).await?;
break;
};
response["id"] = json!(request_id);
socket
.send(Message::Text(response.to_string().into()))
.await?;
}
Ok(methods)
}
#[tokio::test]
async fn lost_initial_thread_reply_keeps_startup_draft_offline() -> Result<()> {
for disconnect_first in [false, true] {
let (mut app, _events, mut ops) = make_test_app_with_channels().await;
app.pending_startup_thread_start = true;
let listener = TcpListener::bind("127.0.0.1:0").await?;
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
app.app_server_target = AppServerTarget::Remote {
endpoint: endpoint.clone(),
};
app.chat_widget
.restore_user_message_to_composer("startup draft".into());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await?;
serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| {
assert_eq!(request.method, "thread/start");
std::future::ready(None)
})
.await
});
let mut session = AppServerSession::new(
crate::connect_remote_app_server(endpoint).await?,
ThreadParamsMode::Remote,
);
let result = crate::app_server_session::start_thread_with_request_handle(
session.request_handle(),
app.config.clone(),
ThreadParamsMode::Remote,
/*remote_cwd_override*/ None,
session.thread_tool_transport(),
)
.await;
let mut tui = crate::tui::test_support::make_test_tui()?;
if disconnect_first {
app.begin_reconnect();
}
app.handle_event(
&mut tui,
&mut session,
AppEvent::StartupThreadStarted { result },
)
.await?;
assert!(app.reconnect.offline);
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)
.await?;
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"startup draft"
);
assert!(
!std::iter::from_fn(|| ops.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. }))
);
session.shutdown().await?;
assert_eq!(server.await??, vec!["initialize", "thread/start"]);
}
Ok(())
}
@@ -267,6 +267,29 @@ async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Re
"rendered error: {rendered:?}"
);
// A completion quarantined during reconnect must not leave the next recap stuck as busy.
app.app_server_target = AppServerTarget::Remote {
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://127.0.0.1:1".into(),
auth_token: None,
},
};
app.request_recap(&app_server, thread_id, RecapTrigger::Manual);
let stale = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv())
.await?
.expect("recap completion before disconnect");
assert!(matches!(stale, AppEvent::RecapStarted { .. }));
assert!(app.begin_reconnect());
app.handle_event(&mut tui, &mut app_server, stale).await?;
while app_event_rx.try_recv().is_ok() {}
app.reconnect.offline = false;
app.request_recap(&app_server, thread_id, RecapTrigger::Manual);
let next = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()).await?;
assert!(
matches!(next, Some(AppEvent::RecapStarted { .. })),
"{next:?}"
);
app_server.shutdown().await?;
proxy.await??;
Ok(())
@@ -0,0 +1,28 @@
---
source: tui/src/app/tests/disconnect_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 100)"
---
Agent command center
Disconnected — agent list is stale
────────────────────────────────────────────────────────────────────────────────────────────────
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
New task › task draft!
ctrl+c quit · actions paused until the list is refreshed
@@ -0,0 +1,14 @@
---
source: tui/src/app/tests/disconnect_tests.rs
expression: "format!(\"{}\\n{}\",\nlines_to_single_string(&app.transcript_cells.last().unwrap().display_lines(80)),\nrender_bottom_popup(&app.chat_widget, 80))"
---
■ Connection lost.
• Connection lost — copy your draft, then relaunch (0s)
• Queued follow-up inputs
↳ queued before disconnect
⌥ + ↑ edit last queued message
› keep this draft!
ctrl+c quit
@@ -0,0 +1,20 @@
---
source: tui/src/app/tests/disconnect_tests.rs
expression: draft
---
queued before disconnectcopied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text copied text copied text
copied text copied text copied text copied text
+11 -1
View File
@@ -465,6 +465,16 @@ async fn startup_draft_delayed_approval_becomes_protected_on_redraw() -> Result<
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
app.app_server_target = AppServerTarget::Remote {
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://127.0.0.1:1".into(),
auth_token: None,
},
};
app.begin_reconnect();
assert!(app.startup_protected_input_boundary);
// The replacement connection replays the protected request after the old request was dropped.
app.reconnect.offline = false;
let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane();
startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new());
@@ -1232,7 +1242,7 @@ async fn startup_thread_start_failure_returns_error() {
.await
.expect("embedded app server");
let err = app
.handle_startup_thread_started(&mut app_server, Err("boom".to_string()))
.handle_startup_thread_started(&mut app_server, Err(color_eyre::eyre::eyre!("boom")))
.await
.expect_err("startup thread failure should exit instead of leaving chat unconfigured");
+1 -1
View File
@@ -373,7 +373,7 @@ pub(crate) enum AppEvent {
/// Result of the fresh startup thread that is attached after the input UI is live.
StartupThreadStarted {
result: Result<AppServerStartedThread, String>,
result: color_eyre::Result<AppServerStartedThread>,
},
/// Register a dynamically created background thread before its first turn starts.
+2 -1
View File
@@ -176,7 +176,8 @@ pub(crate) enum ThreadHistorySupport {
}
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
color_eyre::eyre::eyre!("{context}: {err}")
let message = format!("{context}: {err}");
color_eyre::Report::new(err).wrap_err(message)
}
pub(crate) fn is_history_pagination_unsupported(source: &JSONRPCErrorError) -> bool {
@@ -107,6 +107,10 @@
//! navigation slash commands remain available so users can leave or manage the view. Transcript
//! exports also remain available, including an explicit destination filename.
//!
//! During reconnection, `handle_disconnected_key` edits the draft directly without
//! popup dispatch or submission. Enter and Tab leave the draft intact until reconnection succeeds.
//! Collapsed pastes expand into editable text so the full draft can be copied before quitting.
//!
//! # Reasoning Effort Animations
//!
//! The composer observes the effective reasoning tier whenever model-dependent surfaces refresh.
@@ -292,6 +296,7 @@ mod draft_state;
mod footer_state;
mod history_search;
mod popup_state;
mod reconnect;
mod slash_input;
mod vim_search;
@@ -0,0 +1,37 @@
//! Offline editing bypasses command dispatch and popup actions, retaining the draft in place.
use super::*;
impl ChatComposer {
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
self.cancel_history_search();
self.attachments.clear_remote_image_selection();
self.popups.active = ActivePopup::None;
self.set_disable_paste_burst(/*disabled*/ true);
// Expand in reverse order so remaining ranges stay valid. Textarea replacement
// preserves the cursor and other elements, including images and mentions.
let pending_pastes = std::mem::take(&mut self.draft.pending_pastes);
if !pending_pastes.is_empty() {
for element in self.draft.textarea.text_elements().into_iter().rev() {
if let Some((_, text)) = pending_pastes.iter().find(|(placeholder, _)| {
element.placeholder(self.draft.textarea.text()) == Some(placeholder.as_str())
}) {
self.draft
.textarea
.replace_range(element.byte_range.start..element.byte_range.end, text);
}
}
}
// Enter/Tab and configured submit bindings must never consume the draft offline.
// The basic editor reconciles attachments without invoking composer-level shortcuts.
if !matches!(key.code, KeyCode::Enter | KeyCode::Tab)
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
{
self.handle_input_basic(key);
}
}
}
#[cfg(test)]
#[path = "reconnect_tests.rs"]
mod tests;
@@ -0,0 +1,60 @@
//! Offline edits retain the same attachment bookkeeping as ordinary text edits.
use super::super::tests::new_test_composer;
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn reconnect_expands_pastes_preserving_images_and_cursor() {
let (mut composer, _) = new_test_composer();
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
composer.handle_paste(paste.clone());
composer.attach_image(PathBuf::from("local.png"));
composer.handle_paste(paste);
let expanded = composer.current_text_with_pending();
let images = composer.draft_snapshot().local_images;
composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE));
let draft = composer.draft_snapshot();
assert_eq!(
(
draft.text,
draft.cursor,
draft.local_images,
draft.pending_pastes
),
(
format!("{expanded}!"),
expanded.len() + 1,
images,
Vec::new()
)
);
assert_eq!(draft.text_elements.len(), 1);
}
#[test]
fn reconnect_edit_removes_deleted_attachment_and_paste_metadata() {
let (mut composer, _) = new_test_composer();
composer.attach_image(PathBuf::from("local.png"));
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1));
composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
let draft = composer.draft_snapshot();
assert_eq!(
(draft.text, draft.local_images, draft.pending_pastes),
(String::new(), Vec::new(), Vec::new())
);
}
#[test]
fn reconnect_edit_cancels_history_preview_without_losing_original_draft() {
let (mut composer, _) = new_test_composer();
composer.set_text_content("original draft".into(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor(/*pos*/ 14);
composer.begin_history_search();
composer.apply_history_search_result(HistorySearchResult::Found(HistoryEntry::new(
"history preview".into(),
)));
composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE));
assert_eq!(composer.current_text(), "original draft!");
assert!(!composer.history_search_active());
}
+15
View File
@@ -650,6 +650,16 @@ impl BottomPane {
self.push_view(Box::new(modal));
}
/// Edit the draft without invoking popups, submissions, or remote actions.
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
self.view_stack.clear();
self.delayed_approval_requests.clear();
self.composer
.set_input_enabled(/*enabled*/ true, /*placeholder*/ None);
self.composer.handle_disconnected_key(key);
self.request_redraw();
}
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// If a modal/view is active, handle it here; otherwise forward to composer.
@@ -2374,6 +2384,11 @@ mod tests {
assert_eq!(pane.composer_text(), "ya");
assert!(pane.view_stack.is_empty());
assert_eq!(pane.delayed_approval_requests.len(), 1);
pane.handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE));
pane.pre_draw_tick_at(Instant::now() + APPROVAL_PROMPT_TYPING_IDLE_DELAY);
pane.handle_paste(" kept".into());
assert_eq!(pane.composer_text(), "ya kept");
assert!(pane.view_stack.is_empty());
while let Ok(event) = rx.try_recv() {
assert!(
!matches!(event, AppEvent::SubmitThreadOp { .. }),
+1
View File
@@ -354,6 +354,7 @@ mod goal_menu;
mod ide_context;
use self::ide_context::IdeContextState;
mod input_queue;
mod reconnect;
use self::input_queue::InputQueueState;
mod input_flow;
mod input_restore;
@@ -143,6 +143,7 @@ impl ChatWidget {
|| self.misalignment_policy_violation
|| self.input_queue.suppress_queue_autosend
|| self.input_queue.rate_limit_recovery_pending
|| self.input_queue.recovered_queue
{
return false;
}
@@ -44,6 +44,7 @@ pub(super) struct InputQueueState {
pub(super) suppress_queue_autosend: bool,
/// Hold submissions while a usage failure or backend-directed model fallback is resolved.
pub(super) rate_limit_recovery_pending: bool,
pub(super) recovered_queue: bool,
}
impl InputQueueState {
@@ -52,6 +53,7 @@ impl InputQueueState {
}
pub(super) fn clear(&mut self) {
self.recovered_queue = false;
self.queued_user_messages.clear();
self.queued_user_message_history_records.clear();
self.user_turn_pending_start = false;
@@ -179,6 +179,8 @@ impl ChatWidget {
pub(super) fn pop_latest_queued_composer_state(&mut self) -> Option<ThreadComposerState> {
if let Some(user_message) = self.input_queue.queued_user_messages.pop_back() {
self.input_queue.recovered_queue &= self.input_queue.has_queued_follow_up_messages()
|| !self.input_queue.pending_steers.is_empty();
let history_record = self
.input_queue
.queued_user_message_history_records
@@ -195,6 +197,8 @@ impl ChatWidget {
))
} else {
let user_message = self.input_queue.rejected_steers_queue.pop_back()?;
self.input_queue.recovered_queue &= self.input_queue.has_queued_follow_up_messages()
|| !self.input_queue.pending_steers.is_empty();
let history_record = self
.input_queue
.rejected_steer_history_records
@@ -467,6 +471,7 @@ impl ChatWidget {
.input_queue
.queued_user_message_history_records
.clone(),
recovered_queue: self.input_queue.recovered_queue,
user_turn_pending_start: self.input_queue.user_turn_pending_start,
submit_pending_steers_after_interrupt: self
.input_queue
@@ -487,6 +492,7 @@ impl ChatWidget {
let restored_task_running =
preserve_in_flight_turn && input_state.as_ref().is_some_and(|state| state.task_running);
if let Some(input_state) = input_state {
self.input_queue.recovered_queue = input_state.recovered_queue;
self.current_collaboration_mode = input_state.current_collaboration_mode;
self.active_collaboration_mask = input_state.active_collaboration_mask;
self.safety_buffering_prompt = input_state.safety_buffering_prompt;
@@ -559,6 +565,8 @@ impl ChatWidget {
self.input_queue.clear();
self.restore_composer_state(Default::default());
}
self.input_queue.recovered_queue &= self.input_queue.has_queued_follow_up_messages()
|| !self.input_queue.pending_steers.is_empty();
let effort = self.effective_reasoning_effort();
self.bottom_pane
.set_active_reasoning_effort_baseline(effort.as_ref());
@@ -245,6 +245,10 @@ impl ChatWidget {
self.bottom_pane.selected_index_for_present_view(view_id)
}
pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option<usize> {
self.bottom_pane.selected_index_for_active_view(view_id)
}
pub(crate) fn replace_selection_view_if_present(
&mut self,
view_id: &'static str,
+44
View File
@@ -0,0 +1,44 @@
//! Disconnects preserve editable input but never automatically retry queued submissions.
use super::*;
impl ChatWidget {
pub(crate) fn pause_for_disconnect(&mut self) {
self.input_queue.recovered_queue = true;
self.input_queue.suppress_queue_autosend = true;
self.set_initial_user_message_submit_suppressed(/*suppressed*/ true);
if let Some(message) = self.initial_user_message.take() {
self.restore_user_message_to_composer(message);
}
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ false);
self.set_status_header("Connection lost — copy your draft, then relaunch".to_string());
self.set_footer_hint_override(Some(vec![("ctrl+c".into(), "quit".into())]));
self.add_error_message("Connection lost.".into());
}
pub(crate) fn handle_disconnected_view_key(&mut self, key: KeyEvent) {
self.bottom_pane.handle_key_event(key);
}
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
if key.kind == KeyEventKind::Press && self.chat_keymap.edit_queued_message.is_pressed(key) {
if let Some(composer) = self.pop_latest_queued_composer_state() {
self.restore_composer_state(composer);
} else if let Some(steer) = self.input_queue.pending_steers.pop_back() {
self.restore_user_message_to_composer(user_message_for_restore(
steer.user_message,
&steer.history_record,
));
self.input_queue.recovered_queue &= !self.input_queue.pending_steers.is_empty();
}
self.bottom_pane
.handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE));
self.refresh_pending_input_preview();
self.request_redraw();
} else {
self.bottom_pane.handle_disconnected_key(key);
}
}
}
@@ -304,6 +304,17 @@ async fn parent_owned_thread_blocks_settings_shortcuts() {
assert_chatwidget_snapshot!("parent_owned_thread_rejects_settings_shortcuts", rendered);
}
#[tokio::test]
async fn disconnect_restores_initial_prompt_without_submitting_it() {
let (mut chat, _events, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.initial_user_message = Some("CLI prompt".into());
chat.restore_user_message_to_composer("typed draft".into());
chat.pause_for_disconnect();
chat.submit_initial_user_message_if_pending();
assert_eq!(chat.composer_text_with_pending(), "CLI prompt\ntyped draft");
assert_no_submit_op(&mut ops);
}
#[tokio::test]
async fn parent_owned_thread_restores_pending_initial_prompt() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await;
@@ -1757,6 +1768,7 @@ async fn restore_thread_input_state_applies_running_state_policy() {
rejected_steer_history_records: VecDeque::new(),
queued_user_messages: VecDeque::from([UserMessage::from("already queued").into()]),
queued_user_message_history_records: VecDeque::from([queued_history.clone()]),
recovered_queue: false,
user_turn_pending_start: true,
submit_pending_steers_after_interrupt: true,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
@@ -1790,6 +1802,21 @@ async fn restore_thread_input_state_applies_running_state_policy() {
Some(UserMessage::from("buffered prompt"))
);
chat.pause_for_disconnect();
chat.handle_disconnected_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert!(!chat.has_queued_follow_up_messages());
// Editing the last queued draft must not release the uncertain steer for replay.
assert!(chat.capture_thread_input_state().unwrap().recovered_queue);
chat.handle_disconnected_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert_eq!(
chat.composer_text_with_pending(),
"submitted history\nqueued history"
);
assert!(chat.input_queue.pending_steers.is_empty());
assert!(!chat.capture_thread_input_state().unwrap().recovered_queue);
assert_no_submit_op(&mut op_rx);
chat.set_queue_autosend_suppressed(/*suppressed*/ false);
chat.restore_thread_input_state(
Some(input_state),
ThreadInputStateRestoreMode {
@@ -384,6 +384,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
rejected_steer_history_records: VecDeque::new(),
queued_user_messages,
queued_user_message_history_records: VecDeque::new(),
recovered_queue: false,
user_turn_pending_start: false,
submit_pending_steers_after_interrupt: false,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
@@ -131,6 +131,7 @@ pub(crate) struct ThreadInputState {
pub(super) rejected_steer_history_records: VecDeque<UserMessageHistoryRecord>,
pub(super) queued_user_messages: VecDeque<QueuedUserMessage>,
pub(super) queued_user_message_history_records: VecDeque<UserMessageHistoryRecord>,
pub(crate) recovered_queue: bool,
pub(super) user_turn_pending_start: bool,
pub(super) submit_pending_steers_after_interrupt: bool,
pub(super) current_collaboration_mode: CollaborationMode,
+11 -13
View File
@@ -45,7 +45,7 @@ fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> R
Ok(())
}
struct PtyCodex {
pub(super) struct PtyCodex {
master: File,
child: Child,
parser: vt100::Parser,
@@ -57,7 +57,7 @@ struct PtyCodex {
}
impl PtyCodex {
fn start(repo_root: &Path, codex_home: TempDir) -> Result<Self> {
pub(super) fn start(repo_root: &Path, codex_home: TempDir) -> Result<Self> {
let mut master_fd = -1;
let mut slave_fd = -1;
let mut window_size = libc::winsize {
@@ -89,14 +89,12 @@ impl PtyCodex {
let stdin = slave.try_clone().context("clone pseudo-terminal stdin")?;
let stdout = slave.try_clone().context("clone pseudo-terminal stdout")?;
let codex = codex_utils_cargo_bin::cargo_bin("codex")
.or_else(|_| codex_utils_cargo_bin::cargo_bin("codex-tui"))?;
let codex = codex_utils_cargo_bin::cargo_bin("codex-tui")
.or_else(|_| codex_utils_cargo_bin::cargo_bin("codex"))?;
let child = Command::new(codex)
.arg("--no-alt-screen")
.arg("-C")
.arg(repo_root)
.arg("-c")
.arg("analytics.enabled=false")
.env("TERM", "xterm-256color")
.env("OPENAI_API_KEY", "focus-palette-test")
.env("CODEX_HOME", codex_home.path())
@@ -120,7 +118,7 @@ impl PtyCodex {
})
}
fn wait_for_startup(&mut self) -> Result<()> {
pub(super) fn wait_for_startup(&mut self) -> Result<()> {
let deadline = Instant::now() + STARTUP_TIMEOUT;
while Instant::now() < deadline {
self.read_output(Duration::from_millis(/*millis*/ 50))?;
@@ -194,7 +192,7 @@ impl PtyCodex {
Ok(())
}
fn read_output(&mut self, timeout: Duration) -> Result<()> {
pub(super) fn read_output(&mut self, timeout: Duration) -> Result<()> {
let timeout_ms = timeout.as_millis().min(libc::c_int::MAX as u128) as libc::c_int;
let mut descriptor = libc::pollfd {
fd: self.master.as_raw_fd(),
@@ -224,17 +222,17 @@ impl PtyCodex {
Ok(())
}
fn write_input(&mut self, bytes: &[u8]) -> Result<()> {
pub(super) fn write_input(&mut self, bytes: &[u8]) -> Result<()> {
self.master.write_all(bytes)?;
self.master.flush()?;
Ok(())
}
fn screen_contains(&self, text: &str) -> bool {
pub(super) fn screen_contains(&self, text: &str) -> bool {
self.parser.screen().contents().contains(text)
}
fn screen_contents(&self) -> String {
pub(super) fn screen_contents(&self) -> String {
self.parser.screen().contents()
}
}
@@ -250,11 +248,11 @@ fn contains_bytes(buffer: &[u8], needle: &[u8]) -> bool {
buffer.windows(needle.len()).any(|window| window == needle)
}
fn write_test_config(codex_home: &Path, repo_root: &Path) -> Result<()> {
pub(super) fn write_test_config(codex_home: &Path, repo_root: &Path) -> Result<()> {
let repo_root = repo_root.display();
let config = format!(
"model = \"gpt-5.6-terra\"\nmodel_provider = \"openai\"\n\
suppress_unstable_features_warning = true\n\n\
suppress_unstable_features_warning = true\nanalytics.enabled = false\n\n\
[projects.\"{repo_root}\"]\ntrust_level = \"trusted\"\n"
);
std::fs::write(codex_home.join("config.toml"), config)
+2
View File
@@ -1,6 +1,8 @@
// Aggregates all former standalone integration tests as modules.
#[cfg(unix)]
mod focus_palette;
#[cfg(unix)]
mod reconnect;
mod resize_reflow;
mod status_indicator;
mod vt100_history;
+141
View File
@@ -0,0 +1,141 @@
//! Drives disconnect handling through the real binary and terminal event loop.
use super::focus_palette::PtyCodex;
use super::focus_palette::write_test_config;
use anyhow::Result;
use anyhow::ensure;
use codex_app_server_protocol::JSONRPCMessage;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration;
use std::time::Instant;
use tokio::net::UnixListener;
use tokio_tungstenite::tungstenite::Message;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn disconnect_preserves_editable_draft_in_terminal() -> Result<()> {
let repo_root = codex_utils_cargo_bin::repo_root()?;
// macOS's default temporary directory leaves too little room for the control socket path.
let codex_home = tempfile::tempdir_in("/tmp")?;
write_test_config(codex_home.path(), &repo_root)?;
let socket = codex_app_server_client::app_server_control_socket_path(codex_home.path())?;
std::fs::create_dir_all(socket.parent().unwrap())?;
let listener = UnixListener::bind(socket.as_path())?;
let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel();
let server_cwd = repo_root.clone();
let server = tokio::spawn(async move {
let mut methods = Vec::new();
let id = "00000000-0000-0000-0000-000000000001";
let thread = json!({
"id": id, "sessionId": id, "preview": "", "ephemeral": false,
"modelProvider": "openai", "createdAt": 1, "updatedAt": 2,
"status": {"type": "active", "activeFlags": []}, "cwd": server_cwd,
"cliVersion": "0.0.0", "source": "cli", "turns": [{"id": "running", "items": [], "status": "inProgress", "error": null}]
});
for connection in 0..1 {
let mut socket = loop {
let (stream, _) = listener.accept().await?;
// Startup probes the default daemon socket before opening its WebSocket.
if let Ok(socket) = tokio_tungstenite::accept_async(stream).await {
break socket;
}
};
loop {
let frame = tokio::select! {
_ = &mut disconnect_rx, if connection == 0 => {
socket.close(/*msg*/ None).await?;
break;
}
frame = socket.next() => frame,
};
let Some(Ok(Message::Text(text))) = frame else {
break;
};
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
continue;
};
methods.push(request.method.clone());
let result = match request.method.as_str() {
"initialize" => json!({"userAgent": "reconnect-pty"}),
"account/read" => {
json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false})
}
"model/list" => json!({"data": [], "nextCursor": null}),
"configRequirements/read" => json!({"requirements": null}),
"thread/start" | "thread/resume" => {
json!({"thread": thread, "model": "gpt-5.6-terra", "modelProvider": "openai",
"cwd": server_cwd, "approvalPolicy": "never", "approvalsReviewer": "user",
"sandbox": {"type": "readOnly"}, "reasoningEffort": null})
}
"thread/read" => json!({"thread": thread}),
"thread/goal/get" => json!({"goal": null}),
"skills/list" => json!({"data": []}),
_ => {
socket
.send(Message::Text(
json!({"id": request.id, "error": {
"code": -32601, "message": "method not found"
}})
.to_string()
.into(),
))
.await?;
continue;
}
};
socket
.send(Message::Text(
json!({"id": request.id, "result": result})
.to_string()
.into(),
))
.await?;
}
}
Ok::<_, anyhow::Error>(methods)
});
let mut terminal = PtyCodex::start(&repo_root, codex_home)?;
terminal.wait_for_startup()?;
let mut disconnect_tx = Some(disconnect_tx);
for expected in [
"gpt-5.6-terra",
"preserved-draft",
"Connection lost",
"preserved-draft!",
] {
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30);
while !terminal.screen_contains(expected) && Instant::now() < deadline {
terminal.read_output(Duration::from_millis(/*millis*/ 20))?;
}
ensure!(
terminal.screen_contains(expected),
"missing {expected}; screen:\n{}",
terminal.screen_contents()
);
match expected {
"gpt-5.6-terra" => terminal.write_input(b"preserved-draft")?,
"preserved-draft" => {
disconnect_tx.take().unwrap().send(()).unwrap();
}
"Connection lost" => terminal.write_input(b"!")?,
_ => {}
}
}
ensure!(
terminal.screen_contains("preserved-draft!"),
"draft was lost after recovery"
);
drop(terminal);
let methods = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), server).await???;
assert_eq!(
methods
.iter()
.filter(|method| *method == "thread/resume")
.count(),
0
);
assert!(!methods.iter().any(|method| method == "turn/start"));
Ok(())
}