mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Show reconnect guidance when disconnecting from tasks (#40629)
## Why Exiting a TUI connected to a persistent app server can leave work running, so the existing session-exit summary does not explain how to reconnect or stop the active turn. ## What changed - Distinguish disconnects, interrupted turns, and removed threads when building exit summaries. - For daemon and remote sessions, report whether work may still be running and print commands to reconnect or stop the current turn. - Sanitize remote WebSocket addresses in displayed commands and preserve the configured remote authentication environment-variable option. - Keep the existing token-usage and resume summary for embedded sessions. ## Testing Add coverage for daemon, remote, interrupted, embedded, archived, and deleted session exits, including credential sanitization. GitOrigin-RevId: 9cbbf9c7dd36423a5ed99909a2b4cb32e3e87712
This commit is contained in:
+68
-39
@@ -36,7 +36,6 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use codex_utils_cli::ProfileV2Name;
|
||||
use codex_utils_cli::SharedCliOptions;
|
||||
use owo_colors::OwoColorize;
|
||||
use std::collections::HashSet;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Write;
|
||||
@@ -784,34 +783,6 @@ fn parse_socket_path(raw: &str) -> Result<AbsolutePathBuf, String> {
|
||||
.map_err(|err| format!("failed to resolve socket path `{raw}`: {err}"))
|
||||
}
|
||||
|
||||
fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<String> {
|
||||
let is_fatal = matches!(&exit_info.exit_reason, ExitReason::Fatal(_));
|
||||
let AppExitInfo {
|
||||
token_usage,
|
||||
thread_id: conversation_id,
|
||||
resume_hint,
|
||||
..
|
||||
} = exit_info;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
if !token_usage.is_zero() {
|
||||
lines.push(token_usage.to_string());
|
||||
}
|
||||
|
||||
if let Some(resume_cmd) = resume_hint {
|
||||
let command = if color_enabled {
|
||||
resume_cmd.cyan().to_string()
|
||||
} else {
|
||||
resume_cmd
|
||||
};
|
||||
lines.push(format!("To continue this session, run {command}"));
|
||||
} else if is_fatal && let Some(conversation_id) = conversation_id {
|
||||
lines.push(format!("Session ID: {conversation_id}"));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Handle the app exit and print the results. Optionally run the update action.
|
||||
fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
|
||||
let is_fatal = match &exit_info.exit_reason {
|
||||
@@ -819,12 +790,14 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
|
||||
eprintln!("ERROR: {message}");
|
||||
true
|
||||
}
|
||||
ExitReason::UserRequested => false,
|
||||
ExitReason::UserRequested | ExitReason::TurnInterrupted | ExitReason::ThreadRemoved => {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let update_action = exit_info.update_action;
|
||||
let color_enabled = supports_color::on(Stream::Stdout).is_some();
|
||||
for line in format_exit_messages(exit_info, color_enabled) {
|
||||
for line in exit_info.format_exit_messages(color_enabled) {
|
||||
println!("{line}");
|
||||
}
|
||||
if is_fatal {
|
||||
@@ -2625,7 +2598,7 @@ async fn run_interactive_tui(
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
|
||||
let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env) {
|
||||
let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env.clone()) {
|
||||
Ok(remote_endpoint) => remote_endpoint,
|
||||
Err(err) if is_remote_auth_usage_error(&err) => {
|
||||
return Ok(AppExitInfo::fatal(err.to_string()));
|
||||
@@ -2640,11 +2613,31 @@ async fn run_interactive_tui(
|
||||
remote_endpoint.clone(),
|
||||
)
|
||||
};
|
||||
run_tui_with_recovery(start_tui, remote_auth_token_env.as_deref()).await
|
||||
}
|
||||
|
||||
async fn run_tui_with_recovery<F, Fut>(
|
||||
mut start_tui: F,
|
||||
remote_auth_token_env: Option<&str>,
|
||||
) -> std::io::Result<AppExitInfo>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = std::io::Result<AppExitInfo>>,
|
||||
{
|
||||
let mut attempted_backups = HashSet::new();
|
||||
loop {
|
||||
// Keep the large TUI future out of the CLI dispatcher's stack frame.
|
||||
let err = match Box::pin(start_tui()).await {
|
||||
Ok(exit_info) => return Ok(exit_info),
|
||||
Ok(mut exit_info) => {
|
||||
if let Some(disconnect) = &mut exit_info.disconnect_info
|
||||
&& let Some(env_var) = remote_auth_token_env
|
||||
{
|
||||
disconnect
|
||||
.command
|
||||
.extend(["--remote-auth-token-env".to_string(), env_var.to_string()]);
|
||||
}
|
||||
return Ok(exit_info);
|
||||
}
|
||||
Err(err) => err,
|
||||
};
|
||||
let Some(startup_error) = local_state_db::startup_error(&err) else {
|
||||
@@ -3742,6 +3735,7 @@ mod tests {
|
||||
token_usage,
|
||||
thread_id,
|
||||
resume_hint: codex_utils_cli::resume_hint(thread_name, thread_id),
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
}
|
||||
@@ -3753,23 +3747,58 @@ mod tests {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
};
|
||||
let lines = format_exit_messages(exit_info, /*color_enabled*/ false);
|
||||
let lines = exit_info.format_exit_messages(/*color_enabled*/ false);
|
||||
assert!(lines.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn format_exit_messages_preserves_auth_env_through_tui_runner() {
|
||||
let exit_info = run_tui_with_recovery(
|
||||
|| async {
|
||||
let mut exit_info = sample_exit_info(
|
||||
Some("123e4567-e89b-12d3-a456-426614174000"),
|
||||
/*thread_name*/ None,
|
||||
);
|
||||
exit_info.disconnect_info = Some(codex_tui::DisconnectInfo {
|
||||
command: vec![
|
||||
"codex".to_string(),
|
||||
"--remote".to_string(),
|
||||
"wss://example.com:443/".to_string(),
|
||||
],
|
||||
stop_hint: "press ctrl + x".to_string(),
|
||||
});
|
||||
Ok(exit_info)
|
||||
},
|
||||
Some("CODEX_REMOTE_TOKEN"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
exit_info.format_exit_messages(/*color_enabled*/ false),
|
||||
vec![
|
||||
"Disconnected from this task. Any running work continues.",
|
||||
"Reconnect: codex --remote wss://example.com:443/ --remote-auth-token-env CODEX_REMOTE_TOKEN resume 123e4567-e89b-12d3-a456-426614174000",
|
||||
"Stop the current turn: run codex --remote wss://example.com:443/ --remote-auth-token-env CODEX_REMOTE_TOKEN agents, select this task, and press ctrl + x.",
|
||||
"Token usage so far: total=2 input=0 output=2",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_exit_messages_includes_session_id_for_fatal_exit_without_resume_hint() {
|
||||
let exit_info = AppExitInfo {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: Some(ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap()),
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::Fatal("boom".to_string()),
|
||||
};
|
||||
let lines = format_exit_messages(exit_info, /*color_enabled*/ false);
|
||||
let lines = exit_info.format_exit_messages(/*color_enabled*/ false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec!["Session ID: 123e4567-e89b-12d3-a456-426614174000".to_string()]
|
||||
@@ -3783,7 +3812,7 @@ mod tests {
|
||||
/*thread_name*/ None,
|
||||
);
|
||||
exit_info.exit_reason = ExitReason::Fatal("boom".to_string());
|
||||
let lines = format_exit_messages(exit_info, /*color_enabled*/ false);
|
||||
let lines = exit_info.format_exit_messages(/*color_enabled*/ false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
@@ -3800,7 +3829,7 @@ mod tests {
|
||||
Some("123e4567-e89b-12d3-a456-426614174000"),
|
||||
/*thread_name*/ None,
|
||||
);
|
||||
let lines = format_exit_messages(exit_info, /*color_enabled*/ false);
|
||||
let lines = exit_info.format_exit_messages(/*color_enabled*/ false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
@@ -3817,7 +3846,7 @@ mod tests {
|
||||
Some("123e4567-e89b-12d3-a456-426614174000"),
|
||||
/*thread_name*/ None,
|
||||
);
|
||||
let lines = format_exit_messages(exit_info, /*color_enabled*/ true);
|
||||
let lines = exit_info.format_exit_messages(/*color_enabled*/ true);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[1].contains("\u{1b}[36m"));
|
||||
}
|
||||
@@ -3828,7 +3857,7 @@ mod tests {
|
||||
Some("123e4567-e89b-12d3-a456-426614174000"),
|
||||
Some("my-thread"),
|
||||
);
|
||||
let lines = format_exit_messages(exit_info, /*color_enabled*/ false);
|
||||
let lines = exit_info.format_exit_messages(/*color_enabled*/ false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
|
||||
@@ -217,6 +217,7 @@ mod background_requests;
|
||||
mod config_persistence;
|
||||
mod connector_mentions;
|
||||
mod event_dispatch;
|
||||
mod exit_summary;
|
||||
mod file_change_approvals;
|
||||
mod history_pagination;
|
||||
mod history_ui;
|
||||
@@ -421,6 +422,7 @@ pub struct AppExitInfo {
|
||||
pub token_usage: TokenUsage,
|
||||
pub thread_id: Option<ThreadId>,
|
||||
pub resume_hint: Option<String>,
|
||||
pub disconnect_info: Option<DisconnectInfo>,
|
||||
pub update_action: Option<UpdateAction>,
|
||||
pub exit_reason: ExitReason,
|
||||
}
|
||||
@@ -431,12 +433,15 @@ impl AppExitInfo {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::Fatal(message.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use exit_summary::DisconnectInfo;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppRunControl {
|
||||
Continue,
|
||||
@@ -446,6 +451,9 @@ pub(crate) enum AppRunControl {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ExitReason {
|
||||
UserRequested,
|
||||
TurnInterrupted,
|
||||
/// The current thread was archived or deleted, rather than disconnected.
|
||||
ThreadRemoved,
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
|
||||
@@ -638,7 +638,7 @@ impl App {
|
||||
self.chat_widget.on_commit_tick();
|
||||
}
|
||||
AppEvent::Exit(mode) => {
|
||||
if mode == ExitMode::ShutdownFirst {
|
||||
if matches!(mode, ExitMode::ShutdownFirst | ExitMode::ShutdownAfterInterrupt) {
|
||||
self.show_shutdown_feedback(tui)?;
|
||||
}
|
||||
return Ok(self.handle_exit_mode(app_server, mode).await);
|
||||
@@ -689,7 +689,7 @@ impl App {
|
||||
match app_server.turn_interrupt(thread_id, turn_id).await {
|
||||
Ok(()) => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::Exit(ExitMode::ShutdownFirst));
|
||||
.send(AppEvent::Exit(ExitMode::ShutdownAfterInterrupt));
|
||||
}
|
||||
Err(error) => {
|
||||
self.chat_widget
|
||||
@@ -3062,7 +3062,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
match mode {
|
||||
ExitMode::ShutdownFirst => {
|
||||
ExitMode::ShutdownFirst | ExitMode::ShutdownAfterInterrupt => {
|
||||
// Mark the thread we are explicitly shutting down for exit so
|
||||
// its shutdown completion does not trigger agent failover.
|
||||
self.pending_shutdown_exit_thread_id =
|
||||
@@ -3084,7 +3084,11 @@ impl App {
|
||||
}
|
||||
}
|
||||
self.pending_shutdown_exit_thread_id = None;
|
||||
AppRunControl::Exit(ExitReason::UserRequested)
|
||||
AppRunControl::Exit(if mode == ExitMode::ShutdownAfterInterrupt {
|
||||
ExitReason::TurnInterrupted
|
||||
} else {
|
||||
ExitReason::UserRequested
|
||||
})
|
||||
}
|
||||
ExitMode::Immediate => {
|
||||
self.pending_shutdown_exit_thread_id = None;
|
||||
@@ -3111,7 +3115,7 @@ impl App {
|
||||
}
|
||||
|
||||
match app_server.thread_archive(thread_id).await {
|
||||
Ok(()) => AppRunControl::Exit(ExitReason::UserRequested),
|
||||
Ok(()) => AppRunControl::Exit(ExitReason::ThreadRemoved),
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to archive current thread: {err}"));
|
||||
@@ -3138,7 +3142,7 @@ impl App {
|
||||
}
|
||||
|
||||
match app_server.thread_delete(thread_id).await {
|
||||
Ok(()) => AppRunControl::Exit(ExitReason::UserRequested),
|
||||
Ok(()) => AppRunControl::Exit(ExitReason::ThreadRemoved),
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to delete current thread: {err}"));
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Exit output distinguishes a disconnected client from a stopped embedded server.
|
||||
|
||||
use super::*;
|
||||
use crate::RemoteAppServerEndpoint;
|
||||
use crate::exec_command::escape_command;
|
||||
use crate::status::remote_connection::sanitized_websocket_url;
|
||||
|
||||
/// Reconnection and stop guidance for a task owned by a persistent app server.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DisconnectInfo {
|
||||
/// CLI arguments identifying the server, without credential values.
|
||||
pub command: Vec<String>,
|
||||
pub stop_hint: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn exit_info(&self, exit_reason: ExitReason) -> AppExitInfo {
|
||||
let thread_id = match exit_reason {
|
||||
ExitReason::ThreadRemoved => None,
|
||||
ExitReason::UserRequested | ExitReason::TurnInterrupted | ExitReason::Fatal(_) => {
|
||||
self.chat_widget.thread_id().or(self.primary_thread_id)
|
||||
}
|
||||
};
|
||||
let disconnect_info = thread_id.and_then(|_| {
|
||||
let command = match &self.app_server_target {
|
||||
AppServerTarget::Embedded => return None,
|
||||
AppServerTarget::LocalDaemon { .. } => vec!["codex".to_string()],
|
||||
AppServerTarget::Remote { endpoint } => {
|
||||
let address = match endpoint {
|
||||
RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => {
|
||||
sanitized_websocket_url(websocket_url)
|
||||
.and_then(|url| {
|
||||
let host = url.host()?;
|
||||
let port = url.port_or_known_default()?;
|
||||
// The CLI requires an explicit port, including 80/443.
|
||||
Some(format!("{}://{host}:{port}{}", url.scheme(), url.path()))
|
||||
})
|
||||
.unwrap_or_else(|| "<server-address>".to_string())
|
||||
}
|
||||
RemoteAppServerEndpoint::UnixSocket { socket_path } => {
|
||||
format!("unix://{}", socket_path.display())
|
||||
}
|
||||
};
|
||||
vec!["codex".to_string(), "--remote".to_string(), address]
|
||||
}
|
||||
};
|
||||
let stop_hint = self
|
||||
.keymap
|
||||
.agents
|
||||
.primary_hint("stop", &self.keymap.agents.stop)
|
||||
.map_or_else(
|
||||
|| "use the configured stop shortcut".to_string(),
|
||||
|key| format!("press {}", key.display_label()),
|
||||
);
|
||||
Some(DisconnectInfo { command, stop_hint })
|
||||
});
|
||||
AppExitInfo {
|
||||
token_usage: self.token_usage(),
|
||||
thread_id,
|
||||
resume_hint: resume_hint_for_resumable_thread(
|
||||
thread_id,
|
||||
self.chat_widget.thread_name(),
|
||||
self.chat_widget.rollout_path().as_deref(),
|
||||
),
|
||||
disconnect_info,
|
||||
update_action: self.pending_update_action,
|
||||
exit_reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppExitInfo {
|
||||
/// Format the terminal summary for both the CLI and standalone TUI binaries.
|
||||
pub fn format_exit_messages(self, color_enabled: bool) -> Vec<String> {
|
||||
let color_command = |command: String| {
|
||||
if color_enabled {
|
||||
format!("\u{1b}[36m{command}\u{1b}[39m")
|
||||
} else {
|
||||
command
|
||||
}
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
if let Some(disconnect) = self.disconnect_info
|
||||
&& let Some(thread_id) = self.thread_id
|
||||
{
|
||||
let turn_interrupted = matches!(self.exit_reason, ExitReason::TurnInterrupted);
|
||||
let message = match self.exit_reason {
|
||||
ExitReason::UserRequested | ExitReason::ThreadRemoved => {
|
||||
"Disconnected from this task. Any running work continues."
|
||||
}
|
||||
ExitReason::Fatal(_) => "Disconnected from this task. Work may still be running.",
|
||||
ExitReason::TurnInterrupted => {
|
||||
"Disconnected from this task. The current turn was stopped."
|
||||
}
|
||||
};
|
||||
lines.push(message.to_string());
|
||||
let mut resume_command = disconnect.command.clone();
|
||||
resume_command.extend(["resume".to_string(), thread_id.to_string()]);
|
||||
lines.push(format!(
|
||||
"Reconnect: {}",
|
||||
color_command(escape_command(&resume_command)),
|
||||
));
|
||||
if !turn_interrupted {
|
||||
let mut agents_command = disconnect.command;
|
||||
agents_command.push("agents".to_string());
|
||||
lines.push(format!(
|
||||
"Stop the current turn: run {}, select this task, and {}.",
|
||||
color_command(escape_command(&agents_command)),
|
||||
disconnect.stop_hint,
|
||||
));
|
||||
}
|
||||
if !self.token_usage.is_zero() {
|
||||
let usage = self.token_usage.to_string();
|
||||
lines.push(if turn_interrupted {
|
||||
usage
|
||||
} else {
|
||||
usage.replacen("Token usage:", "Token usage so far:", /*count*/ 1)
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
if !self.token_usage.is_zero() {
|
||||
lines.push(self.token_usage.to_string());
|
||||
}
|
||||
if let Some(resume_command) = self.resume_hint {
|
||||
lines.push(format!(
|
||||
"To continue this session, run {}",
|
||||
color_command(resume_command),
|
||||
));
|
||||
} else if matches!(self.exit_reason, ExitReason::Fatal(_))
|
||||
&& let Some(thread_id) = self.thread_id
|
||||
{
|
||||
lines.push(format!("Session ID: {thread_id}"));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
@@ -868,18 +868,6 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let thread_id = app.chat_widget.thread_id().or(app.primary_thread_id);
|
||||
let resume_hint = resume_hint_for_resumable_thread(
|
||||
thread_id,
|
||||
app.chat_widget.thread_name(),
|
||||
app.chat_widget.rollout_path().as_deref(),
|
||||
);
|
||||
Ok(AppExitInfo {
|
||||
token_usage: app.token_usage(),
|
||||
thread_id,
|
||||
resume_hint,
|
||||
update_action: app.pending_update_action,
|
||||
exit_reason,
|
||||
})
|
||||
Ok(app.exit_info(exit_reason))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +345,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed(
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,75 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn daemon_disconnect_exit_summary_includes_reconnect_and_stop_instructions() -> Result<()> {
|
||||
let (mut app, _, _) = make_test_app_with_channels().await;
|
||||
let thread_id = prepare_running_local_daemon(&mut app)?;
|
||||
app.keymap.agents.stop = vec![crate::key_hint::plain(KeyCode::F(10))];
|
||||
let mut exit_info = app.exit_info(ExitReason::UserRequested);
|
||||
exit_info.token_usage = TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
total_tokens: 12,
|
||||
..Default::default()
|
||||
};
|
||||
let output = exit_info
|
||||
.format_exit_messages(/*color_enabled*/ false)
|
||||
.join("\n")
|
||||
.replace(&thread_id.to_string(), "THREAD_ID");
|
||||
assert_snapshot!("daemon_disconnect_exit", output);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_disconnect_exit_summary_does_not_require_a_local_rollout_or_print_credentials() {
|
||||
let (mut app, _, _) = make_test_app_with_channels().await;
|
||||
app.app_server_target = AppServerTarget::Remote {
|
||||
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: "wss://user:secret@example.com:443/?token=private#secret".to_string(),
|
||||
auth_token: Some("secret-token".to_string()),
|
||||
},
|
||||
};
|
||||
let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
|
||||
app.active_thread_id = Some(thread_id);
|
||||
app.chat_widget.handle_thread_session(test_thread_session(
|
||||
thread_id,
|
||||
test_path_buf("/tmp/project"),
|
||||
));
|
||||
let exit_info = app.exit_info(ExitReason::Fatal("connection lost".to_string()));
|
||||
let lines = exit_info.format_exit_messages(/*color_enabled*/ false);
|
||||
let command = shlex::split(lines[1].strip_prefix("Reconnect: ").unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
crate::resolve_remote_addr(&command[2]).unwrap(),
|
||||
crate::RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: "wss://example.com/".to_string(),
|
||||
auth_token: None,
|
||||
}
|
||||
);
|
||||
assert_snapshot!("remote_disconnect_exit", lines.join("\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_exit_keeps_the_session_summary() {
|
||||
let (mut app, _, _) = make_test_app_with_channels().await;
|
||||
prepare_local_daemon_thread(&mut app).unwrap();
|
||||
app.app_server_target = AppServerTarget::Embedded;
|
||||
let mut exit_info = app.exit_info(ExitReason::UserRequested);
|
||||
exit_info.token_usage = TokenUsage {
|
||||
output_tokens: 2,
|
||||
total_tokens: 2,
|
||||
..Default::default()
|
||||
};
|
||||
exit_info.resume_hint = Some("codex resume THREAD_ID".to_string());
|
||||
assert_eq!(
|
||||
exit_info.format_exit_messages(/*color_enabled*/ false),
|
||||
vec![
|
||||
"Token usage: total=2 input=0 output=2",
|
||||
"To continue this session, run codex resume THREAD_ID",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn prepare_local_daemon_thread(app: &mut App) -> Result<ThreadId> {
|
||||
app.app_server_target = AppServerTarget::LocalDaemon {
|
||||
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
|
||||
@@ -226,10 +295,21 @@ async fn exit_interrupts_before_requesting_shutdown() -> Result<()> {
|
||||
|
||||
assert_matches!(control, AppRunControl::Continue);
|
||||
assert!(op_rx.try_recv().is_err());
|
||||
assert_matches!(
|
||||
app_event_rx.try_recv(),
|
||||
Ok(AppEvent::Exit(ExitMode::ShutdownFirst))
|
||||
);
|
||||
let exit_event = app_event_rx.try_recv()?;
|
||||
assert_matches!(exit_event, AppEvent::Exit(ExitMode::ShutdownAfterInterrupt));
|
||||
let AppRunControl::Exit(reason) = app
|
||||
.handle_event(&mut tui, &mut app_server, exit_event)
|
||||
.await?
|
||||
else {
|
||||
panic!("stop-and-exit must exit");
|
||||
};
|
||||
assert_matches!(reason, ExitReason::TurnInterrupted);
|
||||
let output = app
|
||||
.exit_info(reason)
|
||||
.format_exit_messages(/*color_enabled*/ false)
|
||||
.join("\n")
|
||||
.replace(&thread_id.to_string(), "THREAD_ID");
|
||||
assert_snapshot!("interrupted_disconnect_exit", output);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -348,6 +348,64 @@ async fn make_history_test_app() -> Result<(App, tempfile::TempDir)> {
|
||||
Ok((app, codex_home))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_remote_thread_omits_disconnect_guidance() -> Result<()> {
|
||||
for event in [
|
||||
AppEvent::ArchiveCurrentThread,
|
||||
AppEvent::DeleteCurrentThread,
|
||||
] {
|
||||
let (mut app, codex_home) = make_history_test_app().await?;
|
||||
let thread_id = ThreadId::from_string(
|
||||
&create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2026-01-01T00-00-00",
|
||||
"2026-01-01T00:00:00Z",
|
||||
"Saved user message",
|
||||
Some(app.config.model_provider_id.as_str()),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.expect("create rollout"),
|
||||
)?;
|
||||
let (mut server, _, proxy) = start_recording_app_server(
|
||||
&app.config,
|
||||
/*blocked_thread_list*/ None,
|
||||
/*failed_thread_name*/ None,
|
||||
)
|
||||
.await?;
|
||||
let resumed = server
|
||||
.resume_thread(
|
||||
app.config.clone(),
|
||||
thread_id,
|
||||
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
|
||||
)
|
||||
.await?;
|
||||
app.app_server_target = AppServerTarget::Remote {
|
||||
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
|
||||
};
|
||||
app.active_thread_id = Some(thread_id);
|
||||
app.chat_widget.handle_thread_session(resumed.session);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let AppRunControl::Exit(reason) = app.handle_event(&mut tui, &mut server, event).await?
|
||||
else {
|
||||
panic!("removing the current thread must exit");
|
||||
};
|
||||
assert_matches!(reason, ExitReason::ThreadRemoved);
|
||||
let mut exit_info = app.exit_info(reason);
|
||||
exit_info.token_usage = TokenUsage {
|
||||
output_tokens: 2,
|
||||
total_tokens: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
exit_info.format_exit_messages(/*color_enabled*/ false),
|
||||
vec!["Token usage: total=2 input=0 output=2",]
|
||||
);
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_approved_task_tool_call(
|
||||
app: &App,
|
||||
app_server: &AppServerSession,
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/app/tests/background_exit_tests.rs
|
||||
expression: output
|
||||
---
|
||||
Disconnected from this task. Any running work continues.
|
||||
Reconnect: codex resume THREAD_ID
|
||||
Stop the current turn: run codex agents, select this task, and press f10.
|
||||
Token usage so far: total=12 input=10 output=2
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/app/tests/background_exit_tests.rs
|
||||
expression: output
|
||||
---
|
||||
Disconnected from this task. The current turn was stopped.
|
||||
Reconnect: codex resume THREAD_ID
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/app/tests/background_exit_tests.rs
|
||||
expression: "lines.join(\"\\n\")"
|
||||
---
|
||||
Disconnected from this task. Work may still be running.
|
||||
Reconnect: codex --remote wss://example.com:443/ resume 123e4567-e89b-12d3-a456-426614174000
|
||||
Stop the current turn: run codex --remote wss://example.com:443/ agents, select this task, and press ctrl + x.
|
||||
@@ -1346,6 +1346,8 @@ pub(crate) struct PermissionProfileSelection {
|
||||
pub(crate) enum ExitMode {
|
||||
/// Shutdown core and exit after completion.
|
||||
ShutdownFirst,
|
||||
/// Unsubscribe and exit after the current turn was successfully interrupted.
|
||||
ShutdownAfterInterrupt,
|
||||
/// Exit the UI loop immediately without waiting for shutdown.
|
||||
///
|
||||
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and
|
||||
|
||||
@@ -21,6 +21,7 @@ pub use crate::startup_error::LocalStateDbStartupError;
|
||||
use additional_dirs::add_dir_warning_message;
|
||||
use app::App;
|
||||
pub use app::AppExitInfo;
|
||||
pub use app::DisconnectInfo;
|
||||
pub use app::ExitReason;
|
||||
use app_server_session::AppServerSession;
|
||||
use app_server_session::ThreadParamsMode;
|
||||
@@ -948,6 +949,7 @@ pub async fn run_main(
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
}),
|
||||
@@ -1007,6 +1009,7 @@ async fn run_ratatui_app(
|
||||
token_usage: crate::token_usage::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: Some(action),
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
@@ -1179,6 +1182,7 @@ async fn run_ratatui_app(
|
||||
token_usage: crate::token_usage::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
@@ -1251,6 +1255,7 @@ async fn run_ratatui_app(
|
||||
token_usage: crate::token_usage::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::Fatal(format!(
|
||||
"No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions."
|
||||
@@ -1346,6 +1351,7 @@ async fn run_ratatui_app(
|
||||
token_usage: crate::token_usage::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
@@ -1440,6 +1446,7 @@ async fn run_ratatui_app(
|
||||
token_usage: crate::token_usage::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
@@ -1489,6 +1496,7 @@ async fn run_ratatui_app(
|
||||
token_usage: crate::token_usage::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ use clap::Parser;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_arg0::arg0_dispatch_or_else;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_tui::AppExitInfo;
|
||||
use codex_tui::Cli;
|
||||
use codex_tui::ExitReason;
|
||||
use codex_tui::run_main;
|
||||
@@ -10,34 +9,6 @@ use codex_utils_cli::CliConfigOverrides;
|
||||
use std::io::Write;
|
||||
use supports_color::Stream;
|
||||
|
||||
fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<String> {
|
||||
let is_fatal = matches!(&exit_info.exit_reason, ExitReason::Fatal(_));
|
||||
let AppExitInfo {
|
||||
token_usage,
|
||||
thread_id,
|
||||
resume_hint,
|
||||
..
|
||||
} = exit_info;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
if !token_usage.is_zero() {
|
||||
lines.push(token_usage.to_string());
|
||||
}
|
||||
|
||||
if let Some(resume_cmd) = resume_hint {
|
||||
let command = if color_enabled {
|
||||
format!("\u{1b}[36m{resume_cmd}\u{1b}[39m")
|
||||
} else {
|
||||
resume_cmd
|
||||
};
|
||||
lines.push(format!("To continue this session, run {command}"));
|
||||
} else if is_fatal && let Some(thread_id) = thread_id {
|
||||
lines.push(format!("Session ID: {thread_id}"));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct TopCli {
|
||||
#[clap(flatten)]
|
||||
@@ -67,11 +38,13 @@ fn main() -> anyhow::Result<()> {
|
||||
eprintln!("ERROR: {message}");
|
||||
true
|
||||
}
|
||||
ExitReason::UserRequested => false,
|
||||
ExitReason::UserRequested | ExitReason::TurnInterrupted | ExitReason::ThreadRemoved => {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let color_enabled = supports_color::on(Stream::Stdout).is_some();
|
||||
for line in format_exit_messages(exit_info, color_enabled) {
|
||||
for line in exit_info.format_exit_messages(color_enabled) {
|
||||
println!("{line}");
|
||||
}
|
||||
if is_fatal {
|
||||
|
||||
@@ -92,6 +92,7 @@ pub(crate) async fn cancel_session_start(app_server: AppServerSession) -> AppExi
|
||||
token_usage: Default::default(),
|
||||
thread_id: None,
|
||||
resume_hint: None,
|
||||
disconnect_info: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ pub(crate) fn remote_connection_status_value(
|
||||
};
|
||||
let address = match endpoint {
|
||||
RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => {
|
||||
sanitized_websocket_display_address(websocket_url)
|
||||
sanitized_websocket_url(websocket_url)
|
||||
.map(|url| url.to_string())
|
||||
.unwrap_or_else(|| "<invalid websocket URL>".to_string())
|
||||
}
|
||||
RemoteAppServerEndpoint::UnixSocket { socket_path } => {
|
||||
@@ -33,13 +34,13 @@ pub(crate) fn remote_connection_status_value(
|
||||
Some(RemoteConnectionStatus { address, version })
|
||||
}
|
||||
|
||||
fn sanitized_websocket_display_address(raw: &str) -> Option<String> {
|
||||
pub(crate) fn sanitized_websocket_url(raw: &str) -> Option<Url> {
|
||||
let mut url = Url::parse(raw).ok()?;
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Some(url.to_string())
|
||||
Some(url)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user