From 802f9e07926eadfe1fb13bc13ee8d59d1759d0bb Mon Sep 17 00:00:00 2001 From: Adam Hitchcock Date: Tue, 29 Sep 2026 18:35:32 -0700 Subject: [PATCH] fix(agents): a Claude resume that finds no conversation starts fresh under its id (#1024) * fix(agents): a Claude resume that finds no conversation starts fresh under its id A tab opened and never used, or run with transcript saving off, has no saved conversation, and `claude --resume` then stops at an error. Co-Authored-By: Claude Opus 5.5 * fix(agents): only chain the fresh Claude start where the pane's shell has || Windows PowerShell 5.1 and nu reject a line containing `||` outright, so the fallback took the resume down with it there. The restore line now chains the fresh start only for shells known to have the operator, judged by the pane's own shell: its spawn spec, else the configured or login shell for a local pane. A workspace pane with no explicit shell stays unknown and gets the plain resume, since its default lives on its host. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- crates/tty7-core/src/core/cli_agent.rs | 78 ++++++++++++++++++++++++ crates/tty7-core/src/core/shell_quote.rs | 42 +++++++++++++ src/ui/app.rs | 31 +++++++++- 3 files changed, 148 insertions(+), 3 deletions(-) diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index dac940b0..f25c36a7 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -310,6 +310,47 @@ impl CLIAgent { } } + /// A fresh start under `session_id`, for when [`Self::resume_command`] + /// finds nothing: Claude saves no conversation for a session that never + /// took a turn (or ran with transcript saving off), and resuming one + /// fails at once. `None` for agents that cannot name a new session. + pub fn start_command(self, session_id: &str, launch_argv: Option<&[String]>) -> Option { + if self != CLIAgent::Claude || uuid::Uuid::parse_str(session_id).is_err() { + return None; + } + let flags = self.session_command_flags(session_id, launch_argv)?; + Some(format!("claude{flags} --session-id {session_id}")) + } + + /// The line a restored pane types to bring its agent back: the resume, + /// falling back to [`Self::start_command`] with `||` when the pane's shell + /// has that operator. `shell_program` is that shell, `None` when it is not + /// known — then, as in a shell without `||`, the resume goes alone, since + /// one that cannot parse the line would not run the resume either. + /// + /// `||` fires on any failed exit, not only "no conversation found". The + /// other ways a resume fails fast (a flag this version rejects, no login) + /// fail the fresh start the same way; one that exits non-zero after the + /// user took turns follows with a start under an id that now has a + /// conversation, which Claude turns down as already in use. Telling the + /// cases apart would mean reading Claude's stderr, which a typed line + /// cannot do portably. + pub fn restore_command( + self, + session_id: &str, + launch_argv: Option<&[String]>, + shell_program: Option<&str>, + ) -> Option { + let resume = self.resume_command(session_id, launch_argv)?; + let fresh = shell_program + .filter(|shell| crate::core::shell_quote::runs_or_list(shell)) + .and_then(|_| self.start_command(session_id, launch_argv)); + Some(match fresh { + Some(fresh) => format!("{resume} || {fresh}"), + None => resume, + }) + } + fn session_command_flags( self, session_id: &str, @@ -1163,6 +1204,43 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { mod tests { use super::*; + #[test] + fn claude_starts_fresh_under_the_same_id_with_its_flags() { + const ID: &str = "0b5c3a5e-6d0e-4c1f-9a4b-2f7f1d9e8c11"; + let launched = argv(&["claude", "--model", "opus", "--session-id", ID]); + assert_eq!( + CLIAgent::Claude + .start_command(ID, Some(&launched)) + .as_deref(), + Some(format!("claude --model opus --session-id {ID}").as_str()) + ); + assert_eq!(CLIAgent::Claude.start_command("not-a-uuid", None), None); + assert_eq!(CLIAgent::Codex.start_command(ID, None), None); + } + + #[test] + fn a_restore_falls_back_to_a_fresh_start_only_where_the_shell_has_or() { + const ID: &str = "0b5c3a5e-6d0e-4c1f-9a4b-2f7f1d9e8c11"; + let launched = argv(&["claude", "--model", "opus"]); + let resume = CLIAgent::Claude + .resume_command(ID, Some(&launched)) + .unwrap(); + let restore = |shell| CLIAgent::Claude.restore_command(ID, Some(&launched), shell); + let chained = format!("{resume} || claude --model opus --session-id {ID}"); + assert_eq!(restore(Some("/bin/zsh")).as_deref(), Some(chained.as_str())); + assert_eq!(restore(Some("pwsh.exe")).as_deref(), Some(chained.as_str())); + // Windows PowerShell 5.1 and nu reject the whole line over `||`, and a + // shell nobody could name might be either. + assert_eq!(restore(Some("powershell.exe")), Some(resume.clone())); + assert_eq!(restore(Some("nu")), Some(resume.clone())); + assert_eq!(restore(None), Some(resume)); + // Agents with no way to name a new session keep the bare resume. + assert_eq!( + CLIAgent::Codex.restore_command(ID, None, Some("bash")), + CLIAgent::Codex.resume_command(ID, None) + ); + } + fn argv(parts: &[&str]) -> Vec { parts.iter().map(|s| s.to_string()).collect() } diff --git a/crates/tty7-core/src/core/shell_quote.rs b/crates/tty7-core/src/core/shell_quote.rs index 555fd117..25f0268e 100644 --- a/crates/tty7-core/src/core/shell_quote.rs +++ b/crates/tty7-core/src/core/shell_quote.rs @@ -78,6 +78,24 @@ pub fn quoting_for(shell_program: Option<&str>) -> Quoting { } } +/// Whether `a || b` runs `b` only when `a` fails, in the shell named by +/// `shell_program` (a binary as `ShellSpec::program` reports it). +/// +/// An allowlist, because the shells that lack it do not skip the operator — +/// they refuse the whole line, `a` included. Windows PowerShell 5.1 calls it an +/// invalid statement separator (pwsh 7 added it), nu and elvish have no such +/// operator, and fish only gained it in 3.0 — anything older is long past its +/// end of life, so a bare `fish` counts. `wsl` is out: which shell it lands in +/// is the distro's business. +pub fn runs_or_list(shell_program: &str) -> bool { + const OR_LIST: &[&str] = &[ + "sh", "bash", "zsh", "dash", "ash", "ksh", "mksh", "oksh", "yash", "fish", "pwsh", "cmd", + "csh", "tcsh", "xonsh", + ]; + let base = base_name(shell_program); + OR_LIST.iter().any(|s| base.eq_ignore_ascii_case(s)) +} + /// Quote `path` as a single argument for the shell the pane is running. pub fn quote_for_shell(path: &str, shell_program: Option<&str>) -> String { quote_as(path, quoting_for(shell_program)) @@ -164,6 +182,30 @@ pub fn unquote_word(word: &str, quoting: Quoting) -> String { mod tests { use super::*; + #[test] + fn only_shells_with_an_or_list_are_handed_one() { + for yes in [ + "bash", + "/bin/zsh", + "/opt/homebrew/bin/fish", + r"C:\Program Files\PowerShell\7\pwsh.exe", + r"C:\Program Files\Git\bin\bash.exe", + "CMD.EXE", + ] { + assert!(runs_or_list(yes), "{yes}"); + } + for no in [ + "powershell.exe", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "nu", + "/usr/local/bin/elvish", + "wsl.exe", + "", + ] { + assert!(!runs_or_list(no), "{no}"); + } + } + #[test] fn a_plain_path_is_left_alone() { assert_eq!( diff --git a/src/ui/app.rs b/src/ui/app.rs index b4210b6d..b0c4b7b2 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3891,19 +3891,21 @@ impl Tty7App { return; } let was_focused = pending.read(cx).focus_handle.contains_focused(window, cx); - let resume = (!parts.restored) + let restored = parts.restored; + let view = build_terminal_view(parts, font_size, window, cx); + let resume = (!restored) .then(|| { let spawn = &pending.read(cx).spawn; agent_resume_command( &spawn.agent, spawn.agent_session_id.as_deref(), spawn.agent_launch_argv.as_deref(), + view.read(cx), cx, ) .or_else(|| spawn.run_on_land.clone()) }) .flatten(); - let view = build_terminal_view(parts, font_size, window, cx); if let Some(cmd) = resume { view.read(cx).run_command_line(&cmd); } @@ -9811,6 +9813,7 @@ fn agent_resume_command( agent: &Option, session_id: Option<&str>, launch_argv: Option<&[String]>, + view: &TerminalView, cx: &App, ) -> Option { if !cx.global::().restore_agent_sessions { @@ -9824,7 +9827,28 @@ fn agent_resume_command( ); return None; }; - agent.resume_command(session_id, launch_argv) + agent.restore_command( + session_id, + launch_argv, + pane_shell_program(view, cx).as_deref(), + ) +} + +/// The shell `view` is running, for deciding what a line typed into it may +/// use. A pane spawned without an explicit shell got the configured one or, +/// failing that, the login shell — but only a local pane got this machine's; +/// a workspace pane's default lives on its host, so it stays unknown. +fn pane_shell_program(view: &TerminalView, cx: &App) -> Option { + if let Some(spec) = view.shell_spec() { + return Some(spec.program); + } + if view.workspace().is_some() || view.ssh_spec().is_some() || view.remote_context().is_some() { + return None; + } + Some(match &cx.global::().shell { + Some(shell) if !shell.program.trim().is_empty() => shell.program.clone(), + _ => crate::core::shells::login_shell(), + }) } fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { @@ -10197,6 +10221,7 @@ fn session_to_pane( agent, agent_session_id.as_deref(), agent_launch_argv.as_deref(), + terminal.read(cx), cx, ) { terminal.read(cx).run_command_line(&cmd);