From 5cee042e590ed44d0a319570c784957eff5c197b Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 06:15:14 +0000 Subject: [PATCH] feat: jsonl-session progress --- rust/crates/commands/src/lib.rs | 23 ++- rust/crates/runtime/src/conversation.rs | 34 ++++ rust/crates/runtime/src/lib.rs | 1 + rust/crates/runtime/src/session.rs | 111 ++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 237 +++++++++++++++++++---- 5 files changed, 365 insertions(+), 41 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index b1ae4b0..6a3d234 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -125,8 +125,8 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ }, SlashCommandSpec { name: "session", - summary: "List or switch managed local sessions", - argument_hint: Some("[list|switch ]"), + summary: "List, switch, or fork managed local sessions", + argument_hint: Some("[list|switch |fork [branch-name]]"), resume_supported: false, }, ]; @@ -229,7 +229,7 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> { pub fn render_slash_command_help() -> String { let mut lines = vec![ "Slash commands".to_string(), - " [resume] means the command also works with --resume SESSION.json".to_string(), + " [resume] means the command also works with --resume SESSION.jsonl".to_string(), ]; for spec in slash_command_specs() { let name = match spec.argument_hint { @@ -365,12 +365,19 @@ mod tests { target: Some("abc123".to_string()) }) ); + assert_eq!( + SlashCommand::parse("/session fork incident-review"), + Some(SlashCommand::Session { + action: Some("fork".to_string()), + target: Some("incident-review".to_string()) + }) + ); } #[test] fn renders_help_from_shared_specs() { let help = render_slash_command_help(); - assert!(help.contains("works with --resume SESSION.json")); + assert!(help.contains("works with --resume SESSION.jsonl")); assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/compact")); @@ -385,7 +392,7 @@ mod tests { assert!(help.contains("/diff")); assert!(help.contains("/version")); assert!(help.contains("/export [file]")); - assert!(help.contains("/session [list|switch ]")); + assert!(help.contains("/session [list|switch |fork [branch-name]]")); assert_eq!(slash_command_specs().len(), 15); assert_eq!(resume_supported_slash_commands().len(), 11); } @@ -453,6 +460,12 @@ mod tests { CompactionConfig::default() ) .is_none()); + assert!(handle_slash_command( + "/resume session.jsonl", + &session, + CompactionConfig::default() + ) + .is_none()); assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none()); assert!( handle_slash_command("/config env", &session, CompactionConfig::default()).is_none() diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 94cf9ba..789a23d 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -286,6 +286,11 @@ where &self.session } + #[must_use] + pub fn fork_session(&self, branch_name: Option) -> Session { + self.session.fork(branch_name) + } + #[must_use] pub fn into_session(self) -> Session { self.session @@ -838,6 +843,35 @@ mod tests { assert_eq!(restored.session_id, runtime.session().session_id); } + #[test] + fn forks_runtime_session_without_mutating_original() { + let mut session = Session::new(); + session + .push_user_text("branch me") + .expect("message should append"); + + let runtime = ConversationRuntime::new( + session.clone(), + ScriptedApiClient { call_count: 0 }, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ); + + let forked = runtime.fork_session(Some("alt-path".to_string())); + + assert_eq!(forked.messages, session.messages); + assert_ne!(forked.session_id, session.session_id); + assert_eq!( + forked + .fork + .as_ref() + .map(|fork| (fork.parent_session_id.as_str(), fork.branch_name.as_deref())), + Some((session.session_id.as_str(), Some("alt-path"))) + ); + assert!(runtime.session().fork.is_none()); + } + fn temp_session_path(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 6e1a50a..28e7e41 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -78,6 +78,7 @@ pub use remote::{ }; pub use session::{ ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError, + SessionFork, }; pub use usage::{ format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index 46f0fc0..f102b3b 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -54,6 +54,12 @@ pub struct SessionCompaction { pub summary: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionFork { + pub parent_session_id: String, + pub branch_name: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionPersistence { path: PathBuf, @@ -67,6 +73,7 @@ pub struct Session { pub updated_at_ms: u64, pub messages: Vec, pub compaction: Option, + pub fork: Option, persistence: Option, } @@ -78,6 +85,7 @@ impl PartialEq for Session { && self.updated_at_ms == other.updated_at_ms && self.messages == other.messages && self.compaction == other.compaction + && self.fork == other.fork } } @@ -125,6 +133,7 @@ impl Session { updated_at_ms: now, messages: Vec::new(), compaction: None, + fork: None, persistence: None, } } @@ -178,6 +187,24 @@ impl Session { }); } + #[must_use] + pub fn fork(&self, branch_name: Option) -> Self { + let now = current_time_millis(); + Self { + version: self.version, + session_id: generate_session_id(), + created_at_ms: now, + updated_at_ms: now, + messages: self.messages.clone(), + compaction: self.compaction.clone(), + fork: Some(SessionFork { + parent_session_id: self.session_id.clone(), + branch_name: normalize_optional_string(branch_name), + }), + persistence: None, + } + } + #[must_use] pub fn to_json(&self) -> JsonValue { let mut object = BTreeMap::new(); @@ -209,6 +236,9 @@ impl Session { if let Some(compaction) = &self.compaction { object.insert("compaction".to_string(), compaction.to_json()); } + if let Some(fork) = &self.fork { + object.insert("fork".to_string(), fork.to_json()); + } JsonValue::Object(object) } @@ -249,6 +279,7 @@ impl Session { .get("compaction") .map(SessionCompaction::from_json) .transpose()?; + let fork = object.get("fork").map(SessionFork::from_json).transpose()?; Ok(Self { version, session_id, @@ -256,6 +287,7 @@ impl Session { updated_at_ms, messages, compaction, + fork, persistence: None, }) } @@ -267,6 +299,7 @@ impl Session { let mut updated_at_ms = None; let mut messages = Vec::new(); let mut compaction = None; + let mut fork = None; for (line_number, raw_line) in contents.lines().enumerate() { let line = raw_line.trim(); @@ -300,6 +333,7 @@ impl Session { session_id = Some(required_string(object, "session_id")?); created_at_ms = Some(required_u64(object, "created_at_ms")?); updated_at_ms = Some(required_u64(object, "updated_at_ms")?); + fork = object.get("fork").map(SessionFork::from_json).transpose()?; } "message" => { let message_value = object.get("message").ok_or_else(|| { @@ -332,6 +366,7 @@ impl Session { updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)), messages, compaction, + fork, persistence: None, }) } @@ -389,6 +424,9 @@ impl Session { "updated_at_ms".to_string(), JsonValue::Number(i64_from_u64(self.updated_at_ms, "updated_at_ms")), ); + if let Some(fork) = &self.fork { + object.insert("fork".to_string(), fork.to_json()); + } JsonValue::Object(object) } @@ -634,6 +672,37 @@ impl SessionCompaction { } } +impl SessionFork { + #[must_use] + pub fn to_json(&self) -> JsonValue { + let mut object = BTreeMap::new(); + object.insert( + "parent_session_id".to_string(), + JsonValue::String(self.parent_session_id.clone()), + ); + if let Some(branch_name) = &self.branch_name { + object.insert( + "branch_name".to_string(), + JsonValue::String(branch_name.clone()), + ); + } + JsonValue::Object(object) + } + + fn from_json(value: &JsonValue) -> Result { + let object = value + .as_object() + .ok_or_else(|| SessionError::Format("fork metadata must be an object".to_string()))?; + Ok(Self { + parent_session_id: required_string(object, "parent_session_id")?, + branch_name: object + .get("branch_name") + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned), + }) + } +} + fn message_record(message: &ConversationMessage) -> JsonValue { let mut object = BTreeMap::new(); object.insert("type".to_string(), JsonValue::String("message".to_string())); @@ -723,6 +792,17 @@ fn i64_from_usize(value: usize, key: &str) -> i64 { i64::try_from(value).unwrap_or_else(|_| panic!("{key} out of range for JSON number")) } +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + fn current_time_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -815,7 +895,7 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> { mod tests { use super::{ cleanup_rotated_logs, rotate_session_file_if_needed, ContentBlock, ConversationMessage, - MessageRole, Session, + MessageRole, Session, SessionFork, }; use crate::json::JsonValue; use crate::usage::TokenUsage; @@ -938,6 +1018,35 @@ mod tests { assert!(compaction.summary.contains("summarized")); } + #[test] + fn forks_sessions_with_branch_metadata_and_persists_it() { + let path = temp_session_path("fork"); + let mut session = Session::new(); + session + .push_user_text("before fork") + .expect("message should append"); + + let forked = session + .fork(Some("investigation".to_string())) + .with_persistence_path(path.clone()); + forked + .save_to_path(&path) + .expect("forked session should save"); + + let restored = Session::load_from_path(&path).expect("forked session should load"); + fs::remove_file(&path).expect("temp file should be removable"); + + assert_ne!(restored.session_id, session.session_id); + assert_eq!( + restored.fork, + Some(SessionFork { + parent_session_id: session.session_id, + branch_name: Some("investigation".to_string()), + }) + ); + assert_eq!(restored.messages, forked.messages); + } + #[test] fn rotates_and_cleans_up_large_session_logs() { let path = temp_session_path("rotation"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index d60e33a..6012028 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -47,6 +47,8 @@ const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const VERSION: &str = env!("CARGO_PKG_VERSION"); const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); +const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; +const LEGACY_SESSION_EXTENSION: &str = "json"; type AllowedToolSet = BTreeSet; @@ -589,7 +591,19 @@ fn print_version() { } fn resume_session(session_path: &Path, commands: &[String]) { - let session = match Session::load_from_path(session_path) { + let resolved_path = if session_path.exists() { + session_path.to_path_buf() + } else { + match resolve_session_reference(&session_path.display().to_string()) { + Ok(handle) => handle.path, + Err(error) => { + eprintln!("failed to restore session: {error}"); + std::process::exit(1); + } + } + }; + + let session = match Session::load_from_path(&resolved_path) { Ok(session) => session, Err(error) => { eprintln!("failed to restore session: {error}"); @@ -600,7 +614,7 @@ fn resume_session(session_path: &Path, commands: &[String]) { if commands.is_empty() { println!( "Restored session from {} ({} messages).", - session_path.display(), + resolved_path.display(), session.messages.len() ); return; @@ -612,7 +626,7 @@ fn resume_session(session_path: &Path, commands: &[String]) { eprintln!("unsupported resumed command: {raw_command}"); std::process::exit(2); }; - match run_resume_command(session_path, &session, &command) { + match run_resume_command(&resolved_path, &session, &command) { Ok(ResumeCommandOutcome { session: next_session, message, @@ -973,6 +987,8 @@ struct ManagedSessionSummary { path: PathBuf, modified_epoch_secs: u64, message_count: usize, + parent_session_id: Option, + branch_name: Option, } struct LiveCli { @@ -1433,8 +1449,41 @@ impl LiveCli { ); Ok(true) } + Some("fork") => { + let forked = self.runtime.fork_session(target.map(ToOwned::to_owned)); + let parent_session_id = self.session.id.clone(); + let handle = create_managed_session_handle(&forked.session_id)?; + let branch_name = forked + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()); + let forked = forked.with_persistence_path(handle.path.clone()); + let message_count = forked.messages.len(); + forked.save_to_path(&handle.path)?; + self.runtime = build_runtime( + forked, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + )?; + self.session = handle; + println!( + "Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}", + parent_session_id, + self.session.id, + branch_name.as_deref().unwrap_or("(unnamed)"), + self.session.path.display(), + message_count, + ); + Ok(true) + } Some(other) => { - println!("Unknown /session action '{other}'. Use /session list or /session switch ."); + println!( + "Unknown /session action '{other}'. Use /session list, /session switch , or /session fork [branch-name]." + ); Ok(false) } } @@ -1471,34 +1520,57 @@ fn create_managed_session_handle( session_id: &str, ) -> Result> { let id = session_id.to_string(); - let path = sessions_dir()?.join(format!("{id}.json")); + let path = sessions_dir()?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}")); Ok(SessionHandle { id, path }) } fn resolve_session_reference(reference: &str) -> Result> { let direct = PathBuf::from(reference); + let looks_like_path = direct.extension().is_some() || direct.components().count() > 1; let path = if direct.exists() { direct - } else { - sessions_dir()?.join(format!("{reference}.json")) - }; - if !path.exists() { + } else if looks_like_path { return Err(format!("session not found: {reference}").into()); - } + } else { + resolve_managed_session_path(reference)? + }; let id = path - .file_stem() + .file_name() .and_then(|value| value.to_str()) + .and_then(|name| { + name.strip_suffix(&format!(".{PRIMARY_SESSION_EXTENSION}")) + .or_else(|| name.strip_suffix(&format!(".{LEGACY_SESSION_EXTENSION}"))) + }) .unwrap_or(reference) .to_string(); Ok(SessionHandle { id, path }) } +fn resolve_managed_session_path(session_id: &str) -> Result> { + let directory = sessions_dir()?; + for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] { + let path = directory.join(format!("{session_id}.{extension}")); + if path.exists() { + return Ok(path); + } + } + Err(format!("session not found: {session_id}").into()) +} + +fn is_managed_session_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|extension| { + extension == PRIMARY_SESSION_EXTENSION || extension == LEGACY_SESSION_EXTENSION + }) +} + fn list_managed_sessions() -> Result, Box> { let mut sessions = Vec::new(); for entry in fs::read_dir(sessions_dir()?)? { let entry = entry?; let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + if !is_managed_session_file(&path) { continue; } let metadata = entry.metadata()?; @@ -1508,8 +1580,23 @@ fn list_managed_sessions() -> Result, Box Result, Box Result, Box Result { + format!(" branch={branch_name} from={parent_session_id}") + } + (None, Some(parent_session_id)) => format!(" from={parent_session_id}"), + (Some(branch_name), None) => format!(" branch={branch_name}"), + (None, None) => String::new(), + }; lines.push(format!( - " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}", + " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}", id = session.id, msgs = session.message_count, modified = session.modified_epoch_secs, + lineage = lineage, path = session.path.display(), )); } @@ -2754,7 +2857,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { writeln!(out, " Shorthand non-interactive prompt mode")?; writeln!( out, - " claw --resume SESSION.json [/status] [/compact] [...]" + " claw --resume SESSION.jsonl [/status] [/compact] [...]" )?; writeln!( out, @@ -2814,7 +2917,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!( out, - " claw --resume session.json /status /diff /export notes.txt" + " claw --resume session.jsonl /status /diff /export notes.txt" )?; writeln!(out, " claw login")?; writeln!(out, " claw init")?; @@ -2828,18 +2931,23 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - filter_tool_specs, format_compact_report, format_cost_report, format_model_report, - format_model_switch_report, format_permissions_report, format_permissions_switch_report, - format_resume_report, format_status_report, format_tool_call_start, format_tool_result, + create_managed_session_handle, filter_tool_specs, format_compact_report, + format_cost_report, format_model_report, format_model_switch_report, + format_permissions_report, format_permissions_switch_report, format_resume_report, + format_status_report, format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, parse_git_status_metadata, print_help_to, push_output_block, render_config_report, render_memory_report, render_repl_help, - resolve_model_alias, response_to_events, resume_supported_slash_commands, status_context, - CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + resolve_model_alias, resolve_session_reference, response_to_events, + resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, + StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; - use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; + use runtime::{ + AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session, + }; use serde_json::json; use std::path::PathBuf; + use std::sync::{Mutex, OnceLock}; #[test] fn defaults_to_repl_when_no_args() { @@ -3013,13 +3121,13 @@ mod tests { fn parses_resume_flag_with_slash_command() { let args = vec![ "--resume".to_string(), - "session.json".to_string(), + "session.jsonl".to_string(), "/compact".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::ResumeSession { - session_path: PathBuf::from("session.json"), + session_path: PathBuf::from("session.jsonl"), commands: vec!["/compact".to_string()], } ); @@ -3029,7 +3137,7 @@ mod tests { fn parses_resume_flag_with_multiple_slash_commands() { let args = vec![ "--resume".to_string(), - "session.json".to_string(), + "session.jsonl".to_string(), "/status".to_string(), "/compact".to_string(), "/cost".to_string(), @@ -3037,7 +3145,7 @@ mod tests { assert_eq!( parse_args(&args).expect("args should parse"), CliAction::ResumeSession { - session_path: PathBuf::from("session.json"), + session_path: PathBuf::from("session.jsonl"), commands: vec![ "/status".to_string(), "/compact".to_string(), @@ -3065,7 +3173,7 @@ mod tests { fn shared_help_uses_resume_annotation_copy() { let help = commands::render_slash_command_help(); assert!(help.contains("Slash commands")); - assert!(help.contains("works with --resume SESSION.json")); + assert!(help.contains("works with --resume SESSION.jsonl")); } #[test] @@ -3085,7 +3193,7 @@ mod tests { assert!(help.contains("/diff")); assert!(help.contains("/version")); assert!(help.contains("/export [file]")); - assert!(help.contains("/session [list|switch ]")); + assert!(help.contains("/session [list|switch |fork [branch-name]]")); assert!(help.contains("/exit")); } @@ -3106,9 +3214,9 @@ mod tests { #[test] fn resume_report_uses_sectioned_layout() { - let report = format_resume_report("session.json", 14, 6); + let report = format_resume_report("session.jsonl", 14, 6); assert!(report.contains("Session resumed")); - assert!(report.contains("Session file session.json")); + assert!(report.contains("Session file session.jsonl")); assert!(report.contains("Messages 14")); assert!(report.contains("Turns 6")); } @@ -3210,7 +3318,7 @@ mod tests { "workspace-write", &super::StatusContext { cwd: PathBuf::from("/tmp/project"), - session_path: Some(PathBuf::from("session.json")), + session_path: Some(PathBuf::from("session.jsonl")), loaded_config_files: 2, discovered_config_files: 3, memory_file_count: 4, @@ -3227,7 +3335,7 @@ mod tests { assert!(status.contains("Cwd /tmp/project")); assert!(status.contains("Project root /tmp")); assert!(status.contains("Git branch main")); - assert!(status.contains("Session session.json")); + assert!(status.contains("Session session.jsonl")); assert!(status.contains("Config files loaded 2/3")); assert!(status.contains("Memory files 4")); } @@ -3302,9 +3410,9 @@ mod tests { #[test] fn parses_resume_and_config_slash_commands() { assert_eq!( - SlashCommand::parse("/resume saved-session.json"), + SlashCommand::parse("/resume saved-session.jsonl"), Some(SlashCommand::Resume { - session_path: Some("saved-session.json".to_string()) + session_path: Some("saved-session.jsonl".to_string()) }) ); assert_eq!( @@ -3323,6 +3431,65 @@ mod tests { ); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); + assert_eq!( + SlashCommand::parse("/session fork incident-review"), + Some(SlashCommand::Session { + action: Some("fork".to_string()), + target: Some("incident-review".to_string()) + }) + ); + } + + #[test] + fn help_mentions_jsonl_resume_examples() { + let mut help = Vec::new(); + print_help_to(&mut help).expect("help should render"); + let help = String::from_utf8(help).expect("help should be utf8"); + assert!(help.contains("claw --resume SESSION.jsonl")); + assert!(help.contains("claw --resume session.jsonl /status /diff /export notes.txt")); + } + + #[test] + fn managed_sessions_default_to_jsonl_and_resolve_legacy_json() { + let _guard = cwd_lock().lock().expect("cwd lock"); + let workspace = temp_workspace("session-resolution"); + std::fs::create_dir_all(&workspace).expect("workspace should create"); + let previous = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&workspace).expect("switch cwd"); + + let handle = create_managed_session_handle("session-alpha").expect("jsonl handle"); + assert!(handle.path.ends_with("session-alpha.jsonl")); + + let legacy_path = workspace.join(".claude/sessions/legacy.json"); + std::fs::create_dir_all( + legacy_path + .parent() + .expect("legacy path should have parent directory"), + ) + .expect("session dir should exist"); + Session::new() + .with_persistence_path(legacy_path.clone()) + .save_to_path(&legacy_path) + .expect("legacy session should save"); + + let resolved = resolve_session_reference("legacy").expect("legacy session should resolve"); + assert_eq!(resolved.path, legacy_path); + + std::env::set_current_dir(previous).expect("restore cwd"); + std::fs::remove_dir_all(workspace).expect("workspace should clean up"); + } + + fn cwd_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn temp_workspace(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("claw-cli-{label}-{nanos}")) } #[test]