From c1d26f11df5aa86e999a08a3ea11da7989309f03 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:53:16 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20Cocos=20=E5=92=8C=20Unity?= =?UTF-8?q?=20=E6=8F=92=E4=BB=B6=E7=9A=84=E5=B7=A5=E7=A8=8B=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一插件列表、启动、面板和 Agent 工具的可用性判断,保留开关与平台约束 调整前端自动启动并修复 Cocos 项目切换的旧连接与订阅快照竞态 补充插件宿主、工具目录和前端回归测试,同步插件技术规范与共享决策 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 17 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 111 ++++++++- .../provider_request_builders.rs | 8 +- .../runtime_actions/provider_tool_plan.rs | 2 +- .../runtime_actions/tool_policy_snapshot.rs | 70 +++++- .../src/agent/runtime_tools/cocos_editor.rs | 4 +- .../src/agent/runtime_tools/unity_editor.rs | 4 +- .../src-tauri/src/agent_native_tools.rs | 12 - .../src-tauri/src/builtin_plugins.rs | 91 +------ .../src-tauri/src/editor_adapters.rs | 6 +- .../src-tauri/src/plugin_host.rs | 229 ++++++++++++------ apps/ai-game-creator-shell/src/App.tsx | 18 +- .../src/services/pluginHost.ts | 36 ++- .../appSurface/project-development.suite.ts | 72 ++++++ .../tests/pluginHost.test.ts | 85 +++++-- .../shared-memory/decision-log.md | 9 +- ...AGC Cocos Creator 编辑器桥接模块-2026-09-09.md | 2 +- ...方案】AGC Unity编辑器插件接入-2026-09-18.md | 6 +- ...案】AGC通用插件宿主与编辑器适配-2026-09-09.md | 14 +- plugins/agc-cocos-editor/README.md | 10 +- plugins/agc-cocos-editor/src/entry.mjs | 7 +- plugins/agc-cocos-editor/src/entry.test.mjs | 52 ++++ 22 files changed, 596 insertions(+), 269 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 82c1f7c51..c418832f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2611,8 +2611,8 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str) #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { let prepared = (|| { - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&state.root) { - return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("当前 Unity 插件不可用".to_string()); } enforce_project_permission_policy(&state.root, "unity.editor.execute")?; bridge_reject_unknown_fields(arguments, &["code"])?; @@ -2637,7 +2637,7 @@ async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) }; let root = state.root.clone(); let result = tokio::task::spawn_blocking(move || { - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&root) { + if !crate::builtin_plugins::unity_editor_agent_tool_available() { return Err("当前 Unity 插件不可用".to_string()); } crate::editor_adapters::execute_unity_editor_code(&root, &code) @@ -2667,10 +2667,9 @@ async fn bridge_cocos_call( if !crate::builtin_plugins::is_enabled(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) { return bridge_tool_result("Cocos 编辑器插件已禁用".to_string(), Vec::new(), true); } - if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&state.root) { + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return bridge_tool_result( - "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用,agc_cocos_execute 不可用" - .to_string(), + "当前 Cocos 插件不可用,agc_cocos_execute 不可用".to_string(), Vec::new(), true, ); @@ -2735,9 +2734,9 @@ async fn bridge_cocos_call( // validated Inspector/pipe bridge. It does not mutate AGC's project // files or manifest, so it must not wait on `.agent/project.lock`. // File-writing tools keep their own project lock separately. - if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&root) { + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return Err(cocos_editor_bridge::BridgeError::InvalidInput( - "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(), + "当前 Cocos 插件不可用".to_string(), )); } cocos_editor_bridge::execute_cocos_editor_code_for_project( @@ -2840,7 +2839,7 @@ async fn handle_direct_tool_bridge( let result = match request.tool.as_str() { // 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。 "builtin.plugins.tools" => bridge_tool_result( - json!({"tools": crate::builtin_plugins::available_agent_tools_for_project(&state.root)}).to_string(), + json!({"tools": crate::builtin_plugins::available_agent_tools()}).to_string(), Vec::new(), false, ), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index e4436fff5..f90209a70 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -2057,6 +2057,110 @@ mod tests { } } + #[tokio::test] + async fn builtin_editor_tools_follow_independent_switches_for_non_engine_projects() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-"); + std::fs::create_dir_all(project.path().join(".agent")).unwrap(); + std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); + let bridge = + super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) + .await + .unwrap(); + for (cocos_enabled, unity_enabled) in + [(false, false), (true, false), (false, true), (true, true)] + { + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + cocos_enabled, + ) + .unwrap(); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + unity_enabled, + ) + .unwrap(); + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + bridge.url().to_string(), + call_client_tool_bridge("builtin.plugins.tools", &json!({})), + ) + .await; + assert_eq!(response["isError"], false); + let available: Value = + serde_json::from_str(response["content"][0]["text"].as_str().unwrap()).unwrap(); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + let cocos_expected = + cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute")); + for (runtime_tool, mcp_tool, expected) in [ + ( + crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME, + "agc_cocos_execute", + cocos_expected, + ), + ( + crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME, + "agc_unity_execute", + unity_enabled + && cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )), + ), + ] { + assert_eq!( + available["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool == runtime_tool), + expected, + "{runtime_tool}" + ); + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == mcp_tool), + expected, + "{mcp_tool}" + ); + } + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .filter(|tool| tool["name"] + .as_str() + .is_some_and(cocos_editor_bridge::is_cocos_operation)) + .count(), + if cocos_expected { + cocos_editor_bridge::cocos_operation_catalog().len() + } else { + 0 + }, + ); + } + std::fs::write(config.path().join("extensions/builtin-plugins.json"), "{").unwrap(); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + for tool in ["agc_cocos_execute", "agc_unity_execute"] { + assert!(!specs["tools"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["name"] == tool)); + } + } + #[cfg(all(windows, feature = "cocos-editor-execute"))] #[test] fn builtin_mcp_process_probe() { @@ -2097,13 +2201,6 @@ mod tests { let config = tempfile::tempdir().unwrap(); crate::builtin_plugins::initialize(config.path()).unwrap(); let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-"); - // 工具目录现在按当前项目类型过滤,fixture 必须具备最小 Cocos Creator 结构。 - std::fs::write( - project.path().join("package.json"), - r#"{"creator":{"version":"3.8.8"}}"#, - ) - .unwrap(); - std::fs::create_dir(project.path().join("assets")).unwrap(); std::fs::create_dir_all(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); let bridge = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index b7931e115..af618da53 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -248,8 +248,8 @@ fn build_game_creator_agent_background_tool_plan_request_at( "你正在执行一个自主游戏构建任务。请按自己的判断规划并直接调用当前广告的原生工具完成目标;任务可以与其它 Agent 并行,依赖只作为参考,不要等待或索要平台资产/验收回执。已有观察只代表已发生的事实,完成后直接调用 respond_to_user。\n\n运行上下文:\n{context}\n\n任务:\n{effective_task}\n\n已有观察:\n{observations_json}" ); let mut function_tools = - crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project( - root, agent_id, + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent( + agent_id, )?; remove_relaxed_autonomous_platform_validation_tools(&mut function_tools)?; // Platform-backed generation remains an optional capability. A @@ -487,9 +487,7 @@ fn build_game_creator_agent_background_tool_plan_request_at( .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) .with_function_tools( - crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project( - root, agent_id, - )?, + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?, ) .with_tool_choice(platform_llm::LlmToolChoice::Required); if runtime_owner_artifact_validation_available { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 697738daa..a2eaea3f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -969,7 +969,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at || force_autonomous_pre_mutation { request.function_tools = - crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(root, agent_id)?; + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?; if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools( &mut request.function_tools, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index c851f0d70..86829193b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -162,11 +162,6 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( let mut confirm_tools = Vec::new(); let mut denied_tools = Vec::new(); for tool in agent_runtime_executable_tools() { - if tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME - && !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) - { - continue; - } if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) { denied_tools.push(tool.to_string()); continue; @@ -209,10 +204,6 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( run_profile_binding_fingerprint: String::new(), allowed_tools: agent_runtime_executable_tools() .into_iter() - .filter(|tool| { - *tool != crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME - || crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) - }) .map(str::to_string) .collect(), auto_tools, @@ -222,6 +213,67 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( }) } +#[cfg(test)] +mod builtin_editor_policy_tests { + use super::*; + + #[test] + fn builtin_editor_tools_follow_switches_for_non_engine_projects() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let project = crate::tests::canonical_test_tempdir("builtin-editor-policy-"); + for (cocos_enabled, unity_enabled) in + [(false, false), (true, false), (false, true), (true, true)] + { + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + cocos_enabled, + ) + .unwrap(); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + unity_enabled, + ) + .unwrap(); + let snapshot = + agent_runtime_tool_policy_snapshot_at(project.path(), "project-supervisor") + .unwrap(); + for (tool, expected) in [ + ( + crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME, + cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute")), + ), + ( + crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME, + unity_enabled + && cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )), + ), + ] { + assert_eq!( + snapshot.allowed_tools.iter().any(|entry| entry == tool), + expected, + "{tool}" + ); + assert_eq!( + snapshot + .auto_tools + .iter() + .chain(&snapshot.confirm_tools) + .chain(&snapshot.denied_tools) + .any(|entry| entry == tool), + expected, + "{tool}", + ); + } + } + } +} + pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs index ffefb5398..9d39ae45d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs @@ -33,11 +33,11 @@ pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute( detail: None, }; } - if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(root) { + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return AgentRuntimeToolObservation { tool: "cocos.editor.execute".to_string(), status: "failed".to_string(), - summary: "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(), + summary: "当前 Cocos 插件不可用".to_string(), detail: None, }; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs index d0cd9fe55..d82575582 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs @@ -17,8 +17,8 @@ pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute( if pending_action.is_none() { return Err("unity.editor.execute 必须绑定 durable pending action".to_string()); } - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) { - return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("当前 Unity 插件不可用".to_string()); } crate::editor_adapters::execute_unity_editor_code(root, &input.code) })(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 5375f5055..6ac95a993 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -306,18 +306,6 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( Ok(functions) } -pub(crate) fn build_agent_runtime_native_function_tools_for_project( - root: &std::path::Path, - agent_id: &str, -) -> Result, String> { - let mut tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?; - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) { - let name = native_runtime_function_name_for_tool("unity.editor.execute"); - tools.retain(|tool| tool.name != name); - } - Ok(tools) -} - pub(crate) fn agent_runtime_native_tool_allowed_for_agent(tool: &str) -> bool { agent_runtime_native_capability_registry() .ok() diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs index a3a002a60..d7fbc1ae9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -259,17 +259,6 @@ pub(crate) fn cocos_editor_agent_tool_available() -> bool { agent_tool_available(BuiltinPlugin::CocosEditor) } -/// Cocos 编辑器插件只对当前确认为 Cocos Creator 的项目可用。 -/// -/// 项目类型以项目根的真实结构为准,不能仅凭插件开关或编译 feature 推断。 -pub(crate) fn cocos_editor_agent_tool_available_for_project(root: &Path) -> bool { - cocos_editor_agent_tool_available() - && crate::project::discover_local_cocos_project_root(root) - .ok() - .flatten() - .is_some() -} - pub(crate) fn available_agent_tools() -> Vec<&'static str> { let mut available = Vec::new(); if cocos_editor_agent_tool_available() { @@ -287,33 +276,10 @@ pub(crate) fn available_agent_tools() -> Vec<&'static str> { available } -/// Project-scoped variant used by the isolated DirectProject MCP bridge. -/// Without a project root the safe result is an empty Cocos tool set. -pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str> { - available_agent_tools() - .into_iter() - .filter(|tool| { - if *tool == AGC_UNITY_EDITOR_TOOL_NAME { - unity_editor_agent_tool_available_for_project(root) - } else { - cocos_editor_agent_tool_available_for_project(root) - } - }) - .collect() -} - pub(crate) fn unity_editor_agent_tool_available() -> bool { agent_tool_available(BuiltinPlugin::UnityEditor) } -pub(crate) fn unity_editor_agent_tool_available_for_project(root: &Path) -> bool { - unity_editor_agent_tool_available() - && crate::project::discover_local_unity_project_root(root) - .ok() - .flatten() - .is_some() -} - #[cfg(test)] pub(crate) use tests::test_lock; @@ -330,44 +296,25 @@ mod tests { } #[test] - fn unity_tool_visibility_requires_project_platform_and_independent_toggle() { + fn unity_tool_visibility_requires_platform_and_independent_toggle() { let _guard = test_lock(); let config = tempdir().unwrap(); initialize(config.path()).unwrap(); - let project = tempdir().unwrap(); - for directory in ["Assets", "Packages", "ProjectSettings"] { - fs::create_dir(project.path().join(directory)).unwrap(); - } - fs::write( - project.path().join("ProjectSettings/ProjectVersion.txt"), - "m_EditorVersion: 6000.0.1f1", - ) - .unwrap(); let supported = cfg!(all( windows, target_arch = "x86_64", feature = "unity-editor-execute" )); assert_eq!( - available_agent_tools_for_project(project.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME), + available_agent_tools().contains(&AGC_UNITY_EDITOR_TOOL_NAME), supported ); - assert!( - !available_agent_tools_for_project(config.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME) - ); set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap(); - assert_eq!( - unity_editor_agent_tool_available_for_project(project.path()), - supported - ); + assert_eq!(unity_editor_agent_tool_available(), supported); set_enabled(AGC_UNITY_EDITOR_PLUGIN_ID, false).unwrap(); - assert!(!unity_editor_agent_tool_available_for_project( - project.path() - )); + assert!(!unity_editor_agent_tool_available()); set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap(); - assert!(!unity_editor_agent_tool_available_for_project( - project.path() - )); + assert!(!unity_editor_agent_tool_available()); } #[test] @@ -531,32 +478,4 @@ mod tests { tool_visible_when_enabled ); } - - #[test] - fn project_scoped_availability_requires_a_cocos_creator_root() { - let _guard = test_lock(); - let directory = tempdir().expect("temp config"); - initialize(directory.path()).expect("initialize"); - set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable"); - let non_cocos = tempdir().expect("non-cocos project"); - assert!(!cocos_editor_agent_tool_available_for_project( - non_cocos.path() - )); - - let cocos = tempdir().expect("cocos project"); - fs::write( - cocos.path().join("package.json"), - r#"{"creator":{"version":"3.8.8"}}"#, - ) - .expect("cocos package"); - fs::create_dir(cocos.path().join("assets")).expect("cocos assets"); - assert_eq!( - cocos_editor_agent_tool_available_for_project(cocos.path()), - cfg!(feature = "cocos-editor-execute") - ); - assert_eq!( - available_agent_tools_for_project(non_cocos.path()), - Vec::<&'static str>::new() - ); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index b77ec5a7a..ba8cb2143 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -152,12 +152,12 @@ pub(crate) fn unity_editor_rpc_owned( if method == "connect" { unity_editor_bridge::disconnect_unity_editor(); } - let project = params + params .get("projectPath") .and_then(Value::as_str) .ok_or("缺少 projectPath")?; - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(Path::new(project)) { - return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("Unity 插件不可用".to_string()); } let mut delivery = if method == "execute" { let mut pending = match unity_pending_delivery().try_lock() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 71117b0df..6c8ab9099 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -791,37 +791,6 @@ fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), Stri Ok(()) } -fn plugin_matches_project(id: &str, project: Option<&Path>) -> bool { - match id { - crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID => project.is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }), - crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => project.is_some_and(|path| { - crate::project::discover_local_unity_project_root(path) - .ok() - .flatten() - .is_some() - }), - _ => true, - } -} - -fn require_plugin_project(id: &str, project: &ProjectContext) -> Result<(), String> { - if !plugin_matches_project( - id, - project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref(), - ) { - return Err("编辑器插件与当前项目类型不匹配".to_string()); - } - Ok(()) -} - fn controlled_editor_params(project: &Path, mut params: Value) -> Result { if params.is_null() { params = json!({}); @@ -1020,18 +989,10 @@ impl PluginHost { .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; self.scan_locked(&mut state, &root)?; - let project = state - .active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .clone(); state .plugins .values() - .filter(|record| { - plugin_matches_project(&record.id, project.as_deref()) - && require_plugin_adapter(&record.id, &state.editors).is_ok() - }) + .filter(|record| require_plugin_adapter(&record.id, &state.editors).is_ok()) .map(|record| self.summary_locked(record)) .collect() } @@ -1096,7 +1057,6 @@ impl PluginHost { .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let active_project = state.active_project.clone(); require_plugin_adapter(id, &state.editors)?; - require_plugin_project(id, &active_project)?; let editors = state.editors.clone(); let record = state .plugins @@ -1198,7 +1158,6 @@ impl PluginHost { .plugins .get(id) .ok_or_else(|| "插件不存在".to_string())?; - require_plugin_project(id, &state.active_project)?; if record.running.is_none() || !record.manifest.permissions.contains("ui.register") { return Err("插件面板未激活".to_string()); } @@ -1244,7 +1203,6 @@ impl PluginHost { .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; - require_plugin_project(id, &state.active_project)?; let record = state .plugins .get_mut(id) @@ -1619,9 +1577,6 @@ impl PluginHost { let project = active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())?; - if !plugin_matches_project(&manifest.id, project.as_deref()) { - return Err("编辑器插件与当前项目类型不匹配".to_string()); - } let project = project .as_deref() .ok_or_else(|| "尚未设置当前项目".to_string())?; @@ -1672,7 +1627,7 @@ impl PluginHost { } pub(crate) fn set_active_project(&self, project_path: Option) -> Result<(), String> { - let mut state = self + let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; @@ -1692,23 +1647,19 @@ impl PluginHost { .map_err(|_| "项目上下文锁已损坏".to_string())? .clone(); if previous != project { + let mut editors = state + .editors + .try_lock() + .map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?; + if let Some(editor) = editors.get_mut("cocos-editor") { + editor.disconnect(); + } crate::editor_adapters::disconnect_unity_editor_connection(); } *state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? = project.clone(); - for record in state.plugins.values_mut() { - if !plugin_matches_project(&record.id, project.as_deref()) { - if let Some(mut running) = record.running.take() { - let _ = running.child.kill(); - let _ = running.child.wait(); - } - if record.manifest.enabled { - record.status = "stopped".to_string(); - } - } - } for record in state.plugins.values() { if let Some(running) = record.running.as_ref() { let subscribed = running @@ -1930,6 +1881,7 @@ pub(crate) async fn set_agc_plugin_project_path( #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::Ordering; use tempfile::tempdir; fn manifest() -> PluginManifest { @@ -2152,7 +2104,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let project = Arc::new(Mutex::new(Some(root.path().to_path_buf()))); let editors: EditorRegistry = Arc::new(Mutex::new(BTreeMap::from([( "cocos-editor".to_string(), - Box::new(StubCocosAdapter) as Box, + Box::new(StubCocosAdapter::default()) as Box, )]))); let registrations = Arc::new(Mutex::new(PluginRegistrations::default())); let mut manifest = manifest(); @@ -2187,7 +2139,10 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p assert!(start.elapsed() < Duration::from_millis(100)); } - struct StubCocosAdapter; + #[derive(Default)] + struct StubCocosAdapter { + disconnects: Arc, + } struct StubUnityAdapter; @@ -2218,7 +2173,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p } #[test] - fn workspace_unity_plugin_round_trips_and_stops_when_leaving_project() { + fn workspace_unity_plugin_round_trips_across_project_contexts() { let _guard = crate::builtin_plugins::test_lock(); let config = tempdir().unwrap(); let project = tempdir().unwrap(); @@ -2237,13 +2192,11 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p .unwrap(); host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) .unwrap(); - assert!(!host + assert!(host .list() .unwrap() .iter() .any(|plugin| plugin.id == "agc-unity-editor")); - host.set_active_project(Some(project.path().to_string_lossy().into_owned())) - .unwrap(); host.start("agc-unity-editor").unwrap(); let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -2257,6 +2210,14 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p assert!(Instant::now() < deadline); thread::sleep(Duration::from_millis(20)); } + let plugin_pid = host.state.lock().unwrap().plugins["agc-unity-editor"] + .running + .as_ref() + .unwrap() + .child + .id(); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .unwrap(); let response = host .call( "agc-unity-editor", @@ -2274,15 +2235,51 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p .to_string_lossy() .as_ref() ); + let other = tempdir().unwrap(); + host.set_active_project(Some(other.path().to_string_lossy().into_owned())) + .unwrap(); + let response = host + .call( + "agc-unity-editor", + "unity.editor.execute".to_string(), + json!({"code":"return 3;"}), + ) + .unwrap(); + assert_eq!(response["status"], "completed"); + assert_eq!( + response["result"]["projectPath"], + other + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); host.set_active_project(None).unwrap(); - assert!(!host + assert!(host .list() .unwrap() .iter() .any(|plugin| plugin.id == "agc-unity-editor")); - assert!(host.state.lock().unwrap().plugins["agc-unity-editor"] - .running - .is_none()); + assert_eq!( + host.state.lock().unwrap().plugins["agc-unity-editor"] + .running + .as_ref() + .unwrap() + .child + .id(), + plugin_pid + ); + let response = host + .call( + "agc-unity-editor", + "unity.editor.execute".to_string(), + json!({"code":"return 4;"}), + ) + .unwrap(); + assert_eq!(response["status"], "failed"); + assert_eq!(response["dispatched"], false); + host.stop("agc-unity-editor").unwrap(); } impl EditorAdapter for StubCocosAdapter { @@ -2303,7 +2300,9 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p Err("stub adapter 不建立连接".to_string()) } - fn disconnect(&mut self) {} + fn disconnect(&mut self) { + self.disconnects.fetch_add(1, Ordering::SeqCst); + } fn translate_rpc(&self, _method: &str, params: Value) -> Result { Ok(params) @@ -2329,7 +2328,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let host = PluginHost::default(); crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state"); host.initialize(directory.path()).expect("initialize"); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .expect("register adapter"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); @@ -2381,7 +2380,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p host.initialize(directory.path()).expect("initialize"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .expect("register adapter"); let project = fs::canonicalize(directory.path()) .expect("canonical project") @@ -2425,26 +2424,98 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p } #[test] - fn cocos_plugin_is_hidden_and_cannot_start_for_non_cocos_project() { + fn cocos_plugin_stays_available_across_project_contexts() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); crate::builtin_plugins::initialize(directory.path()).expect("builtin state"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + let adapter = StubCocosAdapter::default(); + let disconnects = Arc::clone(&adapter.disconnects); + host.register_editor_adapter(Box::new(adapter)) .expect("register adapter"); host.set_plugin_workspace(workspace).expect("set workspace"); - let project = tempdir().expect("web project"); - host.set_active_project(Some(project.path().to_string_lossy().into_owned())) - .expect("set active project"); assert!(host .list() .expect("list plugins") .into_iter() - .all(|plugin| plugin.id != "agc-cocos-editor")); - assert!(host.start("agc-cocos-editor").is_err()); + .any(|plugin| plugin.id == "agc-cocos-editor")); + host.start("agc-cocos-editor") + .expect("start without a project"); + let deadline = Instant::now() + Duration::from_secs(15); + while host.read_panel("agc-cocos-editor", "cocos-editor").is_err() + || !host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .as_ref() + .unwrap() + .registrations + .lock() + .unwrap() + .subscriptions + .values() + .any(|event| event == "project.changed") + { + assert!(Instant::now() < deadline, "Cocos panel was not registered"); + thread::sleep(Duration::from_millis(25)); + } + let plugin_pid = host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .as_ref() + .unwrap() + .child + .id(); + let project = tempdir().expect("web project"); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .expect("set active project"); + assert_eq!(disconnects.load(Ordering::SeqCst), 1); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .expect("keep the same active project"); + assert_eq!(disconnects.load(Ordering::SeqCst), 1); + let response = host + .call( + "agc-cocos-editor", + "cocos.editor.execute".to_string(), + json!({"code":"return 1;"}), + ) + .expect("RPC reaches the adapter without a project type gate"); + assert_eq!(response["status"], "completed"); + assert_eq!( + response["response"]["params"]["projectPath"], + project + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + host.set_active_project(None).unwrap(); + assert_eq!(disconnects.load(Ordering::SeqCst), 2); + assert!(host + .list_extensions() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-cocos-editor")); + host.read_panel("agc-cocos-editor", "cocos-editor") + .expect("panel remains available"); + assert_eq!( + host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .as_ref() + .unwrap() + .child + .id(), + plugin_pid + ); + assert!(host + .call( + "agc-cocos-editor", + "cocos.editor.execute".to_string(), + json!({"code":"return 1;"}), + ) + .is_err()); + host.stop("agc-cocos-editor").unwrap(); } #[test] @@ -2487,7 +2558,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p assert!(host.state.lock().unwrap().plugins["agc-cocos-editor"] .running .is_none()); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .unwrap(); assert!(host .list() diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4b29e7fb3..42c202fc7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -307,7 +307,7 @@ import { } from './services/platformSession'; import { setAgcPluginProjectPath, - startAvailableAgcPlugin, + startAvailableAgcEditorPlugins, } from './services/pluginHost'; import { canSubscribeTauriEvents, @@ -617,24 +617,18 @@ export function App({ // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 if (!nextProjectPath && !previousProjectPath) return; let active = true; - const editorPlugin = - workspaceProjectKind === 'cocos' - ? { id: 'agc-cocos-editor', title: 'Cocos Creator' } - : workspaceProjectKind === 'unity' - ? { id: 'agc-unity-editor', title: 'Unity' } - : null; void setAgcPluginProjectPath(nextProjectPath) .then(async () => { - if (active && editorPlugin && nextProjectPath) { - await startAvailableAgcPlugin(editorPlugin.id); + if (active && nextProjectPath) { + await startAvailableAgcEditorPlugins(() => active); } }) .catch((error) => { - if (!active || !editorPlugin || !nextProjectPath) { + if (!active || !nextProjectPath) { return; } setWorkspaceStatus( - `${editorPlugin.title} 插件未就绪:${ + `编辑器插件未就绪:${ error instanceof Error ? error.message : String(error) }`, ); @@ -646,7 +640,7 @@ export function App({ } localProjectPathRef.current = null; }; - }, [localProject?.projectPath, supervisorChatOnly, workspaceProjectKind]); + }, [localProject?.projectPath, supervisorChatOnly]); const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( diff --git a/apps/ai-game-creator-shell/src/services/pluginHost.ts b/apps/ai-game-creator-shell/src/services/pluginHost.ts index b3aee5fc5..44c2209e8 100644 --- a/apps/ai-game-creator-shell/src/services/pluginHost.ts +++ b/apps/ai-game-creator-shell/src/services/pluginHost.ts @@ -33,14 +33,38 @@ export async function startAgcPlugin(id: string) { }) as Promise; } -/** 只消费宿主的能力投影,不因项目类型自行推断原生适配器是否存在。 */ -export async function startAvailableAgcPlugin(id: string) { +/** 只消费宿主的能力投影;项目类型与平台支持均不在前端再次判断。 */ +export async function startAvailableAgcEditorPlugins( + isActive: () => boolean = () => true, +) { const plugins = await listAgcPlugins(); - const plugin = plugins.find((candidate) => candidate.id === id); - if (!plugin?.enabled || !plugin.hasRuntime || plugin.status === 'invalid') { - return; + const available = plugins.filter( + (plugin) => + plugin.builtin && + (plugin.id === 'agc-cocos-editor' || plugin.id === 'agc-unity-editor') && + plugin.enabled && + plugin.hasRuntime && + (plugin.status === 'stopped' || plugin.status === 'discovered'), + ); + const results = await Promise.allSettled( + available.map((plugin) => + isActive() ? startAgcPlugin(plugin.id) : Promise.resolve(), + ), + ); + const errors = results.flatMap((result, index) => + result.status === 'rejected' + ? [ + `${available[index]!.name}:${ + result.reason instanceof Error + ? result.reason.message + : String(result.reason) + }`, + ] + : [], + ); + if (errors.length) { + throw new Error(errors.join(';')); } - return startAgcPlugin(id); } export async function stopAgcPlugin(id: string) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 46df34801..af260f08a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -6980,6 +6980,78 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it.each(['web', 'godot', 'cocos', 'unity'] as const)( + 'starts available editor plugins for a %s project without opening panels', + async (projectKind) => { + const projectPath = `/tmp/editor-plugin-${projectKind}`; + const manifest = createGameCreationAppManifest( + 'editor-plugin-project', + '编辑器插件项目', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const plugins = ['agc-cocos-editor', 'agc-unity-editor'].map((id) => ({ + id, + name: id, + builtin: true, + enabled: true, + hasRuntime: true, + status: 'stopped', + })); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_manifest') return manifest; + if (command === 'set_agc_plugin_project_path') return null; + if (command === 'list_agc_plugins') return plugins; + if (command === 'start_agc_plugin') { + const plugin = plugins.find(({ id }) => id === args?.id)!; + plugin.status = 'running'; + return plugin; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + const element = React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + initialProjectKind: projectKind, + projectSupervisorOnly: true, + }); + const { rerender } = render(element); + + await waitFor(() => { + for (const plugin of plugins) { + expect(invoke).toHaveBeenCalledWith('start_agc_plugin', { + id: plugin.id, + }); + } + }); + rerender(element); + expect( + invoke.mock.calls.filter(([command]) => command === 'start_agc_plugin'), + ).toHaveLength(2); + expect( + invoke.mock.calls.some( + ([command]) => command === 'read_agc_plugin_panel', + ), + ).toBe(false); + const projectBinding = invoke.mock.calls.findIndex( + ([command]) => command === 'set_agc_plugin_project_path', + ); + const firstStart = invoke.mock.calls.findIndex( + ([command]) => command === 'start_agc_plugin', + ); + expect(projectBinding).toBeGreaterThanOrEqual(0); + expect(projectBinding).toBeLessThan(firstStart); + }, + ); + it('allows selecting the model on the first direct-project entry', async () => { const projectPath = '/tmp/first-entry-model-select'; const manifest = createGameCreationAppManifest( diff --git a/apps/ai-game-creator-shell/tests/pluginHost.test.ts b/apps/ai-game-creator-shell/tests/pluginHost.test.ts index c01ef820e..669ec6747 100644 --- a/apps/ai-game-creator-shell/tests/pluginHost.test.ts +++ b/apps/ai-game-creator-shell/tests/pluginHost.test.ts @@ -2,18 +2,27 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { setAgcPluginProjectPath, - startAvailableAgcPlugin, + startAvailableAgcEditorPlugins, } from '../src/services/pluginHost'; afterEach(() => vi.unstubAllGlobals()); +const editorPlugins = ['agc-cocos-editor', 'agc-unity-editor'].map((id) => ({ + id, + name: id === 'agc-cocos-editor' ? 'Cocos Creator' : 'Unity', + builtin: true, + enabled: true, + hasRuntime: true, + status: 'stopped', +})); + describe('插件自动启动使用后端能力投影', () => { it.each( [ [], [ { - id: 'agc-cocos-editor', + ...editorPlugins[0], enabled: false, hasRuntime: true, status: 'stopped', @@ -21,7 +30,7 @@ describe('插件自动启动使用后端能力投影', () => { ], [ { - id: 'agc-cocos-editor', + ...editorPlugins[0], enabled: true, hasRuntime: false, status: 'package', @@ -29,44 +38,78 @@ describe('插件自动启动使用后端能力投影', () => { ], [ { - id: 'agc-cocos-editor', + ...editorPlugins[0], enabled: true, hasRuntime: true, status: 'invalid', }, ], + [{ ...editorPlugins[0], status: 'running' }], + [{ ...editorPlugins[0], status: 'failed' }], + [{ ...editorPlugins[0], status: 'disabled' }], + [{ ...editorPlugins[0], builtin: false }], + [{ ...editorPlugins[0], id: 'other-plugin' }], ].map((plugins) => ({ plugins })), - )('隐藏、禁用或不可执行的插件不启动(%j)', async ({ plugins }) => { + )('只启动宿主投影允许自动启动的内置编辑器插件(%j)', async ({ plugins }) => { const invoke = vi.fn(async () => plugins); vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); - await startAvailableAgcPlugin('agc-cocos-editor'); + await startAvailableAgcEditorPlugins(); expect(invoke).toHaveBeenCalledTimes(1); expect(invoke).toHaveBeenCalledWith('list_agc_plugins'); }); - it.each(['agc-cocos-editor', 'agc-unity-editor'])( - '支持的编辑器插件按原入口启动:%s', - async (pluginId) => { + it.each(['stopped', 'discovered'])( + '从同一份宿主列表启动所有可用的编辑器插件:%s', + async (status) => { const invoke = vi.fn(async (command: string) => command === 'list_agc_plugins' - ? [ - { - id: pluginId, - enabled: true, - hasRuntime: true, - status: 'stopped', - }, - ] + ? editorPlugins.map((plugin) => ({ ...plugin, status })) : {}, ); vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); - await startAvailableAgcPlugin(pluginId); - expect(invoke).toHaveBeenLastCalledWith('start_agc_plugin', { - id: pluginId, - }); + await startAvailableAgcEditorPlugins(); + expect(invoke.mock.calls).toEqual([ + ['list_agc_plugins'], + ['start_agc_plugin', { id: 'agc-cocos-editor' }], + ['start_agc_plugin', { id: 'agc-unity-editor' }], + ]); }, ); + it('一个编辑器插件启动失败仍启动另一个,且不自动重试', async () => { + const invoke = vi.fn(async (command: string, params?: { id: string }) => { + if (command === 'list_agc_plugins') return editorPlugins; + if (params?.id === 'agc-cocos-editor') throw new Error('进程已退出'); + return {}; + }); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + await expect(startAvailableAgcEditorPlugins()).rejects.toThrow( + 'Cocos Creator:进程已退出', + ); + expect(invoke.mock.calls).toEqual([ + ['list_agc_plugins'], + ['start_agc_plugin', { id: 'agc-cocos-editor' }], + ['start_agc_plugin', { id: 'agc-unity-editor' }], + ]); + }); + + it('项目切换后迟到的插件列表不会启动旧项目的插件', async () => { + let finishList!: (plugins: typeof editorPlugins) => void; + const invoke = vi.fn( + () => + new Promise((resolve) => { + finishList = resolve; + }), + ); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + let active = true; + const start = startAvailableAgcEditorPlugins(() => active); + active = false; + finishList(editorPlugins); + await start; + expect(invoke).toHaveBeenCalledTimes(1); + }); + it('快速切换项目时旧 cleanup 不能晚于新项目设置抵达宿主', async () => { const received: Array = []; let finishFirst: () => void = () => undefined; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 61fb4144e..bbe39908a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8855,11 +8855,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(423 passed,含新增 6 条:栏目分流与总览不渲染、UI 栏 5 个入口载荷、前置缺失可点击说明且零请求、角色栏 2 个入口、音频入口走既有链路、上传 + 配对读清单,另 1 条工具栏与 Dock 的 CSS 几何契约);`resourceCanvasBottomToolbar.test.tsx` 15 passed(新增);`resourceCanvasGenerationEntry.test.tsx` 11 passed(新增单类型用例 1 条);`projectResourceLiveIntegration.test.tsx` 25 passed(「生成素材」面板改名断言同步更新);`npm run agc:typecheck` 全绿(**其中的 `check-config.mjs` 报错已因本轮落地调用方而消失**)、`npm run check:encoding`、`git diff --check` 干净。未 commit。 - 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.10 / §7.9 / §8)、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11 / S11a / §7.3)。 -## 2026-09-13 Cocos 插件按当前项目类型暴露 +## 2026-09-20 Cocos 与 Unity 插件独立于工程类型 -- 决策:`agc-cocos-editor` 只有在当前受控项目通过 Cocos Creator 根目录识别(`package.json.creator.version` + 普通 `assets/`)时才暴露插件、面板和 Cocos 工具;无项目或其它项目类型均隐藏并失败关闭。 -- 决策:项目切换离开 Cocos 时立即停止已运行的插件实例;启动、面板读取、插件 RPC、Runtime execute 和 DirectProject MCP 工具目录/执行入口全部再次校验项目类型。Cocos 编辑器操作优先经内置插件入口,禁止回退到项目 `extensions/`、`package.json` 插件或第三方 MCP。 -- 验证:新增 builtin/plugin host 项目级门禁测试,Direct MCP fixture 补最小 Cocos 工程结构;Rust 定向测试、显式 `cocos-editor-execute` feature 编译、编码检查和 `git diff --check` 已执行。 +- 决策:`agc-cocos-editor` 与 `agc-unity-editor` 的插件列表、启动、面板、插件 RPC、Runtime 与 DirectProject 工具暴露不按当前工程类型过滤;无项目、普通 AGC、Godot、Cocos、Unity 上下文遵循同一套 enable、原生适配器、平台与 feature 规则。前端根据宿主列表中各插件状态分别自动启动,不按项目类型二选一,也不自动展开面板。 +- 决策:跨工程类型切换保留插件实例及管理能力,继续更新受控项目上下文、失效旧连接并隔离旧请求回执。实际编辑器操作仍要求当前受控项目匹配真实引擎工程与编辑器目标;显式跨项目路径、缺失项目、无目标进程、身份或握手不匹配均在派发前失败,权限、并发、期限与执行不确定阻断保持有效。 +- 边界:插件可见和工具可调用不能证明任意非引擎目录可成为编辑器执行目标;不改变项目类型、导入或持久数据。Cocos 编辑器操作继续使用内置插件,禁止回退到项目 `extensions/`、`package.json` 插件或第三方 MCP。 +- 验收口径:分别取得宿主/内置开关、工具目录、前端启动投影与真实目标拒绝证据;真实编辑器、安装包和 CI 与定向测试分层报告。 ## 2026-09-14 DirectProject Codex 取消路径白名单并启用完整 sandbox diff --git a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md index c1d7604c9..df5f29201 100644 --- a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md +++ b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md @@ -71,7 +71,7 @@ plugins/agc-cocos-editor/ 宿主按 `AGC_PLUGIN_WORKSPACE`、随包 `/plugins`、开发构建仓库 `plugins/` 的顺序解析工作区;插件包内的 `native/payload` 由构建脚本随包映射,生成的 DLL 不入库。插件协议、权限和面板挂载全部复用通用宿主,Cocos 专属逻辑只存在于本插件包:进程名与 `--project` 解析、Creator 版本校验、named pipe 协议和 Windows 注入。 -该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。除开关和 feature 外,还必须满足“当前受控项目已识别为 Cocos Creator 项目”这一门禁;没有当前项目或项目类型不是 Cocos 时,插件不出现在插件列表、面板、Agent 工具目录或 MCP tools/list 中,启动、面板读取、RPC 和编辑器执行也会失败关闭。项目切换离开 Cocos 后,已运行实例立即停止。Cocos 编辑器操作统一优先通过该内置插件的 `cocos.editor.execute` / `cocos.editor.operation`(DirectProject 对应 `agc_cocos_execute`);不得改走项目目录 `extensions/`、`package.json` 插件或第三方 MCP。开关状态保存在 AppData `extensions/builtin-plugins.json`,隔离 MCP 每次 tools/list 都向绑定宿主询问当前状态与项目门禁。 +该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。插件列表、启动、面板读取、插件 RPC、Agent 工具目录和 MCP tools/list 不按当前工程类型过滤;无当前项目或非 Cocos 项目仍沿用相同的开关、原生适配器、平台和 feature 规则。项目切换不因工程类型不同而停止插件,仍更新受控项目上下文并失效旧连接。实际编辑器操作必须取得当前受控项目对应的真实 Creator 目标并通过原有目录、PID、版本与握手校验;无项目或非引擎目录不能仅凭工具可见就通过执行校验。Cocos 编辑器操作统一优先通过该内置插件的 `cocos.editor.execute` / `cocos.editor.operation`(DirectProject 对应 `agc_cocos_execute`);不得改走项目目录 `extensions/`、`package.json` 插件或第三方 MCP。开关状态保存在 AppData `extensions/builtin-plugins.json`,隔离 MCP 每次 tools/list 都向绑定宿主询问当前可用状态,不自行按工程类型过滤。 ## AGC 项目打开入口 diff --git a/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md b/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md index 3cb49fcbe..f443a6f27 100644 --- a/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md +++ b/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md @@ -3,7 +3,7 @@ > 文档状态:`current` > 规范关系:承接 AGC 通用插件宿主与编辑器适配主规范 -更新时间:`2026-09-18` +更新时间:`2026-09-20` ## 目标与非目标 @@ -18,7 +18,7 @@ ## 入口与行为合同 - Unity 项目以当前受控根目录中的普通 `ProjectSettings/ProjectVersion.txt`、`Assets/` 和 `Packages/` 识别;复用现有打开项目入口,不创建平行工作台。 -- 只有当前项目为 Unity、内置插件启用且平台适配器可用时才显示插件并向 Agent 暴露 Unity 工具。禁用或离开 Unity 项目后停止插件实例,执行入口再次检查开关和项目身份。 +- 插件列表、启动、面板、插件 RPC 和 Agent 工具暴露不按当前工程类型过滤;无项目或非 Unity 项目也沿用相同的内置开关及平台适配器规则。禁用会停止插件实例,跨工程类型切换保留插件管理能力并失效旧连接。前端按宿主列表中各插件自身状态投影自动启动,不因项目类型在 Cocos/Unity 之间二选一,也不自动展开面板。执行入口继续检查开关、权限和真实项目身份;工具可见不代表任意目录可作为 Unity 执行目标。 - 探测只读取进程和项目身份。连接必须匹配规范化项目路径、PID、进程启动身份与实际握手;多个候选时失败,不选择任意实例。助手只接受受控项目、操作和代码,不接受任意可执行文件或 payload 路径。 - 执行接收 UTF-8 C# 代码,最多 128 KiB,拒绝空值和 NUL。连接及执行有总期限,消息最多 2 MiB,并发执行直接拒绝,不积压写请求。 - 成功仅由 Unity 真实执行回执决定;编译或运行错误返回结构化失败与脱敏诊断。主线程同步代码不承诺可硬中止。 @@ -51,7 +51,7 @@ helper 使用一行一条 JSON 请求/响应,请求包含 `id`、`method`、`p | 条款 | 必须取得的证据 | | --- | --- | | 来源与构建 | 固定源码版本、许可、helper 构建和自包含发布检查 | -| 插件接入 | manifest/协议测试,发现、启停、开关和项目级工具过滤测试 | +| 插件接入 | manifest/协议测试,发现、启停、开关、无项目/跨工程类型的工具暴露和前端启动投影测试 | | 执行闭环 | helper 与原生适配器定向测试,Agent 参数及结果映射测试 | | 失败边界 | 跨项目、并发、超时、损坏回执、不确定阻断、重启插件不解除阻断测试 | | 分发 | Windows 构建脚本准备 helper,staging 仅包含目标平台运行文件及许可 | diff --git a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md index 50e5b1c23..9e147ae2d 100644 --- a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md +++ b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md @@ -4,7 +4,7 @@ > 规范关系:AGC 插件与编辑器适配主规范 > 验收范围:插件 manifest、宿主生命周期、RPC、Capability/权限审计、UI 挂载和编辑器适配器边界 -更新时间:`2026-09-18` +更新时间:`2026-09-20` ## 目标与边界 @@ -72,7 +72,16 @@ OpenAI 的标准模型是“Plugin 作为可安装包,组合 Skills、可选 M Windows 与 macOS 构建都将内置插件的清单、JS 入口与面板复制到应用资源目录;staging 每次重建,避免已删除插件或跨目标原生 payload 残留。macOS 不携带 Windows native payload。Cocos 进程桥接仍仅按既有 Windows 平台实现提供,插件文件可被发现不代表 macOS 已支持编辑器控制;JS 入口的系统 Node 前提不变。 -Cocos 插件对用户可见与可启动必须同时满足当前为 Cocos 项目、宿主已注册 `cocos-editor` 原生适配器;没有适配器时从插件/扩展列表隐藏,直接启动或读取面板也在产生子进程前拒绝。正式适配器仅在 Windows 且编译 `cocos-editor-execute` 时注册;Agent 工具使用相同平台与 feature 门禁。前端只按后端列表投影判断是否自动启动,不自行推断平台能力。 +Cocos 与 Unity 插件的可见性、启动、面板、插件 RPC 和 Agent 工具暴露不按当前工程类型过滤;无当前项目以及普通 AGC、Godot、Cocos、Unity 项目使用同一套插件可用性规则。宿主必须已注册插件声明的原生适配器;没有适配器时从插件/扩展列表隐藏,直接启动或读取面板也在产生子进程前拒绝。正式适配器与 Agent 工具继续受各自平台和编译 feature 约束,禁用开关继续阻止启动与工具执行。前端只按后端列表投影判断是否自动启动,不自行推断平台能力或再次按工程类型过滤。 + +### 工程上下文与真实编辑器目标 + +- 工程类型只用于工程识别及对应工程工作流,不作为 `agc-cocos-editor` / `agc-unity-editor` 的管理、面板或工具目录门禁。Runtime、DirectProject MCP、工具策略快照和模型上下文使用一致规则;工具已暴露不代表真实编辑器已连接或操作已成功。 +- 前端对宿主列表中的 Cocos 与 Unity 插件分别根据适配器支持、启用、Runtime 入口和运行状态投影自动启动,不按项目类型二选一;自动启动不自动展开编辑器面板。 +- 项目切换不因新工程类型不同而停止插件或隐藏工具;当前受控项目上下文仍须按既有顺序更新,旧编辑器连接失效。旧请求与回执保留原项目归属,不能更新新项目连接状态。 +- 实际编辑器操作仍须取得有效的当前受控项目和与之匹配的真实编辑器目标。宿主注入项目路径,显式路径必须与当前受控项目一致;适配器继续校验真实工程结构、目标 PID、进程身份、版本与握手。无项目、不匹配的工程目录、无编辑器或不支持的平台均应在发送编辑器操作前明确失败,不回退到其它项目或任意编辑器进程。 +- 插件启用状态、manifest 适配器绑定、`editor.rpc` 权限、超时与并发拒绝、执行结果不确定阻断均保持原合同。取消工程类型过滤不增加自动重试,不清除项目切换或重启插件前已经产生的不确定状态。 +- 不新增或迁移项目类型、内置插件开关、API/DTO、SpacetimeDB schema 或持久项目数据;不扩大 Cocos/Unity 原生适配器的平台支持,也不把任意非引擎目录解释为可执行的编辑器工程。 ### 内置插件与可用开关 @@ -141,6 +150,7 @@ OpenAI 官方 Plugins 文档将 Skills、MCP Server 和可选 UI 定义为同一 - Rust:manifest 路径/权限校验、目录扫描、权限拒绝和通用适配器 registry 边界单测;`cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 - 前端:`agc-plugin-sdk` TypeScript 编译、宿主服务类型检查,以及 `PluginPanelHost` 的挂载/卸载测试。 - 内置插件开关:`builtin_plugins` 单测覆盖默认值、持久化往返、坏文件失败关闭,以及“禁用后工具目录里不再出现该工具”;`plugin_host` 单测覆盖禁用后不能启动、启用后回到 stopped。 +- 工程类型独立性:覆盖无当前项目、普通 AGC、Godot、Cocos、Unity 上下文中的插件列表、启动、面板、RPC 与 Runtime/DirectProject 工具目录一致性;项目切换不因类型变化停止插件。保留禁用开关、缺失原生适配器、平台/feature、显式跨项目路径拒绝与真实编辑器目标校验的独立反例;非引擎目录不能仅因工具可见就通过实际操作校验。 - 插件工作区:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 覆盖工作区扫描与 manifest 启用状态;`cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml` 覆盖 Cocos 适配器;`node --test plugins/agc-cocos-editor/src/entry.test.mjs` 覆盖插件入口协议与 manifest 一致性。 - 通用仓库门禁:`npm run check:encoding`、`git diff --check`;发布前仍需单独执行 AGC package smoke 和安装包 smoke。 diff --git a/plugins/agc-cocos-editor/README.md b/plugins/agc-cocos-editor/README.md index 68e885a80..07e29b4ae 100644 --- a/plugins/agc-cocos-editor/README.md +++ b/plugins/agc-cocos-editor/README.md @@ -3,11 +3,13 @@ Cocos Creator 编辑器桥接插件。用户侧看到的是一个普通 AGC 插件:插件生命周期、UI、 RPC、权限和能力注册全部由通用宿主负责,只有“如何连接 Cocos Creator”属于本插件。 -它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。只有当前受控目录被识别为 Cocos -Creator 项目时才会暴露插件、面板和工具;切换到其它项目会立即停止插件实例并隐藏对应能力。 +它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。插件列表、启动、面板、RPC 和 +Agent 工具暴露不受当前工程类型限制;无当前项目也沿用相同的开关、原生适配器、平台和 +feature 规则。切换工程类型不会停止插件或隐藏能力,仍会更新受控项目并失效旧连接。 用户在运行时设置里只能切换“是否可用”,禁用后插件不能启动,`agc_cocos_execute` 与全部 -`cocos_*` Agent 工具也会从 Agent 工具列表、工具策略快照和上下文里消失;重新启用后仍需 -满足当前项目是 Cocos 的门禁。 +`cocos_*` Agent 工具也会从 Agent 工具列表、工具策略快照和后续上下文里消失。 +实际编辑器操作仍需当前受控项目对应的真实 Creator 目标,并通过项目路径、PID、版本和 +握手校验;工具可见不代表任意目录都能作为 Creator 执行目标。 Cocos Creator 项目目录中的 `extensions/`、`package.json` 插件声明或第三方 MCP 包不属于 AGC Cocos 桥接来源。Agent 处理 Cocos 请求时只使用客户端登记的 `agc-cocos-editor` diff --git a/plugins/agc-cocos-editor/src/entry.mjs b/plugins/agc-cocos-editor/src/entry.mjs index 6457863e8..225b3e58a 100644 --- a/plugins/agc-cocos-editor/src/entry.mjs +++ b/plugins/agc-cocos-editor/src/entry.mjs @@ -45,6 +45,7 @@ export function createCocosEditorPlugin({ }) { let nextId = 1; let activeProjectPath = null; + let projectEpoch = 0; let disposed = false; let executionUncertain = false; let executionPending = false; @@ -190,6 +191,7 @@ export function createCocosEditorPlugin({ const projectPath = event.payload?.projectPath; activeProjectPath = typeof projectPath === 'string' && projectPath ? projectPath : null; + projectEpoch += 1; } return; } @@ -239,10 +241,13 @@ export function createCocosEditorPlugin({ const panel = await request('host.registerPanel', { ...COCOS_EDITOR_PANEL, }); + const epoch = projectEpoch; const subscription = await request('host.events.subscribe', { type: PROJECT_CHANGED_EVENT, }); - activeProjectPath = subscription?.projectPath ?? null; + if (epoch === projectEpoch) { + activeProjectPath = subscription?.projectPath ?? null; + } return { command, capability, diff --git a/plugins/agc-cocos-editor/src/entry.test.mjs b/plugins/agc-cocos-editor/src/entry.test.mjs index 00831b843..0025a5595 100644 --- a/plugins/agc-cocos-editor/src/entry.test.mjs +++ b/plugins/agc-cocos-editor/src/entry.test.mjs @@ -215,6 +215,58 @@ test('project.changed event updates the cached project path', async () => { assert.equal(harness.plugin.activeProjectPath, null); }); +for (const [snapshotPath, projectPath] of [ + [null, 'C:/New'], + ['C:/Old', 'C:/New'], + ['C:/Old', null], +]) { + test(`迟到的订阅快照 ${snapshotPath} 不能覆盖新项目事件 ${projectPath}`, async (t) => { + const requests = []; + const plugin = createCocosEditorPlugin({ + send(message) { + requests.push(message); + if (!message.method) return; + queueMicrotask(async () => { + if (message.method === 'host.events.subscribe') { + await plugin.handleMessage({ + jsonrpc: '2.0', + method: 'host.event', + params: { + type: PROJECT_CHANGED_EVENT, + payload: { projectPath }, + }, + }); + } + await plugin.handleMessage({ + jsonrpc: '2.0', + id: message.id, + result: + message.method === 'host.events.subscribe' + ? { subscriptionId: 'sub-1', projectPath: snapshotPath } + : { ok: true }, + }); + }); + }, + }); + t.after(() => plugin.dispose()); + await plugin.start(); + assert.equal(plugin.activeProjectPath, projectPath); + await plugin.handleMessage({ + jsonrpc: '2.0', + id: 500, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 2;' }, + }); + const rpc = requests.find((message) => message.method === 'host.rpc'); + if (projectPath) { + assert.equal(rpc.params.params.projectPath, projectPath); + } else { + assert.equal(rpc, undefined); + assert.match(requests.at(-1).error.message, /项目路径/); + } + }); +} + test('execute rejects concurrent requests and blocks later requests after uncertainty', async () => { const harness = createHarness(); await startPlugin(harness);