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:
Eric Traut authored and copyberry committed 2026-08-25 15:55:35 +00:00
1 parent 0cdb1f1c83
commit a63cb33e8d
16 files changed
+408 -96

No files matched your search

+68 -39
View File
@@ -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![