Merge branch 'master' into fix/chat-status-lost
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-09-18 00:31:44 +08:00
83 changed files with 6505 additions and 738 deletions
+2
View File
@@ -61,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"
},
@@ -72,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",
@@ -1,4 +1,4 @@
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';
@@ -11,6 +11,8 @@ 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;
@@ -39,6 +41,20 @@ const releaseChannels = {
'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
@@ -143,27 +159,96 @@ export function resolveManifestPlatformKeys(target = releaseTarget) {
throw new Error(`不支持的发布目标:${target}`);
}
async function readRemoteVersion(channel = resolveReleaseChannel()) {
const manifestUrl = updateManifestUrl(channel);
/** 旧协议迁移指针:只在迁移窗口内存在,是历史版本高水位的来源。 */
function legacyBridgeManifestUrl() {
return `${ossBaseUrl()}/latest.json`;
}
async function fetchManifest(manifestUrl, label) {
let response;
try {
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) {
@@ -174,7 +259,7 @@ function replaceVersionLine(source, version, pattern, label) {
export async function prepareReleaseVersion() {
const channel = resolveReleaseChannel();
const localVersion = parseVersion(readPackageJson().version, '本地版本');
const remoteVersion = await readRemoteVersion(channel);
const remoteVersion = await resolveRemoteHighWaterVersion(channel);
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
const nextVersion = requestedVersion
? parseVersion(requestedVersion, '指定版本')
@@ -368,6 +453,8 @@ export function createUpdateManifest(
channel = resolveReleaseChannel(),
target = releaseTarget,
publishedAt = new Date().toISOString(),
notes = readReleaseNotes(),
commit = readHeadCommit(),
} = {},
) {
const signature = readUpdaterSignature(artifactPath);
@@ -378,24 +465,136 @@ export function createUpdateManifest(
for (const key of resolveManifestPlatformKeys(target)) {
platforms[key] = { signature, url };
}
const notes = readReleaseNotes();
return {
version,
...(notes ? { notes } : {}),
pub_date: publishedAt,
platforms,
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
...(commit ? { commit } : {}),
};
}
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() } = {},
{ channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {},
) {
const bytes = fs.readFileSync(artifactPath);
const version = readPackageJson().version;
const fileName = path.basename(artifactPath);
const notes = readReleaseNotes();
return {
version,
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
@@ -405,18 +604,36 @@ export function createLegacyUpdateManifest(
};
}
export function generateUpdateManifest() {
export async function generateUpdateManifest() {
const channel = resolveReleaseChannel();
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
}
const manifest = createUpdateManifest(artifact, { channel });
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, notes });
const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const notesPath = path.join(bundleRoot, 'release-notes.txt');
fs.writeFileSync(
notesPath,
notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
);
const legacyManifest =
channel === 'dev-win'
? createLegacyUpdateManifest(artifact, { channel })
? createLegacyUpdateManifest(artifact, { channel, notes })
: null;
const legacyManifestPath = legacyManifest
? path.join(bundleRoot, 'legacy-latest.json')
@@ -431,6 +648,14 @@ export function generateUpdateManifest() {
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
);
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
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}`,
@@ -441,6 +666,10 @@ export function generateUpdateManifest() {
artifact,
manifest,
manifestPath,
notes,
notesPath,
previousCommit,
commits,
legacyManifest,
legacyManifestPath,
};
@@ -453,5 +682,5 @@ if (
const args = process.argv.slice(2);
if (!args.includes('--no-bundle')) await prepareReleaseVersion();
runTauriBuild(args);
if (!args.includes('--no-bundle')) generateUpdateManifest();
if (!args.includes('--no-bundle')) await generateUpdateManifest();
}
@@ -1,18 +1,32 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } 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,
collectRecentReleaseCommits,
collectReleaseCommits,
compareVersions,
createChannelConfig,
createLegacyUpdateManifest,
createUpdateManifest,
formatRecentReleaseNotes,
formatReleaseNotes,
nextPatchVersion,
resolveManifestPlatformKeys,
resolvePreviousReleaseCommit,
resolveReleaseChannel,
resolveRemoteHighWaterVersion,
selectReleaseArtifact,
updateManifestUrl,
} from './build-release.mjs';
@@ -49,6 +63,22 @@ function withSignedArtifact(fileName, run) {
}
}
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),
@@ -173,6 +203,137 @@ test('next release version follows the higher local or channel version', () => {
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
});
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),
@@ -185,3 +346,141 @@ test('release upload forces overwrite for artifact, signature and channel pointe
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
assert.match(source, /agc\/latest\.json/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}`);
}
});
@@ -54,7 +54,7 @@ function runOssutil(args) {
await prepareReleaseVersion();
runTauriBuild([]);
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
generateUpdateManifest();
await generateUpdateManifest();
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
@@ -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 });
}
},
);
@@ -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"
@@ -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.
@@ -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.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.19",
"version": "2026-08-26.23",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -123,7 +123,7 @@
"agents/openai.yaml",
"references/projection-contract.md"
],
"sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1"
"sha256": "247787975944ce8b21d7c879c39c60ec13608056cff9426ac374c9299937d475"
}
]
}
@@ -5305,7 +5305,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
if prepare_art {
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等),先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码");
} else {
system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。");
@@ -1294,7 +1294,10 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
.map(|asset| bridge_registered_resource(asset, include_sequence_frames))
.collect::<Vec<_>>();
let next_offset = (offset + resources.len() < total).then_some(offset + resources.len());
let pending = list_pending_local_project_resource_edits_at(
let platform_session = (editor_api_mode() == EditorApiMode::PlatformAccount)
.then(current_platform_session)
.flatten();
let pending = list_pending_local_project_resource_edits_for_session_at(
ListPendingLocalProjectResourceEditsInput {
project_path: root
.to_str()
@@ -1302,6 +1305,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
.to_string(),
expected_project_id: manifest.project_id,
},
platform_session.as_ref(),
)?
.into_iter()
.map(|edit| {
@@ -1311,6 +1315,8 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
"mode": edit.generation_mode,
"sourceResourceId": edit.source_resource_id,
"assetName": edit.asset_name,
"backgroundMode": edit.background_mode,
"screenColor": edit.screen_color,
"phase": edit.phase,
"createdAt": edit.created_at,
})
@@ -1380,6 +1386,33 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>)
"gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp"
| "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql"
| "gql" | "vue" | "svelte" => ("code", Some("text/plain")),
/*
* Cocos Creator 资源(3.8.8 的 `engine-extends` 贡献的 `asset-handler` 表)。
*
* 这里只做**发现分类**`model` / `binary` 是发现层新词,与 manifest 资产 `kind`
* 不是同一套口径(登记时的 kind 见 `commands.rs::agent_local_project_file_type`)。
* 只有 `mediaType` 非空的条目才会 `assetImportable=true`,因此这张表必须与登记层
* 的白名单同步增删;`prompt_context.rs::prompt_context_media_type` 是同一套扩展名的
* 第三份投影,同样要跟改。
*/
"glb" => ("model", Some("model/gltf-binary")),
"gltf" => ("model", Some("model/gltf+json")),
"fbx" => ("model", Some("application/octet-stream")),
"mesh" | "skeleton" => ("model", Some("application/json")),
"scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari"
| "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" => {
("document", Some("application/json"))
}
"tmx" | "plist" => ("document", Some("application/xml")),
"effect" | "chunk" | "fnt" | "atlas" => ("document", Some("text/plain")),
"tga" => ("image", Some("image/x-tga")),
"tif" | "tiff" => ("image", Some("image/tiff")),
"hdr" => ("image", Some("image/vnd.radiance")),
"exr" => ("image", Some("image/x-exr")),
"dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => {
("binary", Some("application/octet-stream"))
}
"pcm" => ("audio", Some("audio/pcm")),
_ => ("other", None),
}
}
@@ -1421,8 +1454,10 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
.map(|value| value.to_lowercase());
let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)?
.unwrap_or_else(|| "all".to_string());
if !["all", "image", "font", "audio", "video", "document", "code"]
.contains(&requested_kind.as_str())
if ![
"all", "image", "font", "audio", "video", "document", "code", "model", "binary",
]
.contains(&requested_kind.as_str())
{
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
}
@@ -1772,8 +1807,8 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments:
fn bridge_completed_resource_result(
root: &Path,
kind: DirectResourceGenerationKind,
mode: DirectResourceGenerationMode,
kind: &str,
mode: &str,
result: DeriveLocalProjectResourceResult,
) -> Result<Value, String> {
let asset = result
@@ -1785,8 +1820,8 @@ fn bridge_completed_resource_result(
Ok(json!({
"status": "completed",
"operationId": result.operation_id,
"kind": kind.as_str(),
"mode": mode.as_str(),
"kind": kind,
"mode": mode,
"sourceResourceId": result.source_resource_id,
"committedProjectRevision": result.committed_project_revision,
"resource": bridge_registered_resource(asset, true),
@@ -1881,10 +1916,17 @@ async fn bridge_create_or_derive_resource(
source_version_id: None,
prompt: input.prompt.clone(),
asset_name: input.asset_name.clone(),
background_mode: None,
screen_color: None,
};
with_direct_editor_api_credentials(derive_local_project_resource_at(request)).await?
};
bridge_completed_resource_result(&state.root, input.kind, input.mode, completed)
bridge_completed_resource_result(
&state.root,
input.kind.as_str(),
input.mode.as_str(),
completed,
)
}
.await;
match result {
@@ -1898,7 +1940,8 @@ async fn bridge_create_or_derive_resource(
}
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async {
let _generation_guard = state.resource_generation_gate.lock().await;
let result = with_direct_editor_api_credentials(async {
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&state.root, "asset.register")?;
@@ -1919,74 +1962,72 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
if !source_asset.media_type.starts_with("image/") {
return Err("抠图工具只接受当前项目已登记的图片资源".to_string());
}
let source_resource_id = source_asset
.source
.resource_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && !value.starts_with("local-asset:"))
.ok_or_else(|| "图片资源缺少可供抠图服务使用的正式 resourceId".to_string())?
.to_string();
let (api_base_url, api_key, session) = resolve_canvas_sync_api_credentials(None, None)?;
let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, session.as_ref())?;
let client = crate::http_client::agc_main_site_client_builder()
.build()
.map_err(|_| "创建抠图服务连接失败".to_string())?;
let context =
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
let background_mode = background_mode.unwrap_or("complex").to_string();
let source_resource_id = bridge_asset_canonical_resource_id(source_asset);
let fingerprint = background_removal_request_fingerprint(
&source_asset_id,
&asset_name,
background_mode,
Some(background_mode.as_str()),
screen_color,
);
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let route = "/api/external/v1/editor/images/background-removals";
let mut request_body = json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
});
if background_mode == Some("flat") {
request_body["backgroundMode"] = json!("flat");
let (_, _, platform_session) = resolve_canvas_sync_api_credentials(None, None)?;
let pending = list_pending_local_project_resource_edits_for_session_at(
ListPendingLocalProjectResourceEditsInput {
project_path: state.root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
},
platform_session.as_ref(),
)?;
let matching_pending = pending
.into_iter()
.filter(|pending| {
pending.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval
&& (pending.source_asset_id.as_deref() == Some(source_asset_id.as_str())
|| pending.source_resource_id == format!("local-asset:{source_asset_id}"))
&& pending.asset_name == asset_name
&& pending.background_mode.as_deref().unwrap_or("complex")
== background_mode.as_str()
&& pending.screen_color.as_deref() == screen_color
})
.collect::<Vec<_>>();
if matching_pending.len() > 1 {
return Err("存在多个相同抠图 operation,必须先在客户端完成对账".to_string());
}
if let Some(color) = screen_color {
request_body["screenColor"] = json!(color);
}
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&request_body),
)
.send()
.await
.map_err(|error| format!("抠图服务提交失败:{error}"))?;
let status = response.status();
let payload = response
.json::<Value>()
.await
.map_err(|error| format!("抠图服务响应无法解析:{error}"))?;
if !status.is_success() {
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string());
}
return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16()));
}
let queue_state = external_editor_response_data(&payload).clone();
Ok::<_, String>(json!({
"status": "queued",
"sourceLocalAssetId": source_asset_id,
"assetName": asset_name,
"projectId": manifest.project_id,
"assetFolderId": context.asset_folder_id,
"queueState": bridge_safe_queue_state(queue_state),
}))
}
let completed = if let Some(pending) = matching_pending.into_iter().next() {
resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput {
project_path: state.root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
operation_id: pending.operation_id,
})
.await?
} else {
let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision;
let request = DeriveLocalProjectResourceInput {
project_path: state.root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
expected_project_revision: revision,
operation_id,
idempotency_key,
edit_kind: LocalProjectResourceEditKind::BackgroundRemoval,
generation_mode: LocalProjectResourceGenerationMode::Derive,
source_resource_id,
source_asset_id: Some(source_asset_id.clone()),
source_path: Some(source_asset.local_path.clone()),
source_media_type: Some(source_asset.media_type.clone()),
source_subtype: Some(source_asset.kind.clone()),
producer_task_id: source_asset.source.task_id.clone(),
source_version_id: None,
prompt: "去除背景".to_string(),
asset_name: asset_name.clone(),
background_mode: Some(background_mode),
screen_color: screen_color.map(str::to_string),
};
derive_local_project_resource_at(request).await?
};
emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal");
bridge_completed_resource_result(&state.root, "background-removal", "derive", completed)
})
.await;
match result {
Ok(value) => bridge_tool_result(value.to_string(), Vec::new(), false),
@@ -2012,17 +2053,6 @@ fn background_removal_request_fingerprint(
}
}
fn bridge_safe_queue_state(value: Value) -> Value {
let object = value.as_object();
json!({
"operationId": object.and_then(|value| value.get("operationId")).and_then(Value::as_str),
"status": object.and_then(|value| value.get("status")).and_then(Value::as_str),
"phaseLabel": object.and_then(|value| value.get("phaseLabel")).and_then(Value::as_str),
"progress": object.and_then(|value| value.get("progress")).and_then(Value::as_u64),
"updatedAtMicros": object.and_then(|value| value.get("updatedAtMicros")).and_then(Value::as_u64),
})
}
fn bridge_art_resources(
root: &Path,
asset_paths: &[String],
@@ -3181,7 +3211,9 @@ mod tests {
"supported raster image should be importable: {path}"
);
}
for path in ["assets/theme.bin", "assets/unknown.xyz"] {
// `.bin` 现在是引擎的 BufferAsset 载体(Cocos 资源表里的 `buffer` handler),
// 因此不再属于「未识别文件」;真正未识别的扩展名仍然只能被发现。
for path in ["assets/theme.dat", "assets/unknown.xyz"] {
assert!(
!bridge_project_file_is_asset_importable(path),
"unsupported project file must not be advertised as importable: {path}"
@@ -3201,6 +3233,64 @@ mod tests {
}
}
/// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType`
/// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。
///
/// 变异验证:把任一扩展名从 `bridge_project_file_class` 删掉,本用例必须变红。
#[test]
fn bridge_project_file_class_covers_cocos_creator_assets() {
for (path, expected_class, expected_media_type) in [
("assets/model/hero.glb", "model", "model/gltf-binary"),
("assets/model/hero.gltf", "model", "model/gltf+json"),
("assets/model/hero.fbx", "model", "application/octet-stream"),
("assets/model/hero.mesh", "model", "application/json"),
("assets/model/hero.skeleton", "model", "application/json"),
("assets/scene/main.scene", "document", "application/json"),
("assets/scene/enemy.prefab", "document", "application/json"),
("assets/anim/walk.anim", "document", "application/json"),
(
"assets/anim/graph.animgraph",
"document",
"application/json",
),
("assets/mtl/hero.mtl", "document", "application/json"),
("assets/shader/glow.effect", "document", "text/plain"),
("assets/atlas/hero.plist", "document", "application/xml"),
("assets/map/level.tmx", "document", "application/xml"),
("assets/font/bitmap.fnt", "document", "text/plain"),
("assets/atlas/auto.pac", "document", "application/json"),
("assets/tex/grass.tga", "image", "image/x-tga"),
("assets/tex/height.hdr", "image", "image/vnd.radiance"),
(
"assets/tex/hero.texture",
"binary",
"application/octet-stream",
),
(
"assets/spine/hero.skel",
"binary",
"application/octet-stream",
),
("assets/audio/voice.pcm", "audio", "audio/pcm"),
] {
let (class, media_type) = bridge_project_file_class(path);
assert_eq!(class, expected_class, "{path}");
assert_eq!(media_type, Some(expected_media_type), "{path}");
assert!(
bridge_project_file_is_asset_importable(path),
"Cocos 资源必须可登记:{path}"
);
}
// 引擎工程里的导入缓存不是资源:`.meta` 与未知扩展名仍然只能被发现。
for path in ["assets/tex/hero.png.meta", "assets/world.unknown"] {
assert_eq!(bridge_project_file_class(path).0, "other", "{path}");
assert!(
!bridge_project_file_is_asset_importable(path),
"非资源文件不得可登记:{path}"
);
}
}
#[test]
fn bridge_project_file_listing_projects_importability_per_file() {
let temporary = tempfile::tempdir().expect("create project file listing root");
@@ -3755,20 +3845,4 @@ mod tests {
);
}
}
#[test]
fn bridge_background_removal_queue_projection_is_bounded() {
let projection = bridge_safe_queue_state(json!({
"operationId": "background-removal-1",
"status": "queued",
"phaseLabel": "排队中",
"progress": 0,
"updatedAtMicros": 1,
"error": "private provider detail",
"signedUrl": "https://private.invalid/result"
}));
assert_eq!(projection["operationId"], "background-removal-1");
assert!(projection.get("error").is_none());
assert!(projection.get("signedUrl").is_none());
}
}
@@ -369,7 +369,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
},
"kind": {
"type": "string",
"enum": ["all", "image", "font", "audio", "video", "document", "code"]
"enum": ["all", "image", "font", "audio", "video", "document", "code", "model", "binary"],
"description": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源"
},
"offset": { "type": "integer", "minimum": 0, "maximum": 500 },
"limit": { "type": "integer", "minimum": 1, "maximum": 100 }
@@ -771,7 +772,10 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String>
}
if arguments.get("kind").is_some() {
let kind = bounded_tool_string(arguments, "kind", 16)?;
if !["all", "image", "font", "audio", "video", "document", "code"].contains(&kind.as_str())
if ![
"all", "image", "font", "audio", "video", "document", "code", "model", "binary",
]
.contains(&kind.as_str())
{
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
}
@@ -161,7 +161,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
output.push_str(
"# 项目内未登记媒体文件(仅发现,不是正式资产)\n\n\
- 这些文件真实存在于当前项目,但尚未取得 manifest assetId/localAssetId、来源或 provenance。\n\
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档代码文件可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等)可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
);
for (path, size, media_type) in unregistered.iter().take(48) {
let asset_importable = !media_type.is_empty();
@@ -228,6 +228,27 @@ fn prompt_context_media_type(path: &str) -> Option<&'static str> {
"js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts"
| "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh"
| "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"),
/*
* Cocos Creator 资源。这里只是给未登记文件清单标注媒体类型,取值必须与
* `agent/direct_tool_bridge.rs::bridge_project_file_class` 和
* `commands.rs::agent_local_project_file_type` 一致:少写一个扩展名,
* Agent 的提示词就会把「其实可以登记」的资源说成只能发现。
*/
"glb" => Some("model/gltf-binary"),
"gltf" => Some("model/gltf+json"),
"fbx" | "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => {
Some("application/octet-stream")
}
"scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari"
| "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" | "mesh"
| "skeleton" => Some("application/json"),
"tmx" | "plist" => Some("application/xml"),
"effect" | "chunk" | "fnt" | "atlas" => Some("text/plain"),
"tga" => Some("image/x-tga"),
"tif" | "tiff" => Some("image/tiff"),
"hdr" => Some("image/vnd.radiance"),
"exr" => Some("image/x-exr"),
"pcm" => Some("audio/pcm"),
_ => None,
}
}
@@ -246,10 +267,21 @@ mod tests {
("assets/data.json", "application/json"),
("game/index.html", "text/html"),
("game/main.rs", "text/plain"),
// 引擎资源同样要在未登记清单里被标出可登记:漏一个扩展名,
// Agent 就会把「其实可以登记」的 Cocos 资源说成只能发现。
("assets/model/hero.glb", "model/gltf-binary"),
("assets/model/hero.fbx", "application/octet-stream"),
("assets/anim/walk.anim", "application/json"),
("assets/scene/main.scene", "application/json"),
("assets/shader/glow.effect", "text/plain"),
("assets/atlas/hero.plist", "application/xml"),
("assets/tex/grass.tga", "image/x-tga"),
("assets/audio/voice.pcm", "audio/pcm"),
] {
assert_eq!(prompt_context_media_type(path), Some(expected), "{path}");
}
assert_eq!(prompt_context_media_type("assets/unknown.bin"), None);
// `.bin` 现在是引擎 BufferAsset 的载体,不再是「未识别」;真正未知的扩展名才返回 None。
assert_eq!(prompt_context_media_type("assets/unknown.dat"), None);
}
}
@@ -416,10 +416,6 @@ pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_POLL_MS: u64 = 50;
pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS: u64 = 10 * 60 * 1_000;
pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR: &str =
"agent-runtime-real-e2e-tool-plan-handoff-checkpoint-needs-reconciliation";
pub(super) const AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 3;
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR: u32 = 12;
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 16;
pub(crate) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT: u32 = 2;
pub(super) const AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS: usize = 4;
pub(super) const AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS: u32 = 2_000;
pub(crate) const AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS: u32 = 2_600;
@@ -84,7 +84,7 @@ pub(crate) use response_stream::{
pub(crate) use run_configuration::{
agent_runtime_run_profile_identity_at, bind_game_creator_agent_runtime_run_profile_at,
game_creator_agent_runtime_project_revision_path,
game_creator_agent_runtime_provider_transient_max_retries_at,
game_creator_agent_runtime_provider_transient_retry_policy_at,
game_creator_agent_runtime_run_profile_binding_path,
read_game_creator_agent_runtime_run_profile_binding,
};
@@ -717,14 +717,14 @@ where
Fut: std::future::Future<Output = Result<platform_llm::LlmRunResponse, platform_llm::LlmError>>,
H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse,
{
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
root,
&provider_snapshot.agent_id,
&provider_snapshot.run_id,
llm.max_retries,
)?;
let retry_autonomous_upstream_400 =
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
let max_retries = retry_policy.max_retries;
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
let identity = game_creator_agent_runtime_provider_retry_identity_for_mode(
provider_snapshot,
llm,
@@ -1365,17 +1365,9 @@ where
)?;
return Err("Provider 瞬态错误编码损坏".to_string());
};
let error_max_retries = if error_kind == "upstream-400" {
effective_max_retries
.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT)
} else {
effective_max_retries
};
if existing
.as_ref()
.is_some_and(|record| record.max_retries != error_max_retries)
|| attempt >= error_max_retries
{
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
let error_max_retries = effective_max_retries;
if attempt >= error_max_retries {
crate::provider_retry::remove_at(
root,
&provider_snapshot.agent_id,
@@ -1517,14 +1509,14 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
operation: &str,
request: &LlmRunRequest,
) -> Result<Option<platform_llm::LlmRunResponse>, String> {
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
root,
&provider_snapshot.agent_id,
&provider_snapshot.run_id,
llm.max_retries,
)?;
let retry_autonomous_upstream_400 =
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
let max_retries = retry_policy.max_retries;
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
for attempt in 0..=max_retries {
let request_slot = if attempt == 0 {
provider_snapshot.request_slot.clone()
@@ -1585,11 +1577,8 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
let Some((error_kind, public_error)) = encoded.split_once('\n') else {
return Err("Provider 瞬态错误编码损坏".to_string());
};
let error_max_retries = if error_kind == "upstream-400" {
max_retries.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT)
} else {
max_retries
};
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
let error_max_retries = max_retries;
if attempt >= error_max_retries {
return Err(game_creator_agent_runtime_provider_retry_exhausted_error(
public_error,
@@ -395,12 +395,22 @@ pub(crate) fn agent_runtime_run_profile_identity_at(
Ok((profile, String::new()))
}
pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at(
/// 当前持久 run 的 Provider 瞬态重试策略。
///
/// 重试次数严格使用设置值:运行档位不再把 `maxRetries` 收进固定区间,
/// 只决定上游 400 是否算瞬态错误。
#[derive(Debug)]
pub(crate) struct AgentRuntimeProviderTransientRetryPolicy {
pub(crate) max_retries: u32,
pub(crate) retry_upstream_400: bool,
}
pub(crate) fn game_creator_agent_runtime_provider_transient_retry_policy_at(
root: &Path,
agent_id: &str,
run_id: &str,
configured_max_retries: u32,
) -> Result<u32, String> {
) -> Result<AgentRuntimeProviderTransientRetryPolicy, String> {
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
let stored_identity =
read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, run_id)?
@@ -416,10 +426,8 @@ pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at(
stored_profile,
stored_binding_fingerprint,
)?;
if profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
return Ok(configured_max_retries
.max(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR)
.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT));
}
Ok(configured_max_retries.min(AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT))
Ok(AgentRuntimeProviderTransientRetryPolicy {
max_retries: configured_max_retries,
retry_upstream_400: profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
})
}
@@ -3092,7 +3092,7 @@ mod agent_asset_import_tests {
#[test]
fn local_project_asset_import_registers_multiple_types_and_is_idempotent() {
let project = tempfile::tempdir().expect("create project directory");
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
@@ -3185,7 +3185,7 @@ mod agent_asset_import_tests {
#[test]
fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() {
let project = tempfile::tempdir().expect("create project directory");
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
@@ -3202,7 +3202,7 @@ mod agent_asset_import_tests {
#[test]
fn local_project_asset_import_rejects_hidden_and_build_tree_sources() {
let project = tempfile::tempdir().expect("create project directory");
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
@@ -3232,20 +3232,147 @@ mod agent_asset_import_tests {
#[test]
fn local_project_asset_import_rejects_unknown_and_invalid_text_files() {
let project = tempfile::tempdir().expect("create project directory");
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
fs::create_dir_all(root.join("assets")).expect("create assets directory");
fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file");
fs::write(root.join("assets/unknown.dat"), b"bytes").expect("write unknown file");
fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source");
assert!(
import_local_project_assets_for_agent(root, &["assets/unknown.bin".to_string()])
import_local_project_assets_for_agent(root, &["assets/unknown.dat".to_string()])
.is_err()
);
assert!(
import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err()
);
// `.bin` 是引擎的 BufferAsset 载体,属于「已识别但只能出类型卡」的一类:
// 登记必须成功,否则引擎工程里的 BufferAsset 永远进不了资源画布。
fs::write(root.join("assets/blob.bin"), [0x00, 0x01, 0x02]).expect("write buffer asset");
let imported =
import_local_project_assets_for_agent(root, &["assets/blob.bin".to_string()])
.expect("import engine buffer asset");
assert_eq!(imported.assets.len(), 1);
assert_eq!(imported.assets[0].asset_kind.as_deref(), Some("document"));
}
/// Cocos Creator 资源登记:模型、动画、序列化资源与引擎容器都要能进 manifest,
/// 且 `kind` 只落在**既有 canonical 词表**里(不新增契约值,旧客户端仍能读 manifest)。
///
/// 变异验证:把任一扩展名从 `agent_local_project_file_type` 删掉即变红。
#[test]
fn local_project_asset_import_registers_cocos_creator_assets() {
// 工程自带助手:canonicalize + 目录 owner 归当前用户,避免 `%TEMP%` 临时目录
// 在 Windows owner 校验下直接失败。
let project = crate::tests::canonical_test_tempdir("cocos-asset-import-");
let root = project.path();
init_local_game_project_at(root, "cocos-import", "Cocos import")
.expect("initialize project");
for directory in [
"model", "anim", "scene", "mtl", "shader", "atlas", "map", "tex", "audio",
] {
fs::create_dir_all(root.join("assets").join(directory)).expect("create assets subdir");
}
let mut glb = b"glTF".to_vec();
glb.extend_from_slice(&[2, 0, 0, 0, 12, 0, 0, 0]);
let mut fbx = b"Kaydara FBX Binary \x00".to_vec();
fbx.extend_from_slice(&[0; 16]);
for (path, bytes) in [
("assets/model/hero.glb", glb),
("assets/model/hero.fbx", fbx),
(
"assets/anim/walk.anim",
b"[{\"__type__\":\"cc.AnimationClip\"}]".to_vec(),
),
(
"assets/anim/graph.animgraph",
b"{\"__type__\":\"cc.animation.AnimationGraph\"}".to_vec(),
),
(
"assets/scene/main.scene",
b"[{\"__type__\":\"cc.SceneAsset\"}]".to_vec(),
),
(
"assets/scene/enemy.prefab",
b"[{\"__type__\":\"cc.Prefab\"}]".to_vec(),
),
(
"assets/mtl/hero.mtl",
b"{\"__type__\":\"cc.Material\"}".to_vec(),
),
("assets/shader/glow.effect", b"CCEffect %{\n}".to_vec()),
(
"assets/atlas/hero.plist",
b"<?xml version=\"1.0\"?><plist/>".to_vec(),
),
(
"assets/map/level.tmx",
b"<?xml version=\"1.0\"?><map/>".to_vec(),
),
("assets/tex/hero.texture", vec![0xff, 0x00, 0x01]),
("assets/audio/voice.pcm", vec![0x00, 0x01, 0x02]),
] {
fs::write(root.join(path), bytes).expect("write cocos asset");
}
let relative_paths = [
"assets/model/hero.glb",
"assets/model/hero.fbx",
"assets/anim/walk.anim",
"assets/anim/graph.animgraph",
"assets/scene/main.scene",
"assets/scene/enemy.prefab",
"assets/mtl/hero.mtl",
"assets/shader/glow.effect",
"assets/atlas/hero.plist",
"assets/map/level.tmx",
"assets/tex/hero.texture",
"assets/audio/voice.pcm",
]
.map(str::to_string)
.to_vec();
let imported =
import_local_project_assets_for_agent(root, &relative_paths).expect("import cocos");
let kinds = imported
.assets
.iter()
.map(|asset| (asset.local_path.as_str(), asset.asset_kind.as_deref()))
.collect::<BTreeMap<_, _>>();
assert_eq!(kinds.get("assets/model/hero.glb"), Some(&Some("scene")));
assert_eq!(kinds.get("assets/model/hero.fbx"), Some(&Some("scene")));
assert_eq!(
kinds.get("assets/anim/walk.anim"),
Some(&Some("character-animation"))
);
assert_eq!(
kinds.get("assets/anim/graph.animgraph"),
Some(&Some("character-animation"))
);
assert_eq!(kinds.get("assets/scene/main.scene"), Some(&Some("scene")));
assert_eq!(kinds.get("assets/scene/enemy.prefab"), Some(&Some("scene")));
assert_eq!(kinds.get("assets/mtl/hero.mtl"), Some(&Some("code")));
assert_eq!(kinds.get("assets/shader/glow.effect"), Some(&Some("code")));
assert_eq!(
kinds.get("assets/atlas/hero.plist"),
Some(&Some("document"))
);
// 瓦片地图与场景/预制体同栏(`scene`),不是「文档」:它描述的是可摆放的地图。
assert_eq!(kinds.get("assets/map/level.tmx"), Some(&Some("scene")));
assert_eq!(
kinds.get("assets/tex/hero.texture"),
Some(&Some("document"))
);
assert_eq!(kinds.get("assets/audio/voice.pcm"), Some(&Some("audio")));
// 非 UTF-8 的 `.prefab` 会被 `document` 分支拒绝:结构化文本资源必须是 UTF-8,
// 否则「结构预览」拿到的是一堆乱码。
fs::write(root.join("assets/scene/broken.prefab"), [0xff, 0xfe])
.expect("write invalid prefab");
assert!(import_local_project_assets_for_agent(
root,
&["assets/scene/broken.prefab".to_string()]
)
.is_err());
}
/// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**,
@@ -3962,6 +4089,140 @@ fn agent_local_project_file_type(
},
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
/*
* Cocos Creator3.8.8)资源:三维模型、动画、材质/特效、场景/预制体、图集与
* 压缩纹理容器。登记边界只做两件事:给出可判定的 `asset_kind` 与**预览通道**
* 能支撑的 `media_type`。
*
* - `document`Cocos 自己序列化的文本/JSON(要过 UTF-8 校验),卡面按结构预览;
* - `binary`:客户端无法解码的容器(模型、压缩纹理、Spine 二进制等),只要求非空;
* - `image`:可用原生解码转码成 PNG 再预览的图像容器(tga/tif/tiff/hdr/exr)。
*
* 这些扩展名必须与 `agent/direct_tool_bridge.rs::bridge_project_file_class` 和
* `agent/generation/prompt_context.rs::prompt_context_media_type` 同步,否则会出现
* 「发现得了、登记不了」或「登记得了、Agent 看不见」的分叉。
*/
"scene" | "fire" | "prefab" | "terrain" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "scene",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"tmx" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "scene",
media_type: "application/xml",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"anim" | "animation" | "animgraph" | "animgraphvari" | "animask" => {
Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "character-animation",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
})
}
"mtl" | "material" | "pmtl" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "code",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"effect" | "chunk" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "code",
media_type: "text/plain",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"plist" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
media_type: "application/xml",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"labelatlas" | "pac" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"fnt" | "atlas" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
media_type: "text/plain",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"glb" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "scene",
media_type: "model/gltf-binary",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"gltf" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "scene",
media_type: "model/gltf+json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"fbx" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "scene",
media_type: "application/octet-stream",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"mesh" | "skeleton" => Some(AgentLocalProjectFileType {
// Cocos 的 `.mesh` / `.skeleton` 是网格与骨骼的实例化数据,多数工程里是
// JSON、但也存在二进制变体,因此只按「非空」校验;能不能当文本预览由
// 结构化预览读取自己判定(非 UTF-8 时降级成类型卡)。
category: "binary",
asset_kind: "scene",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" => {
Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "document",
media_type: "application/octet-stream",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
})
}
"psd" | "znt" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "image",
media_type: "application/octet-stream",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"tga" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/x-tga",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"tif" | "tiff" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/tiff",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"hdr" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/vnd.radiance",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"exr" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/x-exr",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"pcm" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
media_type: "audio/pcm",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
_ => None,
};
let file_type = file_type.ok_or_else(|| format!("本地文件类型不受支持:{relative_path}"))?;
@@ -5055,6 +5316,65 @@ pub(crate) fn read_local_project_text_preview_at(
Ok(preview)
}
/**
* 读取引擎(Cocos)序列化资源的**只读结构预览**
*
* 与文本预览分开的理由:`.prefab` / `.scene` / `.anim` / `.effect` 这些扩展名不属于
* 「可编辑文本资源」白名单(那份名单同时服务 UI 编辑器),把它们并进去会顺手改变
* UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体
* 降级成类型卡(`content: null`),不把「不能预览」报成错误。
*/
#[tauri::command]
pub(crate) async fn read_local_project_structured_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectStructuredPreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_structured_preview_at(&project_path, &relative_path, cancellation)
})
.await
}
pub(crate) fn read_local_project_structured_preview_at(
project_path: &str,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectStructuredPreview, String> {
cancellation.check()?;
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let registered_media_type = manifest
.assets
.iter()
.find(|asset| asset.local_path == normalized_path)
.map(|asset| asset.media_type.clone());
let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| {
is_supported_project_structured_resource(&normalized_path, media_type)
}) || manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& is_supported_project_structured_resource(&normalized_path, "")
});
if !is_registered_structured {
return Err("只能读取当前项目已登记的引擎资源".to_string());
}
cancellation.check()?;
load_local_project_structured_preview_with_cancellation(
root,
&normalized_path,
registered_media_type.as_deref().unwrap_or(""),
cancellation,
)
}
#[tauri::command]
pub(crate) async fn read_local_project_media_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
@@ -5094,7 +5414,8 @@ pub(crate) fn read_local_project_media_preview_at(
let kind = match category.trim() {
"art" => ProjectMediaPreviewKind::Art,
"audio" => ProjectMediaPreviewKind::Audio,
_ => return Err("媒体预览类别只支持 art 或 audio".to_string()),
"model" => ProjectMediaPreviewKind::Model,
_ => return Err("媒体预览类别只支持 art、audio 或 model".to_string()),
};
let is_registered_media = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
@@ -5105,6 +5426,9 @@ pub(crate) fn read_local_project_media_preview_at(
ProjectMediaPreviewKind::Audio => {
is_supported_project_audio_resource(&asset.local_path, &asset.media_type)
}
ProjectMediaPreviewKind::Model => {
is_supported_project_model_resource(&asset.local_path, &asset.media_type)
}
}
}) || manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
@@ -5116,6 +5440,9 @@ pub(crate) fn read_local_project_media_preview_at(
ProjectMediaPreviewKind::Audio => {
is_supported_project_audio_resource(&normalized_path, "")
}
ProjectMediaPreviewKind::Model => {
is_supported_project_model_resource(&normalized_path, "")
}
}
});
if !is_registered_media {
@@ -2630,6 +2630,7 @@ fn main() {
read_local_project_image_preview,
save_local_project_asset_file,
read_local_project_text_preview,
read_local_project_structured_preview,
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
write_local_project_file,
@@ -1,5 +1,21 @@
use super::*;
/**
* Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。
*
* 判定刻意收窄到「工程根的**直接子目录**且名字是这四个之一」:`assets/library/` 是资源
* 目录里的普通文件夹,不属于这里。
*/
fn is_engine_generated_root_directory(relative_path: &str) -> bool {
if relative_path.contains('/') {
return false;
}
matches!(
relative_path.to_ascii_lowercase().as_str(),
"library" | "temp" | "profiles" | "local"
)
}
pub(crate) fn list_local_project_files_at(
root: &Path,
) -> Result<ListLocalProjectFilesResult, String> {
@@ -11,6 +27,15 @@ pub(crate) fn list_local_project_files_at(
});
}
/*
* 引擎生成目录的过滤只在**当前目录确实是 Cocos Creator 工程**时生效。
*
* 这里不能把 `library` / `temp` / `profiles` / `local` 加进全局跳过表:这些名字在
* 别的工程里可能是真实源码目录(例如自带 `library/` 的库工程)。而 Cocos 工程的
* `library/` 是导入缓存,常有上万条生成文件,既会挤满 Agent 的发现窗口,也会让
* 前端资源树加载一堆永远用不上的条目。
*/
let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some();
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
@@ -41,6 +66,11 @@ pub(crate) fn list_local_project_files_at(
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0);
if file_type.is_dir() {
if skip_engine_generated_directories
&& is_engine_generated_root_directory(&relative_path)
{
continue;
}
files.push(LocalProjectFileEntry {
path: relative_path,
kind: "directory".to_string(),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -728,13 +728,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
"tool": "canvas.asset_generate",
"reason": "生成可用于首版原型的主角素材",
"input": {
"prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏",
"prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏",
"outputPath": "assets/art-spritesheet.png",
"aspectRatio": "1:1",
"imageSize": "1K",
"assetKind": "art-spritesheet",
"assetLabel": "游戏首版核心美术素材",
"replaceExisting": false
"replaceExisting": false,
"sliceMode": "grid",
"gridX": 2,
"gridY": 2,
"sliceCount": null
}
}
],
@@ -775,7 +779,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
start_game_creator_agent_background_task_at(
&root,
"art-asset-plan",
"为月光厨房生成首版主角素材",
"为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布",
"art-generate-run",
)
.expect("start background task");
@@ -933,6 +937,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
let generation_idempotency_key =
request_header(generation_request, "idempotency-key").expect("generation idempotency key");
assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok());
let generation_body: Value = serde_json::from_str(
generation_request
.split_once("\r\n\r\n")
.expect("generation request body")
.1,
)
.expect("generation request json");
assert_eq!(generation_body["sliceMode"], "grid");
assert_eq!(generation_body["gridX"], 2);
assert_eq!(generation_body["gridY"], 2);
assert!(generation_body["sliceCount"].is_null());
assert!(generation_request.contains(r#""source":"ai-game-creator-client""#));
assert_eq!(
canvas_requests
@@ -2989,6 +3004,65 @@ fn local_project_file_commands_read_write_list_and_delete_text_files() {
fs::remove_dir_all(root).ok();
}
/// 引擎生成目录不进发现结果,但**只在当前目录确实是 Cocos Creator 工程时**生效:
/// 同名目录在别的工程里可能是真实源码目录,全局跳过会把用户代码从发现结果里删掉。
#[test]
fn local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects() {
let cocos = canonical_test_tempdir("engine-generated-dirs-");
let cocos = cocos.path();
fs::create_dir_all(cocos.join("assets")).expect("create assets");
fs::create_dir_all(cocos.join("library/imported")).expect("create library");
fs::create_dir_all(cocos.join("temp/programming")).expect("create temp");
fs::create_dir_all(cocos.join("profiles/v2")).expect("create profiles");
fs::create_dir_all(cocos.join("local")).expect("create local");
fs::write(cocos.join("assets/hero.glb"), b"glTF").expect("write model");
fs::write(cocos.join("library/imported/hero.json"), b"{}").expect("write library file");
fs::write(cocos.join("temp/programming/packer.cpp"), b"//").expect("write temp file");
fs::write(cocos.join("profiles/v2/user.json"), b"{}").expect("write profile file");
fs::write(cocos.join("local/settings.json"), b"{}").expect("write local file");
fs::write(
cocos.join("package.json"),
r#"{ "name": "cocos-project", "creator": { "version": "3.8.8" } }"#,
)
.expect("write cocos package.json");
let listed = list_local_project_files_at(cocos).expect("list cocos project files");
let paths = listed
.files
.iter()
.map(|file| file.path.as_str())
.collect::<Vec<_>>();
assert!(paths.contains(&"assets/hero.glb"), "{paths:?}");
for skipped in [
"library",
"library/imported/hero.json",
"temp",
"temp/programming/packer.cpp",
"profiles",
"profiles/v2/user.json",
"local",
"local/settings.json",
] {
assert!(
!paths.contains(&skipped),
"引擎生成目录必须被过滤:{skipped} / {paths:?}"
);
}
let plain = canonical_test_tempdir("plain-project-dirs-");
let plain = plain.path();
fs::create_dir_all(plain.join("library")).expect("create plain library");
fs::write(plain.join("library/index.ts"), b"//").expect("write plain library file");
let listed = list_local_project_files_at(plain).expect("list plain project files");
assert!(
listed
.files
.iter()
.any(|file| file.path == "library/index.ts"),
"非引擎工程的同名目录不得被过滤"
);
}
#[test]
fn local_project_export_package_uses_runtime_whitelist_and_records() {
let root = unique_project_path();
@@ -662,7 +662,7 @@ async fn chat_with_game_creator_role_agent_stream_does_not_fallback_on_upstream_
}
#[test]
fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile() {
let root = unique_project_path();
init_local_game_project_at(
&root,
@@ -671,16 +671,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
)
.expect("project init");
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
"design-director",
"legacy-standard-run",
99,
)
.expect("legacy standard retry policy"),
3
);
// 历史 standard run 仍走同一身份校验,但重试次数不再被收进区间。
let legacy_standard = game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
"design-director",
"legacy-standard-run",
99,
)
.expect("legacy standard retry policy");
assert_eq!(legacy_standard.max_retries, 99);
assert!(!legacy_standard.retry_upstream_400);
let standard = bind_game_creator_agent_runtime_run_profile_at(
&root,
"design-director",
@@ -691,25 +691,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
)
.expect("bind standard profile");
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&standard.agent_id,
&standard.run_id,
0,
)
.expect("standard zero retry policy"),
.expect("standard zero retry policy")
.max_retries,
0
);
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
&standard.agent_id,
&standard.run_id,
99,
)
.expect("standard capped retry policy"),
3
);
let standard_configured = game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&standard.agent_id,
&standard.run_id,
99,
)
.expect("standard configured retry policy");
assert_eq!(standard_configured.max_retries, 99);
assert!(!standard_configured.retry_upstream_400);
let parent = bind_game_creator_agent_runtime_run_profile_at(
&root,
@@ -720,17 +720,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
None,
)
.expect("bind autonomous parent profile");
for (configured, expected) in [(0, 12), (14, 14), (99, 16)] {
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
&parent.agent_id,
&parent.run_id,
configured,
)
.expect("autonomous parent retry policy"),
expected
);
for (configured, expected) in [(0, 0), (5, 5), (99, 99)] {
let policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&parent.agent_id,
&parent.run_id,
configured,
)
.expect("autonomous parent retry policy");
assert_eq!(policy.max_retries, expected);
assert!(policy.retry_upstream_400);
}
let child_link = AgentRuntimeTaskLink {
@@ -758,14 +757,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
append_game_creator_agent_runtime_task(&root, &child_state)
.expect("append autonomous child task projection");
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&child.agent_id,
&child.run_id,
0,
)
.expect("autonomous child retry policy"),
12
.expect("autonomous child retry policy")
.max_retries,
0
);
assert!(
game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&child.agent_id,
&child.run_id,
0,
)
.expect("autonomous child retry policy")
.retry_upstream_400
);
fs::remove_file(game_creator_agent_runtime_run_profile_binding_path(
@@ -775,7 +785,7 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
))
.expect("remove autonomous child binding");
assert!(
game_creator_agent_runtime_provider_transient_max_retries_at(
game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&child.agent_id,
&child.run_id,
@@ -4558,7 +4568,7 @@ async fn provider_transient_retry_provider_error_is_not_retried() {
}
#[tokio::test]
async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_budget() {
async fn provider_transient_retry_autonomous_upstream_400_uses_configured_budget() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "自主构建 Provider 400 重试测试")
.expect("project init");
@@ -4631,7 +4641,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
"model": "supervisor-autonomous-upstream-400-model",
"apiKind": "openai_chat",
"stream": false,
"maxRetries": 0,
"maxRetries": 2,
"retryBackoffMs": 1
}}
}}
@@ -4676,10 +4686,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
.expect("initial autonomous upstream 400 request");
assert_eq!(waiting.error_kind, "upstream-400");
assert_eq!(waiting.next_attempt, 1);
assert_eq!(
waiting.max_retries,
AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT
);
assert_eq!(waiting.max_retries, 2);
provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity)
.expect("force autonomous upstream 400 retry due");
@@ -4725,10 +4732,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
.collect::<Vec<_>>();
assert_eq!(retry_audits.len(), 1);
assert_eq!(retry_audits[0]["errorKind"], "upstream-400");
assert_eq!(
retry_audits[0]["maxRetries"],
AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT
);
assert_eq!(retry_audits[0]["maxRetries"], 2);
let lifecycle = records
.iter()
.filter(|record| {
@@ -6916,7 +6920,11 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
"imageSize",
"assetKind",
"assetLabel",
"replaceExisting"
"replaceExisting",
"sliceMode",
"gridX",
"gridY",
"sliceCount"
])
);
assert_eq!(
@@ -6927,6 +6935,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"],
serde_json::json!(["0.5K", "1K", "2K", null])
);
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"],
serde_json::json!(["connected-components", "grid", null])
);
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"],
serde_json::json!([

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