Compare commits

..

3 Commits

Author SHA1 Message Date
suzmii 2c9e6fe272 修正 BGM 提示词助手限流用例的断言口径:只承诺客户端可见语义
Project CI / Repository checks (pull_request) Successful in 2m34s
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 6m32s
Project CI / Native shell tests (pull_request) Successful in 18m5s
- server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs:consecutive_valid_requests_are_not_subject_to_a_feature_rate_limit
  的 assert_eq!(mock.finish().len(), 4) 改为「到达 provider 的请求不少于 4 次」,并在该断言失败时打印捕获到的请求原文

为什么新形态才是它真正承诺的语义:用例名只承诺「连续合法请求不被特性限流」,那是客户端可见口径;
本模块的简化路径本身允许一轮内容重试(同文件 simplification_retries_* 四条用例正是它的定向覆盖),
因此 provider 的精确调用次数是实现细节,把它钉死会让一次合法重试变成假红。
「4 次调用都到达 provider」与原有的 200/非 429 断言都保留:短路、提前拒绝、限流仍然会红。
根因见 #327 问题 2.2(PR #316 run 1950:left: 5, right: 4)。
2026-09-11 16:31:35 +08:00
suzmii f9e24f4e16 修复 bgfilter_worker 测试端口 TOCTOU:不再借用内核临时端口
- server-rs/crates/api-server/src/bgfilter_worker.rs:reserved_loopback_port 不再 bind("127.0.0.1:0") 取临时端口后 drop,
  改为在内核动态端口范围之外的固定测试带(20000-29999)内按进程内游标递增分配,并先探测可用性落点
- 同一文件:4 处调用点去掉不再需要的 .await(该 helper 现在是同步分配)

这样「先保持端口关闭、约 700ms 后才开始监听」的重启用例在整个空窗期内独占该端口,
重新 bind 不会再被同一测试二进制里其它用例的 bind(0) 抢走。
根因与证据见 #327 问题 2.1。本机 Windows 复现不出该抢占(动态端口范围 49152-65535,
连续 200 次 bind(0) 无一重复、也已实测刚释放的端口不被立即重新派发),它是 Linux 侧特性。
2026-09-11 16:31:17 +08:00
suzmii a6f5ab9d23 修复 Native shell 作业在测试阶段才解析 crates.io index 的缺口
- .gitea/workflows/project-ci.yml:Native shell tests 新增「Prepare standalone Rust crate dependencies」步骤,
  对被 server-rs workspace exclude 的 agent-runtime-core / agent-runtime-orchestration 先做 cargo fetch,
  把它们在测试阶段的 registry 解析与下载提前到依赖准备阶段;两 crate 未提交 Cargo.lock,故不能带 --locked
- scripts/project-ci-workflow.test.ts:新增用例钉住该步骤存在、覆盖两个独立 crate manifest、且命令不带锁标志

