1use 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#[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 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
238pub 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
267fn 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 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 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}