Compare commits

..

9 Commits

Author SHA1 Message Date
kdletters fd904ea144 修复 DirectProject 写入锁竞争并开启 Cocos 编辑器执行
DirectProject 历史落盘与 Codex 返回记录改用项目写锁有界等待,不再零等待失败拖垮整轮

Codex 返回记录取锁改到阻塞线程池执行,避免占用 runtime worker

agc_write_file 增加取锁/写入/revision 分段计时日志,用于定位长时间持锁

Windows dev 构建默认启用 cocos-editor-execute,使 Agent 具备 agc_cocos_execute

补充 start-tauri-dev 参数单测与历史落盘等待回归用例
2026-09-11 23:25:37 +08:00
kdletters 299877b197 修复 Tauri dev 插件资源自触发重建
build.rs 只在内容变化时落盘插件资源,避免构建产物触发下一轮重建

新增应用目录与 src-tauri 的 .taurignore,把 resources/plugins 排除出 dev 监听
2026-09-11 23:25:31 +08:00
kdletters b67cb18b45 接入 Cocos 项目识别与导入
完善 Cocos Creator 项目识别、导入和 Agent 目录初始化

补充打开项目流程、契约、测试与文档
2026-09-11 19:56:34 +08:00
kdletters 8ba9835d10 合并远端 master 最新修改
保留 AGC 插件系统与 Cocos 适配改动
合并远端项目写锁等待与持锁方诊断修复
同步远端项目锁、配置和测试更新
2026-09-11 11:42:23 +08:00
kdletters 02ff914bd3 修复内置插件跨进程开关与不确定执行阻断
让 Runner 和 CLI 查询持久化开关并即时感知 GUI 状态更新
让隔离 MCP 经现有工具桥获取可用工具,并将开关纳入会话缓存标识
在执行入口复查禁用状态,固定原生函数缓存构建使用的工具快照
修复损坏开关文件保存成功后仍保持关闭的状态
保留插件执行结果不确定的结构化回执和宿主适配器阻断,拒绝并发积压与自动重发
新增跨进程工具目录、热切换、坏文件恢复及不确定执行回归测试并同步文档
2026-09-10 21:37:51 +08:00
kdletters dd36de3aa6 新增 AGC 内置插件模式与插件工作区
- 新增 plugins/ 插件工作区及 agc-cocos-editor 插件包,Cocos 直连模块从宿主源码树迁入插件包
- 新增 server-rs/crates/editor-adapter-api 通用编辑器适配器契约,宿主只保留适配器注册与 RPC 路由
- 新增内置插件登记表与可用开关,状态持久化在 AppData extensions/builtin-plugins.json,状态文件损坏时失败关闭
- 禁用内置插件时先停止进程并拒绝启动,同时从 Runtime 工具目录、工具策略快照、原生函数目录与 DirectProject 工具面移除对应工具
- 插件宿主新增 plugins/ 工作区扫描,内置插件优先于同名 AppData 导入插件,前端只提供可用开关而不提供重命名和卸载入口
- 编辑器操作统一经 host.rpc 路由到插件包自带的 native 适配器,并删除 Cocos 专属 Tauri 命令
- 修正 Windows 打包与开发态 payload 路径,随包映射 plugins 工作区资源
- 同步插件、Cocos 桥接、实施计划文档与 decision log
2026-09-10 20:00:02 +08:00
kdletters 9d2ad69a75 新增 AGC Cocos 直连模块
新增独立 crate server-rs/crates/cocos-editor-bridge:Creator 主进程发现与项目/PID 身份校验、named pipe 命令协议、可选 Windows DLL 注入,默认 feature 全部关闭。
新增 Creator 主进程内 payload:在 \\.\pipe\genarrative-cocos-editor-{pid} 上串行提供 ping/status/execute,execute 只接受有界 JavaScript 函数体,结果不确定时返回 ExecutionUncertain 并禁止自动重放。
AGC 新增 cocos_editor 模块与 prepare_cocos_editor_injection、inject_cocos_editor、ping_cocos_editor、status_cocos_editor、execute_cocos_editor_code 五条命令,注入只加载随包资源目录中的 DLL。
AGC 新增 cocos-editor、cocos-editor-execute、cocos-editor-injection 三层 feature 与资源映射,Windows 构建把 bridge DLL 暂存到 resources/cocos-editor-bridge,并改为按 Cargo profile 目录查找产物 DLL。
Runtime 新增 cocos.editor.execute:同步工具策略快照、并行账本、隔离 Agent 拒绝表、工具描述与动作审计,审计只记录代码长度与 SHA-256。
DirectProject 的 agc_tools 新增 agc_cocos_execute,只接收 code,复用项目写锁和当前项目权限,结果不确定时阻断后续调用。
共享契约新增 cocos.editor.execute(confirm 权限),当前分支命令契约从 64 条更新为 65 条。
新增技术方案文档与决策记录,并在文档索引登记;resources/cocos-editor-bridge 仅提交占位与 .gitignore,生成的 DLL 不入库。
移植到 codex/agc-agent-plugins 的适配:命令契约长度、Tauri 未调用命令 allowlist 与 Windows 资源清单校验随当前分支基线同步更新。
2026-09-10 16:50:49 +08:00
kdletters 7df526868c 合并远端 master 到 codex/agc-agent-plugins
合并远端 master 的 AGC 启动、进程复用与项目写锁回收修复
合并远端 master 的开发脚本、运维文档与项目记忆更新
保留本分支的通用插件宿主、插件宿主服务与 AGC Plugin SDK 改动
2026-09-10 16:33:42 +08:00
kdletters 3c1711c7df 新增 AGC 通用插件宿主与 SDK
统一 Agent Plugin、Skill、MCP 的来源索引和启停状态
新增通用进程宿主、JSON-RPC、能力注册、权限审计与面板挂载
新增 AGC Plugin SDK 与标准 plugin.json 解析支持
保留 EditorAdapter 扩展点,不内置具体编辑器适配器
补充运行时设置界面、项目上下文同步和定向测试
同步更新插件方案、DirectProject、workspace 与项目记忆文档
2026-09-10 13:37:27 +08:00
276 changed files with 43055 additions and 40997 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/
@@ -371,15 +371,7 @@ async function runMissingConfigWizard(setActiveChild, explicitConfigDir) {
}
}
async function secureWindowsPrivateRuntimePath(
targetPath,
options,
secureWindowsPath = null,
) {
if (secureWindowsPath) {
await secureWindowsPath(targetPath, options);
return;
}
async function secureWindowsPrivateRuntimePath(targetPath, options) {
const { secureWindowsGameCreatorPathForCurrentUser } = await import(
'./game-creator-config-wizard.mjs'
);
@@ -391,7 +383,6 @@ async function copyPrivateRuntimeConfigEntry(
runtimeConfigDir,
fileName,
required,
secureWindowsPath = null,
) {
const sourcePath = path.join(sourceConfigDir, fileName);
const sourceMetadata = await lstat(sourcePath).catch((error) => {
@@ -411,11 +402,9 @@ async function copyPrivateRuntimeConfigEntry(
const sourceBytes = await readFile(sourcePath);
const destinationFile = await open(destinationPath, 'wx', 0o600);
try {
await secureWindowsPrivateRuntimePath(
destinationPath,
{ isDirectory: false },
secureWindowsPath,
);
await secureWindowsPrivateRuntimePath(destinationPath, {
isDirectory: false,
});
await destinationFile.writeFile(sourceBytes);
await destinationFile.sync();
} finally {
@@ -448,11 +437,7 @@ async function copyPrivateRuntimeConfigEntry(
return true;
}
export async function prepareSwarmTestRuntimeConfig(
sourceConfigDir,
tempRoot,
{ secureWindowsPath = null } = {},
) {
export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
if (!path.isAbsolute(sourceConfigDir)) {
throw new Error('配置来源目录必须是绝对路径');
}
@@ -472,11 +457,9 @@ export async function prepareSwarmTestRuntimeConfig(
);
try {
if (process.platform === 'win32') {
await secureWindowsPrivateRuntimePath(
runtimeConfigDir,
{ isDirectory: true },
secureWindowsPath,
);
await secureWindowsPrivateRuntimePath(runtimeConfigDir, {
isDirectory: true,
});
} else {
await chmod(runtimeConfigDir, 0o700);
}
@@ -494,14 +477,12 @@ export async function prepareSwarmTestRuntimeConfig(
runtimeConfigDir,
configFileName,
true,
secureWindowsPath,
);
await copyPrivateRuntimeConfigEntry(
canonicalSourceConfigDir,
runtimeConfigDir,
localConfigFileName,
false,
secureWindowsPath,
);
return {
path: await realpath(runtimeConfigDir),
@@ -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
View File
@@ -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) {}
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
@@ -21,7 +21,6 @@
]
},
"opener:default",
"dialog:allow-open",
"dialog:allow-save"
"dialog:allow-open"
]
}
@@ -1 +0,0 @@
你是游戏创作需求的润色助手。把用户给创作 Agent 的一段需求改写成更清晰、可执行的中文需求。要求:保留用户的原始意图、玩法、美术方向、数值与限制条件,不得新增或删除需求点,不得替用户做决定,不得写成方案书或任务清单。只输出润色后的需求正文,不要解释、不要引言、不要 Markdown 标记、不要引号包裹、不要重复用户原文;无法润色时原样输出用户输入。
@@ -14,7 +14,6 @@ mod codex_cli;
mod codex_provider_proxy;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_codex_references;
mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
@@ -41,7 +40,6 @@ pub(crate) use codex_cli::{
pub(crate) use codex_provider_proxy::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_codex_references::*;
pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
@@ -16,21 +16,7 @@ const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str =
"CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED";
const GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL: &str = "https://api.openai.com/v1";
const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v3";
/// 单条 JSON-RPC 消息(NDJSON 一行)的字节上限:**同时**是 stdout 读侧上限与 stdin 写侧守卫。
///
/// 两侧必须共用这一个常量。2026-09-11 的隔离探针实测(codex-cli 0.147.0AGC 捆绑版本):
/// `experimentalRawEvents: true` 的线程收到 `thread/inject_items` 后,会**逐条原样回显**
/// `rawResponseItem/completed` —— 注入一个 5 MiB 的 itemstdout 就回一条 5 243 245 字节的单行。
/// 因此「我们注入得进去」就必须「我们读得回来」;两侧取不同值(曾为读侧 4 MiB、图片单张 5 MiB/
/// 单次 16 MiB)会让一次**原本合法**的注入在随后的读行里被误判成连接级故障。
///
/// 32 MiB 的依据:单张图 base64 data URL 上限 10 MiB`..._IMAGE_MAX_BYTES * 2`)、单次请求图片
/// 总量上限 16 MiB`..._IMAGE_TOTAL_MAX_BYTES`base64 后约 21.3 MiB+ JSON 信封,取 32 MiB
/// 覆盖现有写侧全部单一消息;再大就失去内存/DoS 边界的意义(每连接单行最多驻留该数量级)。
const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 32 * 1024 * 1024;
/// 回显信封余量:item 会被包进 `{"method":"rawResponseItem/completed","params":{…,"item":…}}`
/// 之后再输出,所以**单条 item** 允许占用的字节数必须小于整行上限。
const GAME_CREATOR_CODEX_APP_SERVER_ECHO_ENVELOPE_MARGIN_BYTES: usize = 8 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES: usize = 256 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES: usize = 1024 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX: usize = 128;
@@ -58,13 +44,6 @@ pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX
"codex-app-server-terminal-unknown:";
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str =
"codex-app-server-error:";
/// DirectProject 历史注入超限:专属前缀,供 `direct_runtime.rs` 判「可重试性」与给专属恢复提示。
///
/// 命中它意味着**同一份历史每次都会得到同一结论**(历史文件不变则载荷不变),所以按不可重试
/// 处理,与 `DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS` 同口径(见技术方案
/// `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 末节)。
pub(in crate::agent) const DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX: &str =
"DirectProject 历史注入载荷超过单行上限";
const CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK: &str =
"You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence.";
const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str =
@@ -1091,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" },
@@ -2317,11 +2297,6 @@ impl CodexAppServerConnection {
let mut bytes = serde_json::to_vec(value)
.map_err(|error| format!("序列化 Codex app-server JSON-RPC 失败:{error}"))?;
bytes.push(b'\n');
// 写侧守卫:绝不写出自己读不回来的行(两侧共用 `..._LINE_MAX_BYTES`)。
// 失败关闭并给出字节数,**不做任何截断**。
if let Some(error) = game_creator_codex_app_server_message_oversize_error(bytes.len()) {
return Err(error);
}
let mut writer = self.inner.writer.lock().await;
writer
.write_all(&bytes)
@@ -2642,18 +2617,13 @@ impl CodexAppServerConnection {
return Err(platform_llm::LlmError::InvalidRequest(error));
}
};
let params = serde_json::json!({"threadId": thread_id.clone(), "items": items});
let payload_bytes = serde_json::to_vec(&params)
.map(|bytes| bytes.len().saturating_add(1))
.unwrap_or(usize::MAX);
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
if let Some(error) =
direct_project_history_injection_oversize_error(&params, payload_bytes)
if let Err(error) = self
.request(
"thread/inject_items",
serde_json::json!({"threadId": thread_id, "items": items}),
)
.await
{
self.release_thread(snapshot, &thread_id).await;
return Err(platform_llm::LlmError::InvalidRequest(error));
}
if let Err(error) = self.request("thread/inject_items", params).await {
self.release_thread(snapshot, &thread_id).await;
return Err(platform_llm::LlmError::Transport(error));
}
@@ -3460,61 +3430,6 @@ async fn read_game_creator_codex_app_server_stdout(
}
}
/// 写侧守卫的纯函数部分(便于单测):超限时给出带字节数的错误。
///
/// 与读侧共用同一个上限,保证「我们能注入的」一定「我们读得回来」。
fn game_creator_codex_app_server_message_oversize_error(byte_len: usize) -> Option<String> {
let limit = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES;
(byte_len > limit).then(|| {
format!(
"Codex app-server JSON-RPC 单行超过大小上限(写侧):{byte_len} 字节 > 上限 {limit} 字节;未发送、未截断"
)
})
}
/// DirectProject 历史注入的前置校验(纯函数,便于单测)。
///
/// 判据来自实测:app-server 会把注入的 item **逐条原样回显**,所以
/// ① 单条 item 必须 ≤ 上限 − 回显信封余量;② 整份载荷本身也必须 ≤ 上限。
/// 任一不满足即**失败关闭**(指名 `itemId` 与字节数),**不截断、不摘要、不改写**历史。
fn direct_project_history_injection_oversize_error(
params: &serde_json::Value,
payload_bytes: usize,
) -> Option<String> {
let limit = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES;
if payload_bytes > limit {
return Some(format!(
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:整份历史载荷 {payload_bytes} 字节 > 上限 {limit} 字节;未注入、未截断"
));
}
let Some(items) = params.get("items").and_then(serde_json::Value::as_array) else {
return None;
};
let item_limit = limit.saturating_sub(GAME_CREATOR_CODEX_APP_SERVER_ECHO_ENVELOPE_MARGIN_BYTES);
for item in items {
let Ok(bytes) = serde_json::to_vec(item) else {
return Some(format!(
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:历史记录无法序列化;未注入、未截断"
));
};
if bytes.len() > item_limit {
let item_id = item
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or("<无 id>");
let item_type = item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("<无 type>");
return Some(format!(
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:单条记录 {} 字节 > 单条上限 {item_limit} 字节(itemId={item_id}, type={item_type});未注入、未截断",
bytes.len()
));
}
}
None
}
async fn read_bounded_game_creator_codex_app_server_line<R>(
reader: &mut R,
) -> Result<Option<Vec<u8>>, String>
@@ -5136,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");
@@ -5523,66 +5474,6 @@ while IFS= read -r line; do :; done
assert!(!diagnostic.contains(std::str::from_utf8(&secret).expect("fixture utf8")));
}
/// 写侧对称守卫:我们绝不写出自己读不回来的行。
///
/// 与上面那条读侧用例共用 `..._LINE_MAX_BYTES`,所以这条断言同时锁住「两侧同界」这个不变量:
/// 上限本身必须放行,上限 + 1 必须失败关闭(而不是被静默截断)。
#[test]
fn codex_app_server_write_guard_matches_the_read_cap() {
assert!(game_creator_codex_app_server_message_oversize_error(
GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES
)
.is_none());
let oversized = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1;
let error = game_creator_codex_app_server_message_oversize_error(oversized)
.expect("oversized outbound message must be rejected before it is written");
assert!(error.contains("单行超过大小上限(写侧)"));
assert!(error.contains(&oversized.to_string()));
assert!(error.contains("未发送、未截断"));
}
/// DirectProject 历史注入的前置校验:**单条 item** 与**整份载荷**都必须失败关闭。
///
/// 形状照抄实测(隔离探针,codex-cli 0.147.0):`thread/inject_items` 注入的 item 会被
/// app-server 逐条原样回显成一条 `rawResponseItem/completed`,所以「单条 item 超限」就等于
/// 「回显行超限」——必须在注入前拦下,而不是让它在读行时变成连接级故障。
#[test]
fn direct_project_history_injection_rejects_oversized_items_and_payloads() {
let ok_params = serde_json::json!({
"threadId": "probe-thread",
"items": [{ "type": "message", "id": "msg_1", "role": "user" }],
});
assert!(direct_project_history_injection_oversize_error(&ok_params, 1024).is_none());
let payload_error = direct_project_history_injection_oversize_error(
&ok_params,
GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1,
)
.expect("oversized payload must be rejected");
assert!(payload_error.contains(DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX));
assert!(payload_error.contains("整份历史载荷"));
assert!(payload_error.contains("未注入、未截断"));
let huge = "x".repeat(GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES);
let big_params = serde_json::json!({
"threadId": "probe-thread",
"items": [{
"type": "message",
"id": "msg_big",
"role": "assistant",
"content": [{ "type": "output_text", "text": huge }],
}],
});
let item_error = direct_project_history_injection_oversize_error(&big_params, 1024)
.expect("oversized single item must be rejected");
assert!(item_error.contains(DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX));
assert!(item_error.contains("单条记录"));
assert!(item_error.contains("itemId=msg_big"));
assert!(item_error.contains("type=message"));
assert!(item_error.contains("未注入、未截断"));
}
#[cfg(unix)]
#[tokio::test]
async fn codex_app_server_uses_initialize_thread_and_turn_protocol() {
@@ -1,328 +0,0 @@
use super::*;
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
const DIRECT_CODEX_REFERENCE_HEADER: &str =
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub(crate) enum DirectCodexTurnReference {
#[serde(rename = "resource")]
Resource(DirectCodexResourceReference),
#[serde(rename = "runtime-region")]
RuntimeRegion(DirectCodexRuntimeRegionReference),
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexResourceReference {
pub(crate) resource_id: String,
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) source: Option<String>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexRuntimeRegionReference {
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) run_id: Option<String>,
#[serde(default)]
pub(crate) version_id: Option<String>,
#[serde(default)]
pub(crate) element_tag: Option<String>,
#[serde(default)]
pub(crate) element_role: Option<String>,
#[serde(default)]
pub(crate) text: Option<String>,
#[serde(default)]
pub(crate) width: Option<f64>,
#[serde(default)]
pub(crate) height: Option<f64>,
#[serde(default)]
pub(crate) resource_ids: Vec<String>,
}
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
let sanitized = value
.trim()
.chars()
.filter(|character| !character.is_control())
.take(max_chars)
.collect::<String>();
if sanitized.is_empty() {
None
} else {
Some(sanitized)
}
}
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
}
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
value
.filter(|value| value.is_finite() && *value > 0.0)
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
}
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
let basename = asset
.local_path
.rsplit(['/', '\\'])
.next()
.unwrap_or(&asset.id);
let without_extension = basename
.rsplit_once('.')
.map(|(name, _)| name)
.unwrap_or(basename)
.trim();
if without_extension.is_empty() {
asset.id.clone()
} else {
without_extension
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
.collect()
}
}
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
let resource_id = value.trim();
if resource_id.is_empty()
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|| resource_id.chars().any(char::is_control)
{
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
}
Ok(resource_id.to_string())
}
fn render_resource_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let local_path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
))
}
fn render_runtime_region_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexRuntimeRegionReference,
) -> Result<String, String> {
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| "运行画面区域".to_string());
let run_id = sanitize_reference_source(reference.run_id.as_deref());
let version_id = sanitize_reference_source(reference.version_id.as_deref());
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
let element_role = sanitize_reference_element(reference.element_role.as_deref());
let text = reference
.text
.as_deref()
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
let width = sanitize_reference_dimension(reference.width);
let height = sanitize_reference_dimension(reference.height);
let mut related_resource_ids = Vec::new();
for resource_id in &reference.resource_ids {
let resource_id = validate_resource_reference_id(resource_id)?;
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
return Err("运行画面引用的素材已变化,请重新点选".to_string());
}
related_resource_ids.push(resource_id);
}
let mut parts = vec![format!("名称:{label}")];
if let Some(run_id) = run_id {
parts.push(format!("运行标识:{run_id}"));
}
if let Some(version_id) = version_id {
parts.push(format!("版本标识:{version_id}"));
}
if let Some(element_tag) = element_tag {
parts.push(format!("元素:{element_tag}"));
}
if let Some(element_role) = element_role {
parts.push(format!("角色:{element_role}"));
}
if let Some(text) = text {
parts.push(format!("文本摘要:{text}"));
}
if let (Some(width), Some(height)) = (width, height) {
parts.push(format!("尺寸:{width}x{height}"));
}
if !related_resource_ids.is_empty() {
parts.push(format!("关联素材 ID{}", related_resource_ids.join(",")));
}
Ok(format!("- 运行画面区域:{}", parts.join("")))
}
pub(crate) fn render_direct_codex_references_section(
root: &Path,
references: &[DirectCodexTurnReference],
) -> Result<Option<String>, String> {
if references.is_empty() {
return Ok(None);
}
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
let manifest = read_manifest_for_project(root)?;
let mut lines = Vec::with_capacity(references.len());
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(&manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
}
});
}
Ok(Some(
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
.chain(lines)
.collect::<Vec<_>>()
.join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_project() -> tempfile::TempDir {
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-hero".to_string(),
kind: "character".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/hero.png".to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("character"),
tags: Vec::new(),
});
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
directory
}
#[test]
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("素材 IDasset-hero"));
assert!(section.contains("名称:主角"));
assert!(section.contains("项目路径:assets/hero.png"));
assert!(!section.contains("C:\\secret.png"));
}
#[test]
fn deleted_resource_fails_closed() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
)
.expect("reference json");
let error = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect_err("missing resource");
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
}
#[test]
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("运行画面区域:名称:开始按钮"));
assert!(section.contains("文本摘要:开始游戏"));
assert!(section.contains("尺寸:120x40"));
assert!(section.contains("关联素材 IDasset-hero"));
assert!(!section.contains("onclick"));
assert!(!section.contains("secret"));
}
}
File diff suppressed because it is too large Load Diff
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),
})
}
@@ -1161,12 +1165,6 @@ fn bridge_registered_resource(
"localPath": asset.local_path,
"kind": asset.kind,
"mediaType": asset.media_type,
// Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory`
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
"category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
"tags": asset.tags,
"canvasProjectId": asset.source.canvas_project_id,
"resourceId": asset.source.resource_id,
"assetObjectId": asset.source.asset_object_id,
@@ -1483,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,
@@ -2329,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,
@@ -2341,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
}
@@ -2996,8 +3121,6 @@ mod tests {
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -3061,8 +3184,6 @@ mod tests {
},
]),
image_sequence_duration_ms: Some(4_000),
category: game_creation_app_asset_category_for_kind("character-animation"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -3082,8 +3203,6 @@ mod tests {
projection["imageSequenceFrames"][0]["objectKey"],
"animations/frame-01.png"
);
assert_eq!(projection["category"], "character");
assert_eq!(projection["tags"], json!([]));
let serialized = projection.to_string();
assert!(!serialized.contains("signed.invalid"));
assert!(!serialized.contains("private prompt"));
@@ -3091,83 +3210,6 @@ mod tests {
assert!(!serialized.contains("private.invalid"));
}
/// 落盘分类是明确值时,Agent 侧原样透传。
///
/// (用例名从 `..._verbatim` 改成 `..._keeps_explicit...`:透传只在落盘值不是
/// `unclassified` 时成立,落盘 `unclassified` 要按读显示口径自愈,见下一个用例。)
#[test]
fn bridge_registered_resource_keeps_explicit_manifest_classification() {
let asset = GameCreationAppAssetManifestEntry {
id: "spec-1".to_string(),
kind: "spec".to_string(),
media_type: "application/json".to_string(),
local_path: "assets/specs/hero.json".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category:
shared_contracts::game_creation_app::GameCreationAppAssetCategory::UiInteraction,
tags: vec!["主角".to_string(), "第一版".to_string()],
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: Some("private prompt".to_string()),
model: Some("private model".to_string()),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
};
let projection = bridge_registered_resource(&asset, false);
assert_eq!(projection["kind"], "spec");
assert_eq!(projection["category"], "ui-interaction");
assert_eq!(projection["tags"], json!(["主角", "第一版"]));
assert_eq!(projection["imageSequenceFrames"], Value::Null);
let serialized = projection.to_string();
assert!(!serialized.contains("private prompt"));
assert!(!serialized.contains("private model"));
}
/// Agent 侧必须与 UI 看到同一个分类口径。
///
/// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈),
/// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘
/// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
/// `unclassified`——真机 122 条资产里有 55 条这样分叉。
#[test]
fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() {
let asset = GameCreationAppAssetManifestEntry {
id: "ui-1".to_string(),
kind: "ui".to_string(),
media_type: "application/json".to_string(),
local_path: "assets/UI 设计 1.json".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category:
shared_contracts::game_creation_app::GameCreationAppAssetCategory::Unclassified,
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
};
let projection = bridge_registered_resource(&asset, false);
assert_eq!(projection["kind"], "ui");
assert_eq!(projection["category"], "ui-interaction");
}
#[test]
fn bridge_success_warnings_are_redacted_before_serialization() {
let root = tempfile::tempdir().expect("warning redaction root");
@@ -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",
@@ -256,7 +281,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}),
json!({
"name": "agc_list_registered_assets",
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、功能分类与自定义标签、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
"inputSchema": {
"type": "object",
"properties": {
@@ -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 })
}
@@ -1247,6 +1300,8 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
Err(error) => return mcp_tool_result(error, Vec::new(), true),
};
let redacted = redact_external_mcp_response(&content);
let key = format!("{request_id}\u{0}{sequence}");
let message_id = format!("external-codex-{:x}", Sha256::digest(key.as_bytes()));
let guard = EXTERNAL_MCP_JOURNAL_LOCK
.get_or_init(|| Mutex::new(()))
.lock();
@@ -1333,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)
@@ -1356,6 +1416,29 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
true,
);
}
// Reuse the existing conversation projection so the current UI can read
// the explicit external response without treating it as business truth.
if let Err(error) = append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: record
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
agent_id: None,
},
&message_id,
) {
return mcp_tool_result(
format!("Codex 返回已写入但对话投影失败:{error}"),
Vec::new(),
true,
);
}
records.push(record.clone());
mcp_tool_result(record.to_string(), Vec::new(), false)
}
@@ -1531,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")
@@ -1544,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,
@@ -1742,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]
@@ -1817,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")
@@ -1844,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"));
@@ -1950,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")
@@ -2255,33 +2473,4 @@ mod tests {
}))
.is_err());
}
#[test]
fn recorded_codex_response_only_writes_its_own_journal() {
let temporary = crate::tests::canonical_test_tempdir("direct-tools-record-response-");
let root = temporary.path();
init_local_game_project_at(root, "direct-tools-record", "Codex 返回记录测试")
.expect("init project");
let result = external_mcp_record_response(
root,
&json!({
"requestId": "req-1",
"sequence": 0,
"content": "已完成的返回正文"
}),
);
assert_eq!(result.get("isError"), Some(&json!(false)), "{result}");
let journal = root.join(".agent/conversations/codex-responses.jsonl");
let journal_text = std::fs::read_to_string(&journal).expect("read codex responses journal");
assert!(journal_text.contains("已完成的返回正文"), "{journal_text}");
let project_history = root.join(".agent/conversations/project.jsonl");
let project_history_text = std::fs::read_to_string(&project_history).unwrap_or_default();
assert_eq!(
project_history_text, "",
"显式 Codex 返回不能再往项目主对话写 legacy 行"
);
}
}
@@ -6786,8 +6786,6 @@ fn register_platform_art_slice_manifest_entries_at(
local_path: registration.local_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
tags: Vec::new(),
source,
});
ids.push(id);
@@ -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"),

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