根因与影响见 #327:此前这两个 crate 只在 agent-runtime-*:check 中现场 `Updating crates.io index`,
crates.io 一抖动整条 native shell 作业就红(PR #316 run 1950)。
2026-09-11 16:18:43 +08:00
98 changed files with 354 additions and 9203 deletions
+31
View File
@@ -232,6 +232,37 @@ jobs:
done
done
- name: Prepare standalone Rust crate dependencies
shell: bash
run: |
set -euo pipefail
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
# 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path`
# 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己
# `Updating crates.io index`crates.io 一抖动整条 native shell 作业就红
# (见 #327 / PR #316 run 1950)。
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
# 解析,不再触碰 registry index。
for manifest_path in \
server-rs/crates/agent-runtime-core/Cargo.toml \
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Run native shell gates
run: npm run check:native-shells
-2
View File
@@ -40,8 +40,6 @@ temp*build*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-path/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json
/apps/ai-game-creator-shell/src-tauri/resources/plugins/
/plugins/agc-cocos-editor/native/payload/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
-4
View File
@@ -1,4 +0,0 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
@@ -117,16 +117,6 @@ const allowedUncalledTauriCommands = [
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
'list_agc_plugins',
'list_agc_extensions',
'refresh_agc_plugins',
'start_agc_plugin',
'stop_agc_plugin',
'reload_agc_plugin',
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_project_path',
'set_agc_plugin_enabled',
];
const sourceExtensions = new Set([
'.json',
@@ -1295,7 +1285,7 @@ if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
throw new Error('AI game creator shell identifier drifted');
}
const expectedBundledWindowsResources = {
const expectedBundledCodexResources = {
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
'codex/win-x64/bin/codex-code-mode-host.exe',
@@ -1309,7 +1299,6 @@ const expectedBundledWindowsResources = {
'codex/win-x64/codex-package.json',
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
'resources/plugins': 'plugins',
};
if (tauriConfig.bundle?.resources !== undefined) {
throw new Error(
@@ -1318,8 +1307,8 @@ if (tauriConfig.bundle?.resources !== undefined) {
}
assert.deepEqual(
windowsTauriConfig.bundle?.resources,
expectedBundledWindowsResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory',
expectedBundledCodexResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set',
);
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
throw new Error(
@@ -436,10 +436,6 @@ function spawnChild(command, args, options, spawnImpl = spawn) {
const child = spawnImpl(command, args, {
...options,
shell: useShell,
// npm.cmd and the Windows shell otherwise create a visible console for
// every service in the dev stack. Their stdout/stderr is already inherited
// by the launcher, so no separate terminal window is useful.
windowsHide: process.platform === 'win32' ? true : options.windowsHide,
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
detached: isPosix,
@@ -43,28 +43,6 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
];
}
// `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature 下
// 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具,
// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
function readDevCargoFeatures(env = process.env) {
const override = env.AGC_DEV_CARGO_FEATURES;
if (override !== undefined) {
return override
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
return process.platform === 'win32' ? ['cocos-editor-execute'] : [];
}
function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
if (features.length === 0) return argv;
if (argv.some((value) => value === '--features' || value === '-f')) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
@@ -128,10 +106,7 @@ async function runTauriDev(
shutdownRequested.then(() => false),
]);
if (!prepared || shutdownSignal) return 1;
const tauriArguments = buildTauriArguments(
withDevCargoFeatures(argv),
endpoint.url,
);
const tauriArguments = buildTauriArguments(argv, endpoint.url);
child = spawnCli(tauriArguments, {
env: withAgcDevEndpointEnv(endpoint),
});
@@ -218,7 +193,6 @@ export {
isDirectModuleExecution,
runTauriDev,
spawnTauriCli,
withDevCargoFeatures,
};
if (isDirectModuleExecution()) {
@@ -1,4 +0,0 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
-22
View File
@@ -733,18 +733,6 @@ dependencies = [
"error-code",
]
[[package]]
name = "cocos-editor-bridge"
version = "0.1.0"
dependencies = [
"cc",
"editor-adapter-api",
"serde",
"serde_json",
"sha2",
"windows-sys 0.61.2",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -1217,14 +1205,6 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "editor-adapter-api"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -1729,8 +1709,6 @@ dependencies = [
"axum",
"base64 0.22.1",
"chromiumoxide",
"cocos-editor-bridge",
"editor-adapter-api",
"futures",
"getrandom 0.3.4",
"http",
@@ -6,9 +6,6 @@ publish = false
[features]
default = []
cocos-editor = ["cocos-editor-bridge/process-discovery"]
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-transport"]
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
[build-dependencies]
serde = { version = "1", features = ["derive"] }
@@ -22,8 +19,6 @@ ts-rs = "12.0.1"
typed_floats = { version = "1.0.7", features = ["serde"] }
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false }
editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" }
base64 = "0.22"
axum = "0.8"
chromiumoxide = "0.9.1"
@@ -182,8 +182,6 @@ fn main() {
);
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_codex_cli(&manifest_dir);
stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
validate_seed_task_catalog(&compiled);
@@ -205,146 +203,3 @@ fn main() {
}
tauri_build::build()
}
#[cfg(windows)]
fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() {
return;
}
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
let profile_dir = out_dir
.ancestors()
.find(|path| path.file_name().is_some_and(|name| name == "build"))
.and_then(|build_dir| build_dir.parent())
.expect("AGC Cargo profile directory not found");
let candidates = [
profile_dir.join("deps/cocos_editor_bridge.dll"),
profile_dir.join("cocos_editor_bridge.dll"),
];
let source = candidates
.iter()
.find(|path| path.is_file())
.unwrap_or_else(|| {
panic!(
"Cocos bridge native payload 未构建:{}",
candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join("")
)
});
for destination in [
// 插件工作区里的 payload 是开发态与打包态的唯一真源。
manifest_dir
.join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
// 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。
manifest_dir
.join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
] {
std::fs::create_dir_all(destination.parent().expect("payload resource parent"))
.expect("创建 Cocos bridge payload 目录失败");
std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败");
}
println!("cargo:rerun-if-changed={}", source.display());
}
#[cfg(not(windows))]
fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
///
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
/// Cargo target 目录或 node_modules。
#[cfg(windows)]
fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
let repo_root = manifest_dir
.parent()
.and_then(|app_root| app_root.parent())
.and_then(|apps_dir| apps_dir.parent())
.expect("AGC 应用必须位于仓库 apps 目录下")
.to_path_buf();
let workspace = repo_root.join("plugins");
let destination_root = manifest_dir.join("resources/plugins");
std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
let entries = match std::fs::read_dir(&workspace) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let plugin_root = entry.path();
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
continue;
}
let name = entry.file_name();
let destination = destination_root.join(&name);
copy_plugin_file(
&plugin_root.join("plugin.json"),
&destination.join("plugin.json"),
);
for relative in [
std::path::PathBuf::from("src"),
std::path::PathBuf::from("panels"),
std::path::PathBuf::from("native/payload"),
] {
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
}
println!("cargo:rerun-if-changed={}", plugin_root.display());
}
}
#[cfg(windows)]
fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
let Ok(bytes) = std::fs::read(source) else {
return;
};
if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
return;
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).expect("创建插件资源目录失败");
}
std::fs::write(destination, bytes).expect("复制插件资源失败");
}
#[cfg(windows)]
fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
let entries = match std::fs::read_dir(source) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let target = destination.join(entry.file_name());
let path = entry.path();
if path.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if matches!(name.as_ref(), "target" | "node_modules" | ".git") {
continue;
}
std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
copy_plugin_tree(&path, &target);
} else {
// 测试文件不随包分发。
let name = entry.file_name();
let name = name.to_string_lossy();
if name.contains(".test.") {
continue;
}
stage_plugin_file(&path, &target);
}
}
}
#[cfg(windows)]
fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
if !source.is_file() {
return;
}
std::fs::create_dir_all(destination.parent().expect("插件资源父目录"))
.expect("创建插件资源目录失败");
std::fs::copy(source, destination).expect("复制插件资源失败");
}
#[cfg(not(windows))]
fn stage_plugin_workspace(_manifest_dir: &std::path::Path) {}
@@ -1070,7 +1070,6 @@ fn game_creator_codex_app_server_pool_key(
"skillPackIdentity": skill_pack_identity,
"clientSkillIdentity": client_skill_identity,
"clientMcpIdentity": client_mcp_identity,
"builtinPluginTools": crate::builtin_plugins::available_agent_tools(),
"controlledWebSearch": llm.web_search_enabled,
"directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" },
"providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" },
@@ -5051,42 +5050,6 @@ while IFS= read -r line; do :; done
assert_ne!(disabled, enabled);
}
#[cfg(feature = "cocos-editor-execute")]
#[test]
fn codex_app_server_pool_key_tracks_builtin_plugin_switch() {
let _guard = crate::builtin_plugins::test_lock();
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
let key = || {
game_creator_codex_app_server_pool_key(
&test_llm(),
"codex-cli 0.147.0",
&test_snapshot(),
"credential",
CodexAppServerWorkspaceMode::DirectProject,
)
};
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
false,
)
.unwrap();
let disabled = key();
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
true,
)
.unwrap();
let enabled = key();
assert_ne!(disabled, enabled);
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
false,
)
.unwrap();
assert_eq!(disabled, key());
}
#[test]
fn direct_file_change_approval_is_limited_to_workspace() {
let temp = tempfile::tempdir().expect("temp dir");
@@ -1,4 +1,3 @@
use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait;
use crate::config::prepare_game_creator_private_path_for_read;
use crate::project::{
append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
@@ -205,15 +204,7 @@ fn append_direct_project_history_item_at_with_user_policy(
if !allow_user_item && is_direct_project_codex_user_item(item) {
return Ok(());
}
// DirectProject 历史与 `agc_write_file` 共用项目写锁。文件写入在锁内要跑 Windows
// 私有路径准备与原子替换,现场实测一次 2.6KB 写入占锁 5.5 秒;零等待取锁会让
// 流式历史落盘在写文件期间直接失败,并把整轮判成“项目正在被其他写操作占用”
// (持锁方 commandId=direct-codex.file.write、ownerIsSelf=true)。这里与其它写入口
// 保持同一档有界等待;主路径已在阻塞线程池中执行。
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"conversation.write",
)?;
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
let path = history_path(root);
let history_exists =
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
@@ -438,50 +429,6 @@ mod tests {
assert_eq!(items, vec![item]);
}
#[test]
fn append_waits_for_a_same_process_project_writer() {
// 用 canonical 临时根:Windows 上 `%TEMP%` 的 8.3 短路径会让私有路径所有者
// 校验把测试目录判成“不属于当前用户”。
let temp_root = std::env::temp_dir()
.canonicalize()
.unwrap_or_else(|_| std::env::temp_dir());
let root = temp_root.join(format!(
"genarrative-agc-history-wait-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| value.as_millis())
.unwrap_or_default()
));
std::fs::create_dir_all(&root).expect("create project root");
crate::init_local_game_project_at(&root, "history-wait", "历史等待").expect("init project");
// 模拟 `agc_write_file`:它持锁期间历史落盘必须排队等待,而不是零等待失败后
// 把整轮判成“项目正在被其他写操作占用”。
let holder = crate::project::acquire_project_write_lock(&root, "direct-codex.file.write")
.expect("hold project write lock");
let worker_root = root.clone();
let worker = std::thread::spawn(move || {
append_direct_project_history_item_at(
&worker_root,
&serde_json::json!({
"type": "message",
"role": "assistant",
"id": "waits-for-writer",
"content": [{"type": "output_text", "text": "排队等待"}]
}),
)
});
std::thread::sleep(std::time::Duration::from_millis(300));
drop(holder);
worker
.join()
.expect("append worker")
.expect("history append must wait for the writer");
let items = read_direct_project_history_items_at(&root).expect("read history");
assert_eq!(items.len(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
let root = tempfile::tempdir().expect("temp project");
File diff suppressed because one or more lines are too long
@@ -54,8 +54,6 @@ struct DirectToolBridgeState {
regeneration_gate: tokio::sync::Mutex<()>,
resource_generation_gate: tokio::sync::Mutex<()>,
image_generation_gate: tokio::sync::Mutex<()>,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
cocos_execute_uncertain: tokio::sync::Mutex<bool>,
}
#[derive(Default)]
@@ -689,8 +687,6 @@ fn direct_tool_bridge_state_with_search(
regeneration_gate: tokio::sync::Mutex::new(()),
resource_generation_gate: tokio::sync::Mutex::new(()),
image_generation_gate: tokio::sync::Mutex::new(()),
#[cfg(all(windows, feature = "cocos-editor-execute"))]
cocos_execute_uncertain: tokio::sync::Mutex::new(false),
})
}
@@ -1481,28 +1477,12 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
let acquire_started = std::time::Instant::now();
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"direct-codex.file.write",
)?;
let lock_wait_ms = acquire_started.elapsed().as_millis();
let write_started = std::time::Instant::now();
let written = write_local_project_file_at(root, &path, content)?;
let write_ms = write_started.elapsed().as_millis();
let revision_started = std::time::Instant::now();
let revision = advance_agent_runtime_project_revision_locked(root)?;
// 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。
if lock_wait_ms + write_ms > 200 {
app_log!(
"direct.file.write.timing path={} bytes={} lockWaitMs={} writeMs={} revisionMs={}",
written.path,
content.len(),
lock_wait_ms,
write_ms,
revision_started.elapsed().as_millis()
);
}
Ok::<_, String>(json!({
"status": "completed",
"path": written.path,
@@ -2343,120 +2323,11 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str)
}
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
return bridge_tool_result(
"Cocos Creator 插件已禁用,agc_cocos_execute 不可用".to_string(),
Vec::new(),
true,
);
}
let prepared = (|| {
bridge_reject_unknown_fields(arguments, &["code"])?;
enforce_project_permission_policy(&state.root, "cocos.editor.execute")?;
let code = arguments
.get("code")
.and_then(Value::as_str)
.ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?;
cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string())?;
Ok::<_, String>(code.to_string())
})();
let code = match prepared {
Ok(code) => code,
Err(error) => {
return bridge_tool_result(
redact_agent_runtime_error(&state.root, &error, 480),
Vec::new(),
true,
)
}
};
let mut uncertain = state.cocos_execute_uncertain.lock().await;
if *uncertain {
return bridge_tool_result(
json!({
"status": "needs-reconciliation", "retryAllowed": false,
"message": "先前 Cocos execute 结果待核对,当前 bridge 不再发送执行命令"
})
.to_string(),
Vec::new(),
true,
);
}
let root = state.root.clone();
let result = tokio::task::spawn_blocking(move || {
let _lock = acquire_project_write_lock(&root, "direct-cocos.execute")
.map_err(cocos_editor_bridge::BridgeError::InvalidInput)?;
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
return Err(cocos_editor_bridge::BridgeError::InvalidInput(
"Cocos Creator 插件已禁用".to_string(),
));
}
cocos_editor_bridge::execute_cocos_editor_code_for_project(
root.to_string_lossy().as_ref(),
&code,
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS,
)
})
.await;
match result {
Ok(Ok(response)) => {
let is_error = !response.ok;
let text = json!({
"status": if response.ok { "completed" } else { "failed" },
"requestId": response.request_id,
"result": response.result,
"error": response.error,
})
.to_string();
bridge_tool_result(
redact_agent_runtime_project_paths(&state.root, &text, 32_000),
Vec::new(),
is_error,
)
}
failed => {
let (is_uncertain, error) = match failed {
Ok(Err(error)) => (
matches!(
&error,
cocos_editor_bridge::BridgeError::ExecutionUncertain(_)
),
error.to_string(),
),
Err(_) => (
true,
"Cocos execute worker 退出,执行结果需要核对".to_string(),
),
Ok(Ok(_)) => unreachable!(),
};
*uncertain = is_uncertain;
bridge_tool_result(
json!({
"status": if is_uncertain { "needs-reconciliation" } else { "failed" },
"retryAllowed": !is_uncertain,
"message": redact_agent_runtime_error(&state.root, &error, 480),
})
.to_string(),
Vec::new(),
true,
)
}
}
}
async fn handle_direct_tool_bridge(
State(state): State<Arc<DirectToolBridgeState>>,
Json(request): Json<DirectToolBridgeRequest>,
) -> Json<Value> {
let result = match request.tool.as_str() {
// 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。
"builtin.plugins.tools" => bridge_tool_result(
json!({"tools": crate::builtin_plugins::available_agent_tools()}).to_string(),
Vec::new(),
false,
),
"taonier_prepare_game_art" => bridge_prepare_game_art(&state, &request.arguments).await,
"agc_generate_image" => bridge_generate_image(&state, &request.arguments).await,
"agc_edit_image" => bridge_edit_image(&state, &request.arguments).await,
@@ -2464,8 +2335,6 @@ async fn handle_direct_tool_bridge(
bridge_list_registered_assets(&state.root, &request.arguments)
}
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
#[cfg(all(windows, feature = "cocos-editor-execute"))]
"agc_cocos_execute" => bridge_cocos_execute(&state, &request.arguments).await,
"agc_write_file" => {
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
}
@@ -74,36 +74,11 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option<i32>
})
}
async fn direct_tools_mcp_specs() -> Value {
let mut cocos_editor_available = false;
if cfg!(all(windows, feature = "cocos-editor-execute")) {
// 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。
if let Ok(result) = tokio::time::timeout(
std::time::Duration::from_secs(5),
call_client_tool_bridge("builtin.plugins.tools", &json!({})),
)
.await
{
if result["isError"] == false {
let availability = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.and_then(|text| serde_json::from_str::<Value>(text).ok());
cocos_editor_available = availability
.as_ref()
.and_then(|v| v["tools"].as_array())
.is_some_and(|tools| {
tools
.iter()
.any(|tool| tool == crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME)
});
}
}
}
direct_tools_mcp_specs_for(controlled_web_search_enabled(), cocos_editor_available)
fn direct_tools_mcp_specs() -> Value {
direct_tools_mcp_specs_for(controlled_web_search_enabled())
}
fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value {
fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
let tools = vec![
json!({
"name": "client.session.info",
@@ -456,19 +431,6 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
}),
];
let mut tools = tools;
#[cfg(all(windows, feature = "cocos-editor-execute"))]
if _cocos_editor_available {
tools.push(json!({
"name": "agc_cocos_execute",
"description": "在当前项目已连接的 Cocos Creator 主进程执行 JavaScript 函数体,支持 await 和 return。宿主绑定项目和目标进程,只提交 code。结果待核对或超时后禁止自动重发;使用 Editor.Message 调用 Creator API。",
"inputSchema": {
"type": "object",
"properties": { "code": { "type": "string", "minLength": 1, "maxLength": cocos_editor_bridge::MAX_EXECUTE_CODE_BYTES } },
"required": ["code"],
"additionalProperties": false
}
}));
}
if controlled_web_search {
tools.push(json!({
"name": "agc_web_search",
@@ -541,21 +503,6 @@ fn validate_write_file_arguments(arguments: &Value) -> Result<(), String> {
normalize_relative_path(&path).map(|_| ())
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn call_agc_cocos_execute(arguments: &Value) -> Value {
let validated = validate_tool_object_fields(arguments, &["code"]).and_then(|()| {
let code = arguments
.get("code")
.and_then(Value::as_str)
.ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?;
cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string())
});
if let Err(error) = validated {
return mcp_tool_result(error, Vec::new(), true);
}
call_client_tool_bridge("agc_cocos_execute", arguments).await
}
fn mcp_success(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
@@ -1388,12 +1335,7 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
true,
);
}
// 与 DirectProject 历史落盘同一档有界等待:`agc_write_file` 持锁期间可能持续数秒,
// 零等待取锁会让 Codex 返回记录直接丢失。调用方已把本函数放进阻塞线程池。
let _project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"conversation.write",
) {
let _project_lock = match acquire_project_write_lock(root, "conversation.write") {
Ok(lock) => lock,
Err(error) => {
return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true)
@@ -1614,7 +1556,7 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
))
}
}
"tools/list" => Some(mcp_success(id, direct_tools_mcp_specs().await)),
"tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())),
"tools/call" => {
let tool = request
.pointer("/params/name")
@@ -1627,29 +1569,12 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
let result = match tool {
"client.session.info" => external_mcp_session_info(root),
"conversation.record_codex_response" => {
// 取锁等待是同步轮询(最多约 10 秒),必须放到阻塞线程池,
// 否则会占住 runtime worker。
let journal_root = root.to_path_buf();
let journal_arguments = arguments.clone();
match tokio::task::spawn_blocking(move || {
external_mcp_record_response(&journal_root, &journal_arguments)
})
.await
{
Ok(result) => result,
Err(error) => mcp_tool_result(
format!("Codex 返回记录任务未返回:{error}"),
Vec::new(),
true,
),
}
external_mcp_record_response(root, &arguments)
}
"conversation.list" => external_mcp_conversation_list(root, &arguments),
"conversation.read" => external_mcp_conversation_read(root, &arguments),
"agc_read_skill_resource" => call_agc_read_skill_resource(&arguments),
"agc_write_file" => call_agc_write_file(&arguments).await,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
"agc_cocos_execute" => call_agc_cocos_execute(&arguments).await,
"taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await,
"agc_generate_image" => call_agc_generate_image(&arguments).await,
"agc_edit_image" => call_agc_edit_image(&arguments).await,
@@ -1842,119 +1767,6 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(all(windows, feature = "cocos-editor-execute"))]
#[test]
fn builtin_mcp_process_probe() {
let Ok(expected) = std::env::var("AGC_MCP_TEST_COCOS_EXPECTED") else {
return;
};
assert!(crate::game_creator_runtime_config_dir().is_none());
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let specs = runtime.block_on(direct_tools_mcp_specs());
assert_eq!(
specs["tools"]
.as_array()
.unwrap()
.iter()
.any(|tool| tool["name"] == "agc_cocos_execute"),
expected == "true"
);
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
#[tokio::test]
async fn builtin_tools_follow_host_switch_in_isolated_mcp_processes() {
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-mcp-project-");
// 本用例只访问可用工具摘要和禁用入口,无需初始化完整游戏项目。
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 enabled in [false, true, false, true] {
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
enabled,
)
.unwrap();
// 同一个 MCP 服务重复 tools/list,同时覆盖原生函数永久缓存的切换。
let specs = EXTERNAL_MCP_BRIDGE_URL
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
.await;
assert_eq!(
specs["tools"]
.as_array()
.unwrap()
.iter()
.any(|tool| tool["name"] == "agc_cocos_execute"),
enabled
);
assert_eq!(
crate::agent_native_tools::native_runtime_function_name(
crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME
)
.is_some(),
enabled
);
if !enabled {
let response = EXTERNAL_MCP_BRIDGE_URL
.scope(
bridge.url().to_string(),
call_agc_cocos_execute(&json!({"code":"return 1;"})),
)
.await;
assert_eq!(response["isError"], true);
assert!(response.to_string().contains("插件已禁用"));
}
// 子进程没有真实 AppData,必须只从绑定宿主获取可用性。
let url = bridge.url().to_string();
let result = tokio::task::spawn_blocking(move || {
std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"agent::direct_tools_mcp::tests::builtin_mcp_process_probe",
"--nocapture",
])
.env(DIRECT_TOOL_BRIDGE_URL_ENV, url)
.env("AGC_MCP_TEST_COCOS_EXPECTED", enabled.to_string())
.output()
.unwrap()
})
.await
.unwrap();
assert!(
result.status.success(),
"{} {}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
assert!(
String::from_utf8_lossy(&result.stdout).contains("1 passed"),
"child probe must run"
);
}
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;
assert!(!specs.to_string().contains("agc_cocos_execute"));
drop(bridge);
let specs = EXTERNAL_MCP_BRIDGE_URL
.scope(
"http://127.0.0.1:1/tool-unavailable".to_string(),
direct_tools_mcp_specs(),
)
.await;
assert!(!specs.to_string().contains("agc_cocos_execute"));
}
use std::io::{Read, Write};
#[tokio::test]
@@ -2030,7 +1842,7 @@ mod tests {
DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024,
"MCP request envelope must fit the advertised file-write payload"
);
let specs = direct_tools_mcp_specs_for(false, true);
let specs = direct_tools_mcp_specs_for(false);
let names = specs["tools"]
.as_array()
.expect("tool array")
@@ -2057,11 +1869,6 @@ mod tests {
"agc_remove_background",
"agc_browser_playtest",
]
.into_iter()
.chain(
cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute")
)
.collect::<Vec<_>>()
);
let serialized = specs.to_string();
assert!(!serialized.contains("agc_web_search"));
@@ -2168,7 +1975,7 @@ mod tests {
#[test]
fn tool_catalog_adds_controlled_web_search_only_when_enabled() {
let specs = direct_tools_mcp_specs_for(true, true);
let specs = direct_tools_mcp_specs_for(true);
let search = specs["tools"]
.as_array()
.expect("tool array")
@@ -1606,17 +1606,6 @@ pub(crate) fn agent_runtime_tool_action_input_summary(
.unwrap_or(160)
),
"command.run_limited" => format!("commandId={}", text(&["commandId", "command_id", "id"])),
"cocos.editor.execute" => format!(
"codeChars={} · codeSha256={:x}",
chars(&["code"]),
Sha256::digest(
input
.get("code")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.as_bytes()
)
),
"preview.validate" => {
let viewports = input
.get("viewports")
@@ -362,16 +362,6 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
observe_agent_runtime_limited_command(root, agent_id, run_id, &action.input)
}
"preview.start" => observe_agent_runtime_preview_start(root, agent_id, run_id),
"cocos.editor.execute" => observe_agent_runtime_project_snapshot_with_lock(
root,
agent_id,
run_id,
action,
&action_fingerprint,
pending_action,
true,
|| observe_agent_runtime_cocos_editor_execute(root, action, pending_action),
),
"preview.validate" => {
observe_agent_runtime_preview_validate(
root,
@@ -95,8 +95,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
"command.stdin" => Some("command.stdin"),
"command.terminate" => Some("command.terminate"),
"command.run_limited" => Some("command.run_limited"),
#[cfg(feature = "cocos-editor-execute")]
"cocos.editor.execute" => Some("cocos.editor.execute"),
"preview.start" => Some("preview.start"),
"preview.validate" => Some("preview.validate"),
"image.inspect" => Some("image.inspect"),
@@ -17,7 +17,7 @@ mod canvas_asset_kind_contract_tests {
}
pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
let mut tools = vec![
vec![
GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
"memory.read",
"memory.write",
@@ -63,13 +63,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
"agent.schedule_ready",
"agent.action_history",
"agent.run_status",
];
// 内置插件被用户禁用后,对应 Runtime 工具不再进入工具目录、Agent 上下文
// 和工具策略快照。
if crate::builtin_plugins::cocos_editor_agent_tool_available() {
tools.push(crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME);
}
tools
]
}
pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> {
@@ -1,7 +1,6 @@
use super::*;
mod action_history;
mod cocos_editor;
mod command_ops;
mod context;
mod delegation;
@@ -21,7 +20,6 @@ mod task_ops;
mod ui_workflow;
pub(in crate::agent) use action_history::*;
pub(in crate::agent) use cocos_editor::*;
pub(in crate::agent) use command_ops::*;
pub(in crate::agent) use context::*;
pub(in crate::agent) use delegation::*;
@@ -1,117 +0,0 @@
use super::*;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct CocosEditorExecuteInput {
code: String,
}
pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute(
root: &Path,
action: &AgentRuntimeToolAction,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> AgentRuntimeToolObservation {
let input = match serde_json::from_value::<CocosEditorExecuteInput>(action.input.clone()) {
Ok(input) => input,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(
&format!("cocos.editor.execute 输入无效:{error}"),
240,
),
detail: None,
};
}
};
if pending_action.is_none() {
return AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: "failed".to_string(),
summary: "cocos.editor.execute 必须绑定 durable pending action".to_string(),
detail: None,
};
}
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
return AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: "failed".to_string(),
summary: "Cocos Creator 插件已禁用".to_string(),
detail: None,
};
}
let response = match cocos_editor_bridge::execute_cocos_editor_code_for_project(
root.to_string_lossy().as_ref(),
&input.code,
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS,
) {
Ok(response) => response,
Err(error) => {
let uncertain = matches!(
&error,
cocos_editor_bridge::BridgeError::ExecutionUncertain(_)
);
return AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: if uncertain {
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
} else {
"failed"
}
.to_string(),
summary: redact_agent_runtime_project_paths(root, &error.to_string(), 240),
detail: Some(
serde_json::json!({
"codeChars": input.code.chars().count(),
"error": redact_agent_runtime_project_paths(root, &error.to_string(), 500),
})
.to_string(),
),
};
}
};
let ok = response.ok;
let detail = serde_json::json!({
"processId": response.process_id,
"requestId": response.request_id,
"ok": ok,
"result": response.result.clone(),
"error": response.error.clone(),
"codeChars": input.code.chars().count(),
})
.to_string();
AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: if ok { "ok" } else { "failed" }.to_string(),
summary: if ok {
format!(
"已在 Cocos Creator 执行 {} 字符代码",
input.code.chars().count()
)
} else {
"Cocos Creator execute 返回失败".to_string()
},
detail: Some(detail),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn execute_requires_durable_pending_action_before_touching_editor() {
let action = AgentRuntimeToolAction {
tool: "cocos.editor.execute".to_string(),
reason: Some("test".to_string()),
input: serde_json::json!({"code": "return 1 + 1;"}),
};
let observation =
observe_agent_runtime_cocos_editor_execute(Path::new("C:\\cocos"), &action, None);
assert_eq!(observation.status, "failed");
assert!(observation
.summary
.contains("必须绑定 durable pending action"));
}
}
@@ -231,10 +231,8 @@ fn native_runtime_function_name_for_tool(tool: &str) -> String {
)
}
fn build_agent_runtime_native_capability_registry(
tools: Vec<&'static str>,
) -> Result<CapabilityRegistry<String>, String> {
let definitions = tools
fn build_agent_runtime_native_capability_registry() -> Result<CapabilityRegistry<String>, String> {
let definitions = agent_runtime_native_executable_tools()
.into_iter()
.map(|tool| {
CapabilityDefinition::try_new(
@@ -253,20 +251,9 @@ fn build_agent_runtime_native_capability_registry(
fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry<String>, String>
{
// 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即
// 生效,又不需要每次调用都重建 registry。
static ENABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
static DISABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> =
OnceLock::new();
// 缓存选择与构建消费同一份快照,避免开关变化污染另一份永久缓存。
let tools = agent_runtime_native_executable_tools();
let cache = if tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME) {
&ENABLED_REGISTRY
} else {
&DISABLED_REGISTRY
};
cache
.get_or_init(|| build_agent_runtime_native_capability_registry(tools))
static REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
REGISTRY
.get_or_init(build_agent_runtime_native_capability_registry)
.as_ref()
.map_err(Clone::clone)
}
@@ -1040,9 +1027,6 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"ui.workflow.run" => {
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
}
"cocos.editor.execute" => {
"在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。"
}
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
"agent.delegate" => {
@@ -1232,14 +1216,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
}
}),
"command.exec" | "command.start" => command_start_input_schema(),
"cocos.editor.execute" => json!({
"type": "object",
"required": ["code"],
"additionalProperties": false,
"properties": {
"code": { "type": "string", "minLength": 1, "maxLength": 131072 }
}
}),
"command.output_read" => json!({
"type": "object", "required": ["actionId", "startLine", "maxLines"], "additionalProperties": false,
"properties": {
@@ -1,432 +0,0 @@
//! 内置插件登记表与可用开关。
//!
//! 内置插件随 `plugins/` 工作区随包分发,用户不能卸载,只能控制是否可用。
//! 开关状态持久化在 AppData `extensions/builtin-plugins.json`,同时被两处消费:
//! 插件宿主(禁用后不能启动,状态显示为 disabled)和 Agent 工具目录(禁用后
//! 不出现在工具列表与 Agent 上下文里)。
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
/// Cocos Creator 编辑器插件的插件 id,与 `plugins/agc-cocos-editor/plugin.json` 一致。
pub(crate) const AGC_COCOS_EDITOR_PLUGIN_ID: &str = "agc-cocos-editor";
/// 该插件在 Agent 侧对应的 Runtime 工具名。
pub(crate) const AGC_COCOS_EDITOR_TOOL_NAME: &str = "cocos.editor.execute";
const STATE_FILE_NAME: &str = "builtin-plugins.json";
const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum BuiltinPlugin {
CocosEditor,
}
impl BuiltinPlugin {
pub(crate) fn id(self) -> &'static str {
match self {
Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID,
}
}
/// 未持久化任何开关时的默认状态。
fn default_enabled(self) -> bool {
match self {
Self::CocosEditor => true,
}
}
/// 该插件是否向 Agent 暴露 Runtime 工具。
fn exposes_agent_tools(self) -> bool {
match self {
Self::CocosEditor => true,
}
}
}
pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[BuiltinPlugin::CocosEditor];
pub(crate) fn builtin_plugin(id: &str) -> Option<BuiltinPlugin> {
BUILTIN_PLUGINS
.iter()
.copied()
.find(|plugin| plugin.id() == id.trim())
}
pub(crate) fn is_builtin(id: &str) -> bool {
builtin_plugin(id).is_some()
}
#[derive(Debug, Default)]
struct BuiltinPluginState {
path: Option<PathBuf>,
enabled: BTreeMap<String, bool>,
/// 开关文件不可读或格式不受支持时,内置插件全部按不可用处理。
fail_closed: bool,
}
static STATE: OnceLock<Mutex<BuiltinPluginState>> = OnceLock::new();
fn state() -> &'static Mutex<BuiltinPluginState> {
STATE.get_or_init(|| Mutex::new(BuiltinPluginState::default()))
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct BuiltinPluginStateFile {
#[serde(default)]
schema_version: Option<String>,
#[serde(default)]
enabled: BTreeMap<String, bool>,
}
/// 读取 AppData 里的开关状态;文件缺失按默认状态处理,坏文件失败关闭。
pub(crate) fn initialize(config_dir: &Path) -> Result<(), String> {
let root = config_dir.join("extensions");
let path = root.join(STATE_FILE_NAME);
if let Err(error) = fs::create_dir_all(&root) {
mark_fail_closed(Some(path));
return Err(format!("准备内置插件目录失败:{error}"));
}
let mut guard = state()
.lock()
.map_err(|_| "内置插件状态锁已损坏".to_string())?;
guard.path = Some(path.clone());
reload_state(&mut guard, &path)
}
fn read_state_file(path: &Path) -> Result<BuiltinPluginStateFile, String> {
match fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<BuiltinPluginStateFile>(&bytes) {
Ok(file) if file.schema_version.as_deref() == Some(STATE_SCHEMA_VERSION) => Ok(file),
Ok(_) => Err(format!(
"内置插件开关文件版本不受支持,需要 {STATE_SCHEMA_VERSION}"
)),
Err(error) => Err(format!("内置插件开关文件无效:{error}")),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(BuiltinPluginStateFile::default())
}
Err(error) => Err(format!("读取内置插件开关失败:{error}")),
}
}
fn reload_state(guard: &mut BuiltinPluginState, path: &Path) -> Result<(), String> {
let loaded = match read_state_file(path) {
Ok(loaded) => loaded,
Err(error) => {
guard.fail_closed = true;
guard.enabled = BUILTIN_PLUGINS
.iter()
.map(|plugin| (plugin.id().to_string(), false))
.collect();
return Err(error);
}
};
guard.fail_closed = false;
// 只接受登记表里的 id,避免坏文件把未知对象带进运行时。
guard.enabled = loaded
.enabled
.into_iter()
.filter(|(id, _)| is_builtin(id))
.collect();
Ok(())
}
fn mark_fail_closed(path: Option<PathBuf>) {
if let Ok(mut guard) = state().lock() {
guard.path = path;
guard.fail_closed = true;
guard.enabled = BUILTIN_PLUGINS
.iter()
.map(|plugin| (plugin.id().to_string(), false))
.collect();
}
}
pub(crate) fn is_enabled(id: &str) -> bool {
let Some(plugin) = builtin_plugin(id) else {
return false;
};
// Runner/CLI 已绑定配置根,但不会执行 GUI setup;不推断隔离子进程的 AppData。
let config_dir = crate::game_creator_runtime_config_dir_lock()
.lock()
.ok()
.and_then(|path| path.clone());
state()
.lock()
.map(|mut guard| {
let Some(path) = guard
.path
.clone()
.or_else(|| config_dir.map(|root| root.join("extensions").join(STATE_FILE_NAME)))
else {
return false;
};
// 每次查询读取持久化权威,使运行中的其它进程立即感知开关变化。
let _ = reload_state(&mut guard, &path);
if guard.fail_closed {
return false;
}
guard
.enabled
.get(plugin.id())
.copied()
.unwrap_or_else(|| plugin.default_enabled())
})
// 状态锁损坏时同样 fail-closed,避免异常状态重新放开内置能力。
.unwrap_or(false)
}
/// 内置插件的用户开关;`None` 表示该 id 不是内置插件,由来源自己决定启用状态。
pub(crate) fn toggle_state(id: &str) -> Option<bool> {
builtin_plugin(id).map(|_| is_enabled(id))
}
pub(crate) fn set_enabled(id: &str, enabled: bool) -> Result<bool, String> {
let Some(plugin) = builtin_plugin(id) else {
return Err(format!("{id} 不是内置插件,不能使用内置插件开关"));
};
let mut guard = state()
.lock()
.map_err(|_| "内置插件状态锁已损坏".to_string())?;
let path = guard
.path
.clone()
.ok_or_else(|| "内置插件开关尚未初始化".to_string())?;
// 保留其它进程刚写入的开关;损坏文件按全部禁用起步,允许用户显式修复。
let _ = reload_state(&mut guard, &path);
let previous = guard.enabled.get(plugin.id()).copied();
guard.enabled.insert(plugin.id().to_string(), enabled);
if let Err(error) = persist(&guard) {
match previous {
Some(value) => {
guard.enabled.insert(plugin.id().to_string(), value);
}
None => {
guard.enabled.remove(plugin.id());
}
}
return Err(error);
}
guard.fail_closed = false;
Ok(enabled)
}
fn persist(guard: &BuiltinPluginState) -> Result<(), String> {
let path = guard
.path
.clone()
.ok_or_else(|| "内置插件开关尚未初始化".to_string())?;
let file = BuiltinPluginStateFile {
schema_version: Some(STATE_SCHEMA_VERSION.to_string()),
enabled: guard.enabled.clone(),
};
let bytes = serde_json::to_vec_pretty(&file)
.map_err(|error| format!("序列化内置插件开关失败:{error}"))?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, bytes).map_err(|error| format!("写入内置插件开关失败:{error}"))?;
fs::rename(&temporary, &path).map_err(|error| format!("提交内置插件开关失败:{error}"))?;
Ok(())
}
/// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。
pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool {
plugin.exposes_agent_tools()
&& cfg!(feature = "cocos-editor-execute")
&& is_enabled(plugin.id())
}
pub(crate) fn cocos_editor_agent_tool_available() -> bool {
agent_tool_available(BuiltinPlugin::CocosEditor)
}
pub(crate) fn available_agent_tools() -> Vec<&'static str> {
if cocos_editor_agent_tool_available() {
vec![AGC_COCOS_EDITOR_TOOL_NAME]
} else {
Vec::new()
}
}
#[cfg(test)]
pub(crate) use tests::test_lock;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
pub(crate) fn test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|error| error.into_inner())
}
#[test]
fn builtin_plugins_default_to_enabled_and_reject_unknown_ids() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
assert!(is_builtin(AGC_COCOS_EDITOR_PLUGIN_ID));
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(true));
assert_eq!(toggle_state("imported-plugin"), None);
assert!(!is_enabled("imported-plugin"));
assert!(set_enabled("imported-plugin", false).is_err());
}
#[test]
fn toggle_state_round_trips_through_appdata_file() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
assert_eq!(
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable"),
false
);
let path = directory.path().join("extensions").join(STATE_FILE_NAME);
let written = fs::read_to_string(&path).expect("state file");
assert!(written.contains(STATE_SCHEMA_VERSION));
assert!(written.contains(AGC_COCOS_EDITOR_PLUGIN_ID));
// 重新初始化模拟下次启动读取持久化结果。
initialize(directory.path()).expect("re-initialize");
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn corrupt_state_file_fails_closed() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
let root = directory.path().join("extensions");
fs::create_dir_all(&root).expect("extensions dir");
fs::write(root.join(STATE_FILE_NAME), "{ not json").expect("write corrupt state");
assert!(initialize(directory.path()).is_err());
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(false));
}
#[test]
fn enabling_after_corruption_recovers_without_reinitializing() {
let _guard = test_lock();
let directory = tempdir().unwrap();
fs::create_dir_all(directory.path().join("extensions")).unwrap();
fs::write(
directory.path().join("extensions/builtin-plugins.json"),
"{",
)
.unwrap();
assert!(initialize(directory.path()).is_err());
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap();
assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn availability_reloads_changes_written_by_another_process() {
let _guard = test_lock();
let directory = tempdir().unwrap();
initialize(directory.path()).unwrap();
let path = directory.path().join("extensions/builtin-plugins.json");
for enabled in [false, true, false] {
fs::write(
&path,
serde_json::json!({
"schemaVersion": STATE_SCHEMA_VERSION,
"enabled": {AGC_COCOS_EDITOR_PLUGIN_ID: enabled}
})
.to_string(),
)
.unwrap();
assert_eq!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID), enabled);
}
fs::write(&path, "{").unwrap();
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn runner_process_reads_disabled_state_without_gui_setup() {
let _guard = test_lock();
let directory = tempdir().unwrap();
initialize(directory.path()).unwrap();
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"builtin_plugins::tests::runner_process_probe",
"--nocapture",
])
.env("AGC_BUILTIN_TEST_CONFIG_DIR", directory.path())
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stdout)
);
assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"));
}
#[test]
fn runner_process_probe() {
let Some(config_dir) = std::env::var_os("AGC_BUILTIN_TEST_CONFIG_DIR") else {
return;
};
// Runner/CLI 只绑定配置根目录,不进入 Tauri GUI setup。
crate::set_game_creator_runtime_config_dir(PathBuf::from(config_dir));
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn unsupported_schema_fails_closed() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
let root = directory.path().join("extensions");
fs::create_dir_all(&root).expect("extensions dir");
fs::write(
root.join(STATE_FILE_NAME),
serde_json::json!({"schemaVersion": "agc.builtin-plugins.v0", "enabled": {}})
.to_string(),
)
.expect("write unsupported state");
assert!(initialize(directory.path()).is_err());
assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID));
}
#[test]
fn agent_tool_visibility_follows_the_toggle() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
let tool_visible_when_enabled = cfg!(feature = "cocos-editor-execute");
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
assert_eq!(
cocos_editor_agent_tool_available(),
tool_visible_when_enabled
);
assert_eq!(
crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME),
tool_visible_when_enabled
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable");
assert!(!cocos_editor_agent_tool_available());
assert!(
!crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME)
);
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("re-enable");
assert_eq!(
crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME),
tool_visible_when_enabled
);
}
}

Some files were not shown because too many files have changed in this diff Show More