diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md
index e4bbafbc2..93c8592bb 100644
--- a/.codex/skills/genarrative-external-editor-api/SKILL.md
+++ b/.codex/skills/genarrative-external-editor-api/SKILL.md
@@ -32,7 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
-- Icon spritesheet generation accepts `sliceMode="connected-components"` (default alpha-connectivity detection) or `sliceMode="grid"`. Grid mode requires `gridX` and `gridY` (1-32); use `sliceCount` only to constrain connected-component output.
+- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
index 074bea5a0..bdbab115f 100644
--- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md
+++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
@@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
-`sliceMode` controls atlas splitting. Use `"connected-components"` (default) to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each). `sliceCount` optionally constrains the connected-component result.
+`sliceMode` is required and has no default, so every request must state it. Use `"connected-components"` to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each) only when the requirement names equal grid cells or fixed slots; the dimensions must come from that requirement. `connected-components` must not carry `gridX`/`gridY`, and `sliceCount` constrains the connected-component result instead of expressing a grid. Omitting `sliceMode`, or contradicting the declared mode with grid dimensions, returns 400 before pricing, enqueueing, or any provider call.
## Common Values
diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
index af0ee338a..8a48a8347 100644
--- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
+++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
@@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
-3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a fixed four-category game contract it may send `sliceMode: "grid"`; for free-form assets use `sliceMode: "connected-components"` (the default).
+3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`.
-For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When using the fixed four-category contract, require response `sliceMode: "grid"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `
`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
+For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `
`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py
index c7c0ab33b..bc270fe38 100644
--- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py
+++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py
@@ -617,6 +617,7 @@ class GenarrativeExternalClient:
self,
reference_id: str,
icon_descriptions: list[str],
+ slice_mode: str,
**fields: Any,
) -> Any:
reference_id = normalize_optional_text(reference_id)
@@ -625,6 +626,20 @@ class GenarrativeExternalClient:
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
if not descriptions:
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
+ slice_mode = normalize_optional_text(slice_mode)
+ if slice_mode not in ("connected-components", "grid"):
+ raise GenarrativeApiError(
+ "slice_mode must be declared explicitly as 'connected-components' or 'grid'; the API has no default"
+ )
+ grid_x = fields.get("gridX")
+ grid_y = fields.get("gridY")
+ if slice_mode == "grid":
+ if grid_x is None or grid_y is None:
+ raise GenarrativeApiError("slice_mode='grid' requires both gridX and gridY")
+ elif grid_x is not None or grid_y is not None:
+ raise GenarrativeApiError(
+ "slice_mode='connected-components' must not carry gridX/gridY"
+ )
label = fields.get("assetLabel", "图标图集")
self._apply_canvas_session_fields(fields, label, 1024, 1024)
fields.setdefault("screenColor", "auto")
@@ -635,6 +650,7 @@ class GenarrativeExternalClient:
**fields,
"referenceId": reference_id,
"iconDescriptions": descriptions,
+ "sliceMode": slice_mode,
},
idempotency_key=idempotency_key,
)
@@ -879,9 +895,9 @@ def _self_test() -> None:
client.generate_icon_spritesheet(
"editor-resource-spec",
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
+ "connected-components",
canvasSession=session,
assetLabel="贪吃蛇透明图集",
- sliceMode="connected-components",
referenceId="must-not-override-explicit-reference",
iconDescriptions=["不得覆盖显式图标描述"],
)
diff --git a/.env.example b/.env.example
index bf06357e1..f11a17f8f 100644
--- a/.env.example
+++ b/.env.example
@@ -158,6 +158,15 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
+# AGC 项目定时快照上传目标。对象只落在服务端私有前缀
+# `agc/project-snapshots/v1/{user}/{project}/` 下,客户端直传票据不覆盖该前缀。
+# bucket 与凭据可以与资源 bucket 分离;凭据未设置时回退使用 ALIYUN_OSS_ACCESS_KEY_*,
+# 但 bucket / endpoint 默认指向 AGC 发行 bucket,需要该凭据具备目标 bucket 的 PutObject 权限。
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET="agc-dev"
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT="oss-rg-china-mainland.aliyuncs.com"
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID=""
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET=""
+
# BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的
# `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。
# `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。
diff --git a/.gitignore b/.gitignore
index 2770d44c8..34e0fbde7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,12 @@ temp*build*/
/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/
+/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/bin/
+/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-path/
+/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-resources/
+/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json
+/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
+/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
/plugins/agc-cocos-editor/native/payload/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
diff --git a/CONTEXT.md b/CONTEXT.md
index b8e7a8793..6ac4610f9 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -201,6 +201,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
+## 项目开发对话(DirectProject)
+
+**项目对话历史**:
+AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
+_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
+
+**运行态事件**:
+Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
+_Avoid_: 进度通知、快照轮询、第二套历史
+
+**聊天投影**:
+把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
+_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
+
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
@@ -235,3 +249,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
+- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。
+- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json
index fcaf78727..dffaab261 100644
--- a/apps/ai-game-creator-shell/package.json
+++ b/apps/ai-game-creator-shell/package.json
@@ -1,7 +1,7 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
- "version": "0.1.47",
+ "version": "0.1.67",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
@@ -47,6 +47,7 @@
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
+ "@tauri-apps/plugin-updater": "2.11.0",
"@vitejs/plugin-react": "^5.0.4",
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
@@ -60,6 +61,7 @@
"react-window": "^1.8.11",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
+ "three": "^0.184.0",
"vite": "^6.2.0",
"zustand": "^5.0.14"
},
@@ -71,6 +73,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@types/three": "^0.184.1",
"@types/react-window": "^1.8.8",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs
index e5f153b68..b1fc307f5 100644
--- a/apps/ai-game-creator-shell/scripts/build-release.mjs
+++ b/apps/ai-game-creator-shell/scripts/build-release.mjs
@@ -1,6 +1,7 @@
-import { spawnSync } from 'node:child_process';
+import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
+import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -10,17 +11,74 @@ import {
} from './cargo-features.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
+// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
+const repoRoot = path.resolve(appRoot, '..', '..');
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
-const releaseTarget =
- process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
-const bundleRoot = path.join(
- appRoot,
- 'src-tauri',
- 'target',
- releaseTarget,
- 'release',
- 'bundle',
-);
+function defaultTarget() {
+ return process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
+}
+
+function explicitBuildTarget(args) {
+ let target;
+ const separator = args.indexOf('--');
+ const options = separator < 0 ? args : args.slice(0, separator);
+ for (let index = 0; index < options.length; index += 1) {
+ const argument = options[index];
+ let value;
+ if (argument === '--target' || argument === '-t') {
+ value = options[++index];
+ } else if (argument.startsWith('--target=')) {
+ value = argument.slice('--target='.length);
+ } else {
+ continue;
+ }
+ if (!value?.trim() || value.startsWith('-')) {
+ throw new Error('--target 缺少有效目标');
+ }
+ if (target !== undefined) throw new Error('不能重复指定 --target');
+ target = value.trim();
+ }
+ return target;
+}
+
+function validateReleaseTarget(target) {
+ if (target === 'universal-apple-darwin') {
+ throw new Error(
+ '内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin',
+ );
+ }
+ if (
+ ![
+ 'x86_64-pc-windows-msvc',
+ 'aarch64-apple-darwin',
+ 'x86_64-apple-darwin',
+ ].includes(target)
+ ) {
+ throw new Error(`不支持的发布目标:${target}`);
+ }
+ return target;
+}
+
+/** 在入口冻结目标;所有发布步骤共享同一上下文,不再各自读取默认目标。 */
+export function resolveReleaseContext(args = [], env = process.env) {
+ const target = validateReleaseTarget(
+ explicitBuildTarget(args) ||
+ env.AGC_BUILD_TARGET?.trim() ||
+ defaultReleaseTarget,
+ );
+ return Object.freeze({
+ target,
+ channel: resolveReleaseChannel(env, target),
+ bundleRoot: path.join(
+ appRoot,
+ 'src-tauri',
+ 'target',
+ target,
+ 'release',
+ 'bundle',
+ ),
+ });
+}
const packageJsonPath = path.join(appRoot, 'package.json');
const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json');
const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json');
@@ -28,14 +86,44 @@ const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
const defaultOssBaseUrl =
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
-const updateManifestUrl =
- process.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() ||
- `${process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl}/latest.json`;
+
+/**
+ * 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点,
+ * 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。
+ */
+const releaseChannels = {
+ 'dev-win': 'windows',
+ 'dev-mac': 'darwin',
+};
+
+/**
+ * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
+ * 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。
+ */
+export const agcReleasePathPatterns = [
+ 'apps/ai-game-creator-shell/',
+ 'packages/',
+ 'server-rs/crates/',
+ 'plugins/agc-cocos-editor/',
+ 'apps/desktop-shell/src-tauri/icons/',
+ 'package.json',
+ 'package-lock.json',
+];
+
+function ossBaseUrl() {
+ return (
+ process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
+ ).replace(/\/+$/u, '');
+}
function readPackageJson() {
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
}
+function readReleaseNotes() {
+ return process.env.AGC_UPDATE_RELEASE_NOTES?.trim() || '';
+}
+
export function compareVersions(left, right) {
const leftParts = left.split('.').map(Number);
const rightParts = right.split('.').map(Number);
@@ -66,26 +154,153 @@ export function nextPatchVersion(localVersion, remoteVersion) {
return `${major}.${minor}.${patch + 1}`;
}
-async function readRemoteVersion() {
+export function resolveReleasePlatform(target = defaultTarget()) {
+ if (target.includes('windows')) return 'windows';
+ if (target.includes('apple-darwin')) return 'darwin';
+ if (target.includes('linux')) return 'linux';
+ throw new Error(`不支持的发布目标:${target}`);
+}
+
+export function resolveReleaseChannel(
+ env = process.env,
+ target = defaultTarget(),
+) {
+ const platform = resolveReleasePlatform(target);
+ const requested = env.AGC_UPDATE_CHANNEL?.trim();
+ if (requested) {
+ const channelPlatform = releaseChannels[requested];
+ if (!channelPlatform) {
+ throw new Error(
+ `未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`,
+ );
+ }
+ if (channelPlatform !== platform) {
+ throw new Error(
+ `渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`,
+ );
+ }
+ return requested;
+ }
+ const defaultChannel = Object.entries(releaseChannels).find(
+ ([, channelPlatform]) => channelPlatform === platform,
+ )?.[0];
+ if (!defaultChannel) {
+ throw new Error(
+ `目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`,
+ );
+ }
+ return defaultChannel;
+}
+
+export function updateManifestUrl(channel = resolveReleaseChannel()) {
+ return `${ossBaseUrl()}/${channel}/latest.json`;
+}
+
+/**
+ * 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。
+ */
+export function resolveManifestPlatformKeys(target = defaultTarget()) {
+ validateReleaseTarget(target);
+ if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
+ if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
+ if (target.includes('windows')) {
+ return [
+ target.startsWith('aarch64') ? 'windows-aarch64' : 'windows-x86_64',
+ ];
+ }
+ throw new Error(`不支持的发布目标:${target}`);
+}
+
+/** 旧协议迁移指针:只在迁移窗口内存在,是历史版本高水位的来源。 */
+function legacyBridgeManifestUrl() {
+ return `${ossBaseUrl()}/latest.json`;
+}
+
+async function fetchManifest(manifestUrl, label) {
let response;
try {
- response = await fetch(updateManifestUrl, {
+ response = await fetch(manifestUrl, {
headers: { Accept: 'application/json' },
});
} catch (error) {
- throw new Error(`读取 OSS 版本清单失败:${error.message}`);
+ throw new Error(`读取 ${label} 失败:${error.message}`);
}
if (response.status === 404) return null;
if (!response.ok) {
- throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`);
+ throw new Error(`读取 ${label} 失败:HTTP ${response.status}`);
}
- let manifest;
try {
- manifest = await response.json();
+ return await response.json();
} catch (error) {
- throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`);
+ throw new Error(`${label} 不是有效 JSON:${error.message}`);
}
- return parseVersion(manifest?.version, 'OSS版本清单 version');
+}
+
+async function readManifestVersion(manifestUrl, label) {
+ const manifest = await fetchManifest(manifestUrl, label);
+ return manifest == null
+ ? null
+ : parseVersion(manifest?.version, `${label} version`);
+}
+
+/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
+async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
+ return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单');
+}
+
+/**
+ * 摘要锚点:上次发布对应的提交。
+ *
+ * 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用
+ * 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT`
+ * —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。
+ */
+export async function resolvePreviousReleaseCommit(
+ channel = resolveReleaseChannel(),
+ { override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {},
+) {
+ const explicit = override?.trim();
+ if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
+ return explicit;
+ }
+ try {
+ const manifest = await readRemoteChannelManifest(channel);
+ const commit =
+ typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
+ return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
+ } catch (error) {
+ // 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。
+ console.warn(
+ `[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`,
+ );
+ return null;
+ }
+}
+
+/**
+ * 版本高水位:渠道清单与旧协议迁移指针取较大值。
+ *
+ * 只看渠道清单会在「渠道刚启用、旧指针还停在更高版本」时把版本链改小 ——
+ * 2026-09-17 首次渠道发布就是这样把 0.1.57 退回 0.1.48 的。旧指针只服务
+ * Windows 渠道,其它渠道不参与比较;旧指针 404(迁移窗口结束)后自动只剩渠道清单。
+ */
+export async function resolveRemoteHighWaterVersion(
+ channel = resolveReleaseChannel(),
+) {
+ const channelVersion = await readManifestVersion(
+ updateManifestUrl(channel),
+ 'OSS 渠道清单',
+ );
+ if (channel !== 'dev-win') return channelVersion;
+ const legacyVersion = await readManifestVersion(
+ legacyBridgeManifestUrl(),
+ 'OSS 迁移指针',
+ );
+ if (channelVersion == null) return legacyVersion;
+ if (legacyVersion == null) return channelVersion;
+ return compareVersions(channelVersion, legacyVersion) >= 0
+ ? channelVersion
+ : legacyVersion;
}
function replaceVersionLine(source, version, pattern, label) {
@@ -93,9 +308,10 @@ function replaceVersionLine(source, version, pattern, label) {
return source.replace(pattern, `$1${version}$3`);
}
-export async function prepareReleaseVersion() {
+export async function prepareReleaseVersion(context = resolveReleaseContext()) {
+ const { channel } = context;
const localVersion = parseVersion(readPackageJson().version, '本地版本');
- const remoteVersion = await readRemoteVersion();
+ const remoteVersion = await resolveRemoteHighWaterVersion(channel);
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
const nextVersion = requestedVersion
? parseVersion(requestedVersion, '指定版本')
@@ -158,26 +374,22 @@ export async function prepareReleaseVersion() {
console.log(
requestedVersion
- ? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
- : `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
+ ? `[ai-game-creator-shell] 渠道 ${channel} 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
+ : `[ai-game-creator-shell] 渠道 ${channel} 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
);
return nextVersion;
}
export function buildTauriBuildArguments(
args = [],
- target = releaseTarget,
+ target = defaultTarget(),
platform = process.platform,
) {
const noBundle = args.includes('--no-bundle');
- const targetIndex = args.indexOf('--target');
- const explicitTarget =
- targetIndex >= 0
- ? args[targetIndex + 1]
- : args
- .find((value) => value.startsWith('--target='))
- ?.slice('--target='.length);
+ const explicitTarget = explicitBuildTarget(args);
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
+ if (!noBundle || explicitTarget)
+ validateReleaseTarget(explicitTarget || target);
const features = defaultEditorFeatures(
explicitTarget || (noBundle ? platform : target),
);
@@ -187,18 +399,58 @@ export function buildTauriBuildArguments(
];
}
-export function runTauriBuild(args = []) {
+/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
+export function createChannelConfig(channel = resolveReleaseChannel()) {
+ return {
+ plugins: {
+ updater: {
+ endpoints: [updateManifestUrl(channel)],
+ },
+ },
+ };
+}
+
+function writeChannelConfigFile(channel) {
+ const configPath = path.join(
+ os.tmpdir(),
+ `agc-tauri-channel-${channel}.json`,
+ );
+ fs.writeFileSync(
+ configPath,
+ `${JSON.stringify(createChannelConfig(channel), null, 2)}\n`,
+ );
+ return configPath;
+}
+
+export function runTauriBuild(
+ args = [],
+ context = resolveReleaseContext(args),
+ { spawn = spawnSync } = {},
+) {
+ if (
+ explicitBuildTarget(args) &&
+ explicitBuildTarget(args) !== context.target
+ ) {
+ throw new Error('构建参数与发布上下文目标不一致');
+ }
+ const tauriArguments = buildTauriBuildArguments(args, context.target);
+ const { channel } = context;
+ const configPath = writeChannelConfigFile(channel);
+ console.log(
+ `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
+ );
+ // 最后合并渠道配置,防止用户配置中的端点与实际发布目标分叉。
+ const separator = tauriArguments.indexOf('--');
+ tauriArguments.splice(
+ separator < 0 ? tauriArguments.length : separator,
+ 0,
+ '--config',
+ configPath,
+ );
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
- const result = spawnSync(
+ const result = spawn(
npmCommand,
- [
- '--prefix',
- '../..',
- 'exec',
- 'tauri',
- '--',
- ...buildTauriBuildArguments(args),
- ],
+ ['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
);
if (result.error) throw result.error;
@@ -213,17 +465,22 @@ function listFiles(root) {
});
}
-function artifactPriority(filePath) {
+function artifactPriority(filePath, target) {
const name = path.basename(filePath).toLowerCase();
- if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99;
- if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99;
- if (name.endsWith('.appimage')) return 0;
- if (name.endsWith('.deb')) return 1;
- if (name.endsWith('.rpm')) return 2;
+ if (target.includes('windows')) return name.endsWith('.exe') ? 0 : 99;
+ // 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。
+ if (target.includes('apple-darwin')) {
+ return name.endsWith('.app.tar.gz') ? 0 : 99;
+ }
+ if (name.endsWith('.appimage.tar.gz')) return 0;
+ if (name.endsWith('.appimage')) return 1;
+ if (name.endsWith('.deb')) return 2;
+ if (name.endsWith('.rpm')) return 3;
return 99;
}
-export function selectReleaseArtifact(files) {
+export function selectReleaseArtifact(files, target = defaultTarget()) {
+ validateReleaseTarget(target);
const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim();
if (explicit) {
const resolved = path.resolve(explicit);
@@ -234,44 +491,271 @@ export function selectReleaseArtifact(files) {
}
return (
[...files]
- .filter((filePath) => artifactPriority(filePath) < 99)
+ .filter((filePath) => artifactPriority(filePath, target) < 99)
.sort((left, right) => {
- const priority = artifactPriority(left) - artifactPriority(right);
+ const priority =
+ artifactPriority(left, target) - artifactPriority(right, target);
return priority || left.localeCompare(right);
})[0] ?? null
);
}
-export function createUpdateManifest(artifactPath) {
- const bytes = fs.readFileSync(artifactPath);
+function readUpdaterSignature(artifactPath) {
+ const signaturePath = `${artifactPath}.sig`;
+ if (!fs.existsSync(signaturePath)) {
+ throw new Error(
+ `缺少更新包签名:${signaturePath};需要 bundle.createUpdaterArtifacts 与签名私钥(TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PATH)`,
+ );
+ }
+ const signature = fs.readFileSync(signaturePath, 'utf8').trim();
+ if (!signature) throw new Error(`更新包签名为空:${signaturePath}`);
+ return signature;
+}
+
+export function createUpdateManifest(
+ artifactPath,
+ {
+ target = defaultTarget(),
+ channel = resolveReleaseChannel(process.env, target),
+ publishedAt = new Date().toISOString(),
+ notes = readReleaseNotes(),
+ commit = readHeadCommit(),
+ } = {},
+) {
+ validateReleaseTarget(target);
+ resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
+ const signature = readUpdaterSignature(artifactPath);
const version = readPackageJson().version;
const fileName = path.basename(artifactPath);
- const baseUrl = (
- process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
- ).replace(/\/+$/u, '');
- const encodedFileName = encodeURIComponent(fileName).replace(/%2F/giu, '/');
+ const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
+ const platforms = {};
+ for (const key of resolveManifestPlatformKeys(target)) {
+ platforms[key] = { signature, url };
+ }
return {
version,
- downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`,
- sha256: createHash('sha256').update(bytes).digest('hex'),
- size: bytes.length,
- ...(process.env.AGC_UPDATE_RELEASE_NOTES?.trim()
- ? { releaseNotes: process.env.AGC_UPDATE_RELEASE_NOTES.trim() }
- : {}),
+ ...(notes ? { notes } : {}),
+ pub_date: publishedAt,
+ platforms,
+ // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
+ ...(commit ? { commit } : {}),
};
}
-export function generateUpdateManifest() {
- const artifact = selectReleaseArtifact(listFiles(bundleRoot));
+function readHeadCommit() {
+ try {
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ }).trim();
+ } catch {
+ return '';
+ }
+}
+
+/**
+ * 上一次发布到本次之间的客户端相关提交。
+ *
+ * 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。
+ */
+export function collectReleaseCommits(
+ previousCommit,
+ headCommit = 'HEAD',
+ { cwd = repoRoot, paths = agcReleasePathPatterns } = {},
+) {
+ if (!previousCommit) return null;
+ try {
+ for (const revision of [previousCommit, headCommit]) {
+ execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], {
+ cwd,
+ stdio: 'pipe',
+ });
+ }
+ } catch {
+ return null;
+ }
+ let output;
+ try {
+ output = execFileSync(
+ 'git',
+ [
+ 'log',
+ '--no-merges',
+ '--format=%h%x09%s',
+ `${previousCommit}..${headCommit}`,
+ '--',
+ ...paths,
+ ],
+ { cwd, encoding: 'utf8' },
+ );
+ } catch {
+ return null;
+ }
+ return output
+ .split(/\r?\n/u)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const [sha = '', ...subject] = line.split('\t');
+ return { sha, subject: subject.join('\t') };
+ });
+}
+
+/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */
+export function formatReleaseNotes(
+ commits,
+ { limit = 12, subjectLength = 80, maxLength = 900 } = {},
+) {
+ if (!commits || commits.length === 0) return '';
+ const lines = commits.slice(0, limit).map(({ sha, subject }) => {
+ const trimmed =
+ subject.length > subjectLength
+ ? `${subject.slice(0, subjectLength - 1)}…`
+ : subject;
+ return `- ${trimmed}(${sha})`;
+ });
+ if (commits.length > limit) {
+ lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
+ }
+ const text = lines.join('\n');
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
+}
+
+/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */
+export function collectRecentReleaseCommits({
+ cwd = repoRoot,
+ paths = agcReleasePathPatterns,
+ limit = 8,
+} = {}) {
+ let output;
+ try {
+ output = execFileSync(
+ 'git',
+ ['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths],
+ { cwd, encoding: 'utf8' },
+ );
+ } catch {
+ return null;
+ }
+ const commits = output
+ .split(/\r?\n/u)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const [sha = '', ...subject] = line.split('\t');
+ return { sha, subject: subject.join('\t') };
+ });
+ return commits.length > 0 ? commits : null;
+}
+
+export function formatRecentReleaseNotes(commits) {
+ const notes = formatReleaseNotes(commits, { limit: 8 });
+ if (!notes) return '';
+ return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`;
+}
+
+/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
+export function createLegacyUpdateManifest(
+ artifactPath,
+ { channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {},
+) {
+ const bytes = fs.readFileSync(artifactPath);
+ const version = readPackageJson().version;
+ const fileName = path.basename(artifactPath);
+ return {
+ version,
+ downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ size: bytes.length,
+ ...(notes ? { releaseNotes: notes } : {}),
+ };
+}
+
+export async function generateUpdateManifest(
+ context = resolveReleaseContext(),
+) {
+ const { channel, target, bundleRoot } = context;
+ const artifact = selectReleaseArtifact(listFiles(bundleRoot), target);
if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
}
- const manifest = createUpdateManifest(artifact);
+ const manualNotes = readReleaseNotes();
+ const previousCommit = await resolvePreviousReleaseCommit(channel);
+ const commits = collectReleaseCommits(previousCommit);
+ const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
+ const notes =
+ manualNotes ||
+ formatReleaseNotes(commits) ||
+ formatRecentReleaseNotes(recentCommits);
+ if (!manualNotes && !notes) {
+ console.log(
+ `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`,
+ );
+ }
+ const manifest = createUpdateManifest(artifact, { channel, target, notes });
const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
- console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`);
+ const notesPath = path.join(bundleRoot, 'release-notes.txt');
+ fs.writeFileSync(
+ notesPath,
+ notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
+ );
+ const legacyManifest =
+ channel === 'dev-win'
+ ? createLegacyUpdateManifest(artifact, { channel, notes })
+ : null;
+ const legacyManifestPath = legacyManifest
+ ? path.join(bundleRoot, 'legacy-latest.json')
+ : null;
+ if (legacyManifest && legacyManifestPath) {
+ fs.writeFileSync(
+ legacyManifestPath,
+ `${JSON.stringify(legacyManifest, null, 2)}\n`,
+ );
+ }
+ console.log(
+ `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
+ );
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
- return { artifact, manifestPath, manifest };
+ console.log(
+ manualNotes
+ ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
+ : notes && !previousCommit
+ ? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交`
+ : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`,
+ );
+ console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`);
+ if (legacyManifestPath) {
+ console.log(
+ `[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`,
+ );
+ }
+ return {
+ channel,
+ artifact,
+ manifest,
+ manifestPath,
+ notes,
+ notesPath,
+ previousCommit,
+ commits,
+ legacyManifest,
+ legacyManifestPath,
+ };
+}
+
+export async function buildRelease(
+ args = [],
+ {
+ prepareVersion = prepareReleaseVersion,
+ build = runTauriBuild,
+ generateManifest = generateUpdateManifest,
+ } = {},
+) {
+ const context = resolveReleaseContext(args);
+ if (!args.includes('--no-bundle')) await prepareVersion(context);
+ build(args, context);
+ if (!args.includes('--no-bundle')) return generateManifest(context);
}
if (
@@ -279,7 +763,5 @@ if (
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const args = process.argv.slice(2);
- if (!args.includes('--no-bundle')) await prepareReleaseVersion();
- runTauriBuild(args);
- if (!args.includes('--no-bundle')) generateUpdateManifest();
+ await buildRelease(args);
}
diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs
index d2853082f..8cb9c0214 100644
--- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs
+++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs
@@ -1,26 +1,113 @@
import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
import { test } from 'node:test';
+import { fileURLToPath } from 'node:url';
import {
+ agcReleasePathPatterns,
+ buildRelease,
+ buildTauriBuildArguments,
+ collectRecentReleaseCommits,
+ collectReleaseCommits,
compareVersions,
+ createChannelConfig,
+ createLegacyUpdateManifest,
createUpdateManifest,
+ formatRecentReleaseNotes,
+ formatReleaseNotes,
+ generateUpdateManifest,
nextPatchVersion,
+ resolveManifestPlatformKeys,
+ resolvePreviousReleaseCommit,
+ resolveReleaseChannel,
+ resolveReleaseContext,
+ resolveRemoteHighWaterVersion,
+ runTauriBuild,
selectReleaseArtifact,
+ updateManifestUrl,
} from './build-release.mjs';
-test('selects an explicit release artifact when configured', () => {
- const artifactPath = new URL('../package.json', import.meta.url).pathname;
- const previous = process.env.AGC_UPDATE_ARTIFACT;
- process.env.AGC_UPDATE_ARTIFACT = artifactPath;
- try {
- assert.equal(selectReleaseArtifact([]), artifactPath);
- } finally {
- if (previous === undefined) delete process.env.AGC_UPDATE_ARTIFACT;
- else process.env.AGC_UPDATE_ARTIFACT = previous;
+const windowsTarget = 'x86_64-pc-windows-msvc';
+const universalTarget = 'universal-apple-darwin';
+
+test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
+ assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
+ assert.throws(
+ () => buildTauriBuildArguments(['--target=universal-apple-darwin']),
+ /单架构/,
+ );
+ for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
+ assert.deepEqual(buildTauriBuildArguments([], target), [
+ 'build',
+ '--target',
+ target,
+ ]);
}
});
+function withEnv(overrides, run) {
+ const previous = new Map();
+ for (const [key, value] of Object.entries(overrides)) {
+ previous.set(key, process.env[key]);
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ try {
+ return run();
+ } finally {
+ for (const [key, value] of previous) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ }
+}
+
+function withSignedArtifact(fileName, run) {
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
+ try {
+ const artifact = path.join(directory, fileName);
+ writeFileSync(artifact, 'installation package');
+ writeFileSync(`${artifact}.sig`, 'signature-content\n');
+ return run(artifact);
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+}
+
+function jsonResponse(body, status = 200) {
+ return {
+ status,
+ ok: status >= 200 && status < 300,
+ json: async () => body,
+ };
+}
+
+function withStubbedFetch(handler, run) {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async (url) => handler(String(url));
+ return Promise.resolve(run()).finally(() => {
+ globalThis.fetch = originalFetch;
+ });
+}
+
+test('selects an explicit release artifact when configured', () => {
+ const artifactPath = fileURLToPath(
+ new URL('../package.json', import.meta.url),
+ );
+ withEnv({ AGC_UPDATE_ARTIFACT: artifactPath }, () => {
+ assert.equal(selectReleaseArtifact([]), artifactPath);
+ });
+});
+
test('does not select unsupported files', () => {
assert.equal(
selectReleaseArtifact(['/tmp/latest.json', '/tmp/readme.txt']),
@@ -28,47 +115,608 @@ test('does not select unsupported files', () => {
);
});
-test('manifest contains version, download URL and integrity fields', () => {
- const manifest = createUpdateManifest(
- new URL('../package.json', import.meta.url).pathname,
+test('resolves the channel from the target platform and rejects mismatches', () => {
+ assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win');
+ assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac');
+ assert.equal(
+ resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget),
+ 'dev-mac',
);
- assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
- assert.match(
- manifest.downloadUrl,
- new RegExp(`/agc/${manifest.version}/package\\.json$`, 'u'),
+ assert.throws(
+ () =>
+ resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget),
+ /只能用于 darwin 目标/u,
+ );
+ assert.throws(
+ () =>
+ resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget),
+ /未知发布渠道/u,
);
- assert.equal(manifest.sha256.length, 64);
- assert.equal(typeof manifest.size, 'number');
});
-test('manifest preserves multiline release notes', () => {
- const previous = process.env.AGC_UPDATE_RELEASE_NOTES;
- process.env.AGC_UPDATE_RELEASE_NOTES = '第一行\n第二行\r\n第三行';
- try {
- const manifest = createUpdateManifest(
- new URL('../package.json', import.meta.url).pathname,
+test('channel manifest URL and build-time endpoint follow the channel', () => {
+ withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
+ assert.equal(
+ updateManifestUrl('dev-win'),
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
);
- assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行');
+ assert.deepEqual(createChannelConfig('dev-mac'), {
+ plugins: {
+ updater: {
+ endpoints: [
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json',
+ ],
+ },
+ },
+ });
+ });
+});
+
+test('macOS manifests only advertise the architecture actually built', () => {
+ assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/);
+ assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
+ 'darwin-aarch64',
+ ]);
+ assert.deepEqual(resolveManifestPlatformKeys('x86_64-apple-darwin'), [
+ 'darwin-x86_64',
+ ]);
+ assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [
+ 'windows-x86_64',
+ ]);
+});
+
+test('release context resolves explicit targets before environment/default and fails closed', () => {
+ for (const args of [
+ ['--target', 'aarch64-apple-darwin'],
+ ['--target=aarch64-apple-darwin'],
+ ['-t', 'aarch64-apple-darwin'],
+ ]) {
+ for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) {
+ const context = resolveReleaseContext(args, env);
+ assert.equal(context.target, 'aarch64-apple-darwin');
+ assert.equal(context.channel, 'dev-mac');
+ assert.match(
+ context.bundleRoot.replaceAll('\\', '/'),
+ /target\/aarch64-apple-darwin\/release\/bundle$/,
+ );
+ assert.ok(Object.isFrozen(context));
+ }
+ assert.throws(
+ () => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }),
+ /只能用于 windows/,
+ );
+ }
+ assert.equal(resolveReleaseContext([], {}).target, windowsTarget);
+ assert.equal(
+ resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' })
+ .channel,
+ 'dev-mac',
+ );
+ for (const args of [
+ ['--target'],
+ ['--target='],
+ ['--target', '--no-bundle'],
+ ['--target', windowsTarget, '--target=aarch64-apple-darwin'],
+ ['--target', universalTarget],
+ ['--target', 'unknown'],
+ ])
+ assert.throws(() => resolveReleaseContext(args, {}));
+});
+
+test('explicit macOS target drives version lookup, Tauri endpoint, artifact and manifest together', async () => {
+ const calls = [];
+ const seenContexts = [];
+ await withStubbedFetch(
+ (url) => {
+ calls.push(url);
+ assert.match(url, /\/dev-mac\/latest\.json$/);
+ return jsonResponse({ version: '0.1.67' });
+ },
+ () =>
+ withEnv(
+ { AGC_BUILD_TARGET: undefined, AGC_UPDATE_CHANNEL: undefined },
+ () =>
+ buildRelease(['--target', 'aarch64-apple-darwin'], {
+ prepareVersion: async (context) => {
+ seenContexts.push(context);
+ assert.equal(
+ await resolveRemoteHighWaterVersion(context.channel),
+ '0.1.67',
+ );
+ },
+ build: (args, context) => {
+ seenContexts.push(context);
+ runTauriBuild(args, context, {
+ spawn: (_binary, command) => {
+ const configIndex = command.lastIndexOf('--config');
+ const config = JSON.parse(
+ readFileSync(command[configIndex + 1], 'utf8'),
+ );
+ assert.match(
+ config.plugins.updater.endpoints[0],
+ /\/dev-mac\/latest\.json$/,
+ );
+ assert.ok(command.includes('aarch64-apple-darwin'));
+ assert.ok(
+ !command.includes('--features=cocos-editor-execute'),
+ );
+ return { status: 0 };
+ },
+ });
+ },
+ generateManifest: (context) => {
+ seenContexts.push(context);
+ withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
+ assert.equal(
+ selectReleaseArtifact(
+ ['/tmp/win.exe', artifact, '/tmp/mac.dmg'],
+ context.target,
+ ),
+ artifact,
+ );
+ const manifest = createUpdateManifest(artifact, context);
+ assert.deepEqual(Object.keys(manifest.platforms), [
+ 'darwin-aarch64',
+ ]);
+ assert.match(
+ manifest.platforms['darwin-aarch64'].url,
+ /\/dev-mac\//,
+ );
+ });
+ },
+ }),
+ ),
+ );
+ assert.equal(calls.length, 1, 'Mac 不应读取 Windows 迁移指针');
+ assert.equal(seenContexts.length, 3);
+ assert.ok(seenContexts.every((context) => context === seenContexts[0]));
+});
+
+test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => {
+ const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
+ try {
+ const artifact = path.join(root, '陶泥儿.app.tar.gz');
+ writeFileSync(artifact, 'mac package');
+ writeFileSync(`${artifact}.sig`, 'mac signature');
+ writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
+ const context = {
+ ...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}),
+ bundleRoot: root,
+ };
+ const result = await withStubbedFetch(
+ (url) => {
+ assert.match(url, /\/dev-mac\/latest\.json$/);
+ return jsonResponse({}, 404);
+ },
+ () => generateUpdateManifest(context),
+ );
+ assert.equal(result.artifact, artifact);
+ assert.equal(result.manifestPath, path.join(root, 'latest.json'));
+ assert.equal(result.legacyManifestPath, null);
+ assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']);
+ assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//);
} finally {
- if (previous === undefined) delete process.env.AGC_UPDATE_RELEASE_NOTES;
- else process.env.AGC_UPDATE_RELEASE_NOTES = previous;
+ rmSync(root, { recursive: true, force: true });
}
});
-test('next release version follows the higher local or OSS version', () => {
+test('invalid target or mismatched channel fails before any release side effect', async () => {
+ let touched = false;
+ const sideEffects = {
+ prepareVersion: () => {
+ touched = true;
+ },
+ build: () => {
+ touched = true;
+ },
+ generateManifest: () => {
+ touched = true;
+ },
+ };
+ await assert.rejects(
+ () => buildRelease(['--target', universalTarget], sideEffects),
+ /单架构/,
+ );
+ await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
+ assert.rejects(
+ () => buildRelease(['--target=aarch64-apple-darwin'], sideEffects),
+ /只能用于 windows/,
+ ),
+ );
+ assert.equal(touched, false);
+});
+
+test('Windows remains the default and explicit Windows overrides macOS environment', () => {
+ const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
+ for (const context of [
+ resolveReleaseContext([], {}),
+ resolveReleaseContext(['--target', windowsTarget], {
+ AGC_BUILD_TARGET: 'aarch64-apple-darwin',
+ }),
+ ]) {
+ assert.equal(context.channel, 'dev-win');
+ assert.equal(
+ selectReleaseArtifact(files, context.target),
+ '/tmp/windows.exe',
+ );
+ runTauriBuild(
+ ['--target', windowsTarget, '--config', 'user-config.json'],
+ context,
+ {
+ spawn: (_binary, command) => {
+ assert.ok(command.includes('--features=cocos-editor-execute'));
+ assert.ok(command.includes('user-config.json'));
+ const configIndex = command.lastIndexOf('--config');
+ const config = JSON.parse(
+ readFileSync(command[configIndex + 1], 'utf8'),
+ );
+ assert.match(
+ config.plugins.updater.endpoints[0],
+ /\/dev-win\/latest\.json$/,
+ );
+ return { status: 0 };
+ },
+ },
+ );
+ }
+});
+
+test('no-bundle smoke skips version writes and manifest generation', async () => {
+ const steps = [];
+ await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
+ prepareVersion: () => {
+ steps.push('version');
+ },
+ build: (_args, context) => {
+ steps.push(context.channel);
+ },
+ generateManifest: () => {
+ steps.push('manifest');
+ },
+ });
+ assert.deepEqual(steps, ['dev-mac']);
+});
+
+test('channel manifest carries version, platform keys and signature', () => {
+ withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
+ withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
+ const manifest = createUpdateManifest(artifact, {
+ channel: 'dev-win',
+ target: windowsTarget,
+ publishedAt: '2026-09-17T00:00:00.000Z',
+ });
+ assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
+ assert.equal(manifest.notes, '修复与改进');
+ assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
+ assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
+ assert.equal(
+ manifest.platforms['windows-x86_64'].signature,
+ 'signature-content',
+ );
+ assert.match(
+ manifest.platforms['windows-x86_64'].url,
+ new RegExp(`/agc/dev-win/${manifest.version}/`, 'u'),
+ );
+ });
+ });
+});
+
+test('missing signature fails the channel manifest closed', () => {
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
+ try {
+ const artifact = path.join(directory, '陶泥儿_0.1.48_x64-setup.exe');
+ writeFileSync(artifact, 'installation package');
+ assert.throws(
+ () =>
+ createUpdateManifest(artifact, {
+ channel: 'dev-win',
+ target: windowsTarget,
+ }),
+ /缺少更新包签名/u,
+ );
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test('legacy manifest keeps the sha256 contract of published clients', () => {
+ withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
+ const legacy = createLegacyUpdateManifest(artifact, {
+ channel: 'dev-win',
+ });
+ assert.match(legacy.version, /^\d+\.\d+\.\d+$/u);
+ assert.equal(legacy.sha256.length, 64);
+ assert.equal(legacy.size, 'installation package'.length);
+ assert.match(legacy.downloadUrl, /\/agc\/dev-win\/[\d.]+\//u);
+ });
+});
+
+test('next release version follows the higher local or channel version', () => {
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
});
-test('release upload forces overwrite for versioned artifact and latest pointer', () => {
+test('version high water keeps the legacy pointer during the migration window', async () => {
+ await withStubbedFetch(
+ (url) =>
+ url.endsWith('/agc/dev-win/latest.json')
+ ? jsonResponse({}, 404)
+ : jsonResponse({ version: '0.1.57' }),
+ async () => {
+ assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57');
+ // 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。
+ assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58');
+ },
+ );
+});
+
+test('version high water takes the higher of channel and legacy pointer', async () => {
+ await withStubbedFetch(
+ (url) =>
+ url.endsWith('/agc/dev-win/latest.json')
+ ? jsonResponse({ version: '0.1.60' })
+ : jsonResponse({ version: '0.1.57' }),
+ async () => {
+ assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60');
+ },
+ );
+});
+
+test('version high water ignores the windows migration pointer for other channels', async () => {
+ await withStubbedFetch(
+ (url) => {
+ assert.ok(
+ !url.endsWith('/agc/latest.json'),
+ 'non-windows channel must not read the windows migration pointer',
+ );
+ return jsonResponse({ version: '0.1.12' });
+ },
+ async () => {
+ assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12');
+ },
+ );
+});
+
+test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => {
+ await withStubbedFetch(
+ () => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }),
+ async () => {
+ assert.equal(
+ await resolvePreviousReleaseCommit('dev-win', {
+ override: '6017d46088c04199e99cf89f347b12d67591475e',
+ }),
+ '6017d46088c04199e99cf89f347b12d67591475e',
+ );
+ // 覆盖值非法时忽略,继续用清单里的 commit。
+ assert.equal(
+ await resolvePreviousReleaseCommit('dev-win', {
+ override: 'not-a-sha',
+ }),
+ 'abcdef1234567890',
+ );
+ assert.equal(
+ await resolvePreviousReleaseCommit('dev-win', { override: ' ' }),
+ 'abcdef1234567890',
+ );
+ },
+ );
+
+ await withStubbedFetch(
+ () => jsonResponse({ version: '0.1.61' }),
+ async () => {
+ assert.equal(
+ await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
+ null,
+ );
+ },
+ );
+});
+
+test('release notes anchor degrades to null when the manifest cannot be read', async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => {
+ throw new Error('fetch failed');
+ };
+ try {
+ assert.equal(
+ await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
+ null,
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('recent commit fallback marks that entries may repeat the previous release', () => {
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-'));
+ const git = (...args) =>
+ execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
+ try {
+ git('init', '--quiet');
+ git('config', 'user.email', 'release@example.test');
+ git('config', 'user.name', 'release test');
+ mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
+ recursive: true,
+ });
+ for (const name of ['one', 'two']) {
+ writeFileSync(
+ path.join(directory, `apps/ai-game-creator-shell/${name}.rs`),
+ `fn ${name}() {}\n`,
+ );
+ git('add', '.');
+ git('commit', '--quiet', '-m', `客户端:${name}`);
+ }
+ writeFileSync(path.join(directory, 'README.md'), '# 文档\n');
+ git('add', '.');
+ git('commit', '--quiet', '-m', '文档:说明');
+
+ const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 });
+ assert.deepEqual(
+ recent.map((entry) => entry.subject),
+ ['客户端:two', '客户端:one'],
+ );
+ const notes = formatRecentReleaseNotes(recent);
+ assert.match(
+ notes,
+ /^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u,
+ );
+ assert.equal(formatRecentReleaseNotes([]), '');
+ assert.equal(formatRecentReleaseNotes(null), '');
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test('release upload forces overwrite for artifact, signature and channel pointers', () => {
const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url),
'utf8',
);
assert.equal(
- (source.match(/runOssutil\(\['cp', '--force'/gu) ?? []).length,
- 2,
+ (source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length,
+ 4,
);
+ assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
+ assert.match(source, /agc\/latest\.json/u);
+ assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u);
+});
+
+test('release notes list client commits with short sha and bound their size', () => {
+ const notes = formatReleaseNotes([
+ { sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' },
+ { sha: '55af6014', subject: '修'.repeat(120) },
+ ]);
+ const lines = notes.split('\n');
+ assert.equal(lines.length, 2);
+ assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u);
+ const truncatedSubject = lines[1]
+ .replace(/^- /u, '')
+ .replace(/(55af6014)$/u, '');
+ assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
+ assert.match(truncatedSubject, /…$/u);
+ assert.match(lines[1], /(55af6014)$/u);
+
+ const many = formatReleaseNotes(
+ Array.from({ length: 20 }, (_, index) => ({
+ sha: `sha${index}`,
+ subject: `改动 ${index}`,
+ })),
+ );
+ assert.match(many, /- 其余 8 项客户端改动省略$/u);
+ assert.equal(formatReleaseNotes([]), '');
+ assert.equal(formatReleaseNotes(null), '');
+});
+
+test('release commits cover only client paths and skip merge commits', () => {
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-'));
+ const git = (...args) =>
+ execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
+ try {
+ git('init', '--quiet');
+ git('config', 'user.email', 'release@example.test');
+ git('config', 'user.name', 'release test');
+ git('commit', '--allow-empty', '--quiet', '-m', '基点');
+ const base = git('rev-parse', 'HEAD').trim();
+
+ mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
+ recursive: true,
+ });
+ mkdirSync(path.join(directory, 'docs'), { recursive: true });
+ writeFileSync(
+ path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
+ 'fn main() {}\n',
+ );
+ git('add', '.');
+ git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
+
+ writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
+ git('add', '.');
+ git('commit', '--quiet', '-m', '文档:补充说明');
+
+ writeFileSync(
+ path.join(directory, 'apps/ai-game-creator-shell/other.rs'),
+ 'fn other() {}\n',
+ );
+ git('add', '.');
+ git('commit', '--quiet', '-m', '客户端:修复版本回退');
+
+ git('checkout', '--quiet', '-b', 'side');
+ writeFileSync(
+ path.join(directory, 'apps/ai-game-creator-shell/side.rs'),
+ 'fn side() {}\n',
+ );
+ git('add', '.');
+ git('commit', '--quiet', '-m', '客户端:侧分支改动');
+ git('checkout', '--quiet', 'master');
+ git('merge', '--quiet', '--no-ff', '--no-edit', 'side');
+
+ const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
+ assert.ok(commits, '应能在临时仓库里收集提交');
+ const subjects = commits.map((entry) => entry.subject);
+ // 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
+ assert.deepEqual(subjects, [
+ '客户端:侧分支改动',
+ '客户端:修复版本回退',
+ '客户端:新增更新插件接入',
+ ]);
+ assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha)));
+
+ assert.equal(
+ collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }),
+ null,
+ );
+ assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null);
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test('scheduler path filter stays in sync with client release paths', () => {
+ const jenkinsfile = readFileSync(
+ new URL(
+ '../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
+ import.meta.url,
+ ),
+ 'utf8',
+ );
+ const filterLine = jenkinsfile
+ .split('\n')
+ .find((line) => line.includes('apps/ai-game-creator-shell/*|'));
+ assert.ok(filterLine, '调度管线里应存在发布范围过滤模式');
+ for (const pattern of agcReleasePathPatterns) {
+ const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern;
+ assert.ok(
+ filterLine.includes(bashPattern),
+ `调度管线过滤缺少 ${bashPattern}`,
+ );
+ }
+});
+
+test('scheduler skips the full build only for non-deploy paths', () => {
+ const jenkinsfile = readFileSync(
+ new URL(
+ '../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
+ import.meta.url,
+ ),
+ 'utf8',
+ );
+ const skipLine = jenkinsfile
+ .split('\n')
+ .find((line) => line.includes('docs/*|.codex/*|jenkins/*'));
+ assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式');
+ for (const pattern of [
+ 'docs/*',
+ '.codex/*',
+ 'jenkins/*',
+ 'apps/ai-game-creator-shell/*',
+ 'apps/mobile-shell/*',
+ 'apps/desktop-shell/*',
+ 'apps/preview-deployer-web/*',
+ 'tools/*',
+ '*.md',
+ ]) {
+ assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`);
+ }
});
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index e32dfb4b7..fe8943608 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -35,6 +35,12 @@ const windowsTauriConfig = JSON.parse(
'utf8',
),
);
+const macosTauriConfig = JSON.parse(
+ fs.readFileSync(
+ new URL('../src-tauri/tauri.macos.conf.json', import.meta.url),
+ 'utf8',
+ ),
+);
const cargoManifestSource = fs.readFileSync(
new URL('../src-tauri/Cargo.toml', import.meta.url),
'utf8',
@@ -121,6 +127,10 @@ const allowedUncalledTauriCommands = [
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
+ // 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用;
+ // 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。
+ 'read_local_project_snapshot_state',
+ 'sync_local_project_snapshot',
'reset_design_agent_session',
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
@@ -1354,6 +1364,37 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory',
);
}
+assert.deepEqual(
+ macosTauriConfig.bundle?.resources,
+ Object.fromEntries([
+ ...[
+ 'bin/codex',
+ 'bin/codex-code-mode-host',
+ 'codex-path/rg',
+ 'codex-resources/zsh/bin/zsh',
+ 'codex-package.json',
+ 'NOTICE.md',
+ 'manifest.json',
+ ].map((file) => [
+ `resources/codex/mac-native/${file}`,
+ `coding-agent/mac-native/${file}`,
+ ]),
+ ['resources/plugins', 'plugins'],
+ ]),
+ 'macOS must bundle the complete native Codex layout and plugin workspace',
+);
+assert.deepEqual(
+ macosTauriConfig.plugins?.updater?.endpoints,
+ [
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json',
+ ],
+ 'macOS local builds must not use the Windows update channel',
+);
+assert.equal(
+ macosTauriConfig.bundle?.macOS?.minimumSystemVersion,
+ '15.0',
+ 'macOS deployment baseline must cover the bundled native zsh requirement',
+);
if (tauriConfig.app?.withGlobalTauri !== true) {
throw new Error(
@@ -1718,7 +1759,7 @@ for (const snippet of [
'fn append_local_permission_log_at(',
'"command.auto"',
'GameCreationAppPermission::Auto',
- 'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH',
+ 'fn game_creator_bundled_codex_cli_path',
'validate_game_creator_bundled_codex_cli',
'内置 Codex CLI 完整性校验失败',
]) {
diff --git a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs
new file mode 100644
index 000000000..78e38fdd9
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs
@@ -0,0 +1,222 @@
+import assert from 'node:assert/strict';
+import { spawn, spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
+assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
+const source = path.resolve(process.argv[2] || '');
+assert.ok(
+ source.endsWith('.app') && fs.statSync(source).isDirectory(),
+ '请传入 .app 绝对路径',
+);
+const root = fs.realpathSync(
+ fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')),
+);
+const app = path.join(root, '陶泥儿 隔离测试.app');
+const home = path.join(root, 'home');
+const config = path.join(root, 'config');
+const tmp = path.join(root, 'tmp');
+const codexHome = path.join(root, 'codex-home');
+for (const directory of [home, config, tmp, codexHome]) {
+ fs.mkdirSync(directory, { mode: 0o700 });
+}
+const env = {
+ HOME: home,
+ PATH: '/usr/bin:/bin',
+ TMPDIR: tmp,
+ CODEX_HOME: codexHome,
+};
+
+function run(command, args) {
+ const result = spawnSync(command, args, {
+ cwd: root,
+ env,
+ encoding: 'utf8',
+ timeout: 30_000,
+ maxBuffer: 1024 * 1024,
+ });
+ assert.ifError(result.error);
+ return result;
+}
+
+async function hashFile(file) {
+ const hash = createHash('sha256');
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
+ return hash.digest('hex');
+}
+
+async function handshake(executable) {
+ const child = spawn(executable, ['app-server'], {
+ cwd: root,
+ env,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ let buffered = '';
+ let stderrBytes = 0;
+ try {
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error('app-server 初始化超时')),
+ 15_000,
+ );
+ const finish = (error) => {
+ clearTimeout(timer);
+ if (error) reject(error);
+ else resolve();
+ };
+ child.on('error', finish);
+ child.on('exit', (code) =>
+ finish(new Error(`app-server 提前退出 ${code}`)),
+ );
+ child.stderr.on('data', (chunk) => {
+ stderrBytes += chunk.length;
+ if (stderrBytes > 1024 * 1024)
+ finish(new Error('app-server stderr 超限'));
+ });
+ child.stdout.on('data', (chunk) => {
+ buffered += chunk.toString('utf8');
+ if (buffered.length > 1024 * 1024)
+ return finish(new Error('app-server stdout 超限'));
+ let end;
+ while ((end = buffered.indexOf('\n')) >= 0) {
+ const line = buffered.slice(0, end);
+ buffered = buffered.slice(end + 1);
+ try {
+ const message = JSON.parse(line);
+ if (message.id !== 1) continue;
+ assert.ok(message.result?.userAgent, '初始化必须返回真实服务身份');
+ assert.equal(message.error, undefined);
+ child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
+ finish();
+ } catch (error) {
+ finish(error);
+ }
+ }
+ });
+ child.stdin.on('error', finish);
+ child.stdin.write(
+ `${JSON.stringify({
+ id: 1,
+ method: 'initialize',
+ params: {
+ clientInfo: {
+ name: 'agc_bundle_smoke',
+ title: 'AGC bundle smoke',
+ version: '1',
+ },
+ capabilities: { experimentalApi: true },
+ },
+ })}\n`,
+ );
+ });
+ } finally {
+ if (child.exitCode === null && child.signalCode === null) {
+ await new Promise((resolve) => {
+ const timer = setTimeout(() => child.kill('SIGKILL'), 3000);
+ child.once('exit', () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ child.kill('SIGTERM');
+ });
+ }
+ }
+}
+
+try {
+ fs.cpSync(source, app, { recursive: true });
+ const resources = path.join(app, 'Contents/Resources');
+ const bundle = path.join(resources, 'coding-agent/mac-native');
+ const executable = path.join(bundle, 'bin/codex');
+ const main = path.join(
+ app,
+ 'Contents/MacOS/genarrative-ai-game-creator-shell',
+ );
+ const manifest = JSON.parse(
+ fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'),
+ );
+ assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2');
+ assert.equal(
+ manifest.platform,
+ process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64',
+ );
+ assert.equal(manifest.version, 'codex-cli 0.147.0');
+ const components = [
+ 'bin/codex',
+ 'bin/codex-code-mode-host',
+ 'codex-path/rg',
+ 'codex-resources/zsh/bin/zsh',
+ 'codex-package.json',
+ ];
+ assert.deepEqual(Object.keys(manifest.files).sort(), [...components].sort());
+ for (const component of components) {
+ const file = path.join(bundle, component);
+ assert.equal(await hashFile(file), manifest.files[component], component);
+ if (component !== 'codex-package.json') {
+ fs.accessSync(file, fs.constants.X_OK);
+ const arch = run('/usr/bin/lipo', ['-archs', file]);
+ assert.equal(arch.status, 0, component);
+ assert.equal(
+ arch.stdout.trim(),
+ process.arch === 'arm64' ? 'arm64' : 'x86_64',
+ component,
+ );
+ }
+ }
+ assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
+ const plugin = path.join(resources, 'plugins/agc-cocos-editor');
+ for (const file of [
+ 'plugin.json',
+ 'src/entry.mjs',
+ 'panels/cocos-editor.html',
+ ]) {
+ assert.ok(fs.existsSync(path.join(plugin, file)), file);
+ }
+ const packageFiles = fs.readdirSync(resources, { recursive: true });
+ assert.ok(
+ !packageFiles.some((file) =>
+ /(^|\/)(\.env[^/]*|auth\.json|node_modules|target|\.git)(\/|$)|\.(exe|dll)$/.test(
+ file,
+ ),
+ ),
+ );
+ assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
+ assert.equal(
+ run(path.join(bundle, 'codex-path/rg'), ['--version']).status,
+ 0,
+ );
+ assert.equal(
+ run(path.join(bundle, 'codex-resources/zsh/bin/zsh'), ['--version']).status,
+ 0,
+ );
+
+ // 使用正式 AGC 查找/校验入口,而非只证明 sidecar 可以独立执行。
+ const status = run(main, ['--config-dir', config, '--llm-status']);
+ const statusText = `${status.stdout}\n${status.stderr}`;
+ assert.ok(!statusText.includes('Codex CLI 未安装'), statusText);
+ assert.ok(
+ statusText.includes('authentication-required'),
+ '隔离账号应仅被登录门禁拒绝',
+ );
+ await handshake(executable);
+
+ // 临时复制品缺少辅助程序时,正式入口必须拒绝内置程序;PATH 无全局 Codex 可兜底。
+ fs.renameSync(
+ path.join(bundle, 'bin/codex-code-mode-host'),
+ path.join(root, 'saved-code-mode-host'),
+ );
+ const broken = run(main, ['--config-dir', config, '--llm-status']);
+ assert.notEqual(broken.status, 0);
+ assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
+ console.log(
+ 'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝',
+ );
+ console.log(
+ '未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
+ );
+} finally {
+ fs.rmSync(root, { recursive: true, force: true });
+}
diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs
index fed45aa02..c0a748ad3 100644
--- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs
+++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs
@@ -724,6 +724,7 @@ function canvasAssetCall(agentId) {
assetKind: 'art-spritesheet',
assetLabel: '游戏首版核心美术素材',
replaceExisting: false,
+ sliceMode: 'connected-components',
});
}
@@ -2691,7 +2692,7 @@ function createDeterministicCanvasFixture(apiKey) {
'deterministic spritesheet fixture',
model: 'deterministic-canvas-v1',
provider: 'deterministic-loopback',
- sliceLayout: 'grid-2x2',
+ sliceMode: 'connected-components',
spritesheetResource: {
resourceId,
projectId,
diff --git a/apps/ai-game-creator-shell/scripts/dev-feature-flags.mjs b/apps/ai-game-creator-shell/scripts/dev-feature-flags.mjs
new file mode 100644
index 000000000..1158c4e69
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/dev-feature-flags.mjs
@@ -0,0 +1,20 @@
+/**
+ * `agc` 开发启动下发给 Vite 的客户端特性开关默认值。
+ *
+ * 开发态默认关闭客户端更新检查:`npm run agc` / `agc:serve` 启动的客户端不请求 OSS
+ * 更新清单,也不显示更新入口。需要联调更新流程时显式传
+ * `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1`;此处不覆盖已经显式配置的取值。
+ */
+const agcAppUpdateCheckEnvKey = 'VITE_AGC_ENABLE_APP_UPDATE_CHECK';
+
+function withAgcDevFeatureFlags(env = process.env) {
+ if (String(env[agcAppUpdateCheckEnvKey] ?? '').trim()) {
+ return env;
+ }
+ return {
+ ...env,
+ [agcAppUpdateCheckEnvKey]: '0',
+ };
+}
+
+export { agcAppUpdateCheckEnvKey, withAgcDevFeatureFlags };
diff --git a/apps/ai-game-creator-shell/scripts/release-oss.mjs b/apps/ai-game-creator-shell/scripts/release-oss.mjs
new file mode 100644
index 000000000..0e19ee81b
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/release-oss.mjs
@@ -0,0 +1,27 @@
+/**
+ * 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
+ * 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
+ */
+const redactedCredential = '';
+
+export function readReleaseDryRun(env = process.env) {
+ const value = env.AGC_RELEASE_DRY_RUN?.trim().toLowerCase();
+ return value === '1' || value === 'true';
+}
+
+function quoteArgument(value) {
+ return /[\s"']/u.test(value) ? JSON.stringify(value) : value;
+}
+
+export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
+ const parts = [binary, ...args, '--endpoint', endpoint];
+ if (credentials) {
+ parts.push(
+ '--access-key-id',
+ redactedCredential,
+ '--access-key-secret',
+ redactedCredential,
+ );
+ }
+ return parts.map(quoteArgument).join(' ');
+}
diff --git a/apps/ai-game-creator-shell/scripts/release-oss.test.mjs b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs
new file mode 100644
index 000000000..0e38efc9d
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs
@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { test } from 'node:test';
+
+import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
+
+test('dry run only accepts explicit truthy values', () => {
+ assert.equal(readReleaseDryRun({}), false);
+ assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '1' }), true);
+ assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: ' true ' }), true);
+ assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '0' }), false);
+ assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '' }), false);
+});
+
+test('printed upload command keeps arguments and hides credentials', () => {
+ const command = formatOssutilCommand({
+ binary: 'ossutil',
+ args: [
+ 'cp',
+ '--force',
+ '陶泥儿 0.1.48.exe',
+ 'oss://agc-dev/agc/dev-win/x.exe',
+ ],
+ endpoint: 'oss-rg-china-mainland.aliyuncs.com',
+ credentials: true,
+ });
+ assert.match(command, /^ossutil cp --force /u);
+ assert.match(command, /"陶泥儿 0\.1\.48\.exe"/u);
+ assert.match(command, /oss:\/\/agc-dev\/agc\/dev-win\/x\.exe/u);
+ assert.match(
+ command,
+ /--access-key-id --access-key-secret /u,
+ );
+});
+
+test('uploader gates every ossutil call behind the dry run switch', () => {
+ const source = readFileSync(
+ new URL('./release-upload.mjs', import.meta.url),
+ 'utf8',
+ );
+ assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
+ assert.match(source, /if \(dryRun\) \{/u);
+ assert.match(source, /dry-run:未写入任何 OSS 对象/u);
+});
diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs
index ee5af4452..d39b2fa9f 100644
--- a/apps/ai-game-creator-shell/scripts/release-upload.mjs
+++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs
@@ -1,6 +1,8 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
+import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
+
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
const endpoint =
process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
@@ -8,9 +10,9 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
throw new Error('OSS bucket 或 endpoint 配置无效');
}
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
+const dryRun = readReleaseDryRun();
-const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } =
- await import('./build-release.mjs');
+const { buildRelease } = await import('./build-release.mjs');
function runOssutil(args) {
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
@@ -19,6 +21,18 @@ function runOssutil(args) {
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
}
+ if (dryRun) {
+ // 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。
+ console.log(
+ `[dry-run] ${formatOssutilCommand({
+ binary,
+ args,
+ endpoint,
+ credentials: Boolean(accessKeyId),
+ })}`,
+ );
+ return;
+ }
const credentialArgs = accessKeyId
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
: [];
@@ -36,13 +50,40 @@ function runOssutil(args) {
if (result.status !== 0) process.exit(result.status ?? 1);
}
-await prepareReleaseVersion();
-runTauriBuild([]);
-const { artifact, manifestPath, manifest } = generateUpdateManifest();
-const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`;
+const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
+ await buildRelease(process.argv.slice(2));
+const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
-runOssutil(['cp', '--force', manifestPath, `oss://${bucket}/agc/latest.json`]);
+runOssutil([
+ 'cp',
+ '--force',
+ `${artifact}.sig`,
+ `oss://${bucket}/${artifactKey}.sig`,
+]);
+runOssutil([
+ 'cp',
+ '--force',
+ manifestPath,
+ `oss://${bucket}/agc/${channel}/latest.json`,
+]);
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
-console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/latest.json`);
+console.log(
+ `[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`,
+);
+if (legacyManifestPath) {
+ // 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。
+ runOssutil([
+ 'cp',
+ '--force',
+ legacyManifestPath,
+ `oss://${bucket}/agc/latest.json`,
+ ]);
+ console.log(
+ `[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`,
+ );
+}
+if (dryRun) {
+ console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
+}
diff --git a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs
index db7c8d42c..0bf51e6e6 100644
--- a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs
+++ b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs
@@ -3,6 +3,7 @@ import http from 'node:http';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
+import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs';
import { resolveAgcDevEndpoint, withAgcDevEndpointEnv } from './dev-port.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -104,7 +105,7 @@ const child = spawn(
],
{
cwd: appRoot,
- env: withAgcDevEndpointEnv(endpoint),
+ env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)),
stdio: 'inherit',
// Node 18.20+/20+/24 on Windows rejects spawning .cmd (npm.cmd) without a shell (EINVAL).
shell: true,
diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
index 9c5c9e54d..20cf4a1ea 100644
--- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
+++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
@@ -11,6 +11,7 @@ import {
stopWindowsProcessTree,
stopWindowsWorktreeProcesses,
} from '../../../scripts/dev-windows-process.mjs';
+import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs';
import {
agcVitePortEnvKey,
readAgcDevEndpoint,
@@ -978,7 +979,10 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
'--port',
String(endpoint.port),
],
- { cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) },
+ {
+ cwd: appRoot,
+ env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)),
+ },
);
}
diff --git a/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs b/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs
new file mode 100644
index 000000000..42fa9a5d4
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs
@@ -0,0 +1,113 @@
+import assert from 'node:assert/strict';
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { test } from 'node:test';
+import { setTimeout as delay } from 'node:timers/promises';
+import { fileURLToPath } from 'node:url';
+
+import { createServer, loadConfigFromFile, normalizePath } from 'vite';
+
+test(
+ 'AGC 排除 Rust 构建目录且保留源码与共享组件监听',
+ { timeout: 30_000 },
+ async () => {
+ const loaded = await loadConfigFromFile(
+ { command: 'serve', mode: 'development' },
+ fileURLToPath(new URL('../vite.config.ts', import.meta.url)),
+ );
+ assert.ok(loaded);
+ assert.notEqual(loaded.config.server?.watch, null);
+ assert.notEqual(loaded.config.server?.hmr, false);
+ assert.ok(
+ [loaded.config.server?.watch?.ignored]
+ .flat()
+ .includes('**/src-tauri/target/**'),
+ );
+
+ const fixture = await mkdtemp(join(tmpdir(), 'agc-vite-watch-'));
+ const root = join(fixture, 'apps', 'ai-game-creator-shell');
+ const source = join(root, 'src', 'main.js');
+ const css = join(root, 'src', 'styles.css');
+ const shared = join(fixture, 'packages', 'shared', 'src', 'component.js');
+ const target = join(root, 'src-tauri', 'target');
+ const artifact = join(target, 'debug', 'incremental', 'cache.bin');
+ let server;
+ try {
+ for (const file of [source, css, shared, artifact]) {
+ await mkdir(dirname(file), { recursive: true });
+ await writeFile(
+ file,
+ file === css ? 'body { color: red; }' : 'export default 1;',
+ );
+ }
+ // 使用真实 Vite watcher 和实际配置,仅将扫描根替换为小型夹具;
+ // 不加载业务插件、后端或原生窗口,也不扫描开发机上的大型 target。
+ server = await createServer({
+ configFile: false,
+ envFile: false,
+ root,
+ logLevel: 'silent',
+ server: {
+ watch: loaded.config.server?.watch,
+ middlewareMode: true,
+ hmr: false,
+ fs: { allow: [fixture] },
+ },
+ optimizeDeps: { noDiscovery: true, include: [] },
+ });
+ const waitForWatchedFile = async (file) => {
+ const normalized = normalizePath(file);
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if (
+ Object.entries(server.watcher.getWatched()).some(
+ ([directory, names]) =>
+ names.some(
+ (name) => normalizePath(join(directory, name)) === normalized,
+ ),
+ )
+ )
+ return;
+ await delay(50);
+ }
+ assert.fail(`源码必须仍被监听:${normalized}`);
+ };
+ await waitForWatchedFile(source);
+
+ // 真实模块转换应将 root 外的共享源码加入监听。
+ await server.transformRequest(`/@fs/${normalizePath(shared)}`);
+ for (const file of [source, css, shared]) {
+ const normalized = normalizePath(file);
+ await waitForWatchedFile(file);
+ const changed = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ server.watcher.off('change', onChange);
+ reject(new Error(`未收到源码变更:${normalized}`));
+ }, 5_000);
+ function onChange(path) {
+ if (normalizePath(path) !== normalized) return;
+ clearTimeout(timer);
+ server.watcher.off('change', onChange);
+ resolve();
+ }
+ server.watcher.on('change', onChange);
+ });
+ await writeFile(
+ file,
+ file === css ? 'body { color: blue; }' : 'export default 2;',
+ );
+ await changed;
+ }
+ const targetPath = normalizePath(target);
+ const targetDirectories = Object.keys(server.watcher.getWatched())
+ .map(normalizePath)
+ .filter(
+ (path) => path === targetPath || path.startsWith(`${targetPath}/`),
+ );
+ assert.deepEqual(targetDirectories, [], 'Rust target 不应创建目录监听器');
+ } finally {
+ await server?.close();
+ await rm(fixture, { recursive: true, force: true });
+ }
+ },
+);
diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock
index 7dd55a0f6..bc1227832 100644
--- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock
+++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock
@@ -814,6 +814,16 @@ dependencies = [
"url",
]
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -837,7 +847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
- "core-foundation",
+ "core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
@@ -850,7 +860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
- "core-foundation",
+ "core-foundation 0.10.1",
"libc",
]
@@ -1405,6 +1415,16 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1725,7 +1745,7 @@ dependencies = [
[[package]]
name = "genarrative-ai-game-creator-shell"
-version = "0.1.47"
+version = "0.1.67"
dependencies = [
"agent-runtime-core",
"axum",
@@ -1747,7 +1767,6 @@ dependencies = [
"oxc_parser",
"oxc_semantic",
"oxc_span",
- "percent-encoding",
"platform-agent",
"platform-llm",
"portable-pty",
@@ -1766,6 +1785,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-opener",
+ "tauri-plugin-updater",
"tempfile",
"tokio",
"toml 0.8.2",
@@ -1776,7 +1796,7 @@ dependencies = [
"url",
"uuid",
"windows-sys 0.61.2",
- "zip",
+ "zip 2.4.2",
]
[[package]]
@@ -2220,9 +2240,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
+ "system-configuration",
"tokio",
"tower-service",
"tracing",
+ "windows-registry",
]
[[package]]
@@ -2506,6 +2528,36 @@ dependencies = [
"windows-sys 0.45.0",
]
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.18",
+ "walkdir",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.118",
+]
+
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -2819,6 +2871,12 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "minisign-verify"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3234,6 +3292,18 @@ dependencies = [
"objc2-core-foundation",
]
+[[package]]
+name = "objc2-osa-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -3378,6 +3448,20 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "osakit"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+ "objc2-osa-kit",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "outref"
version = "0.5.2"
@@ -4360,15 +4444,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
+ "hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
+ "tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -4482,6 +4571,33 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "jni 0.22.4",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
+
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -4609,7 +4725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
- "core-foundation",
+ "core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -4952,6 +5068,22 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
[[package]]
name = "similar"
version = "2.7.0"
@@ -5175,6 +5307,27 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -5196,7 +5349,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
- "core-foundation",
+ "core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -5206,7 +5359,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
- "jni",
+ "jni 0.21.1",
"libc",
"log",
"ndk",
@@ -5239,6 +5392,17 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5262,7 +5426,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
- "jni",
+ "jni 0.21.1",
"libc",
"log",
"mime",
@@ -5477,6 +5641,39 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "tauri-plugin-updater"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
+dependencies = [
+ "base64 0.22.1",
+ "dirs",
+ "flate2",
+ "futures-util",
+ "http",
+ "infer",
+ "log",
+ "minisign-verify",
+ "osakit",
+ "percent-encoding",
+ "reqwest 0.13.4",
+ "rustls",
+ "semver",
+ "serde",
+ "serde_json",
+ "tar",
+ "tauri",
+ "tauri-plugin",
+ "tempfile",
+ "thiserror 2.0.18",
+ "time",
+ "tokio",
+ "url",
+ "windows-sys 0.60.2",
+ "zip 4.6.1",
+]
+
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -5487,7 +5684,7 @@ dependencies = [
"dpi",
"gtk",
"http",
- "jni",
+ "jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -5510,7 +5707,7 @@ checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c"
dependencies = [
"gtk",
"http",
- "jni",
+ "jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -6586,6 +6783,15 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "webpki-roots"
version = "1.0.9"
@@ -7192,7 +7398,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
- "jni",
+ "jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -7256,6 +7462,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
[[package]]
name = "yoke"
version = "0.8.3"
@@ -7437,6 +7653,18 @@ dependencies = [
"zopfli",
]
+[[package]]
+name = "zip"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "indexmap 2.14.0",
+ "memchr",
+]
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml
index 4dd833d85..5c3e8eb64 100644
--- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml
+++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "genarrative-ai-game-creator-shell"
-version = "0.1.47"
+version = "0.1.67"
edition = "2021"
publish = false
@@ -33,7 +33,11 @@ chromiumoxide = "0.9.1"
futures = "0.3"
getrandom = "0.3"
http = "1"
-image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
+# `tga` / `tiff` / `hdr` 只服务资源画布的只读预览:引擎图像容器(Cocos 的
+# .tga/.tif/.hdr 等)在浏览器里没有解码器,必须先在原生侧转码成 PNG 再送给前端。
+# 刻意不开 `exr`:它要求 `exr ^1.74.0`,当前依赖源只能到 1.73,打不开就先让
+# `.exr` 走「类型卡」而不是留一半解不出来的预览分支。
+image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "tga", "tiff", "hdr"] }
jsonschema = { version = "0.49.3", default-features = false }
oxc_allocator = "0.143.0"
oxc_ast = "0.143.0"
@@ -50,7 +54,6 @@ similar = "2.7"
platform-llm = { path = "../../../server-rs/crates/platform-llm" }
platform-agent = { path = "../../../server-rs/crates/platform-agent" }
portable-pty = "0.9"
-percent-encoding = "2"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
regex = "1"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
@@ -58,6 +61,7 @@ tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
tauri-plugin-opener = "2"
+tauri-plugin-updater = "2.11.0"
tempfile = "3"
toml = "0.8"
ttf-parser = "0.25.1"
diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs
index e9f6642e0..6bca6d67a 100644
--- a/apps/ai-game-creator-shell/src-tauri/build.rs
+++ b/apps/ai-game-creator-shell/src-tauri/build.rs
@@ -1,31 +1,18 @@
+#[path = "build_support/codex_bundle.rs"]
+mod codex_bundle;
#[path = "build_support/frontend_dist_guard.rs"]
mod frontend_dist_guard;
#[path = "build_support/runtime_prompt_bundle.rs"]
mod runtime_prompt_bundle;
-#[cfg(windows)]
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::path::PathBuf;
-#[cfg(windows)]
use std::io::{BufReader, Read};
-const BUNDLED_CODEX_CLI_VERSION: &str = "codex-cli 0.147.0";
-
-#[cfg(windows)]
-const BUNDLED_CODEX_FILES: [&str; 6] = [
- "bin/codex.exe",
- "bin/codex-code-mode-host.exe",
- "codex-path/rg.exe",
- "codex-resources/codex-command-runner.exe",
- "codex-resources/codex-windows-sandbox-setup.exe",
- "codex-package.json",
-];
-
-#[cfg(windows)]
fn sha256_file(path: &std::path::Path) -> Result {
let file = fs::File::open(path)?;
let mut reader = BufReader::new(file);
@@ -42,7 +29,15 @@ fn sha256_file(path: &std::path::Path) -> Result {
}
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
- #[cfg(windows)]
+ let target = env::var("TARGET").expect("Cargo TARGET");
+ println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
+ let Some(layout) = codex_bundle::for_target(&target) else {
+ assert!(
+ !target.contains("windows") && !target.contains("apple-darwin"),
+ "不支持的 Codex 随包目标:{target}"
+ );
+ return;
+ };
{
let app_root = manifest_dir
.parent()
@@ -51,24 +46,23 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
.parent()
.and_then(|apps_dir| apps_dir.parent())
.expect("AI 游戏创作应用必须位于仓库 apps 目录下");
- let source_candidates = [
- app_root.join(
- "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
- ),
- app_root.join(
- "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
- ),
- repo_root.join(
- "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
- ),
- repo_root.join(
- "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
- ),
- ];
+ let package = layout.npm_package;
+ let source_candidates = [app_root, repo_root]
+ .into_iter()
+ .flat_map(|root| {
+ [
+ root.join(format!("node_modules/@openai/{package}/vendor/{target}")),
+ root.join(format!(
+ "node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}"
+ )),
+ ]
+ })
+ .collect::>();
let source = source_candidates
.iter()
.find(|path| {
- BUNDLED_CODEX_FILES
+ layout
+ .files
.iter()
.all(|relative| path.join(relative).is_file())
})
@@ -83,14 +77,26 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
.join(";")
)
});
- let target_dir = manifest_dir.join("resources/codex/win-x64");
+ let metadata: serde_json::Value = serde_json::from_slice(
+ &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
+ )
+ .expect("Codex 原生包元数据无效");
+ codex_bundle::validate_package_metadata(&metadata, &target, layout)
+ .unwrap_or_else(|error| panic!("{error}"));
+ let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
let notice = target_dir.join("NOTICE.md");
+ if target.contains("apple-darwin") {
+ let source_notice =
+ manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md");
+ stage_plugin_file(&source_notice, ¬ice);
+ println!("cargo:rerun-if-changed={}", source_notice.display());
+ }
if !notice.is_file() {
panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display());
}
fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败");
let mut file_hashes = serde_json::Map::new();
- for relative in BUNDLED_CODEX_FILES {
+ for relative in layout.files {
let source_path = source.join(relative);
let target_path = target_dir.join(relative);
if let Some(parent) = target_path.parent() {
@@ -104,15 +110,23 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
if !target_matches_source {
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
}
+ // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
+ fs::set_permissions(
+ &target_path,
+ fs::metadata(&source_path)
+ .expect("读取组件权限失败")
+ .permissions(),
+ )
+ .expect("保留内置 Codex CLI 组件权限失败");
file_hashes.insert(
relative.to_string(),
serde_json::Value::String(source_sha256),
);
}
let manifest = serde_json::json!({
- "schemaVersion": "genarrative-codex-sidecar.v2",
- "platform": "win32-x64",
- "version": BUNDLED_CODEX_CLI_VERSION,
+ "schemaVersion": codex_bundle::SCHEMA,
+ "platform": layout.platform,
+ "version": codex_bundle::CLI_VERSION,
"files": file_hashes,
});
let manifest_path = target_dir.join("manifest.json");
@@ -126,7 +140,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
{
fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败");
}
- for relative in BUNDLED_CODEX_FILES {
+ for relative in layout.files {
println!("cargo:rerun-if-changed={}", source.join(relative).display());
}
println!("cargo:rerun-if-changed={}", notice.display());
@@ -256,8 +270,11 @@ fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
///
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
/// Cargo target 目录或 node_modules。
-#[cfg(windows)]
fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
+ let target = env::var("TARGET").expect("Cargo TARGET");
+ if !target.contains("windows") && !target.contains("apple-darwin") {
+ return;
+ }
let repo_root = manifest_dir
.parent()
.and_then(|app_root| app_root.parent())
@@ -266,6 +283,10 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
.to_path_buf();
let workspace = repo_root.join("plugins");
let destination_root = manifest_dir.join("resources/plugins");
+ // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。
+ if destination_root.exists() {
+ std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败");
+ }
std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
let entries = match std::fs::read_dir(&workspace) {
Ok(entries) => entries,
@@ -273,6 +294,13 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
};
for entry in entries.flatten() {
let plugin_root = entry.path();
+ assert!(
+ !entry
+ .file_type()
+ .expect("读取插件目录类型失败")
+ .is_symlink(),
+ "插件工作区不允许符号链接"
+ );
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
continue;
}
@@ -287,17 +315,18 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
std::path::PathBuf::from("panels"),
std::path::PathBuf::from("native/payload"),
] {
+ if relative == std::path::Path::new("native/payload") && !target.contains("windows") {
+ continue;
+ }
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;
- };
+ let bytes = std::fs::read(source)
+ .unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display()));
if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
return;
}
@@ -307,7 +336,6 @@ fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
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,
@@ -316,10 +344,17 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
for entry in entries.flatten() {
let target = destination.join(entry.file_name());
let path = entry.path();
+ assert!(
+ !entry
+ .file_type()
+ .expect("读取插件文件类型失败")
+ .is_symlink(),
+ "插件资源不允许符号链接"
+ );
if path.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
- if matches!(name.as_ref(), "target" | "node_modules" | ".git") {
+ if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") {
continue;
}
std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
@@ -331,12 +366,14 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
if name.contains(".test.") {
continue;
}
+ if name.starts_with('.') {
+ 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;
@@ -345,6 +382,3 @@ fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
.expect("创建插件资源目录失败");
std::fs::copy(source, destination).expect("复制插件资源失败");
}
-
-#[cfg(not(windows))]
-fn stage_plugin_workspace(_manifest_dir: &std::path::Path) {}
diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs
new file mode 100644
index 000000000..1811f6a25
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs
@@ -0,0 +1,133 @@
+//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
+
+pub const VERSION: &str = "0.147.0";
+pub const CLI_VERSION: &str = "codex-cli 0.147.0";
+pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
+
+#[derive(Clone, Copy, Debug)]
+pub struct Layout {
+ pub platform: &'static str,
+ pub npm_package: &'static str,
+ pub directory: &'static str,
+ pub executable: &'static str,
+ pub files: &'static [&'static str],
+}
+
+const WINDOWS_FILES: &[&str] = &[
+ "bin/codex.exe",
+ "bin/codex-code-mode-host.exe",
+ "codex-path/rg.exe",
+ "codex-resources/codex-command-runner.exe",
+ "codex-resources/codex-windows-sandbox-setup.exe",
+ "codex-package.json",
+];
+const MAC_FILES: &[&str] = &[
+ "bin/codex",
+ "bin/codex-code-mode-host",
+ "codex-path/rg",
+ "codex-resources/zsh/bin/zsh",
+ "codex-package.json",
+];
+
+pub fn for_target(target: &str) -> Option {
+ match target {
+ "x86_64-pc-windows-msvc" => Some(Layout {
+ platform: "win32-x64",
+ npm_package: "codex-win32-x64",
+ directory: "win-x64",
+ executable: "bin/codex.exe",
+ files: WINDOWS_FILES,
+ }),
+ "aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout {
+ platform: if target.starts_with("aarch64") {
+ "darwin-arm64"
+ } else {
+ "darwin-x64"
+ },
+ npm_package: if target.starts_with("aarch64") {
+ "codex-darwin-arm64"
+ } else {
+ "codex-darwin-x64"
+ },
+ directory: "mac-native",
+ executable: "bin/codex",
+ files: MAC_FILES,
+ }),
+ _ => None,
+ }
+}
+
+pub fn validate_package_metadata(
+ metadata: &serde_json::Value,
+ target: &str,
+ layout: Layout,
+) -> Result<(), String> {
+ if metadata["layoutVersion"] == 1
+ && metadata["version"] == VERSION
+ && metadata["target"] == target
+ && metadata["entrypoint"] == layout.executable
+ && metadata["resourcesDir"] == "codex-resources"
+ && metadata["pathDir"] == "codex-path"
+ {
+ Ok(())
+ } else {
+ Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn platform_layouts_are_explicit_and_preserve_upstream_components() {
+ let mac = for_target("aarch64-apple-darwin").unwrap();
+ assert_eq!(mac.platform, "darwin-arm64");
+ assert_eq!(mac.npm_package, "codex-darwin-arm64");
+ assert!(mac.files.contains(&"codex-resources/zsh/bin/zsh"));
+ assert!(mac.files.contains(&"bin/codex-code-mode-host"));
+ assert!(!mac.files.iter().any(|file| file.ends_with(".exe")));
+ let intel = for_target("x86_64-apple-darwin").unwrap();
+ assert_eq!(intel.platform, "darwin-x64");
+ assert_eq!(intel.npm_package, "codex-darwin-x64");
+ let windows = for_target("x86_64-pc-windows-msvc").unwrap();
+ assert_eq!(windows.directory, "win-x64");
+ assert_eq!(windows.files.len(), 6);
+ assert!(windows
+ .files
+ .contains(&"codex-resources/codex-windows-sandbox-setup.exe"));
+ assert!(for_target("universal-apple-darwin").is_none());
+ assert!(for_target("aarch64-pc-windows-msvc").is_none());
+ assert!(for_target("x86_64-unknown-linux-gnu").is_none());
+ }
+
+ #[test]
+ fn metadata_rejects_version_architecture_and_layout_drift() {
+ let target = "aarch64-apple-darwin";
+ let layout = for_target(target).unwrap();
+ let valid = serde_json::json!({
+ "layoutVersion": 1,
+ "version": VERSION,
+ "target": target,
+ "entrypoint": "bin/codex",
+ "resourcesDir": "codex-resources",
+ "pathDir": "codex-path",
+ });
+ assert!(validate_package_metadata(&valid, target, layout).is_ok());
+ for (key, value) in [
+ ("layoutVersion", serde_json::json!(2)),
+ ("version", serde_json::json!("0.0.0")),
+ ("target", serde_json::json!("x86_64-apple-darwin")),
+ ("entrypoint", serde_json::json!("bin/codex.exe")),
+ ("resourcesDir", serde_json::json!("../private")),
+ ("pathDir", serde_json::json!(null)),
+ ] {
+ let mut invalid = valid.clone();
+ invalid[key] = value;
+ assert!(
+ validate_package_metadata(&invalid, target, layout).is_err(),
+ "{key}"
+ );
+ }
+ }
+}
diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json
index 43257763a..b62ed9e70 100644
--- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json
+++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json
@@ -14,13 +14,13 @@
"allow": [
{ "url": "https://dev.genarrative.world/api/*" },
{ "url": "https://www.genarrative.world/api/*" },
- { "url": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*" },
{ "url": "https://*/api/*" },
{ "url": "http://localhost:*/*" },
{ "url": "http://127.0.0.1:*/*" }
]
},
"opener:default",
+ "updater:default",
"dialog:allow-open",
"dialog:allow-save"
]
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md
index 84fb51e53..5614ef9d4 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md
@@ -10,11 +10,11 @@ Let the client derive projections from real disk changes and trusted tool result
## Workflow
1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media.
-2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
+2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, code, or engine asset such as a Cocos `.glb`/`.prefab`/`.anim`/`.mtl`/`.plist`/`.texture`) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
-6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
+6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, waits for the accepted operation, downloads and registers the completed local asset, and preserves the operation for recovery when the remote result is not yet known.
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md
index 63ddd9a82..7374d1149 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md
@@ -8,7 +8,7 @@ The client projects three distinct facts:
Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance.
-`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
+`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP/TGA/TIFF/HDR images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS/PCM audio, MP4/WEBM/MOV video, recognized text documents, recognized source-code files, and engine (Cocos Creator) assets such as `.glb`/`.gltf`/`.fbx`/`.mesh`/`.skeleton` models, `.anim`/`.animation`/`.animgraph`/`.animgraphvari`/`.animask` animation clips, `.scene`/`.fire`/`.prefab`/`.tmx`/`.terrain`, `.mtl`/`.material`/`.pmtl`/`.effect`/`.chunk`, `.plist`/`.labelatlas`/`.atlas`/`.fnt`/`.pac`, and engine containers such as `.texture`/`.cubemap`/`.rt`/`.dbbin`/`.bin`/`.skel`/`.psd`/`.znt`/`.exr`. `asset.list`/`kind` filtering classifies models as `model` and undecodable engine containers as `binary`; both are discovery categories, not manifest kinds. Registered engine assets are read-only previews on the resource canvas — models render a thumbnail, serialized assets show a structure summary, and containers that the client cannot decode show a type card. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`.
@@ -16,4 +16,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images.
-`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
+`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated request. Ordinary account mode maps the External v1 shaped route to `/api/editor/images/background-removals`; ExternalDeveloper mode uses `/api/external/v1/editor/images/background-removals`. Mode and colour are part of request identity. After acceptance, the client polls the authenticated generation status route, downloads the completed media, and commits it to the local manifest. If completion is unknown, it retains the same local operation for recovery; it never retries with a new identity or exposes internal worker details.
+
+After an interrupted call, inspect `agc_list_registered_assets.pendingOperations`. Calling `agc_remove_background` again with the same source, name, mode, and colour resumes the matching pending operation. A submission marked `reconciliation-required` needs client-side reconciliation and cannot be automatically resumed. Do not change parameters to bypass a pending task. A queued receipt, fixed progress value, or absent local file does not establish that the background-removal provider is waiting in a queue; report only the observed state.
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md
index c9de909a6..b6e070a85 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md
@@ -14,6 +14,9 @@ Implement the user's actual game request in the current project as an npm-manage
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
+ - **画布居中只能由一处负责。** 使用 `Phaser.Scale.FIT` 与 `autoCenter: Phaser.Scale.CENTER_BOTH` 时,canvas 的直接父容器应使用尺寸明确的普通块布局,不再对同一 canvas 叠加 Grid/Flex 居中、`place-items: center`、自动外边距或居中 transform。Phaser 自动计算的 margin 与 CSS 居中叠加会使竖屏画面向右偏移。
+ - 若决定由 CSS 居中,则显式使用 `autoCenter: Phaser.Scale.NO_CENTER`,由 CSS 独立完成定位;外围页面可以继续使用 Grid/Flex,限制只针对同一 canvas 的重复定位。
+ - 出现偏移先检查游戏自身的 CSS 与 Phaser scale 配置,不添加 AGC 预览容器固定偏移补偿。修改布局后重新构建 dist,在桌面、移动及窗口 resize 后检查 canvas 相对游戏父容器居中(误差不超过 1 CSS px)、画面完整且无意外滚动条;不能仅凭 build 成功宣称布局通过。
6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art.
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md
index 77bdf1f9c..5eeea8e90 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md
@@ -7,5 +7,7 @@
- Score, steps, health, timer, or other core state updates consistently.
- Restart restores all state and does not duplicate timers, animation loops, or event listeners.
- Desktop and mobile layouts keep the core scene visible without accidental document scrolling.
+- 画布的缩放与居中由 Phaser 或 CSS 中的一方独立负责。`FIT + CENTER_BOTH` 不与同一 canvas 父容器的 Grid/Flex 居中、自动外边距或居中 transform 叠加;使用 CSS 居中时关闭 Phaser 自动居中(`NO_CENTER`)。
+- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不用修改 AGC 预览位置掩盖。
- HUD and overlays reserve space and do not cover essential interactive content.
- Requested Taonier art is visibly integrated into the core experience when available.
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json
index 1f7b5c124..d1a51e9af 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
- "version": "2026-08-26.18",
+ "version": "2026-08-26.25",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -63,7 +63,7 @@
"agents/openai.yaml",
"references/platform-art-contract.md"
],
- "sha256": "ff3e1645a35fc9bff1ef255aa7bdc2a9729843d68729589b6f2670c84b8130ec"
+ "sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c"
},
{
"name": "agc-web-game-development",
@@ -80,7 +80,7 @@
"agents/openai.yaml",
"references/game-quality-checklist.md"
],
- "sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd"
+ "sha256": "e122d8f3a6d986b594b95c971754d68197bf7896912fa8267d44a7aa129a57ba"
},
{
"name": "agc-browser-playtest",
@@ -123,7 +123,7 @@
"agents/openai.yaml",
"references/projection-contract.md"
],
- "sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1"
+ "sha256": "247787975944ce8b21d7c879c39c60ec13608056cff9426ac374c9299937d475"
}
]
}
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md
index d652b4436..2c737d022 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md
@@ -19,9 +19,26 @@ image, UI design image, or publication material; use `agc_edit_image` for an
edit of an existing registered image; use `taonier_prepare_game_art` only for
the complete game-art package and its canonical slices.
-When `agc_generate_image` is used with `kind="art-spritesheet"`, pass
-`sliceMode="connected-components"` (the default alpha-connectivity splitter)
-or `sliceMode="grid"` with `gridX` and `gridY` (1-32 each). The selected mode is carried
+With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"`
+generate the subject on a solid-colour background and automatically matte it
+away afterwards, producing transparent-background results; write the prompt
+for the subject only, never for a scene. `kind="image"` keeps the rendered
+frame without extra processing.
+
+When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
+required and has no default, so decide it explicitly:
+
+- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user
+ or brief actually names equal grid cells, fixed slots, or a concrete
+ column/row count; those dimensions must come from that requirement.
+- Use `sliceMode="connected-components"` for free-form sheets, an open number of
+ subjects, or a request for one sheet; constrain the subject count with
+ `sliceCount` instead of inventing grid dimensions.
+
+Never assume `2x2` or any other grid to express "four kinds of assets", never
+pass `gridX`/`gridY` together with `connected-components`, and never pass
+`sliceMode` for another `kind`. The client rejects a missing, contradictory, or
+misapplied declaration instead of choosing for you. The selected mode is carried
through the client request and returned result; do not infer it from the number
of slices.
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md
index bf0b74481..603b8077b 100644
--- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md
+++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md
@@ -15,7 +15,8 @@
- On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request.
- `postprocess-failed-source-preserved` means the complete provider source remains usable, but the requested transparent derivative is absent.
- `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent.
-- For direct `agc_generate_image` spritesheet requests, `sliceMode="connected-components"` selects alpha-connectivity detection and `sliceMode="grid"` uses the caller-provided `gridX` and `gridY` (1-32 each). The client preserves the selected mode and grid dimensions in the request identity and result metadata.
+- For direct `agc_generate_image` spritesheet requests, `sliceMode` is required and has no default: `connected-components` selects alpha-connectivity detection, while `grid` uses the caller-provided `gridX` and `gridY` (1-32 each) and is only correct when the requirement names equal grid cells, fixed slots, or a concrete column/row count. `connected-components` must not carry `gridX`/`gridY`, and `sliceMode` must not be sent for another `kind`; the client rejects a missing, contradictory, or misapplied declaration instead of choosing a mode. The client preserves the selected mode and grid dimensions in the request identity and result metadata.
+- The client-owned standard art package declares `sliceMode="connected-components"` with `sliceCount=4` because its four canonical slices are mapped to fixed usage paths: the platform must return exactly four slices or fail with an actionable `422` naming the recognized count, and the client refuses to write a usage manifest whose slice count is not exactly four. A `sliceMode` or grid-dimension echo that disagrees with the request also fails closed before local commit.
- General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim.
- `assetPaths` contains the complete package paths. `slicePaths` contains only slices that the client downloaded, validated, and registered with their platform source identities.
- `resources` contains only safe registered identity fields: local asset/path/kind/media type, Canvas project/resource/asset/task IDs, and reference resource IDs. It never exposes prompts, models, provider routes, absolute paths, URLs, tokens, cookies, or API keys.
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md b/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md
new file mode 100644
index 000000000..affea34fb
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md
@@ -0,0 +1,14 @@
+# 内置 Codex CLI
+
+本安装包包含锁定版本 Codex CLI 0.147.0 的 macOS 原生组件。
+
+Codex CLI 按 Apache License 2.0 分发,源码与许可证见
+https://github.com/openai/codex。
+
+组件来自项目锁定的 `@openai/codex` 原生 npm 依赖,保留上游的
+`bin/codex`、`bin/codex-code-mode-host`、`codex-path/rg`、
+`codex-resources/zsh/bin/zsh` 和 `codex-package.json` 相对布局。
+原生依赖中的 ripgrep 与 zsh 按各自上游许可证分发:
+https://github.com/BurntSushi/ripgrep 和 https://www.zsh.org/。
+
+安装包不包含 API Key、登录状态、用户配置或项目数据。
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs
index e64b321dd..ced9b0a98 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs
@@ -21,6 +21,7 @@ mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
mod direct_thread_manager;
+mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
@@ -40,7 +41,8 @@ use codex_app_server::*;
pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at,
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
- direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView,
+ direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
+ direct_thread_id_for_project, DirectTurnCancelView,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -55,6 +57,7 @@ pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*;
+pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs
index 901467454..98f347d30 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs
@@ -20,6 +20,29 @@ pub(crate) fn direct_codex_canonical_project_identity(
))
}
+/// 项目根目录在 Thread Manager 里的线程身份。
+///
+/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到
+/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有
+/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。
+///
+/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见
+/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把
+/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个
+/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。
+pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String {
+ let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else {
+ return root.to_string_lossy().into_owned();
+ };
+ canonical_root
+ .to_str()
+ .and_then(|value| value.strip_prefix(r"\\?\"))
+ .map(std::path::Path::new)
+ .unwrap_or(canonical_root.as_path())
+ .to_string_lossy()
+ .into_owned()
+}
+
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec {
#[cfg(unix)]
{
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
index 457185fc0..b33ebcf21 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
@@ -13,8 +13,11 @@ use uuid::Uuid;
mod direct_project_history_wire;
use direct_project_history_wire::build_direct_project_history_injection_params;
mod direct_project_identity;
-pub(crate) use direct_project_identity::direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands;
use direct_project_identity::*;
+pub(crate) use direct_project_identity::{
+ direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands,
+ direct_thread_id_for_project,
+};
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
@@ -577,6 +580,17 @@ enum CodexTurnEvent {
item_id: String,
delta: String,
},
+ /// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。
+ ///
+ /// `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)与
+ /// `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)都进这条通道:前者是
+ /// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed`
+ /// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为
+ /// 活动状态,不下发正文。
+ ReasoningDelta {
+ item_id: String,
+ delta: String,
+ },
IntermediateText(String),
Activity(&'static str),
Item {
@@ -584,7 +598,7 @@ enum CodexTurnEvent {
params: serde_json::Value,
},
Request {
- event_type: &'static str,
+ kind: DirectThreadRequestKind,
params: serde_json::Value,
},
RawItem(serde_json::Value),
@@ -754,23 +768,43 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
direct_codex_safe_activity_for_item(item_type)
}
-/// Project an app-server item into the small public payload carried by the
-/// DirectProject event queue. Full item contents are persisted in JSONL and
-/// must not be forwarded through the runtime event stream.
-fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value {
- serde_json::json!({
- "itemType": item
- .get("type")
- .and_then(serde_json::Value::as_str)
- .unwrap_or("unknown"),
- })
+/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。
+///
+/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。
+fn direct_thread_event_item(
+ root: &std::path::Path,
+ item: &serde_json::Value,
+) -> Option {
+ direct_thread_item_from_value(root, item, direct_tool_call_now_ms())
}
-fn direct_thread_item_id(item: &serde_json::Value) -> Option {
- item.get("id")
- .and_then(serde_json::Value::as_str)
- .filter(|value| !value.is_empty())
- .map(str::to_string)
+/// 运行态条目投影:Codex 回显的用户消息整条跳过。
+///
+/// AGC 是 DirectProject 用户消息的唯一来源——回合入口在 `turn/start` 之前就把
+/// `direct-codex:{clientTurnId}:user` 落盘,并下发同一条运行态条目。app-server 之后
+/// 回显的 `userMessage`(`item/started`)和原始 `role=user` item
+/// (`rawResponseItem/completed`)只用于观察和关联:落盘侧已由
+/// `append_direct_project_history_item_at` 过滤,运行态必须用同一口径过滤,否则前端会多
+/// 渲染出两条没有历史对应的孤儿用户气泡,各自开出一个耗时 0 秒的假回合,直到重进页面才
+/// 恢复(那时读的是同一份已过滤的 `project.jsonl`)。
+fn direct_thread_visible_item(
+ root: &std::path::Path,
+ item: &serde_json::Value,
+) -> Option {
+ if is_direct_project_codex_user_item(item) {
+ return None;
+ }
+ direct_thread_event_item(root, item)
+}
+
+/// AGC 预写的 canonical 用户条目 id:`direct-codex:{clientTurnId}:user`。
+///
+/// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 +
+/// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id;
+/// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。
+fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option {
+ let client_turn_id = client_turn_id.trim();
+ (!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user"))
}
fn direct_codex_command_is_game_verification(command: &str) -> bool {
@@ -987,19 +1021,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
}
}
-fn direct_codex_request_event_type(method: &str) -> Option<&'static str> {
+fn direct_codex_request_event_type(method: &str) -> Option {
match method {
"item/fileChange/requestApproval"
| "item/commandExecution/requestApproval"
- | "item/permissions/requestApproval" => Some("approval.requested"),
- "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"),
+ | "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested),
+ "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => {
+ Some(DirectThreadRequestKind::AskRequested)
+ }
_ => None,
}
}
-fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> {
+fn direct_codex_resolution_event_type(method: &str) -> Option {
match method {
- "serverRequest/resolved" => Some("request.resolved"),
+ "serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved),
_ => None,
}
}
@@ -1066,12 +1102,71 @@ fn should_emit_direct_codex_activity(
true
}
+/// 思考正文增量事件:`item/reasoning/summaryTextDelta`(reasoning item 的 `summary`)与
+/// `item/reasoning/textDelta`(它的 `content`)都下发正文,不降级成"preparing 活动文本"。
+///
+/// 这里只是把"完成时才看到"提前为"边生成边看到":两段文本本来就随 `item/completed`
+/// 落进 `project.jsonl` 并展示给用户,可见范围没有放宽;未识别的 plan 文本与命令输出
+/// 仍然只降级为活动状态。运行态读取器和 `direct_codex_notification_event` 共用这一处,
+/// 避免两份实现再次分叉(分叉时就出现过"生产路径从不产生 ReasoningDelta")。
+fn direct_codex_reasoning_delta_event(
+ method: &str,
+ params: &serde_json::Value,
+) -> Option {
+ if !matches!(
+ method,
+ "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta"
+ ) {
+ return None;
+ }
+ params
+ .get("delta")
+ .and_then(serde_json::Value::as_str)
+ .filter(|value| !value.is_empty())
+ .map(|delta| CodexTurnEvent::ReasoningDelta {
+ item_id: params
+ .get("itemId")
+ .and_then(serde_json::Value::as_str)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string)
+ .unwrap_or_else(|| "direct-missing-item".to_string()),
+ delta: delta.to_string(),
+ })
+}
+
+/// DirectProject 的流式正文在进入 Thread Manager 前就完成脱敏;前端不得接触原始增量。
+fn direct_codex_thread_delta_event(
+ root: &std::path::Path,
+ item_id: String,
+ kind: DirectThreadDeltaKind,
+ delta: &str,
+) -> DirectThreadEvent {
+ DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
+}
+
+/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
+///
+/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
+/// 再做一遍——两份实现分叉过一次,结果是生产路径从不产生 `ReasoningDelta`。
+///
+/// Preparing notifications may carry private plan/reasoning text; expose only the safe
+/// activity category. Other categories may retain their bounded, redacted intermediate text.
fn direct_codex_notification_event(
method: &str,
params: &serde_json::Value,
intermediate_text: Option,
safe_activity: Option<&'static str>,
+ turn_id: &str,
) -> Option {
+ if let Some(event) = direct_codex_reasoning_delta_event(method, params) {
+ return Some(event);
+ }
+ if let Some(kind) = direct_codex_resolution_event_type(method) {
+ return Some(CodexTurnEvent::Request {
+ kind,
+ params: params.clone(),
+ });
+ }
let (activity, intermediate_text) = match (&intermediate_text, safe_activity) {
(Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None),
_ => (safe_activity, intermediate_text),
@@ -1086,14 +1181,19 @@ fn direct_codex_notification_event(
"item/agentMessage/delta" => params
.get("delta")
.and_then(serde_json::Value::as_str)
- .filter(|value| !value.trim().is_empty())
+ .filter(|value| !value.is_empty())
.map(|delta| {
let item_id = params
.get("itemId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
- .unwrap_or_else(|| "direct-missing-item".to_string());
+ .unwrap_or_else(|| {
+ eprintln!(
+ "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id"
+ );
+ format!("direct-missing-item:{turn_id}")
+ });
CodexTurnEvent::AgentMessageDelta {
item_id,
delta: delta.to_string(),
@@ -1109,6 +1209,13 @@ fn direct_codex_notification_event(
.cloned()
.unwrap_or(serde_json::Value::Null),
)),
+ method if direct_codex_request_event_type(method).is_some() => {
+ Some(CodexTurnEvent::Request {
+ kind: direct_codex_request_event_type(method)
+ .expect("request kind checked above"),
+ params: params.clone(),
+ })
+ }
_ => Some(CodexTurnEvent::Terminal(params.clone())),
}
}
@@ -2810,6 +2917,9 @@ impl CodexAppServerConnection {
.map(str::trim)
.filter(|turn_id| !turn_id.is_empty())
.map(str::to_string);
+ // 用户消息在这一轮开始前就落盘;把它作为本回合的第一条运行态条目下发,
+ // 前端就能用同一个 itemId 把"本地乐观气泡"和"历史里的同一条"合成一条。
+ let mut direct_persisted_user_item: Option = None;
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let current_prompt = direct_codex_current_user_prompt(&request).trim();
if current_prompt.is_empty() {
@@ -2827,12 +2937,13 @@ impl CodexAppServerConnection {
None => direct_project_local_message_item(
"user",
current_prompt,
- Some(&format!("direct-codex:{client_turn_id}:user")),
+ direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref(),
)
.map_err(platform_llm::LlmError::InvalidRequest)?,
};
append_direct_project_user_message_at(history_root, &user_item)
.map_err(platform_llm::LlmError::InvalidRequest)?;
+ direct_persisted_user_item = Some(user_item);
}
}
let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
@@ -2944,20 +3055,34 @@ impl CodexAppServerConnection {
}
};
turn_start_guard.armed = false;
- let direct_thread_id = history_root.to_string_lossy().into_owned();
+ let direct_thread_id = direct_thread_id_for_project(history_root);
+ // 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断
+ // 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实
+ // 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取
+ // 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。
+ let direct_turn_started_at_ms = direct_tool_call_now_ms();
+ // 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用
+ // `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。
+ let direct_turn_user_item_id = direct_persisted_user_item
+ .as_ref()
+ .and_then(direct_thread_item_identity);
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(
&direct_thread_id,
- DirectThreadRawEventDraft {
- event_type: "turn.started".to_string(),
- turn_id: turn_id.clone(),
- item_id: None,
- payload: serde_json::json!({
- "threadId": thread_id,
- "turnId": turn_id,
- }),
- },
+ DirectThreadEvent::turn_started(direct_turn_started_at_ms)
+ .with_user_item_id(direct_turn_user_item_id.as_deref()),
);
+ if let Some(user_item) = direct_persisted_user_item.as_ref() {
+ if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
+ // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
+ // 保留更早的真实发送时间,不用此事件时间覆盖它。
+ let user_item_at = entry_item.at();
+ append_direct_thread_event(
+ &direct_thread_id,
+ DirectThreadEvent::item_completed(entry_item, user_item_at),
+ );
+ }
+ }
}
let mut receiver = self.register_turn(&turn_id).await;
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
@@ -2975,6 +3100,9 @@ impl CodexAppServerConnection {
game_creator_codex_app_server_hard_timeout_ms(self.inner.workspace_mode, timeout_ms);
let hard_deadline =
tokio::time::Instant::now() + std::time::Duration::from_millis(hard_timeout_ms);
+ // 记录 app-server 是否已经给出终态:`turn/completed` 只在收到被动 terminal
+ // 事件时下发,超时 / 传输中断 / 协议错误都走不到那里。
+ let mut terminal_recorded = false;
let collect = async {
let mut final_text = None;
let mut streamed_text = String::new();
@@ -3012,12 +3140,14 @@ impl CodexAppServerConnection {
direct_project_history.observe_delta(&item_id, &delta);
append_direct_thread_event(
&direct_thread_id,
- DirectThreadRawEventDraft {
- event_type: "item.delta".to_string(),
- turn_id: turn_id.clone(),
- item_id: Some(item_id.clone()),
- payload: serde_json::json!({ "delta": delta.clone() }),
- },
+ // 事件自足:增量自带 item 身份与正文类别(正文 / 思考),
+ // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。
+ direct_codex_thread_delta_event(
+ history_root,
+ item_id.clone(),
+ DirectThreadDeltaKind::Message,
+ &delta,
+ ),
);
}
streamed_text.push_str(&delta);
@@ -3048,6 +3178,19 @@ impl CodexAppServerConnection {
});
}
}
+ Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => {
+ if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
+ append_direct_thread_event(
+ &direct_thread_id,
+ direct_codex_thread_delta_event(
+ history_root,
+ item_id,
+ DirectThreadDeltaKind::Reasoning,
+ &delta,
+ ),
+ );
+ }
+ }
Some(CodexTurnEvent::IntermediateText(text)) => {
if let Some(observer) = direct_observer.as_deref_mut() {
observer(DirectCodexTurnObservation::IntermediateText(text));
@@ -3060,6 +3203,7 @@ impl CodexAppServerConnection {
"rawResponseItem/completed 缺少 item".to_string(),
));
}
+ let entry_item = direct_thread_visible_item(history_root, &item);
let history_root = history_root.to_path_buf();
let history_item = item.clone();
tokio::task::spawn_blocking(move || {
@@ -3073,19 +3217,20 @@ impl CodexAppServerConnection {
})?
.map_err(platform_llm::LlmError::InvalidRequest)?;
direct_project_history.complete_item(&item);
- let item_id = direct_thread_item_id(&item);
- append_direct_thread_event(
- &direct_thread_id,
- DirectThreadRawEventDraft {
- event_type: "item.completed".to_string(),
- turn_id: turn_id.clone(),
- item_id,
- payload: serde_json::json!({}),
- },
- );
+ if let Some(entry_item) = entry_item {
+ // `rawResponseItem/completed` 不带阶段时间,宿主处理到这条
+ // 通知的钟就是该阶段唯一可证明的时间。
+ append_direct_thread_event(
+ &direct_thread_id,
+ DirectThreadEvent::item_completed(
+ entry_item,
+ direct_tool_call_now_ms(),
+ ),
+ );
+ }
}
}
- Some(CodexTurnEvent::Request { event_type, params }) => {
+ Some(CodexTurnEvent::Request { kind, params }) => {
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let request_id = params
.get("requestId")
@@ -3095,14 +3240,7 @@ impl CodexAppServerConnection {
.map(str::to_string);
append_direct_thread_event(
&direct_thread_id,
- DirectThreadRawEventDraft {
- event_type: event_type.to_string(),
- turn_id: turn_id.clone(),
- item_id: None,
- payload: request_id
- .map(|id| serde_json::json!({ "requestId": id }))
- .unwrap_or_else(|| serde_json::json!({})),
- },
+ DirectThreadEvent::request(kind, request_id),
);
}
}
@@ -3213,16 +3351,24 @@ impl CodexAppServerConnection {
&& self.inner.workspace_mode
== CodexAppServerWorkspaceMode::DirectProject
{
- let item_id = direct_thread_item_id(item);
- append_direct_thread_event(
- &direct_thread_id,
- DirectThreadRawEventDraft {
- event_type: "item.started".to_string(),
- turn_id: turn_id.clone(),
- item_id,
- payload: direct_thread_item_started_payload(item),
- },
- );
+ if let Some(entry_item) =
+ direct_thread_visible_item(history_root, item)
+ {
+ // `item/started` 的通知层带 `startedAtMs`:这是工具真正
+ // 开始的阶段时间,优先于条目展示时间与宿主钟。
+ append_direct_thread_event(
+ &direct_thread_id,
+ DirectThreadEvent::item_started(
+ entry_item,
+ direct_thread_item_event_at_ms(
+ ¶ms,
+ item,
+ false,
+ direct_tool_call_now_ms(),
+ ),
+ ),
+ );
+ }
}
}
}
@@ -3262,14 +3408,20 @@ impl CodexAppServerConnection {
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
&& matches!(status, "completed" | "interrupted" | "failed")
{
+ terminal_recorded = true;
+ // 终态时间:`durationMs` 与宿主记下的毫秒起点都可靠时才派生,
+ // 否则取宿主处理这条终态的钟;上游秒级 `completedAt` 一律不用。
append_direct_thread_event(
&direct_thread_id,
- DirectThreadRawEventDraft {
- event_type: "turn.completed".to_string(),
- turn_id: turn_id.clone(),
- item_id: None,
- payload: serde_json::json!({ "status": status }),
- },
+ DirectThreadEvent::turn_completed(
+ status.to_string(),
+ direct_thread_turn_completed_at_ms(
+ turn,
+ Some(direct_turn_started_at_ms),
+ direct_tool_call_now_ms(),
+ ),
+ )
+ .with_user_item_id(direct_turn_user_item_id.as_deref()),
);
}
match status {
@@ -3310,7 +3462,30 @@ impl CodexAppServerConnection {
}
}
};
- let text = match collect.await {
+ let collect_result = collect.await;
+ if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
+ && !terminal_recorded
+ {
+ // 兜底终态:没有这条事件,前端的"最新回合是否在跑"会一直停在运行中。
+ // 事件日志本身不完美(见 `ThreadState::lifecycle_anchor` 的 TODO),
+ // 但"这一轮有没有结束"必须有终态事件。
+ append_direct_thread_event(
+ &direct_thread_id,
+ DirectThreadEvent::turn_completed(
+ if collect_result.is_ok() {
+ "completed"
+ } else {
+ "failed"
+ }
+ .to_string(),
+ // 这条兜底终态没有对应的 app-server 终态载荷,只能取宿主处理它的钟,
+ // 不能拿最后一次正文或工具更新时间当回合终点。
+ direct_tool_call_now_ms(),
+ )
+ .with_user_item_id(direct_turn_user_item_id.as_deref()),
+ );
+ }
+ let text = match collect_result {
Ok(text) => text,
Err(error) => {
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
@@ -3505,6 +3680,15 @@ enum DirectCodexTurnCancelTarget {
/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见
/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然
/// 保持原拒绝语义,什么都不释放。
+///
+/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的
+/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。
+/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。
+fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent {
+ DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms())
+ .with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref())
+}
+
pub(crate) fn cancel_direct_codex_turn_at(
root: &Path,
client_turn_id: Option<&str>,
@@ -3557,6 +3741,12 @@ pub(crate) fn cancel_direct_codex_turn_at(
DirectCodexTurnCancelTarget::Stale(reason) => {
let released =
release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?;
+ // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器),
+ // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。
+ append_direct_thread_event(
+ &direct_thread_id_for_project(root),
+ direct_stale_cancel_turn_completed_event(&released),
+ );
Ok(DirectTurnCancelView {
outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(),
message: format!(
@@ -3948,12 +4138,18 @@ async fn read_game_creator_codex_app_server_stdout(
let Some(turn_id) = turn_id else {
continue;
};
- if let Some(activity) = safe_activity {
- let last_activity = last_direct_activity_by_turn
- .entry(turn_id.clone())
- .or_default();
- if !should_emit_direct_codex_activity(last_activity, activity) {
- continue;
+ // 思考正文走正文通道,不参与 `preparing` 活动的降级与节流:活动节流按类别抑制
+ // 连续的 preparing,逐段思考正文会被整段吃掉——这正是生产路径此前从不产生
+ // `ReasoningDelta` 的原因。正文仍沿用下面的正文节流,避免重复 chunk 反复入队。
+ let is_reasoning_delta = direct_codex_reasoning_delta_event(method, ¶ms).is_some();
+ if !is_reasoning_delta {
+ if let Some(activity) = safe_activity {
+ let last_activity = last_direct_activity_by_turn
+ .entry(turn_id.clone())
+ .or_default();
+ if !should_emit_direct_codex_activity(last_activity, activity) {
+ continue;
+ }
}
}
if let Some(text) = intermediate_text.as_deref() {
@@ -3964,66 +4160,14 @@ async fn read_game_creator_codex_app_server_stdout(
continue;
}
}
- let event = if let Some(event_type) = direct_codex_resolution_event_type(method) {
- CodexTurnEvent::Request { event_type, params }
- } else if let Some(activity) = safe_activity {
- // Preparing notifications may carry private plan/reasoning text;
- // expose only the safe activity category. Other categories may
- // retain their bounded, redacted intermediate text below.
- if activity == "preparing" {
- CodexTurnEvent::Activity(activity)
- } else if let Some(text) = intermediate_text {
- CodexTurnEvent::IntermediateText(text)
- } else {
- CodexTurnEvent::Activity(activity)
- }
- } else if let Some(text) = intermediate_text {
- CodexTurnEvent::IntermediateText(text)
- } else {
- match method {
- "item/agentMessage/delta" => {
- let Some(delta) = params
- .get("delta")
- .and_then(serde_json::Value::as_str)
- .filter(|value| !value.is_empty())
- else {
- continue;
- };
- let item_id = params
- .get("itemId")
- .and_then(serde_json::Value::as_str)
- .filter(|value| !value.is_empty())
- .map(str::to_string)
- .unwrap_or_else(|| {
- eprintln!(
- "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id"
- );
- format!("direct-missing-item:{turn_id}")
- });
- CodexTurnEvent::AgentMessageDelta {
- item_id,
- delta: delta.to_string(),
- }
- }
- "item/started" | "item/completed" => CodexTurnEvent::Item {
- completed: method == "item/completed",
- params,
- },
- "rawResponseItem/completed" => CodexTurnEvent::RawItem(
- params
- .get("item")
- .cloned()
- .unwrap_or(serde_json::Value::Null),
- ),
- method if direct_codex_request_event_type(method).is_some() => {
- CodexTurnEvent::Request {
- event_type: direct_codex_request_event_type(method)
- .expect("request event type checked above"),
- params,
- }
- }
- _ => CodexTurnEvent::Terminal(params),
- }
+ let Some(event) = direct_codex_notification_event(
+ method,
+ ¶ms,
+ intermediate_text,
+ safe_activity,
+ &turn_id,
+ ) else {
+ continue;
};
let sender = if method == "turn/completed" {
last_direct_activity_by_turn.remove(&turn_id);
@@ -4542,11 +4686,29 @@ mod tests {
"arguments": { "path": "game/index.html", "token": "secret" },
"result": { "content": "large output" }
});
- assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
+ // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。
+ let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item");
+ assert_eq!(projected.item_id(), "item-1");
+ let payload = serde_json::to_value(&projected).expect("payload");
assert_eq!(
- direct_thread_item_started_payload(&item),
- serde_json::json!({ "itemType": "mcpToolCall" })
+ payload.get("itemType").and_then(serde_json::Value::as_str),
+ Some("mcpToolCall")
);
+ assert_eq!(
+ payload.get("itemId").and_then(serde_json::Value::as_str),
+ Some("item-1")
+ );
+ // 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。
+ assert!(payload.get("toolCall").is_none(), "{payload}");
+ assert!(payload.get("title").is_none(), "{payload}");
+ assert!(payload.get("summary").is_none(), "{payload}");
+ assert!(payload.get("kind").is_none(), "{payload}");
+ // 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。
+ let arguments = payload
+ .get("arguments")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default();
+ assert!(!arguments.contains("\"secret\""), "{payload}");
}
#[test]
@@ -4738,18 +4900,50 @@ mod tests {
);
}
+ /// 判据:思考正文走 `ReasoningDelta` 正文通道(与 ADR 一致),不再被 `preparing`
+ /// 活动降级吃掉;plan 文本与命令输出仍然只降级成活动类别,不带正文。
#[test]
- fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() {
- let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" });
- assert!(matches!(
- direct_codex_notification_event(
+ fn direct_reasoning_deltas_stream_text_while_plan_and_command_output_stay_activity() {
+ let reasoning = serde_json::json!({ "itemId": "reasoning-1", "delta": "思考正文" });
+ for method in [
+ "item/reasoning/summaryTextDelta",
+ "item/reasoning/textDelta",
+ ] {
+ assert!(
+ matches!(
+ direct_codex_reasoning_delta_event(method, &reasoning),
+ Some(CodexTurnEvent::ReasoningDelta { item_id, delta })
+ if item_id == "reasoning-1" && delta == "思考正文"
+ ),
+ "{method} 必须下发明文思考增量"
+ );
+ // 运行态读取器用的就是这一个分类函数,不能再走「preparing 活动」降级。
+ assert!(
+ matches!(
+ direct_codex_notification_event(
+ method,
+ &reasoning,
+ Some("思考正文".to_string()),
+ Some("preparing"),
+ "turn-1",
+ ),
+ Some(CodexTurnEvent::ReasoningDelta { .. })
+ ),
+ "{method} 在通知分类里不能降级成活动"
+ );
+ }
+ assert!(
+ direct_codex_reasoning_delta_event(
"item/reasoning/textDelta",
- &reasoning,
- Some("hidden reasoning must not leak".to_string()),
- Some("preparing"),
- ),
- Some(CodexTurnEvent::Activity("preparing"))
- ));
+ &serde_json::json!({ "delta": "" }),
+ )
+ .is_none(),
+ "空增量不产生正文事件"
+ );
+ assert!(
+ direct_codex_reasoning_delta_event("turn/plan/updated", &reasoning).is_none(),
+ "非 reasoning 通知不进正文通道"
+ );
let plan = serde_json::json!({ "explanation": "private plan text must not leak" });
assert!(matches!(
@@ -4758,6 +4952,7 @@ mod tests {
&plan,
Some("private plan text must not leak".to_string()),
Some("preparing"),
+ "turn-1",
),
Some(CodexTurnEvent::Activity("preparing"))
));
@@ -4769,11 +4964,216 @@ mod tests {
&command_output,
None,
Some("command-exec"),
+ "turn-1",
),
Some(CodexTurnEvent::Activity("command-exec"))
));
}
+ #[test]
+ fn direct_thread_message_and_reasoning_deltas_are_sanitized_before_enqueue() {
+ let root = std::path::Path::new("/workspace/direct-project");
+ let raw = "key=sk-abcdefghijklmnop project=/workspace/direct-project/assets/a.png private=/root/secret.txt";
+ for kind in [
+ DirectThreadDeltaKind::Message,
+ DirectThreadDeltaKind::Reasoning,
+ ] {
+ let event = direct_codex_thread_delta_event(root, "item-1".to_string(), kind, raw);
+ let wire = serde_json::to_string(&event).expect("serialize direct thread delta");
+ assert!(
+ !wire.contains("sk-abcdefghijklmnop"),
+ "不得泄漏密钥:{wire}"
+ );
+ assert!(
+ !wire.contains("/root/secret.txt"),
+ "不得泄漏绝对路径:{wire}"
+ );
+ assert!(
+ wire.contains("assets/a.png"),
+ "项目内绝对路径应归一成相对路径:{wire}"
+ );
+ }
+ }
+
+ /// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。
+ ///
+ /// 参数:method / params / 正文候选 / 安全活动类别 / turnId。
+ #[test]
+ fn direct_notification_classification_covers_the_reader_branches() {
+ let empty = serde_json::json!({});
+ let resolved = serde_json::json!({ "requestId": "request-1" });
+ assert!(matches!(
+ direct_codex_notification_event(
+ "serverRequest/resolved",
+ &resolved,
+ None,
+ None,
+ "turn-1"
+ ),
+ Some(CodexTurnEvent::Request {
+ kind: DirectThreadRequestKind::RequestResolved,
+ ..
+ })
+ ));
+ let approval = serde_json::json!({ "requestId": "request-2" });
+ assert!(matches!(
+ direct_codex_notification_event(
+ "item/fileChange/requestApproval",
+ &approval,
+ None,
+ None,
+ "turn-1",
+ ),
+ Some(CodexTurnEvent::Request {
+ kind: DirectThreadRequestKind::ApprovalRequested,
+ ..
+ })
+ ));
+ assert!(matches!(
+ direct_codex_notification_event("item/started", &empty, None, None, "turn-1"),
+ Some(CodexTurnEvent::Item {
+ completed: false,
+ ..
+ })
+ ));
+ assert!(matches!(
+ direct_codex_notification_event(
+ "rawResponseItem/completed",
+ &serde_json::json!({ "item": { "type": "reasoning" } }),
+ None,
+ None,
+ "turn-1",
+ ),
+ Some(CodexTurnEvent::RawItem(_))
+ ));
+ // 空正文的 assistant 增量既不产事件,也不产 Terminal 兜底。
+ assert!(direct_codex_notification_event(
+ "item/agentMessage/delta",
+ &serde_json::json!({ "delta": "" }),
+ None,
+ None,
+ "turn-1",
+ )
+ .is_none());
+ assert!(matches!(
+ direct_codex_notification_event(
+ "item/agentMessage/delta",
+ &serde_json::json!({ "delta": "正文" }),
+ None,
+ None,
+ "turn-1",
+ ),
+ Some(CodexTurnEvent::AgentMessageDelta { delta, .. }) if delta == "正文"
+ ));
+ assert!(matches!(
+ direct_codex_notification_event("turn/completed", &empty, None, None, "turn-1"),
+ Some(CodexTurnEvent::Terminal(_))
+ ));
+ }
+
+ /// 阶段时间取自**通知层**字段,形状照抄 codex-cli 0.147 / 0.155 的 v2 协议 schema:
+ /// `item/started` 带 `startedAtMs`、`item/completed` 带 `completedAtMs`(毫秒),
+ /// `turn/completed` 带 `turn.startedAt` / `turn.completedAt`(秒)与 `turn.durationMs`(毫秒)。
+ /// 分类函数把 params 原样交给事件级 `at` 的投影函数,所以字段位置必须在这里钉住;
+ /// 回合边界的秒字段按"不用"锁在这里,避免以后有人再把秒级截断当 0.1 秒精度。
+ #[test]
+ fn direct_lifecycle_stage_times_come_from_notification_params() {
+ let started = serde_json::json!({
+ "threadId": "thread-1",
+ "turnId": "turn-1",
+ "startedAtMs": 1_700_000_000_123u64,
+ "item": {"id": "call-1", "type": "commandExecution", "command": "ls"},
+ });
+ let completed = serde_json::json!({
+ "threadId": "thread-1",
+ "turnId": "turn-1",
+ "completedAtMs": 1_700_000_001_500u64,
+ "item": {"id": "call-1", "type": "commandExecution", "command": "ls"},
+ });
+ for (method, params, expected_at_ms) in [
+ ("item/started", &started, 1_700_000_000_123u64),
+ ("item/completed", &completed, 1_700_000_001_500u64),
+ ] {
+ let Some(CodexTurnEvent::Item {
+ completed,
+ params: event_params,
+ }) = direct_codex_notification_event(method, params, None, None, "turn-1")
+ else {
+ panic!("{method} 必须分类成条目生命周期事件");
+ };
+ let item = event_params.get("item").expect("item payload");
+ assert_eq!(
+ direct_thread_item_event_at_ms(&event_params, item, completed, 9_999),
+ expected_at_ms,
+ "{method} 必须用通知层的阶段时间,而不是宿主钟"
+ );
+ }
+
+ let terminal = serde_json::json!({
+ "threadId": "thread-1",
+ "turn": {
+ "id": "turn-1",
+ "items": [],
+ "status": "completed",
+ "startedAt": 1_700_000_000i64,
+ "completedAt": 1_700_000_042i64,
+ },
+ });
+ let Some(CodexTurnEvent::Terminal(params)) =
+ direct_codex_notification_event("turn/completed", &terminal, None, None, "turn-1")
+ else {
+ panic!("turn/completed 必须分类成终态事件");
+ };
+ let turn = params.get("turn").unwrap_or(¶ms);
+ assert_eq!(
+ direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999),
+ 9_999,
+ "上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟"
+ );
+ let with_duration = serde_json::json!({
+ "id": "turn-1",
+ "items": [],
+ "status": "completed",
+ "startedAt": 1_700_000_000i64,
+ "completedAt": 1_700_000_042i64,
+ "durationMs": 42_500u64,
+ });
+ assert_eq!(
+ direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999),
+ 1_700_000_043_000,
+ "durationMs + 宿主高精度起点才派生结束"
+ );
+ }
+
+ /// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId
+ /// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。
+ #[test]
+ fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() {
+ let event = direct_stale_cancel_turn_completed_event("turn-0001");
+ assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user"));
+ assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间");
+ assert!(matches!(
+ event,
+ DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted"
+ ));
+
+ for missing in ["", " "] {
+ let event = direct_stale_cancel_turn_completed_event(missing);
+ assert_eq!(
+ event.user_item_id(),
+ None,
+ "拿不到 clientTurnId 时不得编造开口条目身份"
+ );
+ }
+
+ // canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。
+ assert_eq!(
+ direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(),
+ Some("direct-codex:turn-0001:user")
+ );
+ assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None);
+ }
+
fn test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
custom_enabled: false,
@@ -5135,6 +5535,50 @@ mod tests {
assert_ne!(first_identity, second_identity);
}
+ /// 判据:线程 id 只由路径决定,不受 manifest 可读性影响。
+ ///
+ /// 变异验证:线程 id 走 `direct_codex_canonical_project_identity`(旧实现)时,manifest
+ /// 读不到会让非 canonical 的调用方路径退回"原始字符串",订阅与回合事件因此落在两条线程上。
+ #[cfg(unix)]
+ #[test]
+ fn direct_project_thread_id_ignores_manifest_readability() {
+ use std::os::unix::fs::symlink;
+
+ let temp = tempfile::tempdir().expect("temp dir");
+ let project = temp.path().join("thread-id-project");
+ init_local_game_project_at(&project, "thread-id-project", "线程身份项目")
+ .expect("initialize project");
+ let stable_link = temp.path().join("current-project");
+ symlink(&project, &stable_link).expect("project symlink");
+
+ let canonical = direct_thread_id_for_project(&project);
+ assert_eq!(
+ canonical,
+ project
+ .canonicalize()
+ .expect("canonical project")
+ .to_string_lossy(),
+ "线程 id 就是 canonical 路径"
+ );
+ assert_eq!(
+ direct_thread_id_for_project(&stable_link),
+ canonical,
+ "符号链接必须归一到同一个线程 id"
+ );
+
+ // manifest 暂时读不到(项目刚创建 / 正被替换)不能让线程 id 变样。
+ std::fs::remove_file(project.join(".agent/manifest.json")).expect("remove manifest");
+ assert!(
+ direct_codex_canonical_project_identity(&project).is_err(),
+ "前提:manifest 读不到时权威身份确实会失败"
+ );
+ assert_eq!(
+ direct_thread_id_for_project(&stable_link),
+ canonical,
+ "manifest 读失败不能把线程 id 退回调用方原始字符串"
+ );
+ }
+
#[test]
fn direct_project_pool_identity_changes_when_manifest_identity_is_replaced_in_place() {
let temp = tempfile::tempdir().expect("temp dir");
@@ -6298,6 +6742,249 @@ while IFS= read -r line; do :; done
assert_eq!(connection.inner.threads.lock().await.len(), 1);
}
+ /// 判据:生产读取器不再把思考正文降级成 `preparing` 活动。
+ ///
+ /// fixture 刻意不发 `turn/started` 等先导活动,所以"活动节流"这条退路不存在:
+ /// 一旦读取器把 `item/reasoning/*Delta` 归回活动通道,observations 里就会出现
+ /// `Activity("preparing")`。同时明文思考只走 DirectProject 正文通道,
+ /// 不得漏进旧的运行态 observation。
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn codex_app_server_streams_reasoning_deltas_without_activity_fallback() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let temp = tempfile::tempdir().expect("temp dir");
+ let executable = temp.path().join("fake-codex-app-server-reasoning-delta");
+ std::fs::write(
+ &executable,
+ r#"#!/bin/sh
+IFS= read -r initialize
+case "$initialize" in *'"method":"initialize"'*) ;; *) exit 51 ;; esac
+printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}'
+IFS= read -r initialized
+case "$initialized" in *'"method":"initialized"'*) ;; *) exit 52 ;; esac
+IFS= read -r thread_start
+case "$thread_start" in *'"method":"thread/start"'*) ;; *) exit 53 ;; esac
+printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}'
+IFS= read -r turn_start
+case "$turn_start" in *'"method":"turn/start"'*) ;; *) exit 54 ;; esac
+printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}'
+printf '%s\n' '{"method":"item/reasoning/textDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"reasoning-1","delta":"SECRET_REASONING_TEXT"}}'
+printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":[]}"}}'
+printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[]}"}}}'
+printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}'
+while IFS= read -r line; do :; done
+"#,
+ )
+ .expect("write fake app-server");
+ let mut permissions = std::fs::metadata(&executable)
+ .expect("fake metadata")
+ .permissions();
+ permissions.set_mode(0o700);
+ std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server");
+
+ let llm = test_llm();
+ let connection =
+ CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str())
+ .await
+ .expect("spawn fake app-server");
+ let mut observations = Vec::new();
+ let mut observer = |observation| observations.push(observation);
+ connection
+ .run_turn_with_direct_observer(
+ &test_snapshot(),
+ &llm,
+ tool_request(),
+ None,
+ Some(&mut observer),
+ None,
+ )
+ .await
+ .expect("run fake app-server turn");
+ drop(observer);
+ assert!(
+ !observations.iter().any(|observation| matches!(
+ observation,
+ DirectCodexTurnObservation::Activity("preparing")
+ )),
+ "思考增量必须走 ReasoningDelta 正文通道,不能降级成 preparing 活动"
+ );
+ assert!(
+ !format!("{observations:?}").contains("SECRET_REASONING_TEXT"),
+ "明文思考不得漏进运行态 observation"
+ );
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn direct_project_turn_does_not_forward_codex_user_echo_as_chat_items() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let temp = tempfile::tempdir().expect("temp dir");
+ let project = temp.path().join("direct-user-echo-project");
+ crate::init_local_game_project_at(&project, "direct-user-echo", "回显过滤")
+ .expect("init project");
+ let executable = temp.path().join("fake-codex-app-server-direct-user-echo");
+ std::fs::write(
+ &executable,
+ r#"#!/bin/sh
+while IFS= read -r line; do
+ id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
+ case "$line" in
+ *'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;;
+ *'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;;
+ *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;;
+ *'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;;
+ *'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;;
+ *'"method":"turn/start"'*)
+ printf '{"id":%s,"result":{"turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}\n' "$id"
+ printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}'
+ printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-user-echo-1","type":"userMessage","clientId":"turn-0001","content":[{"type":"text","text":"请创建菜单"}]}}}'
+ printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-raw-user-echo-1","type":"message","role":"user","content":[{"type":"input_text","text":"请创建菜单"}]}}}'
+ printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-echo","turnId":"turn-echo","itemId":"item-echo-1","delta":"好的"}}'
+ printf '%s\n' '{"method":"item/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"agentMessage","text":"好的"}}}'
+ printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"message","role":"assistant","content":[{"type":"output_text","text":"好的"}]}}}'
+ printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"completed"}}}'
+ ;;
+ esac
+done
+"#,
+ )
+ .expect("write fake app-server");
+ let mut permissions = std::fs::metadata(&executable)
+ .expect("fake metadata")
+ .permissions();
+ permissions.set_mode(0o700);
+ std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server");
+
+ let llm = test_llm();
+ let credential = CodexAppServerCredential::AppDataKey {
+ fingerprint: "fixture-credential".to_string(),
+ };
+ let connection =
+ CodexAppServerConnection::spawn_with_executable_and_credential_at_workspace(
+ &llm,
+ &credential,
+ executable.as_os_str(),
+ Some(&project),
+ CodexAppServerWorkspaceMode::DirectProject,
+ )
+ .await
+ .expect("spawn direct-project app-server");
+
+ let user_item = serde_json::json!({
+ "type": "message",
+ "role": "user",
+ "id": "direct-codex:turn-0001:user",
+ "content": [{"type": "input_text", "text": "请创建菜单"}]
+ });
+ let thread_id = direct_thread_id_for_project(&project);
+ let bootstrap = crate::agent::subscribe_direct_thread(&thread_id);
+ assert!(
+ bootstrap.events.is_empty(),
+ "订阅发生在回合之前,bootstrap 必须为空"
+ );
+ // 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的
+ // 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。
+ let _active_invocation =
+ crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001")
+ .expect("enter direct invocation");
+ let mut observer = |_observation| {};
+ connection
+ .run_turn_with_direct_observer_and_history(
+ &test_snapshot(),
+ &llm,
+ LlmRunRequest::single_turn("系统", "请创建菜单"),
+ Some(&project),
+ Some("turn-0001"),
+ Some(&user_item),
+ None,
+ Some(&mut observer),
+ None,
+ )
+ .await
+ .expect("run direct-project turn");
+ drop(observer);
+
+ let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id)
+ .expect("consume events");
+ // 回合起止必须与开口用户条目同源:前端在「只有锚点 + 历史、运行态为空」的回合里靠这个
+ // 身份把边界认领给同一条用户条目,缺了它就只能隐藏未知用时。
+ let lifecycle_user_item_ids = consumed
+ .events
+ .iter()
+ .filter(|event| {
+ matches!(
+ event,
+ DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. }
+ )
+ })
+ .map(DirectThreadEvent::user_item_id)
+ .collect::>();
+ assert_eq!(
+ lifecycle_user_item_ids,
+ vec![
+ Some("direct-codex:turn-0001:user"),
+ Some("direct-codex:turn-0001:user"),
+ ],
+ "turn.started / turn.completed 都要带本轮开口用户条目的 canonical itemId"
+ );
+ let mut user_items = Vec::new();
+ let mut assistant_items = Vec::new();
+ for event in &consumed.events {
+ let item = match event {
+ DirectThreadEvent::ItemStarted { item, .. }
+ | DirectThreadEvent::ItemCompleted { item, .. } => item,
+ _ => continue,
+ };
+ match item {
+ DirectThreadItem::Message {
+ item_id,
+ role,
+ text,
+ ..
+ } if role.as_str() == "user" => {
+ user_items.push((item_id.clone(), text.clone()));
+ }
+ DirectThreadItem::Message { text, .. } if !text.trim().is_empty() => {
+ assistant_items.push(text.clone());
+ }
+ _ => {}
+ }
+ }
+ assert_eq!(
+ user_items,
+ vec![(
+ "direct-codex:turn-0001:user".to_string(),
+ "请创建菜单".to_string()
+ )],
+ "Codex 回显的用户消息不得再作为聊天条目下发,否则前端会渲染出多余的孤儿用户气泡"
+ );
+ assert_eq!(
+ assistant_items,
+ vec!["好的".to_string()],
+ "assistant 正文仍必须按同一 itemId 下发一次"
+ );
+ let history = read_direct_project_history_items_at(&project).expect("read history");
+ let history_user_items = history
+ .iter()
+ .filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("user"))
+ .cloned()
+ .collect::>();
+ assert!(
+ history
+ .iter()
+ .any(|item| item.get("id").and_then(serde_json::Value::as_str)
+ == Some("item-echo-1")),
+ "assistant 与工具条目仍必须落盘:{history:?}"
+ );
+ assert_eq!(
+ history_user_items,
+ vec![user_item],
+ "回显的用户消息也不得落盘,历史里只能有 AGC 自己那条"
+ );
+ }
+
#[cfg(unix)]
#[tokio::test]
async fn direct_home_app_server_uses_read_only_protocol_and_rejects_file_change_items() {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
index 863b8cdad..79e3aedd1 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
@@ -5,18 +5,10 @@ use std::process::Stdio;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
+#[path = "../../build_support/codex_bundle.rs"]
+mod codex_bundle;
+
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
-const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "coding-agent/win-x64/bin/codex.exe";
-const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str =
- "coding-agent/win-x64/manifest.json";
-const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [
- "bin/codex.exe",
- "bin/codex-code-mode-host.exe",
- "codex-path/rg.exe",
- "codex-resources/codex-command-runner.exe",
- "codex-resources/codex-windows-sandbox-setup.exe",
- "codex-package.json",
-];
const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024;
@@ -38,11 +30,11 @@ fn game_creator_codex_cli_executable_candidates_for(
path: Option<&std::ffi::OsStr>,
) -> Vec {
let mut candidates = Vec::new();
+ if let Some(bundled) = game_creator_bundled_codex_cli_path(resource_dir) {
+ candidates.push(bundled);
+ }
#[cfg(windows)]
{
- if let Some(resource_dir) = resource_dir {
- candidates.push(resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH));
- }
fn append_native_npm_candidates(candidates: &mut Vec, npm_root: &Path) {
let vendor_root = npm_root
.join("node_modules")
@@ -109,52 +101,71 @@ fn game_creator_codex_cli_executable_candidates() -> Vec {
}
fn game_creator_bundled_resource_dir() -> Option {
+ let executable = std::env::current_exe().ok()?;
+ game_creator_bundled_resource_dir_for(&executable)
+}
+
+fn game_creator_bundled_resource_dir_for(executable: &Path) -> Option {
#[cfg(windows)]
{
- std::env::current_exe()
- .ok()
- .and_then(|path| path.parent().map(Path::to_path_buf))
+ executable.parent().map(Path::to_path_buf)
}
- #[cfg(not(windows))]
+ #[cfg(target_os = "macos")]
{
+ let macos = executable.parent()?;
+ let contents = macos.parent()?;
+ // 只接受真正的 app bundle 结构,开发态不从任意相邻目录加载程序。
+ if macos.file_name()? != "MacOS"
+ || contents.file_name()? != "Contents"
+ || contents.parent()?.extension()? != "app"
+ {
+ return None;
+ }
+ Some(contents.join("Resources"))
+ }
+ #[cfg(not(any(windows, target_os = "macos")))]
+ {
+ let _ = executable;
None
}
}
fn game_creator_bundled_codex_cli_path(resource_dir: Option<&Path>) -> Option {
- resource_dir.map(|resource_dir| resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH))
+ let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET"))?;
+ Some(
+ resource_dir?
+ .join("coding-agent")
+ .join(layout.directory)
+ .join(layout.executable),
+ )
}
fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result {
+ let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET"))
+ .ok_or_else(|| "当前平台不支持内置 Codex CLI".to_string())?;
let bundle_root = executable
.parent()
.and_then(Path::parent)
.ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?;
- let manifest_path = bundle_root.join(
- Path::new(GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH)
- .file_name()
- .expect("bundled Codex manifest file name"),
- );
+ let manifest_path = bundle_root.join("manifest.json");
let manifest = std::fs::read_to_string(&manifest_path)
.map_err(|_| "内置 Codex CLI 缺少完整性清单".to_string())
.and_then(|value| {
serde_json::from_str::(&value)
.map_err(|_| "内置 Codex CLI 完整性清单无效".to_string())
})?;
- if manifest.schema_version != "genarrative-codex-sidecar.v2"
- || manifest.platform != "win32-x64"
+ if manifest.schema_version != codex_bundle::SCHEMA
+ || manifest.platform != layout.platform
|| manifest.version.trim().is_empty()
- || GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES
- .iter()
- .any(|relative| {
- manifest.files.get(*relative).map_or(true, |hash| {
- hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit())
- })
+ || layout.files.iter().any(|relative| {
+ manifest.files.get(*relative).map_or(true, |hash| {
+ hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit())
})
+ })
{
return Err("内置 Codex CLI 完整性清单不受支持".to_string());
}
- for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES {
+ for relative in layout.files {
let path = bundle_root.join(relative);
let bytes = std::fs::read(&path).map_err(|_| {
format!(
@@ -163,7 +174,7 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result;
+}
+
+fn ensure_design_panic_hook() {
+ static ONCE: std::sync::Once = std::sync::Once::new();
+ ONCE.call_once(|| {
+ let previous = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ if let Ok(Some(root)) = DESIGN_PANIC_ROOT.try_with(Clone::clone) {
+ let location = info
+ .location()
+ .map(|location| {
+ format!(
+ "{}:{}:{}",
+ location.file(),
+ location.line(),
+ location.column()
+ )
+ })
+ .unwrap_or_else(|| "未知位置".to_string());
+ design_debug(
+ &root,
+ "panic",
+ json!({
+ "location": location,
+ "error": info.payload_as_str().unwrap_or("未知 panic 负载"),
+ }),
+ );
+ }
+ previous(info);
+ }));
+ });
+}
+
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(
tag = "type",
@@ -421,6 +462,10 @@ fn execute_design_tool(
) -> Result {
let args: Value = serde_json::from_str(&call.arguments)
.map_err(|error| format!("工具参数不是有效 JSON:{error}"))?;
+ #[cfg(test)]
+ if call.name == "design_test__panic" {
+ panic!("注入的策划工具 panic");
+ }
match call.name.as_str() {
"get_workflow_status" => Ok(design_workflow_status(session)),
"list_resources" => resources.list().map(Value::String),
@@ -971,7 +1016,20 @@ async fn finish_design_command(
Some(design_view(&session, run)),
));
if run {
- if let Err(error) = run_design_loop(root, resources, &mut session, &mut emit).await {
+ // panic 边界:运行期 panic 转成普通失败,交给既有错误分支恢复(重读检查点、
+ // 写 last_error、发最终 view)。否则 unwind 会杀死 command task,IPC 永不
+ // 返回(前端停在工作态),会话停在无错误的 pending,用户只能看到无声的重试。
+ ensure_design_panic_hook();
+ let run = DESIGN_PANIC_ROOT.scope(
+ Some(root.to_path_buf()),
+ run_design_loop(root, resources, &mut session, &mut emit),
+ );
+ let outcome = std::panic::AssertUnwindSafe(run).catch_unwind().await;
+ let result = match outcome {
+ Ok(result) => result,
+ Err(payload) => Err(design_panic_error(payload)),
+ };
+ if let Err(error) = result {
// 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。
session = read_design_session(root)?.ok_or("策划会话丢失")?;
session.last_error = Some(redact_agent_runtime_error(root, &error, 1800));
@@ -991,6 +1049,12 @@ async fn finish_design_command(
Ok(view)
}
+// panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有
+// design_debug(task-local 提供项目根),不进入用户可见消息。
+fn design_panic_error(_payload: Box) -> String {
+ DESIGN_PANIC_PUBLIC_ERROR.to_string()
+}
+
pub(crate) async fn continue_design_agent_at(
root: &Path,
resources: &DesignResources,
@@ -1632,6 +1696,121 @@ mod tests {
.clone()
}
+ // 进程级 env 在同一 binary 的并行用例间共享:持锁串行化修改,drop 时恢复原值,
+ // 避免 debug 开关泄漏给并发用例。锁中毒时取内部值继续,不让上游失败放大。
+ static DESIGN_DEBUG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+ struct DesignDebugEnvGuard {
+ previous: Option,
+ _lock: std::sync::MutexGuard<'static, ()>,
+ }
+
+ impl Drop for DesignDebugEnvGuard {
+ fn drop(&mut self) {
+ match &self.previous {
+ Some(value) => std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", value),
+ None => std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG"),
+ }
+ }
+ }
+
+ fn enable_design_debug_for_test() -> DesignDebugEnvGuard {
+ let lock = DESIGN_DEBUG_ENV_LOCK
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let previous = std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG").ok();
+ std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1");
+ DesignDebugEnvGuard {
+ previous,
+ _lock: lock,
+ }
+ }
+
+ #[tokio::test(flavor = "current_thread")]
+ async fn design_tool_panic_becomes_visible_retryable_error() {
+ let (_temp, root, resources) = init_design_project();
+ let _debug_env = enable_design_debug_for_test();
+ let panic_call = platform_llm::LlmToolCall {
+ id: "call-panic".into(),
+ name: "design_test__panic".into(),
+ arguments: "{}".into(),
+ };
+ let _fake = fake_provider::install(
+ vec![
+ Ok(fake_response("panic-turn", "", vec![panic_call])),
+ Ok(fake_response("recovery", "已恢复", Vec::new())),
+ ],
+ 0,
+ );
+ let view = continue_design_agent_at(
+ &root,
+ &resources,
+ "turn-panic",
+ DesignInput::Message {
+ text: "需求".into(),
+ },
+ |_| {},
+ )
+ .await
+ .expect("panic 必须转成可恢复视图而不是向上传播");
+ assert!(!view.running);
+ assert!(view.can_retry);
+ assert_eq!(
+ view.session.last_error.as_deref(),
+ Some(DESIGN_PANIC_PUBLIC_ERROR)
+ );
+ let session = read_design_session(&root)
+ .expect("read session")
+ .expect("session exists");
+ assert!(
+ !session.history.iter().any(|item| {
+ item.get("type").and_then(Value::as_str) == Some("function_call_output")
+ && item.get("call_id").and_then(Value::as_str) == Some("call-panic")
+ }),
+ "panic 不得写半个工具输出"
+ );
+
+ // design_debug 经独立线程落盘,轮询等待 panic 记录出现。
+ let debug_dir = root.join(".debug/design-agent");
+ let mut panic_record = None;
+ for _ in 0..100 {
+ panic_record = fs::read_dir(&debug_dir).ok().and_then(|entries| {
+ entries.flatten().find_map(|entry| {
+ let path = entry.path();
+ let name = path.file_name()?.to_string_lossy().into_owned();
+ if name.ends_with("-panic.json") {
+ fs::read_to_string(path).ok()
+ } else {
+ None
+ }
+ })
+ });
+ if panic_record.is_some() {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ let panic_record = panic_record.expect("panic hook 必须把位置和负载写入 design_debug");
+ assert!(panic_record.contains("design_runtime.rs"));
+ assert!(panic_record.contains("注入的策划工具 panic"));
+
+ let view =
+ continue_design_agent_at(&root, &resources, "turn-retry", DesignInput::Retry, |_| {})
+ .await
+ .expect("panic 后可重试");
+ assert!(!view.running);
+ assert!(!view.can_retry);
+ assert!(view.session.last_error.is_none());
+ let session = read_design_session(&root)
+ .expect("read session")
+ .expect("session exists");
+ assert!(session.turn.as_ref().is_some_and(|turn| !turn.pending));
+ assert!(session.history.iter().any(|item| {
+ item.get("type").and_then(Value::as_str) == Some("function_call_output")
+ && item.get("call_id").and_then(Value::as_str) == Some("call-panic")
+ }));
+ }
+
#[test]
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
let session = new_design_session("project", "quality");
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs
index 6f8557a32..86416a42d 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs
@@ -550,6 +550,8 @@ fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value {
"agc_generate_image" => {
copy_string(object, "kind", &mut out);
copy_string(object, "sliceMode", &mut out);
+ copy_number(object, "sliceCount", &mut out);
+ copy_string(object, "screenColor", &mut out);
copy_string(object, "aspectRatio", &mut out);
copy_string(object, "imageSize", &mut out);
copy_string(object, "assetName", &mut out);
@@ -1194,7 +1196,13 @@ mod tests {
"item": {
"type": "mcpToolCall",
"tool": "agc_generate_image",
- "arguments": { "prompt": prompt, "kind": "icon-spec" }
+ "arguments": {
+ "prompt": prompt,
+ "kind": "art-spritesheet",
+ "sliceMode": "connected-components",
+ "sliceCount": 8,
+ "screenColor": "#CFEFFF"
+ }
}
}));
audit.finish(true);
@@ -1205,6 +1213,8 @@ mod tests {
let stored = item["arguments"]["prompt"].as_str().expect("prompt");
assert_eq!(stored.chars().count(), DIRECT_CODEX_AUDIT_BRIEF_CHARS);
assert_eq!(item["arguments"]["promptChars"], json!(5000));
+ assert_eq!(item["arguments"]["sliceCount"], json!(8));
+ assert_eq!(item["arguments"]["screenColor"], json!("#CFEFFF"));
assert_eq!(
item["arguments"]["promptSha256"],
json!(sha256_hex("收".repeat(5000).as_bytes()))
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs
index d5ccc94c4..e33bfae72 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs
@@ -5,8 +5,8 @@ mod validation;
mod wire;
pub(crate) use model::{
- DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
- DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
+ DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
+ DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
};
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs
index b46336b66..266f330cc 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs
@@ -61,13 +61,6 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart {
pub(crate) resource_ids: Vec,
}
-#[derive(Clone, Debug, Deserialize, Serialize, TS)]
-#[serde(rename_all = "camelCase", deny_unknown_fields)]
-#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
-pub(crate) struct DirectCodexUserMessageEnvelope {
- pub(crate) item: DirectCodexUserItem,
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs
index ed9e5e576..f1e6679bb 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs
@@ -1,3 +1,4 @@
+use super::direct_thread_item_identity;
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::{
@@ -102,63 +103,127 @@ fn record(item: &Value) -> Result {
const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024;
+/// 从文件尾向前回读到的一行。
+///
+/// `terminated` 表示这行后面确实有换行符:整份历史里只有末尾那一段可能没有换行,那一段是
+/// append 侧承诺会修复的截断尾。解析失败时跳过这一行继续往前回扫,而不是报错——它是回扫
+/// 见到的第一行,一旦就地结束就会把后面完整的更早历史全部丢掉。
+struct DirectProjectHistoryReverseLine {
+ bytes: Vec,
+ terminated: bool,
+}
+
+/// 从文件尾向前逐行产出 `project.jsonl`。
+///
+/// 历史会随项目长到 MB 级,而"最近一屏"和"按 id 回扫"都只关心尾部若干行:两者共用这一套
+/// 按块回读,"读一屏"不必再从文件头逐行读到尾。
+struct DirectProjectHistoryReverseLines {
+ path: PathBuf,
+ file: File,
+ /// 已读进内存、但还没被换行切出来的更早字节。
+ pending: Vec,
+ chunk: Vec,
+ /// 下一次回读的起始偏移;0 表示文件头已经读完。
+ position: u64,
+ /// 是否已经切过一次换行:切过之后,接下来产出的行前面一定还有换行分隔。
+ saw_delimiter: bool,
+}
+
+impl DirectProjectHistoryReverseLines {
+ fn open(path: &Path) -> Result {
+ let file = File::open(path)
+ .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
+ let position = file
+ .metadata()
+ .map_err(|error| {
+ format!(
+ "读取 DirectProject 历史元数据失败:{}: {error}",
+ path.display()
+ )
+ })?
+ .len();
+ Ok(Self {
+ path: path.to_path_buf(),
+ file,
+ pending: Vec::new(),
+ chunk: vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES],
+ position,
+ saw_delimiter: false,
+ })
+ }
+
+ fn next_line(&mut self) -> Result