Add session delete commands in CLI and TUI (#27476)

## Summary

The app server exposes `thread/delete`, but users cannot invoke it from
the CLI or TUI. Because deletion is irreversible, the user-facing
commands need deliberate confirmation and safer handling of name-based
targets.

- Add `codex delete <SESSION>` with interactive confirmation,
restricting `--force` to UUID targets.
- Resolve exact names across active and archived sessions, including
renamed sessions, and validate prompted UUID targets before
confirmation.
- Add a `/delete` command with a confirmation popup that warns the
current session and its subagent threads will be permanently deleted.

## Manual testing

- Deleted by UUID with `--force` and verified the rollout, session-index
entry, and database row were removed.
- Exercised name-based confirmation for both cancellation and
affirmative deletion; cancellation preserved the session and
confirmation removed it.
- Verified deletion refuses to proceed without `--force`, while
`--force` rejects names, including duplicate names.
- Verified duplicate-name confirmation displays the concrete UUID
selected.
- Deleted an archived session by name.
- Verified an already-missing UUID fails before displaying a
confirmation prompt.
- Exercised `/delete` in the TUI: the popup defaults to No, cancellation
preserves the session, and confirmation deletes the session and exits.
- Verified that `codex delete` works for both archived and non-archived
sessions.
This commit is contained in:
Eric Traut
2026-06-10 18:04:02 -07:00
committed by GitHub
Unverified
parent 36fc79c6f4
commit 9d87b771ce
11 changed files with 301 additions and 56 deletions
+52 -1
View File
@@ -180,6 +180,9 @@ enum Subcommand {
/// Archive a saved session by id or session name.
Archive(SessionArchiveCommand),
/// Permanently delete a saved session by id or session name.
Delete(DeleteCommand),
/// Unarchive a saved session by id or session name.
Unarchive(SessionArchiveCommand),
@@ -354,6 +357,16 @@ struct SessionArchiveConfigOverrides {
config_overrides: CliConfigOverrides,
}
#[derive(Debug, Args)]
struct DeleteCommand {
#[clap(flatten)]
session: SessionArchiveCommand,
/// Delete without prompting. SESSION must be a UUID.
#[arg(long, default_value_t = false)]
force: bool,
}
#[derive(Debug, Parser)]
struct ForkCommand {
/// Conversation/session id (UUID). When provided, forks this session.
@@ -829,6 +842,17 @@ async fn run_session_archive_cli_command(
.map_err(|err| anyhow::anyhow!("{err}"))
}
fn delete_action(target: &str, force: bool) -> anyhow::Result<codex_tui::SessionArchiveAction> {
if force && codex_protocol::ThreadId::from_string(target).is_err() {
anyhow::bail!("--force requires a session UUID; names must be confirmed interactively");
}
let confirmation = match force {
true => codex_tui::DeleteConfirmation::Skip,
false => codex_tui::DeleteConfirmation::Prompt,
};
Ok(codex_tui::SessionArchiveAction::Delete(confirmation))
}
async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> {
match cmd.subcommand {
DebugAppServerSubcommand::SendMessageV2(cmd) => {
@@ -1233,6 +1257,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
println!("{output}");
}
Some(Subcommand::Delete(DeleteCommand { session, force })) => {
let action = delete_action(&session.target, force)?;
let output = run_session_archive_cli_command(
action,
session,
interactive,
root_config_overrides.clone(),
root_remote.clone(),
root_remote_auth_token_env.clone(),
arg0_paths.clone(),
)
.await?;
println!("{output}");
}
Some(Subcommand::Unarchive(cmd)) => {
let output = run_session_archive_cli_command(
codex_tui::SessionArchiveAction::Unarchive,
@@ -1597,6 +1635,7 @@ fn profile_v2_for_subcommand<'a>(
| Subcommand::Review(_)
| Subcommand::Resume(_)
| Subcommand::Archive(_)
| Subcommand::Delete(_)
| Subcommand::Unarchive(_)
| Subcommand::Fork(_)
| Subcommand::Mcp(_)
@@ -1605,7 +1644,7 @@ fn profile_v2_for_subcommand<'a>(
subcommand: DebugSubcommand::PromptInput(_),
}) => Ok(Some(profile_v2)),
_ => anyhow::bail!(
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex delete`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
),
}
}
@@ -2026,6 +2065,7 @@ fn unsupported_subcommand_name_for_strict_config(
| Some(Subcommand::ExecServer(_))
| Some(Subcommand::Resume(_))
| Some(Subcommand::Archive(_))
| Some(Subcommand::Delete(_))
| Some(Subcommand::Unarchive(_))
| Some(Subcommand::Fork(_))
| Some(Subcommand::Doctor(_)) => None,
@@ -2868,6 +2908,17 @@ mod tests {
assert!(interactive.bypass_hook_trust);
}
#[test]
fn delete_force_requires_uuid() {
assert!(delete_action("123e4567-e89b-12d3-a456-426614174000", true).is_ok());
let err = delete_action("my-thread", true).expect_err("name should require prompt");
assert_eq!(
err.to_string(),
"--force requires a session UUID; names must be confirmed interactively"
);
}
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[test]
fn sandbox_parses_permissions_profile() {