mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +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
142 lines
5.9 KiB
Rust
142 lines
5.9 KiB
Rust
//! 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(())
|
|
}
|