Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd904ea144 | |||
| 299877b197 | |||
| b67cb18b45 | |||
| 8ba9835d10 | |||
| 02ff914bd3 | |||
| dd36de3aa6 | |||
| 9d2ad69a75 | |||
| 7df526868c | |||
| 3c1711c7df |
@@ -232,37 +232,6 @@ 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
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
|
||||
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
|
||||
# “构建 -> 监听 -> 再构建”的自触发循环。
|
||||
resources/plugins/
|
||||
@@ -117,6 +117,16 @@ 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',
|
||||
@@ -1285,7 +1295,7 @@ if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
|
||||
throw new Error('AI game creator shell identifier drifted');
|
||||
}
|
||||
|
||||
const expectedBundledCodexResources = {
|
||||
const expectedBundledWindowsResources = {
|
||||
'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',
|
||||
@@ -1299,6 +1309,7 @@ const expectedBundledCodexResources = {
|
||||
'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(
|
||||
@@ -1307,8 +1318,8 @@ if (tauriConfig.bundle?.resources !== undefined) {
|
||||
}
|
||||
assert.deepEqual(
|
||||
windowsTauriConfig.bundle?.resources,
|
||||
expectedBundledCodexResources,
|
||||
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set',
|
||||
expectedBundledWindowsResources,
|
||||
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory',
|
||||
);
|
||||
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
|
||||
throw new Error(
|
||||
|
||||
@@ -436,6 +436,10 @@ 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,6 +43,28 @@ 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,
|
||||
@@ -106,7 +128,10 @@ async function runTauriDev(
|
||||
shutdownRequested.then(() => false),
|
||||
]);
|
||||
if (!prepared || shutdownSignal) return 1;
|
||||
const tauriArguments = buildTauriArguments(argv, endpoint.url);
|
||||
const tauriArguments = buildTauriArguments(
|
||||
withDevCargoFeatures(argv),
|
||||
endpoint.url,
|
||||
);
|
||||
child = spawnCli(tauriArguments, {
|
||||
env: withAgcDevEndpointEnv(endpoint),
|
||||
});
|
||||
@@ -193,6 +218,7 @@ export {
|
||||
isDirectModuleExecution,
|
||||
runTauriDev,
|
||||
spawnTauriCli,
|
||||
withDevCargoFeatures,
|
||||
};
|
||||
|
||||
if (isDirectModuleExecution()) {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
|
||||
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
|
||||
# “构建 -> 监听 -> 再构建”的自触发循环。
|
||||
resources/plugins/
|
||||
+22
@@ -733,6 +733,18 @@ 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"
|
||||
@@ -1205,6 +1217,14 @@ 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"
|
||||
@@ -1709,6 +1729,8 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chromiumoxide",
|
||||
"cocos-editor-bridge",
|
||||
"editor-adapter-api",
|
||||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"http",
|
||||
|
||||
@@ -6,6 +6,9 @@ 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"] }
|
||||
@@ -19,6 +22,8 @@ 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,6 +182,8 @@ 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);
|
||||
@@ -203,3 +205,146 @@ 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) {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.dll
|
||||
@@ -1070,6 +1070,7 @@ 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" },
|
||||
@@ -5050,6 +5051,42 @@ 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,3 +1,4 @@
|
||||
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,
|
||||
@@ -204,7 +205,15 @@ fn append_direct_project_history_item_at_with_user_policy(
|
||||
if !allow_user_item && is_direct_project_codex_user_item(item) {
|
||||
return Ok(());
|
||||
}
|
||||
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
|
||||
// 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 path = history_path(root);
|
||||
let history_exists =
|
||||
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
|
||||
@@ -429,6 +438,50 @@ 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,6 +54,8 @@ 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)]
|
||||
@@ -687,6 +689,8 @@ 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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1477,12 +1481,28 @@ 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,
|
||||
@@ -2323,11 +2343,120 @@ 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,
|
||||
@@ -2335,6 +2464,8 @@ 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,11 +74,36 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option<i32>
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_tools_mcp_specs() -> Value {
|
||||
direct_tools_mcp_specs_for(controlled_web_search_enabled())
|
||||
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_for(controlled_web_search: bool) -> Value {
|
||||
fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value {
|
||||
let tools = vec![
|
||||
json!({
|
||||
"name": "client.session.info",
|
||||
@@ -431,6 +456,19 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
|
||||
}),
|
||||
];
|
||||
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",
|
||||
@@ -503,6 +541,21 @@ 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 })
|
||||
}
|
||||
@@ -1335,7 +1388,12 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
|
||||
true,
|
||||
);
|
||||
}
|
||||
let _project_lock = match acquire_project_write_lock(root, "conversation.write") {
|
||||
// 与 DirectProject 历史落盘同一档有界等待:`agc_write_file` 持锁期间可能持续数秒,
|
||||
// 零等待取锁会让 Codex 返回记录直接丢失。调用方已把本函数放进阻塞线程池。
|
||||
let _project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"conversation.write",
|
||||
) {
|
||||
Ok(lock) => lock,
|
||||
Err(error) => {
|
||||
return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true)
|
||||
@@ -1556,7 +1614,7 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
|
||||
))
|
||||
}
|
||||
}
|
||||
"tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())),
|
||||
"tools/list" => Some(mcp_success(id, direct_tools_mcp_specs().await)),
|
||||
"tools/call" => {
|
||||
let tool = request
|
||||
.pointer("/params/name")
|
||||
@@ -1569,12 +1627,29 @@ 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" => {
|
||||
external_mcp_record_response(root, &arguments)
|
||||
// 取锁等待是同步轮询(最多约 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,
|
||||
),
|
||||
}
|
||||
}
|
||||
"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,
|
||||
@@ -1767,6 +1842,119 @@ 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]
|
||||
@@ -1842,7 +2030,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);
|
||||
let specs = direct_tools_mcp_specs_for(false, true);
|
||||
let names = specs["tools"]
|
||||
.as_array()
|
||||
.expect("tool array")
|
||||
@@ -1869,6 +2057,11 @@ 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"));
|
||||
@@ -1975,7 +2168,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_catalog_adds_controlled_web_search_only_when_enabled() {
|
||||
let specs = direct_tools_mcp_specs_for(true);
|
||||
let specs = direct_tools_mcp_specs_for(true, true);
|
||||
let search = specs["tools"]
|
||||
.as_array()
|
||||
.expect("tool array")
|
||||
|
||||
@@ -1606,6 +1606,17 @@ 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,6 +362,16 @@ 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,6 +95,8 @@ 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"),
|
||||
|
||||
+8
-2
@@ -17,7 +17,7 @@ mod canvas_asset_kind_contract_tests {
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
|
||||
vec![
|
||||
let mut tools = vec![
|
||||
GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
|
||||
"memory.read",
|
||||
"memory.write",
|
||||
@@ -63,7 +63,13 @@ 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,6 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
mod action_history;
|
||||
mod cocos_editor;
|
||||
mod command_ops;
|
||||
mod context;
|
||||
mod delegation;
|
||||
@@ -20,6 +21,7 @@ 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::*;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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,8 +231,10 @@ fn native_runtime_function_name_for_tool(tool: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_agent_runtime_native_capability_registry() -> Result<CapabilityRegistry<String>, String> {
|
||||
let definitions = agent_runtime_native_executable_tools()
|
||||
fn build_agent_runtime_native_capability_registry(
|
||||
tools: Vec<&'static str>,
|
||||
) -> Result<CapabilityRegistry<String>, String> {
|
||||
let definitions = tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
CapabilityDefinition::try_new(
|
||||
@@ -251,9 +253,20 @@ fn build_agent_runtime_native_capability_registry() -> Result<CapabilityRegistry
|
||||
|
||||
fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry<String>, String>
|
||||
{
|
||||
static REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
|
||||
REGISTRY
|
||||
.get_or_init(build_agent_runtime_native_capability_registry)
|
||||
// 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即
|
||||
// 生效,又不需要每次调用都重建 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))
|
||||
.as_ref()
|
||||
.map_err(Clone::clone)
|
||||
}
|
||||
@@ -1027,6 +1040,9 @@ 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" => {
|
||||
@@ -1216,6 +1232,14 @@ 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": {
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! 内置插件登记表与可用开关。
|
||||
//!
|
||||
//! 内置插件随 `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
Reference in New Issue
Block a user