Files
codex/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs
T
Eric Traut a7913390f7 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
2026-08-31 21:00:25 +00:00

61 lines
2.2 KiB
Rust

//! 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());
}