diff --git a/crates/pa-cli/src/interactive_mode.rs b/crates/pa-cli/src/interactive_mode.rs index 2579231e1..4d38851ce 100644 --- a/crates/pa-cli/src/interactive_mode.rs +++ b/crates/pa-cli/src/interactive_mode.rs @@ -416,10 +416,32 @@ fn build_tui_options( let script_path = std::env::var_os("PRIME_AGENT_FAUX_SCRIPT").map(PathBuf::from); // Flag order (fork -> resume -> create): the daemon opens the fork — // never the source — through the create `sessionPath`. - let session = match &options.session.fork { + let mut session = match &options.session.fork { Some(selector) => fork_startup_selection(selector, &config.cwd, session_dir.as_deref())?, None => session_selection(&options.session, session_dir.as_deref()), }; + // A plain resume follows the saved project. Resolve its file before + // changing cwd, and use the same project for client settings and the + // daemon create config. Explicit --cwd and --fork retain their directory. + let mut resolved_options = options.clone(); + if !options.session.cwd_from_flag && options.session.fork.is_none() { + if let SessionSelection::Resume(path) = &mut session { + *path = std::path::absolute(&*path)?; + if let Some(header) = pa_core::session::manager::read_session_header(path) + .filter(|header| !header.cwd.is_empty()) + { + if !Path::new(&header.cwd).is_dir() { + return Err(anyhow!( + "Saved session directory is unavailable: {}. Supply --cwd to reopen elsewhere.", + header.cwd + )); + } + resolved_options.config.cwd = PathBuf::from(header.cwd); + } + } + } + let options = &resolved_options; + let config = &options.config; let settings = pa_core::settings::SettingsManager::create(&config.cwd, &config.agent_dir); let code_block_indent = settings.get_code_block_indent(); let show_images = settings.get_show_images(); diff --git a/crates/pa-cli/src/interactive_mode/tests.rs b/crates/pa-cli/src/interactive_mode/tests.rs index fa70af277..c7d3e3244 100644 --- a/crates/pa-cli/src/interactive_mode/tests.rs +++ b/crates/pa-cli/src/interactive_mode/tests.rs @@ -1048,3 +1048,45 @@ async fn startup_flush_never_blocks_the_first_frame() { ), } } + +#[test] +fn interactive_resume_restores_saved_cwd_and_project_settings_unless_overridden() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("saved-project"); + std::fs::create_dir_all(project.join(".prime/agent")).unwrap(); + std::fs::write( + project.join(".prime/agent/settings.json"), + r#"{"markdown":{"codeBlockIndent":" "}}"#, + ) + .unwrap(); + let mut options = run_options_for_continue(dir.path()); + let saved = seed_saved_session( + &options.config.agent_dir.join("sessions"), + "cwdresume", + &project, + ); + for explicit in [true, false] { + options.session.resume = Some(saved.to_string_lossy().into_owned()); + options.session.cwd_from_flag = explicit; + let (tui, _) = build_tui_options( + &options, + dir.path().join("d.sock"), + std::sync::Arc::default(), + ) + .unwrap(); + let expected = if explicit { + dir.path() + } else { + project.as_path() + }; + assert_eq!( + (&tui.cwd, tui.create_config()["cwd"].clone(), tui.session), + ( + &expected.to_path_buf(), + serde_json::json!(expected), + SessionSelection::Resume(saved.clone()) + ) + ); + assert_eq!(tui.code_block_indent, if explicit { " " } else { " " }); + } +} diff --git a/crates/pa-daemon/src/supervisor/worker_lifecycle.rs b/crates/pa-daemon/src/supervisor/worker_lifecycle.rs index fe1bf06bf..1ef8fa1be 100644 --- a/crates/pa-daemon/src/supervisor/worker_lifecycle.rs +++ b/crates/pa-daemon/src/supervisor/worker_lifecycle.rs @@ -134,10 +134,39 @@ impl Supervisor { return Err(anyhow!("Supervisor is shutting down")); } let config_object = config.as_ref().and_then(Value::as_object); - let cwd_value = config_object + let explicit_cwd = config_object .and_then(|config| config.get("cwd")) - .and_then(Value::as_str) + .and_then(Value::as_str); + // A path-only reopen follows the session, not the supervisor's launch + // directory. Pin the file before changing the worker's process cwd so a + // relative sessionPath still opens the same file. Explicit-cwd creates + // retain their existing relative-path semantics. + let session_path = match (session_path, explicit_cwd) { + (Some(path), None) => Some( + std::path::absolute(crate::paths::expand_tilde(path)?)? + .to_string_lossy() + .into_owned(), + ), + _ => session_path.clone(), + }; + let saved_cwd = if explicit_cwd.is_none() { + session_path + .as_deref() + .filter(|path| Path::new(path).is_file()) + .and_then(|path| pa_core::session::manager::read_session_header(Path::new(path))) + .map(|header| header.cwd) + .filter(|cwd| !cwd.is_empty()) + } else { + None + }; + if let Some(cwd) = saved_cwd.as_ref().filter(|cwd| !Path::new(cwd).is_dir()) { + return Err(anyhow!( + "Saved session directory is unavailable: {cwd}. Supply an explicit cwd to reopen elsewhere." + )); + } + let cwd_value = explicit_cwd .map(str::to_string) + .or(saved_cwd) .or_else(|| { std::env::current_dir() .ok() diff --git a/crates/pa-daemon/tests/startup_no_auto_resume_e2e.rs b/crates/pa-daemon/tests/startup_no_auto_resume_e2e.rs index 605c15572..1e6eddd09 100644 --- a/crates/pa-daemon/tests/startup_no_auto_resume_e2e.rs +++ b/crates/pa-daemon/tests/startup_no_auto_resume_e2e.rs @@ -265,12 +265,16 @@ fn write_ts_era_session(agent_dir: &Path, session_id: &str, name: &str, state: & let sessions = agent_dir.join("sessions"); std::fs::create_dir_all(&sessions).expect("sessions dir"); let path = sessions.join(format!("{session_id}.jsonl")); + // A user resume restores this directory; keep the fixture independent of + // machine-specific paths such as /work. + let cwd = agent_dir.join("project"); + std::fs::create_dir_all(&cwd).expect("project dir"); let lines = json_lines(vec![ json!({ "type": "session", "id": session_id, "timestamp": "2026-01-05T10:00:00.000Z", - "cwd": "/work", + "cwd": cwd, }), json!({ "type": "session_info", @@ -326,7 +330,8 @@ fn due_job( "activeSessionId": session_id, "sessionId": session_id, "sessionFile": session_file.to_string_lossy(), - "cwd": "/work", + "cwd": pa_core::session::manager::read_session_header(session_file) + .expect("session header").cwd, "prompt": prompt, "schedule": { "kind": "interval", "expression": "every 2m", "intervalMs": 120_000 }, "createdAt": "2026-01-05T10:00:04.000Z", @@ -466,6 +471,7 @@ fn a_due_scheduled_job_never_boots_its_session_at_daemon_start() { ); let created = client.read_response("c1"); assert_eq!(created["success"], true, "resume create failed: {created}"); + assert_eq!(created["data"]["cwd"], json!(agent_dir.join("project"))); let active_id = created["data"]["id"] .as_str() .or_else(|| created["data"]["activeSessionId"].as_str()) diff --git a/crates/pa-daemon/tests/supervisor_e2e/create.rs b/crates/pa-daemon/tests/supervisor_e2e/create.rs index 0d583068d..5ec440759 100644 --- a/crates/pa-daemon/tests/supervisor_e2e/create.rs +++ b/crates/pa-daemon/tests/supervisor_e2e/create.rs @@ -223,3 +223,226 @@ fn create_with_continue_recent_is_refused() { "the plain create opened a fresh session, not the saved one" ); } + +/// A cold reopen must discover project resources in the saved directory, even +/// when the caller (Agents View inline Reply) sends no cwd. An explicit fallback +/// or override must still win. +#[test] +fn cold_reopen_uses_saved_cwd_unless_explicitly_overridden() { + let dir = tempfile::tempdir_in("/tmp").expect("temp dir"); + let project = dir.path().join("project ! saved"); + let override_dir = dir.path().join("override"); + let agent_dir = dir.path().join("agent"); + for path in [&project, &override_dir, &agent_dir] { + std::fs::create_dir_all(path).expect("directory"); + } + for path in [&project, &override_dir] { + std::fs::write(path.join("AGENTS.md"), "CWD_REGRESSION_PROJECT_CONTEXT").unwrap(); + let skill = path.join(".agents/skills/cwd-regression"); + std::fs::create_dir_all(&skill).unwrap(); + std::fs::write( + skill.join("SKILL.md"), + "---\nname: cwd-regression\ndescription: Cwd regression skill.\n---\nProject skill.\n", + ) + .unwrap(); + } + let socket = dir.path().join("daemon.sock"); + let alias = dir.path().join("project-alias"); + std::os::unix::fs::symlink(&project, &alias).unwrap(); + let _daemon = spawn_daemon_in(&socket, &agent_dir, dir.path()); + let (mut client, _) = Client::connect(&socket); + for (index, (explicit, relative, recorded)) in [ + (true, false, &project), + (false, false, &project), + (false, true, &project), + (false, false, &alias), + (true, true, &project), + ] + .into_iter() + .enumerate() + { + let file_dir = if explicit && relative { + &override_dir + } else { + dir.path() + }; + let saved = file_dir.join(format!("saved-{index}.jsonl")); + std::fs::write( + &saved, + format!( + "{}\n", + serde_json::json!({ + "type": "session", "version": 3, + "id": format!("cwd-regression-{index}"), + "timestamp": "2026-10-12T00:00:00.000Z", "cwd": recorded, + }) + ), + ) + .expect("saved session"); + let mut config = serde_json::json!({}); + if explicit { + config["cwd"] = serde_json::json!(override_dir); + } + client.send_command("resume", &serde_json::json!({ + "type": "create", "sessionPath": if relative { saved.file_name().unwrap().to_str().unwrap() } else { saved.to_str().unwrap() }, "config": config, + })); + let response = client.read_response("resume"); + assert_eq!(response["success"], true, "{response}"); + let expected = if explicit { &override_dir } else { recorded }; + assert_eq!( + response["data"]["cwd"], + serde_json::json!(expected), + "{response}" + ); + assert_eq!( + response["data"]["sessionId"], + format!("cwd-regression-{index}") + ); + let active = &response["data"]["id"]; + client.send_command( + "context", + &serde_json::json!({ + "type": "get_system_prompt", "activeSessionId": active, + }), + ); + let context = client.read_response("context"); + assert_eq!(context["success"], true, "{context}"); + let prompt = context["data"]["systemPrompt"].as_str().unwrap(); + assert!( + prompt.contains("CWD_REGRESSION_PROJECT_CONTEXT"), + "{context}" + ); + assert!(prompt.contains("cwd-regression"), "{context}"); + client.send_command( + "pwd", + &serde_json::json!({ + "type": "execute_bash_and_wait", "activeSessionId": active, + "command": "pwd -P", + }), + ); + let pwd = client.read_response("pwd"); + assert_eq!(pwd["success"], true, "{pwd}"); + assert_eq!( + pwd["data"]["output"].as_str().unwrap().trim(), + expected.canonicalize().unwrap().to_str().unwrap() + ); + } + client.send_command("shutdown", &serde_json::json!({"type": "shutdown"})); + assert_eq!(client.read_response("shutdown")["success"], true); +} + +#[test] +fn missing_saved_cwd_requires_an_explicit_fallback() { + let dir = tempfile::tempdir_in("/tmp").unwrap(); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let saved = dir.path().join("missing.jsonl"); + std::fs::write( + &saved, + format!( + "{}\n", + serde_json::json!({ + "type": "session", "version": 3, "id": "missing-cwd", + "timestamp": "2026-10-12T00:00:00.000Z", "cwd": dir.path().join("gone"), + }) + ), + ) + .unwrap(); + let socket = dir.path().join("daemon.sock"); + let _daemon = spawn_daemon_in(&socket, &agent_dir, dir.path()); + let (mut client, _) = Client::connect(&socket); + client.send_command( + "missing", + &serde_json::json!({"type": "create", "sessionPath": saved}), + ); + let missing = client.read_response("missing"); + assert_eq!(missing["success"], false, "{missing}"); + assert!(missing["error"] + .as_str() + .unwrap() + .contains("Saved session directory is unavailable")); + client.send_command( + "fallback", + &serde_json::json!({ + "type": "create", "sessionPath": saved, "config": {"cwd": dir.path()}, + }), + ); + let fallback = client.read_response("fallback"); + assert_eq!(fallback["success"], true, "{fallback}"); + assert_eq!(fallback["data"]["cwd"], serde_json::json!(dir.path())); + client.send_command("fresh", &serde_json::json!({"type": "create"})); + let fresh = client.read_response("fresh"); + assert_eq!(fresh["success"], true, "{fresh}"); + assert_eq!( + fresh["data"]["cwd"], + serde_json::json!(dir.path().canonicalize().unwrap()) + ); + client.send_command("shutdown", &serde_json::json!({"type": "shutdown"})); + assert_eq!(client.read_response("shutdown")["success"], true); +} + +/// Uses a real Python kernel; only provider responses are scripted. +#[test] +#[ignore = "requires PRIME_AGENT_KERNEL_PYTHON with the kernel runtime installed"] +fn cold_reopen_python_kernel_uses_saved_cwd_and_explicit_override() { + assert!(std::env::var_os("PRIME_AGENT_KERNEL_PYTHON").is_some()); + let dir = tempfile::tempdir_in("/tmp").unwrap(); + let project = dir.path().join("saved-project"); + let override_dir = dir.path().join("override"); + let agent_dir = dir.path().join("agent"); + for path in [&project, &override_dir, &agent_dir] { + std::fs::create_dir_all(path).unwrap(); + } + let script = dir.path().join("kernel.json"); + std::fs::write(&script, serde_json::json!({ + "engine": "faux", "responses": [ + {"content": [{"type": "toolCall", "name": "python_repl", "id": "kernel-cwd", + "arguments": {"code": "import os, pathlib\npathlib.Path('kernel-cwd.txt').write_text(os.getcwd())\nprint(os.getcwd())"}}]}, + {"text": "done"} + ] + }).to_string()).unwrap(); + let socket = dir.path().join("daemon.sock"); + let _daemon = spawn_daemon_in(&socket, &agent_dir, dir.path()); + let (mut client, _) = Client::connect(&socket); + for (index, explicit) in [false, true].into_iter().enumerate() { + let saved = dir.path().join(format!("kernel-{index}.jsonl")); + std::fs::write( + &saved, + format!( + "{}\n", + serde_json::json!({ + "type": "session", "version": 3, "id": format!("kernel-cwd-{index}"), + "timestamp": "2026-10-12T00:00:00.000Z", "cwd": project, + }) + ), + ) + .unwrap(); + let mut config = serde_json::json!({"script": script}); + if explicit { + config["cwd"] = serde_json::json!(override_dir); + } + client.send_command( + "open", + &serde_json::json!({ + "type": "create", "sessionPath": saved, "config": config, + }), + ); + let opened = client.read_response("open"); + assert_eq!(opened["success"], true, "{opened}"); + client.send_command( + "python", + &serde_json::json!({ + "type": "prompt_and_wait", "activeSessionId": opened["data"]["id"], + "message": "Record the Python kernel working directory.", + }), + ); + let response = client.read_response("python"); + assert_eq!(response["success"], true, "{response}"); + let expected = if explicit { &override_dir } else { &project }; + let observed = std::fs::read_to_string(expected.join("kernel-cwd.txt")) + .expect("the real Python tool wrote its relative-path cwd probe"); + assert_eq!(observed, expected.canonicalize().unwrap().to_str().unwrap()); + } + client.send_command("shutdown", &serde_json::json!({"type": "shutdown"})); + assert_eq!(client.read_response("shutdown")["success"], true); +} diff --git a/crates/pa-daemon/tests/supervisor_e2e/main.rs b/crates/pa-daemon/tests/supervisor_e2e/main.rs index 661898cc9..e04c81561 100644 --- a/crates/pa-daemon/tests/supervisor_e2e/main.rs +++ b/crates/pa-daemon/tests/supervisor_e2e/main.rs @@ -55,8 +55,18 @@ impl Drop for Daemon { // immediately afterwards, reaping it. #[allow(clippy::zombie_processes)] fn spawn_daemon(socket: &std::path::Path, agent_dir: &std::path::Path) -> Daemon { + spawn_daemon_in(socket, agent_dir, &std::env::current_dir().unwrap()) +} + +#[allow(clippy::zombie_processes)] // the timeout path exits the test process +fn spawn_daemon_in( + socket: &std::path::Path, + agent_dir: &std::path::Path, + cwd: &std::path::Path, +) -> Daemon { let binary = env!("CARGO_BIN_EXE_pa-daemon"); let child = Command::new(binary) + .current_dir(cwd) .arg("supervisor") .arg("--socket") .arg(socket)