合并 master:接入 AGC 内置插件宿主与 Cocos 编辑器能力(#338)
Project CI / Repository checks (pull_request) Successful in 2m49s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / Backend tests (pull_request) Successful in 6m42s
Project CI / Native shell tests (pull_request) Successful in 17m35s

合并 origin/master 4a46f89c9 到 chore/agc-acceptance-build(HEAD 55fab1776),本次合并共涉及 117 个文件(113 个自动合并 + 4 个冲突)。

- 自动合并 113 个文件:插件宿主与插件工作区(plugin_host.rs / builtin_plugins.rs / editor_adapter(_s).rs / plugins/agc-cocos-editor / cocos-editor-bridge crate / editor-adapter-api)、构建与打包脚本、locked Cargo.lock 及 master 侧锁与资源链路改动;不逐条列出。
- 冲突 1 apps/ai-game-creator-shell/src/App.tsx:271-278:两侧各自新增一个 import(本分支 ./services/tauriEventSubscription、master ./services/pluginHost),两个 import 都保留,并按 simple-import-sort 口径排在 ./services/platformSession 之后、tauriEventSubscription 之前;四个符号都仍被使用。
- 冲突 2 docs/project-memory/shared-memory/decision-log.md:8338-8469:两侧在同一位置追加记录,逐字保留两侧记录且各自原有顺序不变(先本分支的 2026-09-11 资源卡预览 data URL / loopback HTTP 两条,后 master 的 2026-09-09~2026-09-12 通用插件宿主、Cocos 桥接与内置插件开关条目),不改写、不合并措辞。
- 冲突 3 docs/project-memory/shared-memory/pitfalls.md:79-112:同上,保留本分支的「2026-09-05 资源画本转场与视口分离」与 master 的「2026-09-11 首页自动工作区必须使用 AGC 管理目录」「2026-09-12 Cocos 项目识别不等于编辑器桥就绪」「2026-09-11 Cocos 项目必须走独立导入分支」,内容逐字保留、两侧原顺序不变。
- 冲突 4 apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs:400-435:判据是「两侧不互斥」,解法为 both(语义超集)。
- 冲突 4 保留本分支的有界退避重试循环(DIRECT_PROJECT_HISTORY_CONTENTION_RETRY_BACKOFF_MS + is_direct_project_history_contention_failure + 测试注入点)与单次尝试函数 append_direct_project_history_item_once(含锁外幂等回扫与锁内状态复核)。
- 冲突 4 保留 master 的有界等待取项目写锁:把该函数内原来的零等待 crate::project::acquire_project_write_lock(root, "conversation.write") 换成 acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "conversation.write"),并原样保留 master 的注释(agc_write_file 占锁 5.5 秒期间,零等待取锁会让流式历史落盘直接失败)。
- 冲突 4 判断依据:master 只替换了同一处的取锁语义(零等待 → 约 10 秒有界等待,并有 append_waits_for_a_same_process_project_writer 用例锁定),不改变函数签名、调用方与错误类型;HEAD 的重试循环因此仍然可达,与有界等待叠加后语义更强——写文件占锁由有界等待吃掉,等待窗口耗尽后的残余争用再由一次 250ms 短退避重试兜底。未删除任何一侧行为,也未放弃任何一侧语义。
- 冲突 4 位置取舍:有界等待取锁仍放在锁外回扫与序列化之后(本分支结构),保住「幂等回扫与序列化留在锁外、不让他人等整份历史读完」的性能语义;master 的注释与调用参数逐字保留。
- 验证:npm run typecheck、npm --prefix apps/ai-game-creator-shell run typecheck、cargo check --locked --all-targets(exit 0,0 error)、npm run check:rustfmt、npm run check:encoding、git diff --check 全部通过;冲突标记扫描为空。
- 定向 Rust 测试(--test-threads=1):direct_project_history 19 passed / 0 failed(含本分支 contention_failure_is_retried_with_bounded_backoff、contention_failure_beyond_the_backoff_budget_fails_closed、shape_failure_is_not_retried 与 master 的 append_waits_for_a_same_process_project_writer);conversation 36 passed / 0 failed;write_lock 21 passed / 0 failed。两侧用例同时绿,证明有界等待取锁与有界退避重试叠加后语义自洽。
- 未跑全量测试(按仓库约定本地只跑定向,全量交给 CI);本次不 push。
This commit is contained in:
2026-09-13 15:33:48 +08:00
117 changed files with 15159 additions and 320 deletions
+2
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
@@ -4,6 +4,11 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
const releaseTarget =
@@ -159,10 +164,30 @@ export async function prepareReleaseVersion() {
return nextVersion;
}
export function runTauriBuild(args = []) {
export function buildTauriBuildArguments(
args = [],
target = releaseTarget,
platform = process.platform,
) {
const noBundle = args.includes('--no-bundle');
const hasTarget = args.includes('--target');
const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget];
const targetIndex = args.indexOf('--target');
const explicitTarget =
targetIndex >= 0
? args[targetIndex + 1]
: args
.find((value) => value.startsWith('--target='))
?.slice('--target='.length);
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
const features = defaultEditorFeatures(
explicitTarget || (noBundle ? platform : target),
);
return [
'build',
...withDefaultCargoFeatures([...targetArgs, ...args], features),
];
}
export function runTauriBuild(args = []) {
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(
npmCommand,
@@ -172,9 +197,7 @@ export function runTauriBuild(args = []) {
'exec',
'tauri',
'--',
'build',
...targetArgs,
...args,
...buildTauriBuildArguments(args),
],
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
);
@@ -0,0 +1,24 @@
/** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */
export function withDefaultCargoFeatures(argv, features) {
const separator = argv.indexOf('--');
const cargoArgs = separator < 0 ? argv : argv.slice(0, separator);
if (
!features.length ||
cargoArgs.some(
(value) =>
value === '--features' ||
value === '-f' ||
value.startsWith('--features=') ||
/^-f.+/u.test(value),
)
) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
export function defaultEditorFeatures(target) {
return target === 'win32' || target.includes('windows')
? ['cocos-editor-execute']
: [];
}
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildTauriBuildArguments } from './build-release.mjs';
import { withDefaultCargoFeatures } from './cargo-features.mjs';
test('Windows release includes the same editor feature as development', () => {
assert.deepEqual(
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
[
'build',
'--features=cocos-editor-execute',
'--target',
'x86_64-pc-windows-msvc',
],
);
assert.deepEqual(
buildTauriBuildArguments(
['--no-bundle'],
'x86_64-pc-windows-msvc',
'linux',
),
['build', '--no-bundle'],
);
assert.deepEqual(
buildTauriBuildArguments(['--target=aarch64-apple-darwin']),
['build', '--target=aarch64-apple-darwin'],
);
});
test('explicit Cargo features override defaults in every supported spelling', () => {
for (const args of [
['--features', 'custom'],
['--features=custom'],
['-f', 'custom'],
['-fcustom'],
]) {
assert.deepEqual(
withDefaultCargoFeatures(args, ['cocos-editor-execute']),
args,
);
}
assert.deepEqual(
withDefaultCargoFeatures(
['--', '--features=app'],
['cocos-editor-execute'],
),
['--features=cocos-editor-execute', '--', '--features=app'],
);
});
@@ -118,6 +118,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',
@@ -1289,7 +1299,7 @@ if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
const expectedBundledDesignAgentResources = {
'design-agent': 'design-agent',
};
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',
@@ -1303,6 +1313,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',
};
assert.deepEqual(
tauriConfig.bundle?.resources,
@@ -1318,8 +1329,8 @@ for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
}
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 (
Object.prototype.hasOwnProperty.call(
@@ -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,
@@ -1,6 +1,7 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { withDefaultCargoFeatures } from './cargo-features.mjs';
import {
readAgcDevEndpoint,
resolveAgcDevEndpoint,
@@ -47,6 +48,24 @@ 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()) {
return withDefaultCargoFeatures(argv, features);
}
function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
@@ -110,7 +129,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),
@@ -207,6 +229,7 @@ export {
isDirectModuleExecution,
runTauriDev,
spawnTauriCli,
withDevCargoFeatures,
};
if (isDirectModuleExecution()) {
@@ -0,0 +1,4 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
+25
View File
@@ -733,6 +733,20 @@ dependencies = [
"error-code",
]
[[package]]
name = "cocos-editor-bridge"
version = "0.1.0"
dependencies = [
"cc",
"editor-adapter-api",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"tungstenite",
"windows-sys 0.61.2",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -1205,6 +1219,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 +1731,8 @@ dependencies = [
"axum",
"base64 0.22.1",
"chromiumoxide",
"cocos-editor-bridge",
"editor-adapter-api",
"futures",
"getrandom 0.3.4",
"http",
@@ -4281,6 +4305,7 @@ dependencies = [
"cookie",
"cookie_store",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
@@ -6,6 +6,9 @@ publish = false
[features]
default = []
cocos-editor = ["cocos-editor-bridge/process-discovery"]
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
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) {}
@@ -326,6 +326,35 @@ fn game_creator_codex_app_server_error_detail_indicates_auth_failure(
|| detail.contains("http 403")
}
fn game_creator_codex_app_server_error_detail(error: &serde_json::Value) -> String {
let Some(error) = error.as_object() else {
return String::new();
};
["message", "additionalDetails", "code"]
.into_iter()
.filter_map(|field| error.get(field).and_then(serde_json::Value::as_str))
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn game_creator_codex_app_server_error_detail_indicates_stream_requirement(
error: &serde_json::Value,
) -> bool {
let detail = game_creator_codex_app_server_error_detail(error);
detail.contains("stream must be set to true")
|| detail.contains("stream=true")
|| detail.contains("stream is required")
}
fn game_creator_codex_app_server_error_detail_indicates_timeout(error: &serde_json::Value) -> bool {
let detail = game_creator_codex_app_server_error_detail(error);
detail.contains("timed out")
|| detail.contains("timeout")
|| detail.contains("request deadline exceeded")
|| detail.contains("deadline exceeded")
}
fn game_creator_codex_app_server_error_detail_indicates_request_too_large(
error: &serde_json::Value,
) -> bool {
@@ -383,6 +412,15 @@ fn game_creator_codex_app_server_failed_turn_error(
if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) {
return game_creator_codex_app_server_error_kind("request-too-large");
}
if game_creator_codex_app_server_error_detail_indicates_stream_requirement(error) {
return game_creator_codex_app_server_error_kind("stream-required");
}
if game_creator_codex_app_server_error_detail_indicates_timeout(error) {
return platform_llm::LlmError::Connectivity {
attempts: 1,
message: "Codex app-server 上游请求超时".to_string(),
};
}
if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) {
return game_creator_codex_app_server_error_kind("unauthorized");
}
@@ -1091,6 +1129,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" },
@@ -2489,10 +2528,12 @@ impl CodexAppServerConnection {
// Codex's default provider in Debug builds.
self.inner._provider_proxy.is_some() || !llm.api_key.trim().is_empty(),
);
let result = self
.request("thread/start", params)
.await
.map_err(platform_llm::LlmError::Transport)?;
let result = match self.request("thread/start", params).await {
Ok(result) => result,
Err(error) => {
return Err(platform_llm::LlmError::Transport(error));
}
};
let thread_id = result
.pointer("/thread/id")
.and_then(serde_json::Value::as_str)
@@ -4896,6 +4937,37 @@ mod tests {
}
}
#[test]
fn codex_app_server_failed_turn_maps_stream_and_timeout_details() {
let stream_error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({
"status": "failed",
"error": {
"message": "Stream must be set to true",
"codexErrorInfo": "other"
}
}));
assert_eq!(
stream_error,
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:stream-required".to_string()
)
);
let timeout_error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({
"status": "failed",
"error": {
"message": "provider request timed out",
"codexErrorInfo": "other"
}
}));
assert_eq!(
timeout_error,
platform_llm::LlmError::Connectivity {
attempts: 1,
message: "Codex app-server 上游请求超时".to_string()
}
);
}
#[test]
fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() {
for detail in [
@@ -5137,6 +5209,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,
@@ -449,7 +450,15 @@ fn append_direct_project_history_item_once(root: &Path, item: &Value) -> Result<
return Err(error);
}
let line = record(item)?;
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 lock = project_append_lock_for(&path)?;
let _append_guard = lock.lock("DirectProject 历史追加写")?;
// 回扫到取锁之间可能有别的进程(或本项目另一条链)追加过:只有文件状态仍与回扫时一致
@@ -943,6 +952,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),
})
}
@@ -1481,12 +1485,28 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
reject_command_output_wrapper(content)?;
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道:
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
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,
@@ -2322,11 +2342,193 @@ 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 {
bridge_cocos_call(state, arguments, None).await
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
async fn bridge_cocos_call(
state: &DirectToolBridgeState,
arguments: &Value,
operation: Option<&str>,
) -> 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 = (|| {
enforce_project_permission_policy(&state.root, "cocos.editor.execute")?;
if let Some(operation) = operation {
let tool = cocos_editor_bridge::cocos_operation_catalog()
.iter()
.find(|tool| tool["name"] == operation)
.ok_or_else(|| "未知 Cocos 操作".to_string())?;
let validator = jsonschema::validator_for(&tool["inputSchema"])
.map_err(|e| format!("Cocos schema 错误:{e}"))?;
if let Err(error) = validator.validate(arguments) {
return Err(format!("Cocos 参数无效:{error}"));
}
return cocos_editor_bridge::build_cocos_operation_code(operation, arguments)
.map_err(|e| e.to_string());
}
bridge_reject_unknown_fields(arguments, &["code"])?;
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 Ok(mut uncertain) = state.cocos_execute_uncertain.try_lock() else {
return bridge_tool_result(
json!({"status":"failed","retryAllowed":false,"message":"已有 Cocos 操作执行中,请等待回执"}).to_string(),
Vec::new(), true);
};
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 timeout_ms = if operation.is_some() {
60_000
} else {
cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS
};
let result = tokio::task::spawn_blocking(move || {
// Cocos execute talks to the already-open Creator process through its
// validated Inspector/pipe bridge. It does not mutate AGC's project
// files or manifest, so it must not wait on `.agent/project.lock`.
// File-writing tools keep their own project lock separately.
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
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,
timeout_ms,
)
})
.await;
match result {
Ok(Ok(response)) => {
let mut result = response.result;
let status = if operation.is_some() {
result
.as_ref()
.and_then(|v| v["status"].as_str())
.unwrap_or(if response.ok { "completed" } else { "failed" })
.to_string()
} else if response.ok {
"completed".to_string()
} else {
"failed".to_string()
};
let is_error = !response.ok || status != "completed";
if status == "needs-reconciliation" {
*uncertain = true;
}
// 截图以 MCP image block 返回,不能被文本截断破坏 base64。
let mut images = Vec::new();
if operation == Some("cocos_preview_debug_capture") {
if let Some(value) = result
.as_mut()
.and_then(|v| v.get_mut("result"))
.and_then(Value::as_object_mut)
{
if let Some(image) = value.remove("__image") {
if image["mimeType"] == "image/png" {
if let Some(data) = image["data"].as_str() {
images.push(data.to_string());
}
}
}
}
}
let text = json!({
"status": status,
"requestId": response.request_id,
"result": result,
"error": response.error,
})
.to_string();
bridge_tool_result(
redact_agent_runtime_project_paths(
&state.root,
&text,
if operation.is_some() {
2 * 1024 * 1024
} else {
32_000
},
),
images,
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,
@@ -2334,6 +2536,12 @@ 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,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
operation if cocos_editor_bridge::is_cocos_operation(operation) => {
bridge_cocos_call(&state, &request.arguments, Some(operation)).await
}
"agc_write_file" => {
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
}
@@ -2379,14 +2587,11 @@ pub(crate) async fn start_direct_tool_bridge(
.route(&route, post(handle_direct_tool_bridge))
.layer(DefaultBodyLimit::max(DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES))
.with_state(Arc::clone(&state));
let url = format!("http://127.0.0.1:{}{route}", address.port());
let task = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
Ok(DirectToolBridge {
url: format!("http://127.0.0.1:{}{route}", address.port()),
state,
task,
})
Ok(DirectToolBridge { url, state, task })
}
#[cfg(test)]
@@ -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,24 @@ 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
}
}));
tools.extend(
cocos_editor_bridge::cocos_operation_catalog()
.iter()
.cloned(),
);
}
if controlled_web_search {
tools.push(json!({
"name": "agc_web_search",
@@ -503,6 +546,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 })
}
@@ -1328,7 +1386,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)
@@ -1526,7 +1589,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")
@@ -1539,12 +1602,33 @@ 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,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
operation if cocos_editor_bridge::is_cocos_operation(operation) => {
call_client_tool_bridge(operation, &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,
@@ -1737,8 +1821,250 @@ 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"
);
assert_eq!(
specs["tools"]
.as_array()
.unwrap()
.iter()
.filter(|tool| tool["name"]
.as_str()
.is_some_and(cocos_editor_bridge::is_cocos_operation))
.count(),
if expected == "true" { 36 } else { 0 }
);
}
#[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 {
for tool in cocos_editor_bridge::cocos_operation_catalog() {
let response = EXTERNAL_MCP_BRIDGE_URL
.scope(
bridge.url().to_string(),
call_client_tool_bridge(tool["name"].as_str().unwrap(), &json!({})),
)
.await;
assert_eq!(response["isError"], true);
assert!(response.to_string().contains("插件已禁用"));
}
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};
#[cfg(all(windows, feature = "cocos-editor-execute"))]
#[tokio::test]
#[ignore = "显式指定已打开的自有 Cocos smoke 工程后运行"]
async fn cocos_real_tools_list_and_call() {
let _guard = crate::builtin_plugins::test_lock();
let root =
PathBuf::from(std::env::var("AGC_COCOS_TEST_PROJECT").expect("explicit smoke root"))
.canonicalize()
.unwrap();
let package: Value =
serde_json::from_slice(&std::fs::read(root.join("package.json")).unwrap()).unwrap();
assert_eq!(
package["name"], "agc-cocos-capability-smoke",
"只操作自有测试工程"
);
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
std::fs::create_dir_all(root.join(".agent")).unwrap();
if !root.join(".agent/manifest.json").exists() {
std::fs::write(root.join(".agent/manifest.json"), "{}").unwrap();
}
let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(&root, false)
.await
.unwrap();
let tools = EXTERNAL_MCP_BRIDGE_URL
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
.await;
assert_eq!(
tools["tools"]
.as_array()
.unwrap()
.iter()
.filter(|t| t["name"]
.as_str()
.is_some_and(cocos_editor_bridge::is_cocos_operation))
.count(),
36
);
async fn invoke(root: &Path, url: &str, name: &str, args: Value) -> Value {
let reply = EXTERNAL_MCP_BRIDGE_URL.scope(url.to_string(), handle_direct_tools_mcp_request(root, json!({
"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":name,"arguments":args}
}))).await.unwrap();
assert_eq!(reply["result"]["isError"], false, "{name}: {reply}");
reply["result"].clone()
}
let ping = invoke(&root, bridge.url(), "cocos_ping", json!({})).await;
assert!(ping.to_string().contains("ready"));
let hierarchy = invoke(&root, bridge.url(), "cocos_get_hierarchy", json!({})).await;
let parsed: Value =
serde_json::from_str(hierarchy["content"][0]["text"].as_str().unwrap()).unwrap();
let tree = &parsed["result"]["result"]["tree"];
let canvas = tree["children"]
.as_array()
.unwrap()
.iter()
.find(|n| n["name"] == "Canvas")
.unwrap();
invoke(
&root,
bridge.url(),
"cocos_create_ui_label",
json!({
"parentNid":canvas["uuid"], "name":"AGC_MCP_SMOKE", "text":"MCP", "save":false
}),
)
.await;
invoke(&root, bridge.url(), "cocos_mcp_undo_last", json!({})).await;
if let Ok(url) = std::env::var("AGC_COCOS_TEST_PREVIEW_URL") {
invoke(
&root,
bridge.url(),
"cocos_preview_debug_start",
json!({"url":url}),
)
.await;
let capture = invoke(
&root,
bridge.url(),
"cocos_preview_debug_capture",
json!({}),
)
.await;
assert!(capture["content"]
.as_array()
.unwrap()
.iter()
.any(|b| b["type"] == "image" && b["mimeType"] == "image/png"));
invoke(&root, bridge.url(), "cocos_preview_debug_stop", json!({})).await;
}
let final_tree = invoke(&root, bridge.url(), "cocos_get_hierarchy", json!({})).await;
assert!(!final_tree.to_string().contains("AGC_MCP_SMOKE"));
}
#[test]
fn cocos_mcp_schemas_accept_real_arguments_and_reject_unknown_fields() {
for tool in cocos_editor_bridge::cocos_operation_catalog() {
let validator = jsonschema::validator_for(&tool["inputSchema"]).unwrap();
assert!(!validator.is_valid(&json!({"unregistered-field":1})));
}
let ui = cocos_editor_bridge::cocos_operation_catalog()
.iter()
.find(|t| t["name"] == "cocos_apply_ui_spec")
.unwrap();
let validator = jsonschema::validator_for(&ui["inputSchema"]).unwrap();
assert!(validator.is_valid(&json!({"parentNid":1,"nodes":[{"kind":"container","children":[{"kind":"label","text":"测试"}]}],"save":false})));
assert!(!validator.is_valid(&json!({"parentNid":1,"nodes":[{"kind":"shell-command"}]})));
}
#[tokio::test]
async fn loopback_tool_bridge_client_omits_agc_marker() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback fixture");
@@ -1812,7 +2138,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")
@@ -1839,6 +2165,22 @@ mod tests {
"agc_remove_background",
"agc_browser_playtest",
]
.into_iter()
.chain(
cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute")
)
.chain(
cocos_editor_bridge::cocos_operation_catalog()
.iter()
.filter_map(|tool| {
if cfg!(all(windows, feature = "cocos-editor-execute")) {
tool["name"].as_str()
} else {
None
}
})
)
.collect::<Vec<_>>()
);
let serialized = specs.to_string();
assert!(!serialized.contains("agc_web_search"));
@@ -1935,7 +2277,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")
@@ -66,12 +66,10 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
return blocker;
}
}
if !relaxed_autonomous {
if let Some(blocker) =
agent_runtime_autonomous_art_director_canvas_only_action_block(agent_id, task, tool)
{
return blocker;
}
if let Some(blocker) =
agent_runtime_autonomous_art_director_canvas_only_action_block(agent_id, task, tool)
{
return blocker;
}
let command_id = game_creator_agent_runtime_tool_command_id(tool);
if let Some(command_id) = command_id {
@@ -364,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"),
@@ -138,12 +138,16 @@ pub(crate) fn ensure_current_autonomous_ready_child_mutation_at_locked(
if game_creator_agent_runtime_cancel_requested_for(root, &normalized_agent_id, run_id) {
return Err("当前 Run 已收到取消请求,禁止继续修改项目".to_string());
}
if autonomous_relaxed_run_at(root, &normalized_agent_id, run_id)? {
return Ok(());
}
let relaxed_autonomous = autonomous_relaxed_run_at(root, &normalized_agent_id, run_id)?;
let binding =
read_game_creator_agent_runtime_run_profile_binding(root, &normalized_agent_id, run_id)?;
let Some(binding) = binding else {
if relaxed_autonomous {
// Keep the relaxed lane compatible with legacy runs that predate
// durable Run Profile sidecars; bound runs still receive the
// current-root and lineage checks below.
return Ok(());
}
let task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
&normalized_agent_id,
@@ -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> {
@@ -173,6 +179,16 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
auto_tools.push(tool.to_string());
}
}
if isolated {
// Keep the isolated-child contract explicit even when a denied tool
// is unavailable on the current platform and therefore absent from
// the executable catalog (for example, the Windows-only Cocos bridge).
for tool in ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS {
if !denied_tools.iter().any(|candidate| candidate == tool) {
denied_tools.push((*tool).to_string());
}
}
}
Ok(AgentRuntimeToolPolicySnapshot {
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
@@ -1908,7 +1908,7 @@ mod tests {
)
.expect("first persist");
rewind_session_keep_gdd_file(&root, &session);
let hydrated = hydrate_planning_session_v2(
let hydrated = hydrate_planning_session_v2_sync(
root.to_string_lossy().to_string(),
Some(session.session_id.clone()),
)

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