mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 08:02:02 +00:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
l0ng-ai
parent
f7e4101992
commit
802f9e0792
@@ -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<String> {
|
||||
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<String> {
|
||||
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<AgentEvent> {
|
||||
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<String> {
|
||||
parts.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
+28
-3
@@ -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<crate::core::cli_agent::CLIAgent>,
|
||||
session_id: Option<&str>,
|
||||
launch_argv: Option<&[String]>,
|
||||
view: &TerminalView,
|
||||
cx: &App,
|
||||
) -> Option<String> {
|
||||
if !cx.global::<Config>().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<String> {
|
||||
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::<Config>().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);
|
||||
|
||||
Reference in New Issue
Block a user