From 56b6bc680b3962d0a973c1c119b5555016d75459 Mon Sep 17 00:00:00 2001 From: jarvis24young <749843026@qq.com> Date: Fri, 26 Jun 2026 09:13:21 +0800 Subject: [PATCH] feat(cli): add /cd slash command for in-session workspace switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `/cd ` slash command to the CLI chat mode that re-points the active session's working directory without restarting it. The session id, message history, and prompt-cache prefix are preserved; subsequent turns simply execute against the new cwd. Ported from Claude Code's `/cd` semantics (changelog 2.1.169). The key design choice is splitting "logical cwd" from "persistence anchor": - `SessionConfig.workspace_path` (existing) continues to drive tool execution and `build_workspace_binding` — so subsequent turns run in the new cwd. - A new `SessionConfig.storage_workspace_path` (optional, serde-default `None`, backward-compatible) freezes the original workspace on the first `/cd`. Storage helpers (`CoreSessionStorePort::resolve_storage_path_for_config`, `SessionManager::effective_session_workspace_path`) prefer this anchor so the session file, turn snapshots, and prompt cache stay rooted at the original directory and remain discoverable from its workspace listing. - `start_dialog_turn_internal` resolves its fallback finalize storage path via `effective_session_workspace_path` instead of the live workspace binding, so safety-net turn persistence honors the anchor. - Forked subagents reset `storage_workspace_path = None` so a child session's anchor follows its own (current) workspace, not the parent's post-`/cd` history. CLI resolution handles `~`/`~/...` expansion, relative paths joined against the current cwd, ASCII quote stripping, and rejects Windows drive-relative forms (`C:foo`) that would otherwise be silently anchored to the shell's per-drive cwd. The core update runs before the adapter mutation so a core-side failure leaves session state internally consistent. Switching is rejected while a turn is in flight. Pre-`/cd` sessions and existing on-disk session files are unaffected: `storage_workspace_path` deserializes to `None` and the helper falls back to `workspace_path`, preserving prior behavior byte-for-byte. --- src/apps/cli/src/commands.rs | 12 + src/apps/cli/src/modes/chat.rs | 283 ++++++++++++++++++ src/apps/desktop/src/api/agentic_api.rs | 1 + .../src/agentic/coordination/coordinator.rs | 43 ++- .../assembly/core/src/agentic/core/session.rs | 31 ++ .../core/src/agentic/fork_agent/mod.rs | 6 + .../src/agentic/session/session_manager.rs | 131 ++++++-- .../src/agentic/session/session_store_port.rs | 5 +- 8 files changed, 484 insertions(+), 28 deletions(-) diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs index a35e41e66..698247fe7 100644 --- a/src/apps/cli/src/commands.rs +++ b/src/apps/cli/src/commands.rs @@ -72,6 +72,10 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ name: "/usage", description: "Generate session usage report", }, + CommandSpec { + name: "/cd", + description: "Switch session workspace without restarting", + }, CommandSpec { name: "/exit", description: "Exit the app", @@ -228,4 +232,12 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].name, "/help"); } + + #[test] + fn test_cd_registered() { + let names: Vec<&str> = COMMAND_SPECS.iter().map(|s| s.name).collect(); + assert!(names.contains(&"/cd"), "/cd should be registered in COMMAND_SPECS"); + let result = match_substring_in("/cd", COMMAND_SPECS); + assert!(result.iter().any(|s| s.name == "/cd")); + } } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 3552d9631..a5aed110f 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -143,6 +143,80 @@ fn agent_display_name(agent_type: &str) -> &'static str { } } +/// Strip a single pair of matching surrounding ASCII quotes (`"` or `'`). +/// Smart/curly quotes (U+2018..U+201D) are intentionally not handled — they are +/// rare in shell-style path input and would complicate the matching logic. +fn strip_surrounding_quotes(input: &str) -> &str { + let bytes = input.as_bytes(); + if bytes.len() >= 2 { + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if (first == b'"' || first == b'\'') && first == last { + return &input[1..input.len() - 1]; + } + } + input +} + +/// Reject Windows drive-relative paths like `C:foo` (no separator after the +/// drive letter). These are treated as relative by `PathBuf::is_absolute`, but +/// `Path::join` discards the base path's prefix and silently anchors against +/// the shell's per-drive cwd, which is almost never what the user intended. +#[cfg(windows)] +fn is_windows_drive_relative(path: &std::path::Path) -> bool { + use std::path::{Component, Prefix}; + let mut components = path.components(); + let Some(Component::Prefix(prefix)) = components.next() else { + return false; + }; + if !matches!(prefix.kind(), Prefix::Disk(_)) { + return false; + } + // Drive-relative if the prefix is NOT immediately followed by a root separator. + !matches!(components.next(), Some(Component::RootDir)) +} + +#[cfg(not(windows))] +fn is_windows_drive_relative(_path: &std::path::Path) -> bool { + false +} + +/// Resolve the `/cd` target path: handle `~` / `~/...` expansion and resolve +/// relative paths against `base`. Returns the absolute (but not yet canonicalized) +/// path, or a human-readable error message. +fn resolve_cd_target(arg: &str, base: &std::path::Path) -> std::result::Result { + let trimmed = arg.trim(); + if trimmed.is_empty() { + return Err("empty path".to_string()); + } + + // Tilde expansion: `~` or `~/...` → home dir. Other tilde-prefixed forms + // (e.g. `~user/...`) are not supported; treat them as literal paths. + let expanded: PathBuf = if trimmed == "~" { + dirs::home_dir().ok_or_else(|| "home directory not available".to_string())? + } else if let Some(rest) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) { + let mut home = + dirs::home_dir().ok_or_else(|| "home directory not available".to_string())?; + home.push(rest); + home + } else { + PathBuf::from(trimmed) + }; + + if is_windows_drive_relative(&expanded) { + return Err(format!( + "drive-relative path `{}` is ambiguous; use an absolute path like `C:\\foo`", + expanded.display() + )); + } + + if expanded.is_absolute() { + Ok(expanded) + } else { + Ok(base.join(expanded)) + } +} + impl ChatMode { pub fn new( config: CliConfig, @@ -1748,6 +1822,16 @@ impl ChatMode { chat_state.metadata.total_tokens )); } + "/cd" => { + // Preserve path argument verbatim (paths may contain spaces) by stripping + // the command token and any single leading whitespace block from the raw + // command line, rather than re-joining whitespace-split parts. + let raw_arg = command + .strip_prefix(parts[0]) + .map(|rest| rest.trim_start()) + .unwrap_or(""); + self.handle_cd_command(raw_arg, chat_view, chat_state, rt_handle); + } "/exit" => { if chat_state.is_processing { tracing::info!("User requested cancellation via /exit"); @@ -2892,6 +2976,116 @@ impl ChatMode { /// time — does not require `is_processing` to be false because the /// registry swap is atomic and a held `SkillInfo` reference is not /// kept across the call. + /// Handle `/cd [path]` — switch the session workspace in-place. + /// + /// With no argument, prints the current workspace. With an argument, validates + /// the target exists and is a directory, then updates `self.workspace`, + /// `chat_state.workspace`, and the agent adapter's internal workspace path so + /// that the next `start_dialog_turn` honors the new cwd. The session itself + /// (id, message history, model state) is preserved. + fn handle_cd_command( + &mut self, + raw_arg: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let trimmed = raw_arg.trim(); + + // Bare `/cd` → report current workspace + if trimmed.is_empty() { + let current = self.agent.workspace_path_string(); + chat_state.add_system_message(format!("Current workspace: {}", current)); + chat_view.set_status(Some(format!("cwd: {}", current))); + return; + } + + // Reject while a turn is in flight — workspace switch mid-turn would + // make tool calls land in a different cwd than the model was prompted with. + if chat_state.is_processing { + chat_view.set_status(Some( + "Cannot switch workspace while processing. Press Ctrl+C to cancel first." + .to_string(), + )); + return; + } + + // Strip optional surrounding quotes so users can quote paths with spaces. + let unquoted = strip_surrounding_quotes(trimmed); + + let target = match resolve_cd_target(unquoted, &self.agent.workspace_path_buf()) { + Ok(path) => path, + Err(msg) => { + chat_state.add_system_message(format!("/cd failed: {}", msg)); + chat_view.set_status(Some(format!("/cd failed: {}", msg))); + return; + } + }; + + let canonical = match dunce::canonicalize(&target) { + Ok(p) => p, + Err(err) => { + let msg = format!("cannot resolve {}: {}", target.display(), err); + chat_state.add_system_message(format!("/cd failed: {}", msg)); + chat_view.set_status(Some(format!("/cd failed: {}", msg))); + return; + } + }; + + if !canonical.is_dir() { + let msg = format!("{} is not a directory", canonical.display()); + chat_state.add_system_message(format!("/cd failed: {}", msg)); + chat_view.set_status(Some(format!("/cd failed: {}", msg))); + return; + } + + let canonical_str = canonical.to_string_lossy().to_string(); + + // Update order matters. We need: + // 1. The core session config (`session.config.workspace_path`) — + // what `start_dialog_turn_internal` actually reads to construct + // the WorkspaceBinding for tool execution + // (see coordinator.rs `build_workspace_binding(&session.config)`). + // 2. The agent adapter mutex — used to forward `workspace_path` + // on start_dialog_turn calls (metadata mirroring). + // + // We try the core update FIRST. If it fails, we never touch the + // adapter, leaving session state internally consistent. On success we + // then swap the adapter — and on the (very unlikely) chance the + // adapter swap is observable as a separate operation, this ordering + // ensures the worst case is "next turn uses old metadata but correct + // execution cwd", not the reverse. + let agent = self.agent.clone(); + let canonical_for_adapter = canonical.clone(); + let canonical_for_core = canonical_str.clone(); + let session_id = chat_state.core_session_id.clone(); + + let outcome = tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if !session_id.is_empty() { + agent + .coordinator() + .update_session_workspace_path(&session_id, &canonical_for_core) + .await?; + } + agent.set_workspace_path(Some(canonical_for_adapter)).await; + Ok::<(), anyhow::Error>(()) + }) + }); + + if let Err(err) = outcome { + chat_state.add_system_message(format!("/cd failed: {}", err)); + chat_view.set_status(Some(format!("/cd failed: {}", err))); + return; + } + + self.workspace = Some(canonical_str.clone()); + chat_state.workspace = Some(canonical_str.clone()); + + chat_state.add_system_message(format!("Workspace switched to: {}", canonical_str)); + chat_view.set_status(Some(format!("cwd → {}", canonical_str))); + } + fn reload_skills_from_disk( &self, chat_view: &mut ChatView, @@ -3662,3 +3856,92 @@ impl ChatMode { } } } + +#[cfg(test)] +mod cd_helper_tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn strip_quotes_handles_matching_double_quotes() { + assert_eq!(strip_surrounding_quotes("\"hello\""), "hello"); + } + + #[test] + fn strip_quotes_handles_matching_single_quotes() { + assert_eq!(strip_surrounding_quotes("'hello'"), "hello"); + } + + #[test] + fn strip_quotes_passes_through_unquoted() { + assert_eq!(strip_surrounding_quotes("hello"), "hello"); + } + + #[test] + fn strip_quotes_passes_through_mismatched() { + assert_eq!(strip_surrounding_quotes("\"hello'"), "\"hello'"); + assert_eq!(strip_surrounding_quotes("\""), "\""); + } + + #[test] + fn resolve_cd_target_rejects_empty() { + let base = PathBuf::from("/base"); + assert!(resolve_cd_target("", &base).is_err()); + assert!(resolve_cd_target(" ", &base).is_err()); + } + + #[test] + fn resolve_cd_target_joins_relative() { + let base = PathBuf::from("/base"); + let result = resolve_cd_target("sub/dir", &base).expect("relative path should resolve"); + assert_eq!(result, base.join("sub/dir")); + } + + #[test] + fn resolve_cd_target_passes_absolute_unchanged() { + let base = PathBuf::from("/base"); + let abs = if cfg!(windows) { "C:\\abs\\path" } else { "/abs/path" }; + let result = resolve_cd_target(abs, &base).expect("absolute path should resolve"); + assert_eq!(result, PathBuf::from(abs)); + } + + #[test] + fn resolve_cd_target_expands_tilde_root() { + let base = PathBuf::from("/base"); + let expected = dirs::home_dir(); + let result = resolve_cd_target("~", &base); + match (expected, result) { + (Some(home), Ok(resolved)) => assert_eq!(resolved, home), + (None, _) => {} // host has no home dir — acceptable in some CI environments + (Some(_), Err(_)) => panic!("expected tilde to expand when home dir exists"), + } + } + + #[test] + fn resolve_cd_target_expands_tilde_subpath() { + let base = PathBuf::from("/base"); + if let Some(home) = dirs::home_dir() { + let result = resolve_cd_target("~/sub", &base).expect("~/sub should resolve"); + assert_eq!(result, home.join("sub")); + } + } + + #[cfg(windows)] + #[test] + fn resolve_cd_target_rejects_drive_relative() { + let base = PathBuf::from("D:\\base"); + let result = resolve_cd_target("C:foo", &base); + assert!(result.is_err(), "drive-relative path must be rejected"); + assert!(result.unwrap_err().contains("drive-relative")); + } + + #[cfg(not(windows))] + #[test] + fn resolve_cd_target_drive_relative_check_is_noop_on_unix() { + let base = PathBuf::from("/base"); + // On non-Windows, `C:foo` is just a normal relative path with a colon. + let result = resolve_cd_target("C:foo", &base).expect("should resolve on unix"); + assert_eq!(result, base.join("C:foo")); + } +} + diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 924e5511a..16f8c0032 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -657,6 +657,7 @@ pub async fn create_session( enable_context_compression: c.enable_context_compression.unwrap_or(true), compression_threshold: c.compression_threshold.unwrap_or(0.8), workspace_path: Some(request.workspace_path.clone()), + storage_workspace_path: None, workspace_id: request.workspace_id.clone(), remote_connection_id: remote_conn.clone(), remote_ssh_host: remote_ssh_host.clone(), diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index e483942d7..d42bf4565 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -3338,9 +3338,24 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Pre-resolve the on-disk session storage path (mirror dir for remote workspaces) // so the safety-net writer never has to re-resolve without remote_connection_id / // remote_ssh_host (which would silently fall back to a slugified raw remote path). - let session_storage_path = session_workspace - .as_ref() - .map(|workspace| workspace.session_storage_dir().to_path_buf()); + // + // IMPORTANT: this MUST anchor to the persistence root, not the live + // workspace binding. After an in-session `/cd`, `session_workspace` + // reflects the NEW cwd (so tool execution lands in the right place), + // but the on-disk session file and its sidecar artifacts (turn + // snapshots, prompt cache) stay rooted at the ORIGINAL workspace. + // `effective_session_workspace_path` resolves via + // `config.storage_workspace_path` first and falls back to + // `workspace_path` for sessions that never invoked `/cd`. + let session_storage_path = self + .session_manager + .effective_session_workspace_path(&session_id) + .await + .or_else(|| { + session_workspace + .as_ref() + .map(|workspace| workspace.session_storage_dir().to_path_buf()) + }); let runtime_tool_restrictions = if is_miniapp_headless_agent_run( user_message_metadata.as_ref(), @@ -5842,6 +5857,28 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + /// Switch the working directory used by subsequent dialog turns without + /// recreating the session. The session id, message history, and the + /// session file's on-disk location are preserved; only the workspace + /// binding used to construct tool execution context for the next turn + /// is updated. See `SessionManager::update_session_workspace_path` for + /// the storage semantics. + pub async fn update_session_workspace_path( + &self, + session_id: &str, + workspace_path: &str, + ) -> BitFunResult<()> { + let trimmed = workspace_path.trim(); + if trimmed.is_empty() { + return Err(BitFunError::validation( + "Workspace path must not be empty".to_string(), + )); + } + self.session_manager + .update_session_workspace_path(session_id, trimmed) + .await + } + /// Update the session-level prompt-cache guard mode for the latest /// scheduler-accepted user submission. pub async fn update_last_submitted_agent_type( diff --git a/src/crates/assembly/core/src/agentic/core/session.rs b/src/crates/assembly/core/src/agentic/core/session.rs index b677c0a1a..460912c82 100644 --- a/src/crates/assembly/core/src/agentic/core/session.rs +++ b/src/crates/assembly/core/src/agentic/core/session.rs @@ -145,8 +145,24 @@ pub struct SessionConfig { pub compression_threshold: f32, /// Workspace path bound to this session. Used to run AI in the correct workspace /// without changing the desktop's foreground workspace. + /// + /// This is the *logical* working directory for tool execution. An in-session + /// `/cd` mutates this value so subsequent tool calls happen in the new cwd. + /// For the *physical* storage location of the session file (which must remain + /// stable across `/cd`), see `storage_workspace_path`. #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, + /// Persistence anchor — the original workspace path under which this session + /// was created. Once a session has been `/cd`-switched, this preserves the + /// directory where the session file and its sidecar artifacts (turn + /// snapshots, prompt cache) continue to live, so the session remains + /// discoverable from its original workspace listing. + /// + /// `None` means "no /cd has happened yet" — every storage operation should + /// transparently fall back to `workspace_path`, preserving pre-existing + /// behavior for sessions that never invoke `/cd`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage_workspace_path: Option, /// Stable workspace id for resolving workspace-scoped metadata such as related directories. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, @@ -175,6 +191,7 @@ impl Default for SessionConfig { enable_context_compression: true, compression_threshold: 0.8, // 80% workspace_path: None, + storage_workspace_path: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -183,6 +200,20 @@ impl Default for SessionConfig { } } +impl SessionConfig { + /// Returns the workspace path to anchor persistence/storage against. + /// + /// Prefers `storage_workspace_path` (set once after the first `/cd`) and + /// falls back to `workspace_path` for pre-`/cd` sessions. Callers that + /// touch the on-disk session file, turn snapshots, or prompt cache should + /// use this helper so the storage location remains stable across `/cd`. + pub fn effective_storage_workspace_path(&self) -> Option<&str> { + self.storage_workspace_path + .as_deref() + .or(self.workspace_path.as_deref()) + } +} + /// Session summary (for list display) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionSummary { diff --git a/src/crates/assembly/core/src/agentic/fork_agent/mod.rs b/src/crates/assembly/core/src/agentic/fork_agent/mod.rs index 6ee627e69..c3bbc79f5 100644 --- a/src/crates/assembly/core/src/agentic/fork_agent/mod.rs +++ b/src/crates/assembly/core/src/agentic/fork_agent/mod.rs @@ -51,6 +51,12 @@ impl ForkAgentContextSnapshot { pub fn build_child_session_config(&self, max_turns_override: Option) -> SessionConfig { let mut config = self.session_config.clone(); config.workspace_path = Some(self.workspace_path.clone()); + // The child is a freshly created session; its persistence should be + // anchored to its own (current) workspace, NOT inherit the parent's + // post-`/cd` storage anchor. Reset to None so storage resolution + // falls back to the live `workspace_path` until the child itself + // invokes `/cd`. + config.storage_workspace_path = None; config.remote_connection_id = self.remote_connection_id.clone(); config.remote_ssh_host = self.remote_ssh_host.clone(); config.model_id = self.session_model_id.clone(); diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 45a45c1a9..487fca52b 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -507,7 +507,12 @@ impl SessionManager { /// Resolve the effective storage path for a session by ID. /// For remote workspaces, maps the remote path to a local session storage path. - async fn effective_session_storage_path(&self, session_id: &str) -> Option { + /// For `/cd`-switched sessions, anchors to the original workspace via + /// `config.storage_workspace_path` rather than the live `workspace_path`. + pub(crate) async fn effective_session_workspace_path( + &self, + session_id: &str, + ) -> Option { let config = self.sessions.get(session_id)?.config.clone(); self.effective_storage_path_for_config(&config).await } @@ -850,7 +855,7 @@ impl SessionManager { return; } - let Some(workspace_path) = self.effective_session_storage_path(session_id).await else { + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { debug!( "Skipping context snapshot persistence because workspace path is unavailable: session_id={}, turn_index={}, reason={}", session_id, turn_index, reason @@ -898,7 +903,7 @@ impl SessionManager { } let cache = if self.should_persist_session_id(session_id) { - match self.effective_session_storage_path(session_id).await { + match self.effective_session_workspace_path(session_id).await { Some(workspace_path) => { match self .load_prompt_cache_from_persistence(&workspace_path, session_id) @@ -974,7 +979,7 @@ impl SessionManager { return; } - let Some(workspace_path) = self.effective_session_storage_path(session_id).await else { + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { debug!( "Skipping prompt cache persistence because workspace path is unavailable: session_id={}, reason={}", session_id, reason @@ -1511,7 +1516,7 @@ impl SessionManager { return None; } - let workspace_path = self.effective_session_storage_path(session_id).await?; + let workspace_path = self.effective_session_workspace_path(session_id).await?; match self .load_turn_skill_agent_snapshot_from_persistence( &workspace_path, @@ -1560,7 +1565,7 @@ impl SessionManager { return cached_snapshot; } - let workspace_path = self.effective_session_storage_path(session_id).await?; + let workspace_path = self.effective_session_workspace_path(session_id).await?; let scan_floor_exclusive = cached_snapshot.as_ref().map(|snapshot| snapshot.0); for index in (0..=turn_index).rev() { if scan_floor_exclusive.is_some_and(|floor| index <= floor) { @@ -1607,7 +1612,7 @@ impl SessionManager { return; } - let Some(workspace_path) = self.effective_session_storage_path(session_id).await else { + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { debug!( "Skipping turn skill-agent snapshot persistence because workspace path is unavailable: session_id={}, turn_index={}", session_id, turn_index @@ -1644,7 +1649,7 @@ impl SessionManager { return; } - let Some(workspace_path) = self.effective_session_storage_path(session_id).await else { + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { debug!( "Skipping first-turn skill-agent baseline recovery persistence because workspace path is unavailable: session_id={}", session_id @@ -1691,7 +1696,7 @@ impl SessionManager { return; } - let Some(workspace_path) = self.effective_session_storage_path(session_id).await else { + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { debug!( "Skipping listing reminder baseline override persistence because workspace path is unavailable: session_id={}", session_id @@ -1732,7 +1737,7 @@ impl SessionManager { return None; } - let workspace_path = self.effective_session_storage_path(session_id).await?; + let workspace_path = self.effective_session_workspace_path(session_id).await?; let snapshot = match self .persistence_manager .load_skill_agent_baseline_override_snapshot(&workspace_path, session_id) @@ -2049,7 +2054,7 @@ impl SessionManager { session_id: &str, new_state: SessionState, ) -> BitFunResult<()> { - let effective_path = self.effective_session_storage_path(session_id).await; + let effective_path = self.effective_session_workspace_path(session_id).await; // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. // Collect the data needed for persistence, then release the guard before doing I/O. @@ -2093,7 +2098,7 @@ impl SessionManager { expected_turn_id: &str, new_state: SessionState, ) -> BitFunResult { - let effective_path = self.effective_session_storage_path(session_id).await; + let effective_path = self.effective_session_workspace_path(session_id).await; let should_persist = if let Some(mut session) = self.sessions.get_mut(session_id) { let owns_processing_turn = matches!( @@ -2143,7 +2148,7 @@ impl SessionManager { /// Update session title (in-memory + persistence) pub async fn update_session_title(&self, session_id: &str, title: &str) -> BitFunResult<()> { let normalized_title = Self::normalize_session_title_input(title)?; - let workspace_path = self.effective_session_storage_path(session_id).await; + let workspace_path = self.effective_session_workspace_path(session_id).await; { let Some(mut session) = self.sessions.get_mut(session_id) else { @@ -2234,7 +2239,7 @@ impl SessionManager { } if self.should_persist_session_id(session_id) { - let effective_path = self.effective_session_storage_path(session_id).await; + let effective_path = self.effective_session_workspace_path(session_id).await; let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); // Ref guard released -- DashMap shard lock is free. if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { @@ -2252,6 +2257,84 @@ impl SessionManager { Ok(()) } + /// Update the session's configured workspace path in memory and persist it. + /// + /// This re-points the *logical* working directory used by subsequent dialog + /// turns (tool execution cwd, workspace binding) without altering: + /// + /// - The session's existing message history or turn ids + /// - The on-disk storage location of the session file and its sidecar + /// artifacts (turn snapshots, prompt cache), which remain anchored at + /// `config.storage_workspace_path` so the session stays discoverable + /// from its original workspace listing + /// - `session_workspace_index`, which keeps pointing at the original + /// storage path for the same reason + /// + /// On the FIRST invocation for a given session, the existing + /// `config.workspace_path` is copied into `config.storage_workspace_path` + /// to "freeze" the persistence anchor before the live cwd diverges. + /// Subsequent calls leave `storage_workspace_path` untouched. + /// + /// This matches the semantics of an interactive `/cd` switch — preserves + /// prompt-cache prefix and session id, and does not relocate the session + /// from a UI/listing perspective. + pub async fn update_session_workspace_path( + &self, + session_id: &str, + workspace_path: &str, + ) -> BitFunResult<()> { + if let Some(mut session) = self.sessions.get_mut(session_id) { + // Freeze the persistence anchor on first /cd. + // + // Only freeze when there's an existing non-empty `workspace_path` to + // anchor against. If the session was created without any workspace + // (rare — typically only synthetic/test sessions), there is nothing + // meaningful to preserve, and copying `None` would just make + // `effective_storage_workspace_path` fall through to the new live + // `workspace_path` anyway. Leaving `storage_workspace_path = None` + // keeps the fallback behavior consistent and avoids freezing a + // semantically-empty anchor. + if session.config.storage_workspace_path.is_none() { + if let Some(original) = session + .config + .workspace_path + .as_ref() + .filter(|path| !path.is_empty()) + { + session.config.storage_workspace_path = Some(original.clone()); + } + } + session.config.workspace_path = Some(workspace_path.to_string()); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + + // Persist via the (now-stable) storage anchor — resolved through + // `effective_session_workspace_path`, which prefers + // `storage_workspace_path` and falls back to `workspace_path`. + if self.should_persist_session_id(session_id) { + let storage_path = self.effective_session_workspace_path(session_id).await; + let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); + if let (Some(storage_path), Some(session)) = (storage_path, session_snapshot) { + self.persistence_manager + .save_session(&storage_path, &session) + .await?; + } + } + + debug!( + "Session workspace path updated: session_id={}, workspace_path={}", + session_id, workspace_path + ); + + Ok(()) + } + /// Update the most recent scheduler-accepted user submission mode. /// /// This state is intentionally independent from rollback-sensitive history @@ -2274,7 +2357,7 @@ impl SessionManager { } if self.should_persist_session_id(session_id) { - let effective_path = self.effective_session_storage_path(session_id).await; + let effective_path = self.effective_session_workspace_path(session_id).await; let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { self.persistence_manager @@ -2367,7 +2450,7 @@ impl SessionManager { } if self.should_persist_session_id(session_id) { - let effective_path = self.effective_session_storage_path(session_id).await; + let effective_path = self.effective_session_workspace_path(session_id).await; let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); // Ref guard released -- DashMap shard lock is free. if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { @@ -3553,7 +3636,7 @@ impl SessionManager { ))); } - self.effective_session_storage_path(session_id) + self.effective_session_workspace_path(session_id) .await .ok_or_else(|| { BitFunError::Validation(format!( @@ -4040,7 +4123,7 @@ impl SessionManager { } let workspace_path = self - .effective_session_storage_path(session_id) + .effective_session_workspace_path(session_id) .await .ok_or_else(|| { BitFunError::Validation(format!( @@ -4152,7 +4235,7 @@ impl SessionManager { } let workspace_path = self - .effective_session_storage_path(session_id) + .effective_session_workspace_path(session_id) .await .ok_or_else(|| { BitFunError::Validation(format!( @@ -4214,7 +4297,7 @@ impl SessionManager { } let workspace_path = self - .effective_session_storage_path(session_id) + .effective_session_workspace_path(session_id) .await .ok_or_else(|| { BitFunError::Validation(format!( @@ -4280,7 +4363,7 @@ impl SessionManager { } let workspace_path = self - .effective_session_storage_path(session_id) + .effective_session_workspace_path(session_id) .await .ok_or_else(|| { BitFunError::Validation(format!( @@ -4344,7 +4427,7 @@ impl SessionManager { } let workspace_path = self - .effective_session_storage_path(session_id) + .effective_session_workspace_path(session_id) .await .ok_or_else(|| { BitFunError::Validation(format!( @@ -4400,7 +4483,7 @@ impl SessionManager { /// canonical turn history instead of the runtime context cache. pub async fn get_messages(&self, session_id: &str) -> BitFunResult> { if self.config.enable_persistence { - if let Some(workspace_path) = self.effective_session_storage_path(session_id).await { + if let Some(workspace_path) = self.effective_session_workspace_path(session_id).await { let messages = self .rebuild_messages_from_turns(&workspace_path, session_id) .await?; @@ -4483,7 +4566,7 @@ impl SessionManager { session_id: &str, compression_state: CompressionState, ) -> BitFunResult<()> { - let effective_path = self.effective_session_storage_path(session_id).await; + let effective_path = self.effective_session_workspace_path(session_id).await; // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. let session_snapshot = if let Some(mut session) = self.sessions.get_mut(session_id) { diff --git a/src/crates/assembly/core/src/agentic/session/session_store_port.rs b/src/crates/assembly/core/src/agentic/session/session_store_port.rs index b1466f36a..c4e4f97f8 100644 --- a/src/crates/assembly/core/src/agentic/session/session_store_port.rs +++ b/src/crates/assembly/core/src/agentic/session/session_store_port.rs @@ -40,7 +40,10 @@ impl CoreSessionStorePort { pub async fn resolve_storage_path_for_config( config: &SessionConfig, ) -> Option { - let workspace_path = config.workspace_path.as_ref()?; + // Storage path follows the persistence anchor (set on /cd), not the + // live workspace path. Falls back to `workspace_path` when no /cd has + // happened yet — preserving original behavior for unmodified sessions. + let workspace_path = config.effective_storage_workspace_path()?; let request = SessionStoragePathRequest { workspace_path: PathBuf::from(workspace_path), remote_connection_id: config.remote_connection_id.clone(),