From f6a8092e26888631e7bef67012f51111a3b6e2c5 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 05:21:21 -0700 Subject: [PATCH] fix(miniapp): finalize agent-generated updates Add a first-class FinalizeMiniApp tool that syncs source files, recompiles runtime output, and notifies open MiniApps. Track content hashes so no-op syncs and draft applies do not manufacture versions, and keep customization actions blocked until the Agent turn finishes. Document the active-app and draft workflows in the bundled skill. --- .../core/builtin_skills/miniapp-dev/SKILL.md | 47 ++++- .../agentic/agents/definitions/modes/claw.rs | 4 +- .../agents/definitions/modes/cowork.rs | 4 +- .../assembly/core/src/agentic/agents/mod.rs | 1 + .../src/agentic/tools/agent-tool-exposure.md | 1 + .../implementations/miniapp_finalize_tool.rs | 182 ++++++++++++++++++ .../implementations/miniapp_init_tool.rs | 4 +- .../src/agentic/tools/implementations/mod.rs | 2 + .../tools/product_runtime/materialization.rs | 1 + .../core/src/agentic/tools/registry.rs | 2 + .../assembly/core/src/miniapp/manager.rs | 29 ++- .../product-domains/src/miniapp/lifecycle.rs | 98 ++++++++-- .../src/miniapp/runtime_facade.rs | 22 ++- .../product-domains/src/miniapp/types.rs | 5 + .../tests/miniapp_contracts.rs | 61 +++++- .../execution/tool-provider-groups/src/lib.rs | 2 + .../customization/MiniAppCustomizePanel.tsx | 29 ++- .../miniAppCustomizationPrompt.test.ts | 1 + .../miniAppCustomizationPrompt.ts | 1 + .../miniAppCustomizationSession.test.ts | 33 ++++ .../miniAppCustomizationSession.ts | 9 + .../flow_chat/tool-cards/toolCardMetadata.ts | 10 + .../api/service-api/MiniAppAPI.ts | 1 + 23 files changed, 513 insertions(+), 36 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/miniapp_finalize_tool.rs diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md index d8ae6ceae5..5d98a332c0 100644 --- a/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md @@ -98,7 +98,41 @@ description: 'Generate and refine BitFun MiniApps. Use when the user wants a new - 需要 npm 依赖 - 需要较长链路或较复杂的后台逻辑 -### 4. 用 `InitMiniApp` 创建骨架 +### 4. 选择正确的编辑流程 + +先判断当前任务属于哪一种,不要混用: + +#### 新建 MiniApp + +1. 调用 `InitMiniApp`,保存返回的 `app_id` 和根目录。 +2. 只在返回的根目录中编辑 `source/index.html`、`source/style.css`、 + `source/ui.js`、按需编辑 `source/worker.js`,以及确有必要的 `meta.json` + 产品字段。 +3. 编辑完成后必须调用 `FinalizeMiniApp`,传入刚才的 `app_id`。 +4. 只有 `FinalizeMiniApp` 成功后才算交付完成。 + +#### 更新已有 MiniApp + +1. 复用已有应用的 `app_id` 和根目录,不要再次调用 `InitMiniApp` 创建副本。 +2. 在已有根目录中完成修改。 +3. 每一批文件修改完成后必须调用一次 `FinalizeMiniApp`。 +4. 如果预期有修改但返回 `changed: false`,检查文件是否写进了正确的应用根目录; + 不要靠手动增加版本号掩盖问题。 + +`FinalizeMiniApp` 会重新从磁盘读取源码、编译 `compiled.html`、持久化内容修订, +并通知已经打开的 MiniApp 刷新。版本号由它管理;不要手改 `version`、 +`created_at`、`updated_at`、`runtime` 或 `compiled.html`。 + +#### 定制草稿 + +如果当前提示明确给出了 `Draft root` / 草稿目录: + +- 只编辑草稿目录 +- 不调用 `InitMiniApp` +- 不调用 `FinalizeMiniApp` +- 由定制面板负责“刷新草稿预览”和“应用草稿” + +### 5. 用 `InitMiniApp` 创建骨架 创建后,围绕这些文件工作: @@ -115,7 +149,7 @@ description: 'Generate and refine BitFun MiniApps. Use when the user wants a new - `ui.js` 负责状态、渲染、事件、i18n - `worker.js` 只承载真正需要后台执行的逻辑 -### 5. 只使用真实存在的宿主能力 +### 6. 只使用真实存在的宿主能力 MiniApp 里可用的是 `window.app`。 @@ -140,7 +174,7 @@ MiniApp 里可用的是 `window.app`。 - [`api-reference.md`](api-reference.md) -### 6. 不要假设这些 API 存在 +### 7. 不要假设这些 API 存在 默认**不要**写这些不存在的接口: @@ -163,7 +197,7 @@ await app.shell.exec('git ...', { cwd: app.workspaceDir }) await app.fs.readFile(...) ``` -### 7. 从第一版就带上 i18n 和 theme +### 8. 从第一版就带上 i18n 和 theme 不要把多语言和主题适配留到最后。 @@ -175,7 +209,7 @@ await app.fs.readFile(...) - 样式优先使用 `--bitfun-*` - 测试 light/dark + zh/en -### 8. 先做核心体验,不补假内容 +### 9. 先做核心体验,不补假内容 如果缺素材、图标、真实数据: @@ -249,6 +283,9 @@ await app.fs.readFile(...) - i18n 至少覆盖 `zh-CN` / `en-US` - light/dark 没有明显样式问题 - 没有遗留 “TODO / 占位 / Lorem ipsum” +- 普通新建/更新流程已成功调用 `FinalizeMiniApp` +- 预期有改动时 `FinalizeMiniApp` 返回 `changed: true` +- 没有手动修改生命周期字段或 `compiled.html` ## 发布到市场 diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index 58d8732bb4..944a91d40c 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -43,6 +43,7 @@ impl ClawMode { // agent/tool instead of being surfaced as a ControlHub domain. "ControlHub".to_string(), "InitMiniApp".to_string(), + "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), @@ -96,9 +97,10 @@ mod tests { use bitfun_agent_runtime::prompt::UserContextSection; #[test] - fn claw_mode_includes_init_miniapp_in_default_tools() { + fn claw_mode_includes_miniapp_lifecycle_tools_in_defaults() { let tools = ClawMode::new().default_tools(); assert!(tools.contains(&"InitMiniApp".to_string())); + assert!(tools.contains(&"FinalizeMiniApp".to_string())); assert!(tools.contains(&"ListModels".to_string())); } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index 660ba8b703..281e0da177 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -54,6 +54,7 @@ impl CoworkMode { "WebFetch".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), + "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), ], } @@ -108,9 +109,10 @@ mod tests { use crate::agentic::agents::Agent; #[test] - fn cowork_mode_includes_init_miniapp_in_default_tools() { + fn cowork_mode_includes_miniapp_lifecycle_tools_in_defaults() { let tools = CoworkMode::new().default_tools(); assert!(tools.contains(&"InitMiniApp".to_string())); + assert!(tools.contains(&"FinalizeMiniApp".to_string())); assert!(tools.contains(&"ListModels".to_string())); } } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index d984559e23..d23d7c27e1 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -130,6 +130,7 @@ pub fn shared_coding_mode_tools() -> Vec { "ReviewPlatform".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), + "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), diff --git a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md index 0f7872a082..208f98e985 100644 --- a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md +++ b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md @@ -44,6 +44,7 @@ Notes: | `GenerativeUI` | Deferred | None | - | | `Git` | Deferred | `ReviewFixer`, `ReviewWorker`, `ReviewJudge` | Direct | | `InitMiniApp` | Direct | None | - | +| `FinalizeMiniApp` | Direct | None | - | | `PublishMiniApp` | Direct | None | - | | `ControlHub` | Deferred | `ComputerUse` | Direct | | `ComputerUse` | Deferred | `ComputerUse` | Direct | diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_finalize_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_finalize_tool.rs new file mode 100644 index 0000000000..5a6f1969cb --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_finalize_tool.rs @@ -0,0 +1,182 @@ +//! FinalizeMiniApp tool — compile direct MiniApp file edits and publish the +//! resulting runtime revision to every open product surface. + +use crate::agentic::tools::framework::{PermissionIntent, Tool, ToolResult, ToolUseContext}; +use crate::infrastructure::events::{emit_global_event, BackendEvent}; +use crate::miniapp::lifecycle::miniapp_runtime_event_payload; +use crate::miniapp::try_get_global_miniapp_manager; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +pub struct FinalizeMiniAppTool; + +impl FinalizeMiniAppTool { + pub fn new() -> Self { + Self + } +} + +impl Default for FinalizeMiniAppTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for FinalizeMiniAppTool { + fn name(&self) -> &str { + "FinalizeMiniApp" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Finalize source files edited for an installed BitFun MiniApp. + +Call this after every successful Read/Write/Edit pass under a MiniApp root returned by InitMiniApp, and after modifying an existing MiniApp. It: +- reloads source files from disk; +- recompiles and persists compiled.html; +- increments the MiniApp version only when user-controlled content changed; +- emits runtime update events so an already-open MiniApp reloads. + +Input: app_id and optional theme ('dark' or 'light'). +Do not call this for a customization draft root; the customization host owns draft sync and apply. +Do not edit meta.json version fields manually; this tool owns version transitions. +Returns app_id, version, changed, content_hash, and source_revision."# + .to_string()) + } + + fn short_description(&self) -> String { + "Finalize MiniApp file edits and refresh open runtimes.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["app_id"], + "properties": { + "app_id": { + "type": "string", + "description": "Installed MiniApp id returned by InitMiniApp." + }, + "theme": { + "type": "string", + "enum": ["dark", "light"], + "description": "Theme used for the persisted compiled preview. Defaults to dark." + } + } + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let app_id = input + .get("app_id") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + Ok(vec![PermissionIntent::new( + "custom_tool", + vec![format!("miniapp:FinalizeMiniApp:{app_id}")], + )]) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let manager = try_get_global_miniapp_manager() + .ok_or_else(|| BitFunError::tool("MiniAppManager not initialized".to_string()))?; + let app_id = input + .get("app_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| BitFunError::validation("Missing required field: app_id"))?; + let theme = input.get("theme").and_then(Value::as_str).unwrap_or("dark"); + + let previous = manager + .get_meta(app_id) + .await + .map_err(|error| BitFunError::tool(format!("Failed to load MiniApp: {error}")))?; + let app = manager + .sync_from_fs(app_id, theme, context.workspace_root()) + .await + .map_err(|error| BitFunError::tool(format!("Failed to finalize MiniApp: {error}")))?; + let changed = app.version != previous.version; + let reason = if changed { + "agent-finalize" + } else { + "agent-finalize-noop" + }; + + for event_name in ["miniapp-recompiled", "miniapp-updated"] { + let _ = emit_global_event(BackendEvent::Custom { + event_name: event_name.to_string(), + payload: miniapp_runtime_event_payload(&app, reason), + }) + .await; + } + + let result_text = if changed { + format!( + "MiniApp '{}' finalized at version {}. Open runtimes were notified to reload.", + app.name, app.version + ) + } else { + format!( + "MiniApp '{}' was recompiled with no content change; version remains {}. Open runtimes were notified to reload.", + app.name, app.version + ) + }; + + Ok(vec![ToolResult::Result { + data: json!({ + "app_id": app.id, + "version": app.version, + "changed": changed, + "content_hash": app.runtime.content_hash, + "source_revision": app.runtime.source_revision, + }), + result_for_assistant: Some(result_text), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::FinalizeMiniAppTool; + use crate::agentic::tools::framework::{Tool, ToolExposure, ToolUseContext}; + use serde_json::json; + + #[test] + fn finalize_miniapp_stays_expanded_for_assistant_updates() { + let tool = FinalizeMiniAppTool::new(); + assert_eq!(tool.default_exposure(), ToolExposure::Direct); + } + + #[test] + fn finalize_miniapp_emits_stable_permission_identity() { + let tool = FinalizeMiniAppTool::new(); + let context = ToolUseContext::for_tool_listing(None, None); + let intents = tool + .permission_intents(&json!({ "app_id": "demo-app" }), &context) + .expect("permission intent"); + + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].action, "custom_tool"); + assert_eq!( + intents[0].resources, + ["miniapp:FinalizeMiniApp:demo-app".to_string()] + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs index d7cd7b9163..39a78f0d23 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs @@ -69,7 +69,7 @@ Input: name, description, icon, category. The tool creates the app directory and - manifest (meta.json), source/index.html, source/style.css, source/ui.js, source/worker.js, package.json, storage.json. -Returns app_id and the app root directory. Use the root directory and file names above with Read/Write/Edit to implement the app. The MiniApp uses window.app (app.fs, app.call, app.dialog, etc.) — see miniapp-dev skill for API reference."# +Returns app_id and the app root directory. Use the root directory and file names above with Read/Write/Edit to implement the app, then call FinalizeMiniApp with the returned app_id. FinalizeMiniApp recompiles the edited files, persists the new content revision, and refreshes already-open runtimes. The MiniApp uses window.app (app.fs, app.call, app.dialog, etc.) — see miniapp-dev skill for API reference."# .to_string()) } @@ -214,7 +214,7 @@ Returns app_id and the app root directory. Use the root directory and file names .await; let result_text = format!( - "MiniApp '{}' skeleton created. app_id: {}. Root directory: {}. Use Read/Write/Edit tools with files under this root, then open in Toolbox to run.", + "MiniApp '{}' skeleton created. app_id: {}. Root directory: {}. Use Read/Write/Edit tools with files under this root, then call FinalizeMiniApp with this app_id before opening it.", app.name, app.id, app_dir_str ); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 76792d01a5..c58d48f444 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -28,6 +28,7 @@ pub mod grep_tool; pub mod list_models_tool; pub mod ls_tool; pub mod mcp_tools; +pub mod miniapp_finalize_tool; pub mod miniapp_init_tool; pub mod miniapp_publish_tool; pub mod page_deploy_tool; @@ -77,6 +78,7 @@ pub use ls_tool::LSTool; pub use mcp_tools::{ GetMCPPromptTool, ListMCPPromptsTool, ListMCPResourcesTool, ReadMCPResourceTool, }; +pub use miniapp_finalize_tool::FinalizeMiniAppTool; pub use miniapp_init_tool::InitMiniAppTool; pub use miniapp_publish_tool::PublishMiniAppTool; pub use page_deploy_tool::PageDeployTool; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index d887144912..105babd6a8 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -67,6 +67,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "Worktree" => Some(Arc::new(WorktreeTool::new())), "ReviewPlatform" => Some(Arc::new(ReviewPlatformTool::new())), "InitMiniApp" => Some(Arc::new(InitMiniAppTool::new())), + "FinalizeMiniApp" => Some(Arc::new(FinalizeMiniAppTool::new())), "PublishMiniApp" => Some(Arc::new(PublishMiniAppTool::new())), "PageDeploy" => Some(Arc::new(PageDeployTool::new())), "PagePublish" => Some(Arc::new(PagePublishTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 6828d0e159..de46029383 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -544,6 +544,7 @@ mod tests { "Worktree", "ReviewPlatform", "InitMiniApp", + "FinalizeMiniApp", "PublishMiniApp", "PageDeploy", "PagePublish", @@ -700,6 +701,7 @@ mod tests { assert!(registry.is_tool_deferred("Worktree")); assert!(registry.is_tool_deferred("ReviewPlatform")); assert!(!registry.is_tool_deferred("InitMiniApp")); + assert!(!registry.is_tool_deferred("FinalizeMiniApp")); assert!(!registry.is_tool_deferred("PublishMiniApp")); } diff --git a/src/crates/assembly/core/src/miniapp/manager.rs b/src/crates/assembly/core/src/miniapp/manager.rs index ee46794a16..a340e693c2 100644 --- a/src/crates/assembly/core/src/miniapp/manager.rs +++ b/src/crates/assembly/core/src/miniapp/manager.rs @@ -154,6 +154,14 @@ impl MiniAppManager { .map_err(map_miniapp_port_error) } + /// Get persisted metadata without normalizing runtime state. + /// + /// Agent-side finalization uses this raw baseline before source files are + /// synchronized, so legacy/runtime repair cannot hide a direct file edit. + pub async fn get_meta(&self, app_id: &str) -> BitFunResult { + self.storage.load_meta(app_id).await + } + /// Create a new MiniApp (generates id, sets created_at/updated_at, compiles). #[allow(clippy::too_many_arguments)] pub async fn create( @@ -1076,6 +1084,21 @@ mod tests { assert_eq!(manager.list_versions(&app.id).await.unwrap(), vec![1, 2]); } + #[tokio::test] + async fn sync_from_fs_recompiles_without_versioning_unchanged_content() { + let manager = test_manager(); + let app = create_sample_app(&manager).await; + + let synced = manager.sync_from_fs(&app.id, "dark", None).await.unwrap(); + + assert_eq!(synced.version, app.version); + assert_eq!(synced.updated_at, app.updated_at); + assert_eq!(synced.runtime.source_revision, app.runtime.source_revision); + assert_eq!(synced.runtime.content_hash, app.runtime.content_hash); + assert!(!synced.compiled_html.is_empty()); + assert!(manager.list_versions(&app.id).await.unwrap().is_empty()); + } + #[tokio::test] async fn import_from_path_preserves_fallback_files_recompile_and_runtime_state() { let manager = test_manager(); @@ -1209,7 +1232,7 @@ mod tests { } #[tokio::test] - async fn apply_draft_does_not_require_manifest_metadata() { + async fn unchanged_draft_apply_does_not_require_manifest_or_create_a_version() { let manager = test_manager(); let app = create_sample_app(&manager).await; let draft = manager.create_draft(&app.id, "dark", None).await.unwrap(); @@ -1223,9 +1246,9 @@ mod tests { .await .unwrap(); - assert_eq!(applied.version, app.version + 1); + assert_eq!(applied.version, app.version); assert_eq!(applied.source.css, app.source.css); - assert_eq!(manager.list_versions(&app.id).await.unwrap(), vec![1]); + assert!(manager.list_versions(&app.id).await.unwrap().is_empty()); } #[tokio::test] diff --git a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs index 241d4ba9c1..434b9b2a2d 100644 --- a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs +++ b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs @@ -6,6 +6,7 @@ use crate::miniapp::types::{ MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, }; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; #[derive(Debug, Clone)] pub struct MiniAppCreateInput { @@ -45,6 +46,46 @@ pub fn build_source_revision(version: u32, updated_at: i64) -> String { format!("src:{version}:{updated_at}") } +fn canonical_json(value: Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.into_iter().map(canonical_json).collect()), + Value::Object(map) => { + let mut entries = map.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut canonical = serde_json::Map::new(); + for (key, value) in entries { + canonical.insert(key, canonical_json(value)); + } + Value::Object(canonical) + } + value => value, + } +} + +/// Hash the user-controlled MiniApp content while excluding lifecycle counters, +/// compiled output, and runtime state. +pub fn miniapp_content_hash(app: &MiniApp) -> String { + let payload = canonical_json(json!({ + "name": &app.name, + "description": &app.description, + "icon": &app.icon, + "category": &app.category, + "tags": &app.tags, + "source": &app.source, + "permissions": &app.permissions, + "aiContext": &app.ai_context, + "runtimeProfile": app.runtime_profile, + "i18n": &app.i18n, + })); + let encoded = serde_json::to_vec(&payload) + .expect("MiniApp content consists only of serializable domain fields"); + format!("sha256:{}", hex::encode(Sha256::digest(encoded))) +} + +fn refresh_content_hash(app: &mut MiniApp) { + app.runtime.content_hash = miniapp_content_hash(app); +} + pub fn build_deps_revision(source: &MiniAppSource) -> String { let mut deps: Vec = source .npm_dependencies @@ -64,6 +105,7 @@ pub fn build_runtime_state( ) -> MiniAppRuntimeState { MiniAppRuntimeState { source_revision: build_source_revision(version, updated_at), + content_hash: String::new(), deps_revision: build_deps_revision(source), deps_dirty, worker_restart_required, @@ -86,7 +128,7 @@ pub fn build_created_app( true, ); - MiniApp { + let mut app = MiniApp { id, name: input.name, description: input.description, @@ -103,7 +145,9 @@ pub fn build_created_app( runtime, runtime_profile: Default::default(), i18n: None, - } + }; + refresh_content_hash(&mut app); + app } pub fn apply_update_patch( @@ -157,6 +201,7 @@ pub fn apply_update_patch( } app.runtime.ui_recompile_required = false; ensure_runtime_state(&mut app); + refresh_content_hash(&mut app); app } @@ -164,6 +209,7 @@ pub fn prepare_draft_app(mut app: MiniApp, compiled_html: String, now: i64) -> M app.updated_at = now; app.compiled_html = compiled_html; ensure_runtime_state(&mut app); + refresh_content_hash(&mut app); app } @@ -181,6 +227,7 @@ pub fn apply_draft_source_sync_result( !app.source.npm_dependencies.is_empty(), true, ); + refresh_content_hash(&mut app); app } @@ -200,6 +247,7 @@ pub fn apply_draft_permission_update_result( !app.source.npm_dependencies.is_empty(), true, ); + refresh_content_hash(&mut app); app } @@ -209,6 +257,16 @@ pub fn apply_draft_to_active( compiled_html: String, now: i64, ) -> MiniApp { + let current_hash = miniapp_content_hash(current); + let draft_hash = miniapp_content_hash(&draft); + if current_hash == draft_hash { + let mut app = current.clone(); + app.compiled_html = compiled_html; + ensure_runtime_state(&mut app); + refresh_content_hash(&mut app); + return app; + } + let mut app = current.clone(); app.name = draft.name; app.description = draft.description; @@ -229,6 +287,7 @@ pub fn apply_draft_to_active( !app.source.npm_dependencies.is_empty(), true, ); + refresh_content_hash(&mut app); app } @@ -238,6 +297,10 @@ pub fn ensure_runtime_state(app: &mut MiniApp) -> bool { app.runtime.source_revision = build_source_revision(app.version, app.updated_at); changed = true; } + if app.runtime.content_hash.is_empty() { + refresh_content_hash(app); + changed = true; + } let deps_revision = build_deps_revision(&app.source); if app.runtime.deps_revision != deps_revision { app.runtime.deps_revision = deps_revision; @@ -271,6 +334,7 @@ pub fn prepare_rollback_app(current: &MiniApp, mut target: MiniApp, now: i64) -> !target.source.npm_dependencies.is_empty(), true, ); + refresh_content_hash(&mut target); target } @@ -289,16 +353,25 @@ pub fn apply_sync_from_fs_result( ) -> MiniApp { let mut app = previous.clone(); app.source = source; - app.version += 1; - app.updated_at = now; app.compiled_html = compiled_html; - app.runtime = build_runtime_state( - app.version, - app.updated_at, - &app.source, - !app.source.npm_dependencies.is_empty(), - true, - ); + let content_hash = miniapp_content_hash(&app); + let content_changed = + previous.runtime.content_hash.is_empty() || previous.runtime.content_hash != content_hash; + if content_changed { + app.version += 1; + app.updated_at = now; + app.runtime = build_runtime_state( + app.version, + app.updated_at, + &app.source, + !app.source.npm_dependencies.is_empty(), + true, + ); + } else { + ensure_runtime_state(&mut app); + app.runtime.ui_recompile_required = false; + } + app.runtime.content_hash = content_hash; app } @@ -310,6 +383,7 @@ pub fn apply_import_runtime_state(app: &mut MiniApp) { !app.source.npm_dependencies.is_empty(), true, ); + refresh_content_hash(app); } pub fn prepare_imported_meta(meta: &mut MiniAppMeta, id: &str, now: i64) { @@ -371,6 +445,7 @@ pub fn miniapp_runtime_event_payload(app: &MiniApp, reason: &str) -> Value { "reason": reason, "runtime": { "sourceRevision": app.runtime.source_revision, + "contentHash": app.runtime.content_hash, "depsRevision": app.runtime.deps_revision, "depsDirty": app.runtime.deps_dirty, "workerRestartRequired": app.runtime.worker_restart_required, @@ -444,6 +519,7 @@ mod tests { "reason": "create", "runtime": { "sourceRevision": "src:1:123", + "contentHash": app.runtime.content_hash.clone(), "depsRevision": "", "depsDirty": false, "workerRestartRequired": true, diff --git a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs index 1febc74628..970108ad6f 100644 --- a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs +++ b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs @@ -18,8 +18,8 @@ use crate::miniapp::lifecycle::{ apply_draft_permission_update_result, apply_draft_source_sync_result, apply_draft_to_active, apply_import_runtime_state, apply_recompile_result, apply_sync_from_fs_result, apply_update_patch, build_created_app, clear_worker_restart_required_state, - ensure_runtime_state, mark_deps_installed_state, prepare_draft_app, prepare_rollback_app, - MiniAppCreateInput, MiniAppUpdatePatch, + ensure_runtime_state, mark_deps_installed_state, miniapp_content_hash, prepare_draft_app, + prepare_rollback_app, MiniAppCreateInput, MiniAppUpdatePatch, }; use crate::miniapp::market::{InstalledMarketOrigin, MarketPackageMeta}; use crate::miniapp::ports::{ @@ -198,6 +198,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { ); next.i18n = i18n; next.runtime_profile = MiniAppRuntimeProfile::MarketStrict; + next.runtime.content_hash = miniapp_content_hash(&next); let metadata = strict_package_metadata(MiniAppCustomizationOriginKind::Market, Some(origin), now); @@ -244,6 +245,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { ); app.i18n = i18n; app.runtime_profile = MiniAppRuntimeProfile::MarketStrict; + app.runtime.content_hash = miniapp_content_hash(&app); let metadata = strict_package_metadata(origin_kind, market_origin, now); self.storage @@ -355,9 +357,11 @@ impl<'a> MiniAppRuntimeFacade<'a> { ) -> MiniAppPortResult { let app_id = current.id.clone(); let app = apply_draft_to_active(¤t, draft_app, compiled_html, now); - self.storage - .save_version(app_id.clone(), current.version, current) - .await?; + if app.version != current.version { + self.storage + .save_version(app_id.clone(), current.version, current) + .await?; + } self.storage.save(app.clone()).await?; self.record_draft_applied(app_id, draft_id, baseline, now) .await?; @@ -510,9 +514,11 @@ impl<'a> MiniAppRuntimeFacade<'a> { now: i64, ) -> MiniAppPortResult { let app = apply_sync_from_fs_result(&previous, source, compiled_html, now); - self.storage - .save_version(app_id, previous.version, previous) - .await?; + if app.version != previous.version { + self.storage + .save_version(app_id, previous.version, previous) + .await?; + } self.storage.save(app.clone()).await?; Ok(app) } diff --git a/src/crates/contracts/product-domains/src/miniapp/types.rs b/src/crates/contracts/product-domains/src/miniapp/types.rs index e2920747bc..db0d378e18 100644 --- a/src/crates/contracts/product-domains/src/miniapp/types.rs +++ b/src/crates/contracts/product-domains/src/miniapp/types.rs @@ -199,6 +199,11 @@ pub struct MiniAppAiContext { pub struct MiniAppRuntimeState { /// Revision used for UI / source lifecycle changes. pub source_revision: String, + /// Stable hash of the persisted MiniApp content, excluding version/runtime metadata. + /// + /// Direct file edits leave this value unchanged until the MiniApp is finalized, + /// which lets the lifecycle distinguish a real content update from a recompile. + pub content_hash: String, /// Revision derived from npm dependencies. pub deps_revision: String, /// Dependencies changed and need install before reliable worker startup. diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index 5c78fdf8b9..a3bc1b266e 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -38,8 +38,8 @@ use bitfun_product_domains::miniapp::lifecycle::{ apply_import_runtime_state, apply_recompile_result, apply_sync_from_fs_result, apply_update_patch, build_created_app, build_deps_revision, build_runtime_state, build_source_revision, build_worker_revision, clear_worker_restart_required_state, - ensure_runtime_state, mark_deps_installed_state, prepare_draft_app, prepare_rollback_app, - workspace_dir_string, MiniAppCreateInput, MiniAppUpdatePatch, + ensure_runtime_state, mark_deps_installed_state, miniapp_content_hash, prepare_draft_app, + prepare_rollback_app, workspace_dir_string, MiniAppCreateInput, MiniAppUpdatePatch, }; use bitfun_product_domains::miniapp::permission_policy::resolve_policy; use bitfun_product_domains::miniapp::ports::{ @@ -1216,6 +1216,63 @@ fn miniapp_lifecycle_manager_state_helpers_preserve_core_transitions() { assert_eq!(imported.runtime.deps_revision, "lodash@^4.17.21"); } +#[test] +fn miniapp_content_hash_ignores_runtime_artifacts_and_tracks_product_content() { + let mut app = sample_miniapp_for_lifecycle(MiniAppSource { + css: "body { color: black; }".to_string(), + ..MiniAppSource::default() + }); + let initial_hash = miniapp_content_hash(&app); + + app.version += 1; + app.updated_at += 1000; + app.compiled_html = "different runtime artifact".to_string(); + app.runtime.source_revision = "src:99:9999".to_string(); + assert_eq!(miniapp_content_hash(&app), initial_hash); + + app.source.css = "body { color: red; }".to_string(); + assert_ne!(miniapp_content_hash(&app), initial_hash); +} + +#[test] +fn miniapp_sync_and_draft_apply_do_not_create_versions_for_unchanged_content() { + let mut current = sample_miniapp_for_lifecycle(MiniAppSource { + css: "body { color: black; }".to_string(), + ..MiniAppSource::default() + }); + ensure_runtime_state(&mut current); + + let synced = apply_sync_from_fs_result( + ¤t, + current.source.clone(), + "recompiled".to_string(), + 5000, + ); + assert_eq!(synced.version, current.version); + assert_eq!(synced.updated_at, current.updated_at); + assert_eq!( + synced.runtime.source_revision, + current.runtime.source_revision + ); + assert_eq!(synced.runtime.content_hash, current.runtime.content_hash); + assert_eq!(synced.compiled_html, "recompiled"); + + let applied = apply_draft_to_active( + ¤t, + current.clone(), + "draft recompiled".to_string(), + 6000, + ); + assert_eq!(applied.version, current.version); + assert_eq!(applied.updated_at, current.updated_at); + assert_eq!( + applied.runtime.source_revision, + current.runtime.source_revision + ); + assert_eq!(applied.runtime.content_hash, current.runtime.content_hash); + assert_eq!(applied.compiled_html, "draft recompiled"); +} + #[test] fn miniapp_lifecycle_create_and_update_helpers_preserve_manager_contract() { let source = MiniAppSource { diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 9a0bd6cb07..f5b3fa20a3 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -181,6 +181,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "Worktree", "ReviewPlatform", "InitMiniApp", + "FinalizeMiniApp", "PublishMiniApp", "PageDeploy", "PagePublish", @@ -399,6 +400,7 @@ mod tests { "Worktree", "ReviewPlatform", "InitMiniApp", + "FinalizeMiniApp", "PublishMiniApp", "PageDeploy", "PagePublish", diff --git a/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.tsx b/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.tsx index 4ee0b0a302..3f00668f94 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.tsx +++ b/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { AlertTriangle, Check, Eye, EyeOff, Loader2, RefreshCw, Send, Trash2, X } from 'lucide-react'; import { Button, IconButton } from '@/component-library'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import type { MiniApp, MiniAppCustomizationMetadata, MiniAppDraft } from '@/infrastructure/api/service-api/MiniAppAPI'; import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; import { useI18n } from '@/infrastructure/i18n'; @@ -12,6 +13,7 @@ import { requiresPermissionConfirmation } from './miniAppCustomizationRisk'; import { getNextMiniAppPreviewOpenState } from './miniAppCustomizationPreview'; import { cleanupMiniAppCustomizationSession, + isMiniAppCustomizationSessionRunning, launchMiniAppCustomizationSession, } from './miniAppCustomizationSession'; import type { MiniAppCustomizationState } from './miniAppCustomizationTypes'; @@ -76,9 +78,30 @@ export const MiniAppCustomizePanel: React.FC = ({ const [dismissingBuiltinUpdate, setDismissingBuiltinUpdate] = useState(false); const [customizationMetadata, setCustomizationMetadata] = useState(null); const theme = themeType ?? 'dark'; + const subscribeToFlowChat = useCallback( + (onStoreChange: () => void) => flowChatStore.subscribe(() => onStoreChange()), + [], + ); + const getEditorRunningSnapshot = useCallback( + () => isMiniAppCustomizationSessionRunning( + state.customizationSessionId + ? flowChatStore.getState().sessions.get(state.customizationSessionId) + : null, + ), + [state.customizationSessionId], + ); + const editorRunning = useSyncExternalStore( + subscribeToFlowChat, + getEditorRunningSnapshot, + () => false, + ); const trimmedRequest = userRequest.trim(); - const busy = state.stage === 'drafting' || state.stage === 'applying' || discarding || refreshing; + const busy = state.stage === 'drafting' + || state.stage === 'applying' + || editorRunning + || discarding + || refreshing; const hasPreview = state.draft !== null; const builtinUpdateNotice = useMemo( () => getMiniAppBuiltinUpdateNotice(customizationMetadata), @@ -167,7 +190,7 @@ export const MiniAppCustomizePanel: React.FC = ({ const draft = state.draft ?? await miniAppAPI.createDraft(app.id, theme, workspacePath); setState((prev) => ({ ...prev, - stage: 'preview', + stage: 'drafting', draft, permissionDiff: null, error: null, diff --git a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationPrompt.test.ts b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationPrompt.test.ts index 44462d8eb7..08004ce031 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationPrompt.test.ts +++ b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationPrompt.test.ts @@ -14,6 +14,7 @@ describe('buildMiniAppCustomizationPrompt', () => { expect(prompt).toContain('Draft root: C:/Users/me/AppData/Roaming/BitFun/miniapps/.drafts/builtin-gomoku/draft-1'); expect(prompt).toContain('Edit only files under the draft root.'); expect(prompt).toContain('Do not edit the active app directory.'); + expect(prompt).toContain('Do not call InitMiniApp or FinalizeMiniApp for this draft;'); expect(prompt).toContain('Make the board lighter'); }); }); diff --git a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationPrompt.ts b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationPrompt.ts index df8185a156..b2430f84ee 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationPrompt.ts +++ b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationPrompt.ts @@ -13,6 +13,7 @@ export function buildMiniAppCustomizationPrompt(params: { '', 'Edit only files under the draft root.', 'Do not edit the active app directory.', + 'Do not call InitMiniApp or FinalizeMiniApp for this draft; the customization host owns draft sync and apply.', 'Do not add permissions unless the user request truly needs them.', 'If new fs, shell, net, node, npm, or ai permissions are needed, explain why before changing them.', 'After editing source files, tell the user to refresh the draft preview before applying.', diff --git a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.test.ts b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.test.ts index 5eb612c665..e332cea26d 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.test.ts +++ b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { buildMiniAppCustomizationSessionRequest, createMiniAppCustomizationSessionId, + isMiniAppCustomizationSessionRunning, } from './miniAppCustomizationSession'; +import type { DialogTurn, Session } from '@/flow_chat/types/flow-chat'; describe('buildMiniAppCustomizationSessionRequest', () => { it('creates a hidden subagent session request for MiniApp customization', () => { @@ -31,3 +33,34 @@ describe('buildMiniAppCustomizationSessionRequest', () => { ); }); }); + +describe('isMiniAppCustomizationSessionRunning', () => { + function sessionWithTurnStatus(status: DialogTurn['status']): Pick { + return { + dialogTurns: [{ status } as DialogTurn], + }; + } + + it.each([ + 'pending', + 'image_analyzing', + 'processing', + 'finishing', + 'cancelling', + ] satisfies DialogTurn['status'][])('blocks draft actions while the Agent turn is %s', (status) => { + expect(isMiniAppCustomizationSessionRunning(sessionWithTurnStatus(status))).toBe(true); + }); + + it.each([ + 'completed', + 'cancelled', + 'error', + ] satisfies DialogTurn['status'][])('unblocks draft actions after the Agent turn is %s', (status) => { + expect(isMiniAppCustomizationSessionRunning(sessionWithTurnStatus(status))).toBe(false); + }); + + it('treats an absent or empty session as idle', () => { + expect(isMiniAppCustomizationSessionRunning(null)).toBe(false); + expect(isMiniAppCustomizationSessionRunning({ dialogTurns: [] })).toBe(false); + }); +}); diff --git a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.ts b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.ts index a0090f0d55..851ff07b69 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.ts +++ b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.ts @@ -1,4 +1,6 @@ import type { CreateSessionRequest } from '@/infrastructure/api/service-api/AgentAPI'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { isTransientTurnStatus } from '@/flow_chat/utils/dialogTurnStability'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('MiniAppCustomizationSession'); @@ -37,6 +39,13 @@ export function createMiniAppCustomizationSessionId(appId: string): string { return `miniapp-customize-${appId}-${Date.now()}`; } +export function isMiniAppCustomizationSessionRunning( + session: Pick | null | undefined, +): boolean { + const lastTurn = session?.dialogTurns.at(-1); + return Boolean(lastTurn && isTransientTurnStatus(lastTurn.status)); +} + export async function launchMiniAppCustomizationSession(params: { appId: string; appName: string; diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index 70fdce1ce7..05a13adc20 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -321,6 +321,16 @@ export const TOOL_CARD_CONFIGS: Record = { displayMode: 'standard', primaryColor: UI_EXCEPTION_ACCENTS.miniApp }, + 'FinalizeMiniApp': { + toolName: 'FinalizeMiniApp', + displayName: 'Finalize Mini App', + icon: 'APP', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: 'Compile Mini App edits and refresh open runtimes', + displayMode: 'standard', + primaryColor: UI_EXCEPTION_ACCENTS.miniApp + }, 'PublishMiniApp': { toolName: 'PublishMiniApp', displayName: 'Publish Mini App', diff --git a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts index 065cb11304..0fb43080aa 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts @@ -156,6 +156,7 @@ export interface AgentCancelStaleRunsResult { export interface MiniAppRuntimeState { source_revision: string; + content_hash: string; deps_revision: string; deps_dirty: boolean; worker_restart_required: boolean;