diff --git a/src/apps/desktop/src/api/miniapp_agent_api.rs b/src/apps/desktop/src/api/miniapp_agent_api.rs index e9a174138b..f48b78559d 100644 --- a/src/apps/desktop/src/api/miniapp_agent_api.rs +++ b/src/apps/desktop/src/api/miniapp_agent_api.rs @@ -26,16 +26,16 @@ use openbitfun_core::agentic::coordination::{ use openbitfun_core::agentic::core::{MessageContent, MessageRole, Session, SessionConfig}; use openbitfun_core::miniapp::agent_bridge::{ agent_run_id_from_request, build_agent_submission_plan, extract_agent_turn_text, - plan_agent_workspace, require_agent_prompt, require_enabled_agent_permissions, - validate_reused_session, MiniAppAgentRateLimiter, MiniAppAgentRunRecord, - MiniAppAgentRunRegistry, MiniAppAgentSubmissionPlan, MiniAppAgentTurnMessage, - MiniAppAgentTurnMessageRole, MINIAPP_AGENT_KIND, UNKNOWN_AGENT_RUN_MESSAGE, - UNKNOWN_AGENT_SESSION_MESSAGE, + require_agent_prompt, require_enabled_agent_permissions, validate_reused_session, + MiniAppAgentRateLimiter, MiniAppAgentRunRecord, MiniAppAgentRunRegistry, + MiniAppAgentSubmissionPlan, MiniAppAgentTurnMessage, MiniAppAgentTurnMessageRole, + MINIAPP_AGENT_KIND, UNKNOWN_AGENT_RUN_MESSAGE, UNKNOWN_AGENT_SESSION_MESSAGE, }; use openbitfun_core::miniapp::agent_context::{ remove_agent_context_snapshot, reserve_agent_context_snapshot, MiniAppAgentContextInput, MiniAppAgentContextSnapshot, }; +use openbitfun_core::miniapp::agent_workspace::prepare_agent_workspace; use openbitfun_core::OpenBitFunError; // ============== Run registry ============== @@ -393,15 +393,13 @@ pub async fn miniapp_agent_ensure_session( .miniapp_manager .path_manager() .miniapp_dir(&request.app_id); - let workspace_plan = plan_agent_workspace( + let workspace_plan = prepare_agent_workspace( + &state.workspace_service, None, Some(request.app_data_workspace.as_str()), &app_data_dir, - )?; - if workspace_plan.create_if_missing { - std::fs::create_dir_all(&workspace_plan.path) - .map_err(|e| format!("Failed to create MiniApp agent workspace: {}", e))?; - } + ) + .await?; let run_sequence = AGENT_RUN_COUNTER.fetch_add(1, Ordering::Relaxed); let run_id = agent_run_id_from_request(&request.app_id, None, run_sequence); @@ -504,15 +502,13 @@ pub async fn miniapp_agent_run( .miniapp_manager .path_manager() .miniapp_dir(&request.app_id); - let workspace_plan = plan_agent_workspace( + let workspace_plan = prepare_agent_workspace( + &state.workspace_service, request.workspace_path.as_deref(), request.app_data_workspace.as_deref(), &app_data_dir, - )?; - if workspace_plan.create_if_missing { - std::fs::create_dir_all(&workspace_plan.path) - .map_err(|e| format!("Failed to create MiniApp agent workspace: {}", e))?; - } + ) + .await?; let workspace_path = workspace_plan.workspace_path.clone(); let run_sequence = if request .run_id diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 62f3509a7f..7484ff61f9 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -290,6 +290,12 @@ cargo test --locked -p openbitfun-core --no-default-features --features agent-ru cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib list_workspaces ``` +MiniApp Agent appdata workspace registration and pre-ID session compatibility: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,tools-miniapp --lib miniapp::agent_workspace::tests +``` + Skill hook activation, session cleanup, and tool preflight/permission ordering: ```bash diff --git a/src/crates/assembly/core/src/miniapp/agent_workspace.rs b/src/crates/assembly/core/src/miniapp/agent_workspace.rs new file mode 100644 index 0000000000..722e34a8bf --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/agent_workspace.rs @@ -0,0 +1,142 @@ +//! Prepare host-owned MiniApp Agent workspaces before session creation/restore. + +use std::path::Path; + +use super::agent_bridge::{plan_agent_workspace, MiniAppAgentWorkspacePlan}; +use crate::service::workspace::WorkspaceService; + +pub async fn prepare_agent_workspace( + workspace_service: &WorkspaceService, + explicit_workspace_path: Option<&str>, + app_data_workspace: Option<&str>, + app_data_dir: &Path, +) -> Result { + let plan = plan_agent_workspace(explicit_workspace_path, app_data_workspace, app_data_dir)?; + if plan.create_if_missing { + tokio::fs::create_dir_all(&plan.path) + .await + .map_err(|e| format!("Failed to create MiniApp agent workspace: {e}"))?; + // Appdata topics predate workspace IDs and were never opened in the + // workspace catalog. Register their host-owned directory before either + // creating a session or restoring its legacy path-only config. Keep + // the original session/history and the user's desktop selection. + // Explicit workspace operands do not establish local ownership and + // must continue through the existing workspace/remote admission path. + workspace_service + .register_local_workspace_record(plan.path.clone()) + .await + .map_err(|e| format!("Failed to register MiniApp agent workspace: {e}"))?; + } + Ok(plan) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::core::SessionConfig; + use crate::service::workspace::legacy_compat::upgrade_session_workspace_reference; + + #[tokio::test] + async fn appdata_registration_upgrades_legacy_sessions_without_changing_selection() { + let root = tempfile::tempdir().unwrap(); + let service = WorkspaceService::new_isolated_for_tests(root.path().join("user")).await; + let project = root.path().join("project"); + tokio::fs::create_dir_all(&project).await.unwrap(); + let opened = service.open_workspace(project).await.unwrap(); + let appdata = root.path().join("miniapps/legacy-app"); + let chat = appdata.join("chat"); + tokio::fs::create_dir_all(&chat).await.unwrap(); + let chat = dunce::canonicalize(chat).unwrap(); + let legacy_payload = serde_json::json!({ + "max_context_tokens": 128000, + "auto_compact": true, + "enable_tools": true, + "safe_mode": true, + "max_turns": 100, + "enable_context_compression": true, + "workspace_path": chat, + "model_id": "saved-model", + }); + let mut config: SessionConfig = serde_json::from_value(legacy_payload.clone()).unwrap(); + let error = upgrade_session_workspace_reference(&mut config, &service) + .await + .unwrap_err(); + assert!(error + .to_string() + .contains("Legacy session workspace is unavailable")); + + let plan = prepare_agent_workspace(&service, None, Some("chat"), &appdata) + .await + .unwrap(); + upgrade_session_workspace_reference(&mut config, &service) + .await + .unwrap(); + let id = config.workspace_id.clone().unwrap(); + assert_eq!(config.project_workspace_id.as_deref(), Some(id.as_str())); + assert_eq!(config.workspace_path.as_deref(), chat.to_str()); + assert_eq!(config.model_id.as_deref(), Some("saved-model")); + assert_eq!(dunce::canonicalize(plan.path).unwrap(), chat); + + // Old readers still receive the original path and model; the additive + // IDs survive a persisted-config round trip for upgraded readers. + let saved = serde_json::to_value(&config).unwrap(); + assert_eq!(saved["workspace_path"], legacy_payload["workspace_path"]); + assert_eq!(saved["model_id"], legacy_payload["model_id"]); + let mut restored: SessionConfig = serde_json::from_value(saved).unwrap(); + prepare_agent_workspace(&service, None, Some("chat"), &appdata) + .await + .unwrap(); + upgrade_session_workspace_reference(&mut restored, &service) + .await + .unwrap(); + assert_eq!(restored.workspace_id.as_deref(), Some(id.as_str())); + + assert_eq!(service.get_current_workspace().await.unwrap().id, opened.id); + assert_eq!(service.get_opened_workspaces().await.len(), 1); + assert!(service + .get_recent_workspaces() + .await + .iter() + .all(|w| w.id != id)); + } + + #[tokio::test] + async fn new_appdata_topics_are_registered_but_explicit_operands_are_not() { + let root = tempfile::tempdir().unwrap(); + let service = WorkspaceService::new_isolated_for_tests(root.path().join("user")).await; + let appdata = root.path().join("miniapps/app"); + let plan = prepare_agent_workspace( + &service, + Some("/remote/project"), + Some("topics/new"), + &appdata, + ) + .await + .unwrap(); + assert!(plan.path.is_dir()); + let records = service.list_workspace_infos().await; + assert_eq!(records.len(), 1); + assert_eq!( + records[0].root_path, + dunce::canonicalize(&plan.path).unwrap() + ); + assert!(service.get_opened_workspaces().await.is_empty()); + assert!(service.get_recent_workspaces().await.is_empty()); + assert!(service.get_current_workspace().await.is_none()); + + // A remote POSIX operand (or an unknown local folder) must not be + // registered locally just because a MiniApp supplied its path. + let explicit = prepare_agent_workspace(&service, Some("/remote/project"), None, &appdata) + .await + .unwrap(); + assert!(!explicit.create_if_missing); + assert_eq!(service.list_workspace_infos().await.len(), 1); + assert!( + prepare_agent_workspace(&service, None, Some("../outside"), &appdata) + .await + .is_err() + ); + assert!(!appdata.join("../outside").exists()); + assert_eq!(service.list_workspace_infos().await.len(), 1); + } +} diff --git a/src/crates/assembly/core/src/miniapp/mod.rs b/src/crates/assembly/core/src/miniapp/mod.rs index 96d0019bf8..937387ffa2 100644 --- a/src/crates/assembly/core/src/miniapp/mod.rs +++ b/src/crates/assembly/core/src/miniapp/mod.rs @@ -2,6 +2,8 @@ #[cfg(feature = "agent-runtime")] pub mod agent_context; +#[cfg(feature = "agent-runtime")] +pub mod agent_workspace; pub mod builtin; pub mod compiler; pub mod exporter;