diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 5314e289..4256f513 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -1,6 +1,7 @@ //! Core commands: help, clear, exit, model use std::fmt::Write; +use std::path::PathBuf; use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name}; use crate::localization::{MessageId, tr}; @@ -182,6 +183,50 @@ pub fn profile_switch(_app: &mut App, arg: Option<&str>) -> CommandResult { ) } +pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { + let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else { + return CommandResult::message(format!("Current workspace: {}", app.workspace.display())); + }; + + let expanded = match expand_workspace_path(raw_path) { + Ok(path) => path, + Err(message) => return CommandResult::error(message), + }; + let candidate = if expanded.is_absolute() { + expanded + } else { + app.workspace.join(expanded) + }; + + if !candidate.exists() { + return CommandResult::error(format!("Workspace does not exist: {}", candidate.display())); + } + if !candidate.is_dir() { + return CommandResult::error(format!( + "Workspace is not a directory: {}", + candidate.display() + )); + } + + let workspace = candidate.canonicalize().unwrap_or(candidate); + CommandResult::with_message_and_action( + format!("Switching workspace to {}...", workspace.display()), + AppAction::SwitchWorkspace { workspace }, + ) +} + +fn expand_workspace_path(path: &str) -> Result { + if path == "~" { + return dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string()); + } + if let Some(rest) = path.strip_prefix("~/") { + let home = + dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string())?; + return Ok(home.join(rest)); + } + Ok(PathBuf::from(path)) +} + /// Show `DeepSeek` dashboard and docs links pub fn deepseek_links(app: &mut App) -> CommandResult { let locale = app.ui_locale; @@ -331,6 +376,7 @@ mod tests { use crate::tui::history::HistoryCell; use std::path::PathBuf; use std::time::Instant; + use tempfile::tempdir; fn create_test_app() -> App { let options = TuiOptions { @@ -506,6 +552,62 @@ mod tests { assert!(matches!(result.action, Some(AppAction::Quit))); } + #[test] + fn workspace_without_arg_shows_current_workspace() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, None); + let msg = result.message.expect("workspace should be shown"); + assert!(msg.contains("Current workspace:")); + assert!(msg.contains("/tmp/test-workspace")); + assert!(result.action.is_none()); + } + + #[test] + fn workspace_existing_absolute_dir_returns_switch_action() { + let mut app = create_test_app(); + let dir = tempdir().expect("temp dir"); + let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap())); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() + )); + } + + #[test] + fn workspace_relative_dir_resolves_from_current_workspace() { + let root = tempdir().expect("temp dir"); + let child = root.path().join("child"); + std::fs::create_dir(&child).expect("child dir"); + let mut app = create_test_app(); + app.workspace = root.path().to_path_buf(); + + let result = workspace_switch(&mut app, Some("child")); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap() + )); + } + + #[test] + fn workspace_rejects_missing_path() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, Some("definitely-missing")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("does not exist")); + } + + #[test] + fn workspace_rejects_file_path() { + let root = tempdir().expect("temp dir"); + let file = root.path().join("file.txt"); + std::fs::write(&file, "not a directory").expect("test file"); + let mut app = create_test_app(); + + let result = workspace_switch(&mut app, Some(file.to_str().unwrap())); + assert!(result.is_error); + assert!(result.message.unwrap().contains("not a directory")); + } + #[test] fn test_model_change_updates_state() { let mut app = create_test_app(); diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index aaa9d0fe..186e9744 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -230,6 +230,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/home", description_id: MessageId::CmdHomeDescription, }, + CommandInfo { + name: "workspace", + aliases: &["cwd"], + usage: "/workspace [path]", + description_id: MessageId::CmdWorkspaceDescription, + }, CommandInfo { name: "note", aliases: &[], @@ -552,6 +558,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "links" | "dashboard" | "api" | "lianjie" => core::deepseek_links(app), "feedback" => feedback::feedback(app, arg), "home" | "stats" | "overview" | "zhuye" | "shouye" => core::home_dashboard(app), + "workspace" | "cwd" => core::workspace_switch(app, arg), "note" => note::note(app, arg), "memory" => memory::memory(app, arg), "attach" | "image" | "media" | "fujian" => attachment::attach(app, arg), @@ -1057,6 +1064,7 @@ mod tests { use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::MutexGuard; + use tempfile::tempdir; fn create_test_app() -> App { let options = TuiOptions { @@ -1291,6 +1299,17 @@ mod tests { } } + #[test] + fn execute_workspace_alias_switches_workspace() { + let dir = tempdir().expect("temp dir"); + let mut app = create_test_app(); + let result = execute(&format!("/cwd {}", dir.path().display()), &mut app); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() + )); + } + #[test] fn removed_set_and_deepseek_commands_show_migration_hints() { let mut app = create_test_app(); diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 111ba6fb..e1e98997 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -313,6 +313,7 @@ pub enum MessageId { CmdTrustDescription, CmdLspDescription, CmdShareDescription, + CmdWorkspaceDescription, CmdUndoDescription, CmdVerboseDescription, CmdCacheAdvice, @@ -543,6 +544,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdTrustDescription, MessageId::CmdLspDescription, MessageId::CmdShareDescription, + MessageId::CmdWorkspaceDescription, MessageId::CmdUndoDescription, MessageId::CmdVerboseDescription, MessageId::CmdCacheAdvice, @@ -995,6 +997,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdTrustDescription => { "Manage workspace trust and per-path allowlist (`/trust add `, `/trust list`, `/trust on|off`)" } + MessageId::CmdWorkspaceDescription => "Show or switch the current workspace", MessageId::CmdUndoDescription => "Remove last message pair", MessageId::CmdVerboseDescription => "Toggle full live thinking in the transcript", MessageId::CmdCacheAdvice => { @@ -1375,6 +1378,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdTrustDescription => { "ワークスペースの信頼設定とパス別許可リストを管理(`/trust add `、`/trust list`、`/trust on|off`)" } + MessageId::CmdWorkspaceDescription => "現在のワークスペースを表示または切り替え", MessageId::CmdUndoDescription => "最後のメッセージ対を削除", MessageId::CmdVerboseDescription => "ライブ思考表示の詳細モードを切り替え", MessageId::CmdCacheAdvice => { @@ -1708,6 +1712,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdTrustDescription => { "管理工作区信任与按路径的白名单(`/trust add `、`/trust list`、`/trust on|off`)" } + MessageId::CmdWorkspaceDescription => "显示或切换当前工作空间", MessageId::CmdUndoDescription => "移除最后一组消息对", MessageId::CmdVerboseDescription => "切换实时思考内容的完整显示", MessageId::CmdCacheAdvice => { @@ -2049,6 +2054,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdTrustDescription => { "Gerenciar a confiança do workspace e a allowlist por caminho (`/trust add `, `/trust list`, `/trust on|off`)" } + MessageId::CmdWorkspaceDescription => "Mostrar ou trocar o workspace atual", MessageId::CmdUndoDescription => "Remover o último par de mensagens", MessageId::CmdVerboseDescription => "Alternar pensamento ao vivo completo no transcript", MessageId::CmdCacheAdvice => { @@ -2434,6 +2440,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CmdTrustDescription => { "Gestionar la confianza del workspace y la lista de paths permitidos (`/trust add `, `/trust list`, `/trust on|off`)" } + MessageId::CmdWorkspaceDescription => "Mostrar o cambiar el workspace actual", MessageId::CmdUndoDescription => "Eliminar el último par de mensajes", MessageId::CmdVerboseDescription => { "Alternar pensamiento en vivo completo en la transcripción" diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index 4c771229..a773edde 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -701,6 +701,7 @@ pub type SharedTaskManager = Arc; pub struct TaskManager { cfg: TaskManagerConfig, + default_workspace: Mutex, executor: Arc, tasks_dir: PathBuf, artifacts_dir: PathBuf, @@ -766,8 +767,10 @@ impl TaskManager { let (tasks, queue) = load_state(&tasks_dir, &queue_path)?; let cancel_token = CancellationToken::new(); + let default_workspace = cfg.default_workspace.clone(); let manager = Arc::new(Self { cfg, + default_workspace: Mutex::new(default_workspace), executor, tasks_dir, artifacts_dir, @@ -810,6 +813,15 @@ impl TaskManager { self.cancel_token.is_cancelled() } + pub async fn set_default_workspace(&self, workspace: PathBuf) { + let mut default_workspace = self.default_workspace.lock().await; + *default_workspace = workspace; + } + + pub async fn default_workspace(&self) -> PathBuf { + self.default_workspace.lock().await.clone() + } + /// Enqueue a new task. pub async fn add_task(&self, req: NewTaskRequest) -> Result { let prompt = req.prompt.trim().to_string(); @@ -822,9 +834,10 @@ impl TaskManager { id: format!("task_{}", &Uuid::new_v4().to_string()[..8]), prompt, model: req.model.unwrap_or_else(|| self.cfg.default_model.clone()), - workspace: req - .workspace - .unwrap_or_else(|| self.cfg.default_workspace.clone()), + workspace: match req.workspace { + Some(workspace) => workspace, + None => self.default_workspace().await, + }, mode: req.mode.unwrap_or_else(|| self.cfg.default_mode.clone()), allow_shell: req.allow_shell.unwrap_or(self.cfg.allow_shell), trust_mode: req.trust_mode.unwrap_or(self.cfg.trust_mode), @@ -1765,6 +1778,24 @@ mod tests { Ok(()) } + #[tokio::test] + async fn default_workspace_updates_for_future_tasks() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4())); + let new_workspace = + std::env::temp_dir().join(format!("deepseek-workspace-{}", Uuid::new_v4())); + let manager = + TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await?; + + manager.set_default_workspace(new_workspace.clone()).await; + let task = manager + .add_task(NewTaskRequest::from_prompt("test workspace default")) + .await?; + + assert_eq!(manager.default_workspace().await, new_workspace); + assert_eq!(task.workspace, new_workspace); + Ok(()) + } + #[tokio::test] async fn record_tool_metadata_updates_explicit_task() -> Result<()> { let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4())); diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 7dfa0247..7dea0295 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -12,7 +12,7 @@ use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::{Read, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -603,6 +603,11 @@ impl ShellManager { self.sandbox_manager.is_available() } + #[allow(dead_code)] + pub fn default_workspace(&self) -> &Path { + &self.default_workspace + } + /// Execute a shell command with the configured sandbox policy. #[allow(dead_code)] pub fn execute( diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 414ed1c7..0ff37b51 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -57,6 +57,31 @@ pub enum OnboardingState { None, } +pub(crate) fn resolve_skills_dir( + workspace: &Path, + global_skills_dir: &Path, + config: &Config, +) -> PathBuf { + let agents_skills_dir = workspace.join(".agents").join("skills"); + if agents_skills_dir.exists() { + return agents_skills_dir; + } + + let local_skills_dir = workspace.join("skills"); + if local_skills_dir.exists() { + return local_skills_dir; + } + + if config.skills_dir.is_none() + && let Some(global_agents) = crate::skills::agents_global_skills_dir() + && global_agents.exists() + { + return global_agents; + } + + global_skills_dir.to_path_buf() +} + fn initial_onboarding_state( skip_onboarding: bool, was_onboarded: bool, @@ -1361,21 +1386,7 @@ impl App { // Initialize plan state let plan_state = new_shared_plan_state(); - let agents_skills_dir = workspace.join(".agents").join("skills"); - let local_skills_dir = workspace.join("skills"); - let agents_global_skills_dir = crate::skills::agents_global_skills_dir(); - let skills_dir = if agents_skills_dir.exists() { - agents_skills_dir - } else if local_skills_dir.exists() { - local_skills_dir - } else if config.skills_dir.is_none() - && let Some(global_agents) = agents_global_skills_dir - && global_agents.exists() - { - global_agents - } else { - global_skills_dir - }; + let skills_dir = resolve_skills_dir(&workspace, &global_skills_dir, config); let cached_skills = Self::discover_cached_skills(&workspace); let input_history = crate::composer_history::load_history(); @@ -4062,6 +4073,10 @@ pub enum AppAction { /// Profile name to load. profile: String, }, + /// Switch the workspace used by tools, hooks, tasks, and session metadata. + SwitchWorkspace { + workspace: PathBuf, + }, /// Export and share the current session as a web URL. ShareSession { history_len: usize, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c10e0ba7..1bc7bcca 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -44,7 +44,7 @@ use crate::core::coherence::CoherenceState; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::Op; -use crate::hooks::HookEvent; +use crate::hooks::{HookEvent, HookExecutor}; use crate::llm_client::LlmClient; use crate::models::{ ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model, @@ -345,7 +345,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { match load_result { Ok(Some(saved)) => { - let recovered = apply_loaded_session(&mut app, &saved); + let recovered = apply_loaded_session(&mut app, config, &saved); if !recovered { app.status_message = Some(format!( "Resumed session: {}", @@ -5462,6 +5462,9 @@ async fn apply_command_result( AppAction::Mcp(action) => { handle_mcp_ui_action(app, config, action).await; } + AppAction::SwitchWorkspace { workspace } => { + switch_workspace(app, engine_handle, task_manager, config, workspace).await; + } AppAction::SwitchProfile { profile } => { app.config_profile = Some(profile.clone()); match Config::load(app.config_path.clone(), Some(&profile)) { @@ -5567,6 +5570,72 @@ fn open_external_url(url: &str) -> Result<()> { Ok(()) } +fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) { + app.workspace = workspace.clone(); + app.hooks = HookExecutor::new(config.hooks_config(), workspace.clone()); + app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config); + app.refresh_skill_cache(); + app.workspace_context = None; + if let Ok(mut cell) = app.workspace_context_cell.lock() { + *cell = None; + } + app.workspace_context_refreshed_at = None; + app.file_tree = None; + + let shell_manager = crate::tools::shell::new_shared_shell_manager(workspace); + app.runtime_services.shell_manager = Some(shell_manager); + app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone())); +} + +async fn sync_runtime_workspace_state(task_manager: &SharedTaskManager, workspace: PathBuf) { + task_manager.set_default_workspace(workspace).await; +} + +async fn switch_workspace( + app: &mut App, + engine_handle: &mut EngineHandle, + task_manager: &SharedTaskManager, + config: &Config, + workspace: PathBuf, +) { + if app.is_loading { + app.status_message = + Some("Cannot switch workspace while a request is running.".to_string()); + app.add_message(HistoryCell::System { + content: "Cannot switch workspace while a request is running.".to_string(), + }); + return; + } + + if app.workspace == workspace { + app.status_message = Some(format!("Workspace unchanged: {}", workspace.display())); + return; + } + + apply_workspace_runtime_state(app, config, workspace.clone()); + sync_runtime_workspace_state(task_manager, workspace.clone()).await; + + let _ = engine_handle.send(Op::Shutdown).await; + let engine_config = build_engine_config(app, config); + *engine_handle = spawn_engine(engine_config, config); + if !app.api_messages.is_empty() { + let _ = engine_handle + .send(Op::SyncSession { + session_id: app.current_session_id.clone(), + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: workspace.clone(), + }) + .await; + } + + app.add_message(HistoryCell::System { + content: format!("Switched workspace to {}", workspace.display()), + }); + app.status_message = Some(format!("Workspace: {}", workspace.display())); +} + async fn handle_mcp_ui_action( app: &mut App, config: &Config, @@ -6537,7 +6606,8 @@ async fn handle_view_events( match manager.load_session(&session_id) { Ok(session) => { - let recovered = apply_loaded_session(app, &session); + let recovered = apply_loaded_session(app, config, &session); + sync_runtime_workspace_state(task_manager, app.workspace.clone()).await; let _ = engine_handle .send(Op::SyncSession { session_id: app.current_session_id.clone(), @@ -6882,7 +6952,7 @@ async fn apply_provider_picker_api_key( switch_provider(app, engine_handle, config, provider, None).await; } -fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { +fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession) -> bool { let (messages, recovered_draft) = recover_interrupted_user_tail(&session.messages); app.api_messages = messages; app.clear_history(); @@ -6928,7 +6998,7 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { app.viewport.transcript_selection.clear(); app.model.clone_from(&session.metadata.model); app.update_model_compaction_budget(); - app.workspace.clone_from(&session.metadata.workspace); + apply_workspace_runtime_state(app, config, session.metadata.workspace.clone()); app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); app.session.total_conversation_tokens = app.session.total_tokens; app.session.session_cost = session.metadata.cost.session_cost_usd; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 67aa6868..e6645ed5 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1198,7 +1198,7 @@ fn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() { "finish the Qthresh proof bundle", )]); - let recovered = apply_loaded_session(&mut app, &session); + let recovered = apply_loaded_session(&mut app, &Config::default(), &session); assert!(recovered); assert!(app.api_messages.is_empty()); @@ -1249,7 +1249,7 @@ fn apply_loaded_session_resets_unpersisted_telemetry() { let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]); session.metadata.total_tokens = 500; - let recovered = apply_loaded_session(&mut app, &session); + let recovered = apply_loaded_session(&mut app, &Config::default(), &session); assert!(!recovered); assert_eq!(app.session.total_tokens, 500); @@ -1269,6 +1269,76 @@ fn apply_loaded_session_resets_unpersisted_telemetry() { assert!(app.session.turn_cache_history.is_empty()); } +#[tokio::test] +async fn apply_loaded_session_resets_workspace_runtime_state() { + let mut app = create_test_app(); + let config = Config::default(); + let old_shell_manager = app + .runtime_services + .shell_manager + .as_ref() + .expect("shell manager") + .clone(); + let old_context_cell = app.workspace_context_cell.clone(); + app.workspace_context = Some("old workspace context".to_string()); + if let Ok(mut cell) = old_context_cell.lock() { + *cell = Some("old workspace context".to_string()); + } + app.workspace_context_refreshed_at = Some(Instant::now()); + app.file_tree = Some(crate::tui::file_tree::FileTreeState::new( + PathBuf::from(".").as_path(), + )); + + let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]); + session.metadata.workspace = TempDir::new().expect("temp dir").path().to_path_buf(); + + let recovered = apply_loaded_session(&mut app, &config, &session); + + assert!(!recovered); + assert_eq!(app.workspace, session.metadata.workspace); + assert!(app.workspace_context.is_none()); + assert!(app.workspace_context_refreshed_at.is_none()); + assert!(app.file_tree.is_none()); + assert!(old_context_cell.lock().expect("context cell").is_none()); + let new_shell_manager = app + .runtime_services + .shell_manager + .as_ref() + .expect("shell manager") + .clone(); + assert!(!std::sync::Arc::ptr_eq( + &old_shell_manager, + &new_shell_manager + )); + assert_eq!( + new_shell_manager + .lock() + .expect("shell manager") + .default_workspace(), + session.metadata.workspace.as_path() + ); + assert!(app.runtime_services.hook_executor.is_some()); +} + +#[test] +fn apply_loaded_session_updates_current_workspace_display() { + let mut app = create_test_app(); + let config = Config::default(); + let workspace = TempDir::new().expect("temp dir"); + let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]); + session.metadata.workspace = workspace.path().to_path_buf(); + + let recovered = apply_loaded_session(&mut app, &config, &session); + let result = commands::execute("/workspace", &mut app); + + assert!(!recovered); + assert_eq!( + result.message, + Some(format!("Current workspace: {}", workspace.path().display())) + ); + assert!(result.action.is_none()); +} + #[tokio::test] async fn drain_web_config_events_applies_draft_without_closing_session() { let mut app = create_test_app(); @@ -3510,7 +3580,7 @@ fn apply_loaded_session_restores_artifact_registry() { storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"), }); - let recovered = apply_loaded_session(&mut app, &session); + let recovered = apply_loaded_session(&mut app, &Config::default(), &session); assert!(!recovered); assert_eq!(app.session_artifacts, session.artifacts);