Skip to main content

winx_code_agent/state/
agent_resume.rs

1//! Durable launch evidence for an interactive agent whose PTY was lost.
2//!
3//! Explicit selectors identify the target requested at launch, not a backend-confirmed
4//! conversation or the conversation currently selected after an in-TUI switch.
5
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8
9use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11
12use super::persistence::get_state_dir;
13
14const RECORD_BYTES_CAP: usize = 16 * 1024;
15const COMMAND_BYTES_CAP: usize = 8 * 1024;
16const PATH_BYTES_CAP: usize = 4096;
17
18/// What Winx requested in its last accepted foreground agent launch.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct AgentSessionRecord {
21    pub agent: String,
22    pub command: String,
23    pub cwd: String,
24    pub launched_at_unix_ms: u64,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub workspace_root: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub scoped_thread_id: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub execution_token: Option<crate::runtime::ShellExecutionToken>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub conversation_selector: Option<ConversationSelector>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct ConversationSelector {
38    pub id: String,
39    pub kind: SelectorKind,
40    pub source: SelectorSource,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum SelectorKind {
46    NewSession,
47    ResumeTarget,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum SelectorSource {
53    LaunchArgument,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum RecoveryClassification {
59    ExplicitTarget,
60    BestEffort,
61    Unavailable,
62}
63
64impl RecoveryClassification {
65    pub const fn as_str(self) -> &'static str {
66        match self {
67            Self::ExplicitTarget => "explicit_target",
68            Self::BestEffort => "best_effort",
69            Self::Unavailable => "unavailable",
70        }
71    }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
75pub struct RecoveryAdvice {
76    pub classification: RecoveryClassification,
77    pub source: Option<SelectorSource>,
78    pub current_conversation_verified: bool,
79    pub resume_argv: Option<Vec<String>>,
80}
81
82impl AgentSessionRecord {
83    pub fn from_launch(
84        agent: &str,
85        command: &str,
86        cwd: &Path,
87        workspace_root: &Path,
88        scoped_thread_id: &str,
89        execution_token: Option<crate::runtime::ShellExecutionToken>,
90    ) -> Self {
91        Self {
92            agent: agent.to_string(),
93            command: command.to_string(),
94            cwd: cwd.to_string_lossy().into_owned(),
95            launched_at_unix_ms: now_unix_ms(),
96            workspace_root: Some(workspace_root.to_string_lossy().into_owned()),
97            scoped_thread_id: Some(scoped_thread_id.to_string()),
98            execution_token,
99            conversation_selector: selector_from_command(command)
100                .filter(|(backend, _)| *backend == agent)
101                .map(|(_, selector)| selector),
102        }
103    }
104
105    /// A literal recovery target only; an empty string means no executable advice.
106    pub fn resume_command(&self) -> String {
107        self.recovery_advice().resume_argv.map_or_else(String::new, |argv| argv.join(" "))
108    }
109
110    pub fn recovery_advice(&self) -> RecoveryAdvice {
111        let unavailable = || RecoveryAdvice {
112            classification: RecoveryClassification::Unavailable,
113            source: None,
114            current_conversation_verified: false,
115            resume_argv: None,
116        };
117        if validate_record(self).is_err() {
118            return unavailable();
119        }
120        let Some(selector) = self.conversation_selector.as_ref() else {
121            return RecoveryAdvice {
122                classification: if matches!(self.agent.as_str(), "claude" | "codex")
123                    && simple_launch_text(&self.command)
124                    && self.command.trim() == self.agent
125                {
126                    RecoveryClassification::BestEffort
127                } else {
128                    RecoveryClassification::Unavailable
129                },
130                ..unavailable()
131            };
132        };
133        if self.workspace_root.as_deref().is_none_or(str::is_empty)
134            || self.scoped_thread_id.as_deref().is_none_or(str::is_empty)
135            || selector_from_command(&self.command)
136                .is_none_or(|(agent, parsed)| agent != self.agent || &parsed != selector)
137        {
138            return unavailable();
139        }
140        let resume_argv = match self.agent.as_str() {
141            "claude" => vec!["claude".into(), "--resume".into(), selector.id.clone()],
142            "codex" => vec!["codex".into(), "resume".into(), selector.id.clone()],
143            _ => return unavailable(),
144        };
145        RecoveryAdvice {
146            classification: RecoveryClassification::ExplicitTarget,
147            source: Some(SelectorSource::LaunchArgument),
148            current_conversation_verified: false,
149            resume_argv: Some(resume_argv),
150        }
151    }
152
153    pub fn recovery_advice_for(&self, thread_id: &str, workspace_root: &Path) -> RecoveryAdvice {
154        let mut advice = self.recovery_advice();
155        if self.scoped_thread_id.as_deref().is_some_and(|recorded| recorded != thread_id)
156            || self
157                .workspace_root
158                .as_deref()
159                .is_some_and(|recorded| Path::new(recorded) != workspace_root)
160        {
161            advice.classification = RecoveryClassification::Unavailable;
162            advice.source = None;
163            advice.resume_argv = None;
164        }
165        advice
166    }
167}
168
169fn simple_launch_text(command: &str) -> bool {
170    !command.is_empty()
171        && command.bytes().all(|byte| {
172            byte.is_ascii_alphanumeric()
173                || matches!(byte, b' ' | b'_' | b'-' | b'/' | b'.' | b'=' | b':')
174        })
175}
176
177fn selector_from_command(command: &str) -> Option<(&'static str, ConversationSelector)> {
178    if command.len() > COMMAND_BYTES_CAP || !simple_launch_text(command) {
179        return None;
180    }
181    let mut tokens = command.split_ascii_whitespace();
182    let (agent, option, id) = (tokens.next()?, tokens.next()?, tokens.next()?);
183    if tokens.next().is_some() || !valid_uuid(id) {
184        return None;
185    }
186    let (agent, kind) = match (agent, option) {
187        ("claude", "--session-id") => ("claude", SelectorKind::NewSession),
188        ("claude", "--resume" | "-r") => ("claude", SelectorKind::ResumeTarget),
189        ("codex", "resume") => ("codex", SelectorKind::ResumeTarget),
190        _ => return None,
191    };
192    Some((
193        agent,
194        ConversationSelector { id: id.to_string(), kind, source: SelectorSource::LaunchArgument },
195    ))
196}
197
198fn valid_uuid(value: &str) -> bool {
199    value.len() == 36
200        && value.bytes().enumerate().all(|(index, byte)| {
201            if matches!(index, 8 | 13 | 18 | 23) {
202                byte == b'-'
203            } else {
204                byte.is_ascii_hexdigit()
205            }
206        })
207}
208
209fn validate_record(record: &AgentSessionRecord) -> Result<()> {
210    if record.agent.is_empty()
211        || record.agent.len() > 64
212        || !record
213            .agent
214            .bytes()
215            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
216        || record.command.len() > COMMAND_BYTES_CAP
217        || record.cwd.is_empty()
218        || record.cwd.len() > PATH_BYTES_CAP
219        || record.cwd.chars().any(char::is_control)
220        || record.workspace_root.as_ref().is_some_and(|root| {
221            root.is_empty() || root.len() > PATH_BYTES_CAP || root.chars().any(char::is_control)
222        })
223        || record.scoped_thread_id.as_ref().is_some_and(|id| {
224            id.is_empty()
225                || id.len() > PATH_BYTES_CAP
226                || crate::types::normalize_thread_id(id) != *id
227        })
228        || record.conversation_selector.as_ref().is_some_and(|selector| !valid_uuid(&selector.id))
229        || record.execution_token.as_ref().is_some_and(|token| {
230            token.guardian_epoch.len() > 256 || token.session_epoch.len() > 256
231        })
232    {
233        bail!("invalid or oversized agent-session record");
234    }
235    Ok(())
236}
237
238/// Canonical agent id when `command` launches a known interactive coding
239/// agent in the foreground, `None` otherwise.
240///
241/// Heuristic on the command string winx itself executes (there is no process
242/// tree to inspect at recovery time): take the last `&&`/`;` segment (so
243/// `cd x && claude` counts), refuse pipelines (a piped agent is not an
244/// interactive TUI), skip leading `VAR=value` assignments, and match the
245/// basename of the first real token against the known launcher set.
246pub fn detect_agent_launch(command: &str) -> Option<&'static str> {
247    let segment = command.split("&&").last()?.split(';').next_back()?.trim();
248    if segment.contains('|') {
249        return None;
250    }
251    let token = segment.split_whitespace().find(|token| !is_env_assignment(token))?;
252    agent_for_basename(basename(token))
253}
254
255fn is_env_assignment(token: &str) -> bool {
256    token.split_once('=').is_some_and(|(name, _)| {
257        !name.is_empty()
258            && name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
259            && !name.starts_with(|ch: char| ch.is_ascii_digit())
260    })
261}
262
263fn basename(token: &str) -> &str {
264    token.rsplit('/').next().unwrap_or(token)
265}
266
267/// Launcher basenames for the interactive agents winx knows how to recognize
268/// (the same set the turn manifests cover), normalized to manifest ids.
269fn agent_for_basename(name: &str) -> Option<&'static str> {
270    match name {
271        "claude" | "claude-code" => Some("claude"),
272        "codex" => Some("codex"),
273        "gemini" => Some("gemini"),
274        "agy" | "antigravity" => Some("agy"),
275        "cursor-agent" | "cursor" => Some("cursor"),
276        "opencode" | "opencode2" => Some("opencode"),
277        "copilot" | "ghcs" => Some("copilot"),
278        "kimi" => Some("kimi"),
279        "kiro" | "kiro-cli" => Some("kiro"),
280        "droid" => Some("droid"),
281        "amp" => Some("amp"),
282        "grok" => Some("grok"),
283        "hermes" => Some("hermes"),
284        "kilo" => Some("kilo"),
285        "qodercli" => Some("qodercli"),
286        "qwen" => Some("qwen"),
287        "pi" => Some("pi"),
288        "devin" | "devin-cli" => Some("devin"),
289        "cline" => Some("cline"),
290        "maki" => Some("maki"),
291        "muse" | "muse-code" | "muse-cli" => Some("muse"),
292        _ => None,
293    }
294}
295
296fn record_path(thread_id: &str) -> Result<PathBuf> {
297    if thread_id.is_empty()
298        || thread_id.len() > PATH_BYTES_CAP
299        || crate::types::normalize_thread_id(thread_id) != thread_id
300    {
301        bail!("invalid scoped agent-session thread");
302    }
303    Ok(get_state_dir()?.join(format!("{thread_id}_agent_session.json")))
304}
305
306pub fn save_agent_session(thread_id: &str, record: &AgentSessionRecord) -> Result<()> {
307    save_agent_session_to_path(&record_path(thread_id)?, record)
308}
309
310pub fn save_agent_session_to_path(path: &Path, record: &AgentSessionRecord) -> Result<()> {
311    validate_record(record)?;
312    let json = serde_json::to_vec(record).context("failed to serialize agent session")?;
313    if json.len() > RECORD_BYTES_CAP {
314        bail!("agent-session record exceeds its byte limit");
315    }
316    match std::fs::symlink_metadata(path) {
317        Ok(metadata) if !metadata.is_file() || metadata.file_type().is_symlink() => {
318            bail!("agent-session path must be a regular file");
319        }
320        Ok(_) => {}
321        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
322        Err(error) => return Err(error.into()),
323    }
324    let parent = path
325        .parent()
326        .filter(|parent| !parent.as_os_str().is_empty())
327        .unwrap_or_else(|| Path::new("."));
328    let mut temporary = tempfile::Builder::new()
329        .prefix(".winx-agent-")
330        .tempfile_in(parent)
331        .context("failed to create private agent-session file")?;
332    temporary.write_all(&json)?;
333    temporary.flush()?;
334    temporary.persist(path).map_err(|error| error.error)?;
335    Ok(())
336}
337
338pub fn load_agent_session(thread_id: &str) -> Result<Option<AgentSessionRecord>> {
339    load_agent_session_from_path(&record_path(thread_id)?)
340}
341
342pub fn load_agent_session_from_path(path: &Path) -> Result<Option<AgentSessionRecord>> {
343    let file = match crate::os::open_read_nofollow(path) {
344        Ok(file) => file,
345        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
346        Err(error) => return Err(error).context("failed to open agent-session record"),
347    };
348    if file.metadata()?.len() > RECORD_BYTES_CAP as u64 {
349        bail!("agent-session record exceeds its byte limit");
350    }
351    let mut bytes = Vec::new();
352    file.take(RECORD_BYTES_CAP as u64 + 1).read_to_end(&mut bytes)?;
353    if bytes.len() > RECORD_BYTES_CAP {
354        bail!("agent-session record exceeds its byte limit");
355    }
356    let record: Option<AgentSessionRecord> = serde_json::from_slice(&bytes).ok();
357    Ok(record.filter(|record| validate_record(record).is_ok()))
358}
359
360pub fn clear_agent_session(thread_id: &str) -> Result<()> {
361    clear_agent_session_at_path(&record_path(thread_id)?)
362}
363
364pub fn clear_agent_session_at_path(path: &Path) -> Result<()> {
365    match std::fs::remove_file(path) {
366        Ok(()) => Ok(()),
367        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
368        Err(error) => {
369            Err(error).with_context(|| format!("failed to clear agent session {}", path.display()))
370        }
371    }
372}
373
374pub fn now_unix_ms() -> u64 {
375    std::time::SystemTime::now()
376        .duration_since(std::time::UNIX_EPOCH)
377        .map_or(0, |duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
378}
379
380#[cfg(test)]
381#[allow(clippy::expect_used)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn detects_direct_and_wrapped_agent_launches() {
387        assert_eq!(detect_agent_launch("claude"), Some("claude"));
388        assert_eq!(detect_agent_launch("claude --dangerously-skip-permissions"), Some("claude"));
389        assert_eq!(detect_agent_launch("cd ~/repo && claude"), Some("claude"));
390        assert_eq!(detect_agent_launch("FOO=bar codex --model gpt-5"), Some("codex"));
391        assert_eq!(detect_agent_launch("/usr/local/bin/gemini"), Some("gemini"));
392        assert_eq!(detect_agent_launch("antigravity"), Some("agy"));
393        assert_eq!(detect_agent_launch("cursor-agent"), Some("cursor"));
394    }
395
396    #[test]
397    fn refuses_non_agents_pipelines_and_backgrounded_noise() {
398        assert_eq!(detect_agent_launch("ls -la"), None);
399        assert_eq!(detect_agent_launch("claude | tee log"), None);
400        assert_eq!(detect_agent_launch("echo claude"), None);
401        assert_eq!(detect_agent_launch("claudette"), None);
402        assert_eq!(detect_agent_launch("FOO=claude env"), None);
403        assert_eq!(detect_agent_launch(""), None);
404    }
405
406    #[test]
407    fn compound_commands_use_the_last_segment() {
408        // The last segment is what owns the PTY foreground.
409        assert_eq!(detect_agent_launch("claude; ls"), None);
410        assert_eq!(detect_agent_launch("git pull && codex"), Some("codex"));
411    }
412
413    #[test]
414    fn unbound_records_have_no_executable_recovery_advice() {
415        let record = |agent: &str, command: &str| AgentSessionRecord {
416            agent: agent.to_string(),
417            command: command.to_string(),
418            cwd: "/repo".to_string(),
419            launched_at_unix_ms: 0,
420            workspace_root: None,
421            scoped_thread_id: None,
422            execution_token: None,
423            conversation_selector: None,
424        };
425        assert_eq!(record("claude", "claude --verbose").resume_command(), "");
426        assert_eq!(record("codex", "codex").resume_command(), "");
427        assert_eq!(record("grok", "grok --fast").resume_command(), "");
428    }
429
430    #[test]
431    fn record_roundtrip_and_clear() {
432        let dir = tempfile::tempdir().expect("temp dir");
433        let path = dir.path().join("t1_agent_session.json");
434        let record = AgentSessionRecord {
435            agent: "claude".to_string(),
436            command: "claude".to_string(),
437            cwd: "/repo".to_string(),
438            launched_at_unix_ms: 42,
439            workspace_root: None,
440            scoped_thread_id: None,
441            execution_token: None,
442            conversation_selector: None,
443        };
444        save_agent_session_to_path(&path, &record).expect("save");
445        assert_eq!(load_agent_session_from_path(&path).expect("load"), Some(record));
446        clear_agent_session_at_path(&path).expect("clear");
447        assert_eq!(load_agent_session_from_path(&path).expect("load"), None);
448        // Clearing an absent record is idempotent.
449        clear_agent_session_at_path(&path).expect("clear absent");
450    }
451
452    #[test]
453    fn corrupt_record_reads_as_absent() {
454        let dir = tempfile::tempdir().expect("temp dir");
455        let path = dir.path().join("t2_agent_session.json");
456        std::fs::write(&path, "not json").expect("write");
457        assert_eq!(load_agent_session_from_path(&path).expect("load"), None);
458    }
459
460    fn selector_record(agent: &str, command: &str, id: &str, kind: &str) -> AgentSessionRecord {
461        serde_json::from_value(serde_json::json!({
462            "agent": agent, "command": command, "cwd": "/repo", "launched_at_unix_ms": 42,
463            "workspace_root": "/repo", "scoped_thread_id": "selector_scope",
464            "conversation_selector": {"id": id, "kind": kind, "source": "launch_argument"}
465        }))
466        .expect("selector record")
467    }
468
469    #[test]
470    fn selector_recovery_targets_the_recorded_uuid_and_never_latest() {
471        let id = "550e8400-e29b-41d4-a716-446655440000";
472        for (agent, command, kind, expected) in [
473            (
474                "claude",
475                format!("claude --session-id {id}"),
476                "new_session",
477                format!("claude --resume {id}"),
478            ),
479            (
480                "claude",
481                format!("claude --resume {id}"),
482                "resume_target",
483                format!("claude --resume {id}"),
484            ),
485            ("claude", format!("claude -r {id}"), "resume_target", format!("claude --resume {id}")),
486            ("codex", format!("codex resume {id}"), "resume_target", format!("codex resume {id}")),
487        ] {
488            let record = selector_record(agent, &command, id, kind);
489            assert_eq!(record.resume_command(), expected, "{command}");
490            let encoded = serde_json::to_value(&record).expect("serialize record");
491            assert_eq!(encoded["conversation_selector"]["source"], "launch_argument");
492            assert_eq!(encoded["conversation_selector"]["id"], id);
493        }
494    }
495
496    #[test]
497    fn selector_recovery_legacy_and_unsupported_records_never_replay_commands() {
498        for (agent, command) in [
499            ("claude", "claude"),
500            ("codex", "codex"),
501            ("grok", "grok 'repeat the expensive initial task'"),
502        ] {
503            let record: AgentSessionRecord = serde_json::from_value(serde_json::json!({
504                "agent":agent,"command":command,"cwd":"/repo","launched_at_unix_ms":1
505            }))
506            .expect("legacy record");
507            assert!(
508                record.resume_command().is_empty(),
509                "unsafe recovery: {}",
510                record.resume_command()
511            );
512        }
513    }
514
515    #[test]
516    fn selector_recovery_ambiguous_or_tampered_records_have_no_executable_advice() {
517        let id = "550e8400-e29b-41d4-a716-446655440000";
518        let other = "550e8400-e29b-41d4-a716-446655440001";
519        for command in [
520            format!("claude --session-id {id}; echo unrelated"),
521            format!("claude --session-id {id} && echo unrelated"),
522            format!("claude --session-id {id} | cat"),
523            format!("claude --session-id {id} > output"),
524            format!("claude --session-id {id} &"),
525            format!("claude --resume {id} --fork-session"),
526            format!("claude --session-id {id} --resume {id}"),
527            format!("claude --session-id {id} --session-id {other}"),
528            format!("HOME=/other claude --session-id {id}"),
529            format!("cd /other && claude --session-id {id}"),
530            format!("echo $(claude --session-id {id})"),
531            format!("claude --session-id {other}"),
532            "claude --continue".to_string(),
533        ] {
534            let record = selector_record("claude", &command, id, "new_session");
535            assert!(record.resume_command().is_empty(), "ambiguous command: {command}");
536        }
537        for invalid_id in
538            ["--last", "name", "/tmp/session", "550e8400-e29b-41d4-a716-44665544000z", ""]
539        {
540            let record = selector_record(
541                "claude",
542                &format!("claude --session-id {invalid_id}"),
543                invalid_id,
544                "new_session",
545            );
546            assert!(record.resume_command().is_empty(), "invalid selector: {invalid_id}");
547        }
548    }
549
550    #[test]
551    fn selector_recovery_survives_restart_without_choosing_a_newer_record() {
552        let dir = tempfile::tempdir().expect("temp dir");
553        let first = "550e8400-e29b-41d4-a716-446655440000";
554        let newer = "550e8400-e29b-41d4-a716-446655440001";
555        for (name, id) in [("a", first), ("b", newer)] {
556            save_agent_session_to_path(
557                &dir.path().join(name),
558                &selector_record("claude", &format!("claude --session-id {id}"), id, "new_session"),
559            )
560            .expect("persist selector");
561        }
562        let record =
563            load_agent_session_from_path(&dir.path().join("a")).expect("reload").expect("record");
564        assert_eq!(record.resume_command(), format!("claude --resume {first}"));
565    }
566
567    #[test]
568    fn selector_recovery_oversized_state_does_not_replace_a_valid_record() {
569        let dir = tempfile::tempdir().expect("temp dir");
570        let path = dir.path().join("record");
571        let id = "550e8400-e29b-41d4-a716-446655440000";
572        let valid =
573            selector_record("claude", &format!("claude --session-id {id}"), id, "new_session");
574        save_agent_session_to_path(&path, &valid).expect("initial save");
575        let prior = std::fs::read(&path).expect("prior bytes");
576        let mut oversized = valid;
577        oversized.command = "x".repeat(17 * 1024);
578        assert!(
579            save_agent_session_to_path(&path, &oversized).is_err(),
580            "oversized record accepted"
581        );
582        assert_eq!(std::fs::read(&path).expect("retained bytes"), prior);
583        std::fs::write(&path, vec![b' '; 17 * 1024]).expect("oversized input");
584        assert!(load_agent_session_from_path(&path).ok().flatten().is_none());
585    }
586
587    #[cfg(unix)]
588    #[test]
589    fn selector_recovery_symlink_state_is_never_followed_for_read_or_write() {
590        let dir = tempfile::tempdir().expect("temp dir");
591        let target = dir.path().join("target");
592        let link = dir.path().join("record");
593        let id = "550e8400-e29b-41d4-a716-446655440000";
594        let record =
595            selector_record("claude", &format!("claude --session-id {id}"), id, "new_session");
596        save_agent_session_to_path(&target, &record).expect("target");
597        let prior = std::fs::read(&target).expect("prior target");
598        std::os::unix::fs::symlink(&target, &link).expect("symlink");
599        assert!(load_agent_session_from_path(&link).ok().flatten().is_none(), "followed symlink");
600        assert!(save_agent_session_to_path(&link, &record).is_err(), "wrote through symlink");
601        assert_eq!(std::fs::read(&target).expect("target unchanged"), prior);
602    }
603    #[test]
604    fn selector_recovery_factory_marks_unsupported_and_ambiguous_launches_unavailable() {
605        let id = "550e8400-e29b-41d4-a716-446655440000";
606        for (agent, command) in [
607            ("claude", format!("claude --session-id {id} --resume {id}")),
608            ("claude", format!("claude --resume {id} --fork-session")),
609            ("claude", format!("claude --session-id {id} --verbose")),
610            ("claude", "claude --session-id not-a-uuid".to_string()),
611            ("claude", "claude --resume".to_string()),
612            ("claude", "claude --continue".to_string()),
613            ("claude", "claude --model sonnet".to_string()),
614            ("claude", format!("HOME=/other claude --session-id {id}")),
615            ("claude", format!("cd /other && claude --session-id {id}")),
616            ("claude", format!("claude --session-id {id} | cat")),
617            ("codex", "codex resume --last".to_string()),
618            ("codex", format!("codex resume {id} --all")),
619        ] {
620            let record = AgentSessionRecord::from_launch(
621                agent,
622                &command,
623                Path::new("/repo"),
624                Path::new("/repo"),
625                "selector_scope",
626                None,
627            );
628            let advice = record.recovery_advice();
629            assert_eq!(advice.classification, RecoveryClassification::Unavailable, "{command}");
630            assert!(advice.resume_argv.is_none(), "{command}");
631            assert!(!advice.current_conversation_verified);
632        }
633        for agent in ["claude", "codex"] {
634            let record = AgentSessionRecord::from_launch(
635                agent,
636                agent,
637                Path::new("/repo"),
638                Path::new("/repo"),
639                "selector_scope",
640                None,
641            );
642            assert_eq!(record.recovery_advice().classification, RecoveryClassification::BestEffort);
643        }
644    }
645}