修复 #599:首页建项不再等待 AI 项目命名,改为后台条件改名
Project CI / Backend tests (pull_request) Failing after 17s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m9s
Project CI / Repository checks (pull_request) Failing after 30s
Project CI / Frontend tests (pull_request) Successful in 3m21s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m24s
Project CI / Native shell tests (pull_request) Successful in 7m5s

- 首页自动建项:预检通过后立即以宿主兜底名创建工作区并进入项目,create_automatic_local_game_project 固定传 name: null,不再等待 suggest_automatic_project_name。
- 命名请求在建项前并行打出,结果交给后台任务;命名慢、超时、失败或返回非法内容都不影响建项、附件导入与首轮创作。
- 新增 Rust 命令 rename_local_game_project_if_unchanged(expectedProjectId + expectedName 条件改名):项目 ID 不符、用户已手动改名、名称未变一律返回 renamed: false 并保持现状,空名/控制字符/超长失败关闭,改名不推进项目 revision。
- 后台改名结果等「建项主体收尾」再写当前项目上下文与最近项目行重检,避免被进项目的兜底上下文覆盖。
- 新增前端时序测试 tests/homeProjectNamingAsync.test.tsx(9 例,promise 闸门而非 sleep)与 appSurface 客户端流程用例。
- 新增 Rust 条件改名单测 5 例;更新实施计划专题、decision-log、pitfalls。

验证:npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx(9 passed);npm run test -- apps/ai-game-creator-shell/tests(1910 passed / 17 skipped);cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell conditional_project_rename_tests(5 passed);cargo check 同 features 通过;npm run agc:typecheck、cargo fmt --check、npm run check:encoding、git diff --check 全绿。
This commit is contained in:
2026-10-03 19:44:00 +08:00
parent 8aee5db84e
commit e3c91c4e2c
10 changed files with 822 additions and 6 deletions
@@ -502,6 +502,223 @@ pub(crate) fn rename_local_game_project(
})
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConditionalRenameLocalProjectResult {
pub(crate) renamed: bool,
pub(crate) manifest: GameCreationAppManifest,
pub(crate) revision: u64,
}
/// AI 后台命名回填用的条件改名核心。
///
/// 只有「项目身份未变」且「当前名称仍是这次创建时的兜底名」时才落盘改名;任何一项不匹配都
/// 原样返回当前 manifest / revision 并把 `renamed` 置为 false,绝不覆盖用户已经改过的名字。
/// 与 [`rename_local_game_project`] 一致:改名是簿记写入,刻意不推项目 revision。
pub(crate) fn rename_local_game_project_if_unchanged_at(
root: &Path,
expected_project_id: &str,
expected_name: &str,
name: &str,
) -> Result<ConditionalRenameLocalProjectResult, String> {
let next_name = normalize_game_creation_project_name(name.trim())?;
enforce_project_permission_policy(root, "project.rename")?;
let _lock = acquire_project_write_lock(root, "project.rename")?;
let current = read_existing_manifest_for_project(root)?;
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
// 项目身份不匹配:目录换了项目或身份被改写,一律不改名。
if current.project_id != expected_project_id.trim() {
return Ok(ConditionalRenameLocalProjectResult {
renamed: false,
manifest: current,
revision,
});
}
// 用户已经手动改过名(或别的流程改过):兜底名不再成立,绝不覆盖。
if current.name != expected_name.trim() {
return Ok(ConditionalRenameLocalProjectResult {
renamed: false,
manifest: current,
revision,
});
}
// 结果与现状一致:没有必要再写一次盘。
if current.name == next_name {
return Ok(ConditionalRenameLocalProjectResult {
renamed: false,
manifest: current,
revision,
});
}
let manifest = mutate_manifest_at(root, |manifest| {
manifest.name = next_name;
Ok(manifest.clone())
})?;
Ok(ConditionalRenameLocalProjectResult {
renamed: true,
manifest,
revision: read_game_creator_agent_runtime_project_revision(root)?.revision,
})
}
#[tauri::command]
pub(crate) fn rename_local_game_project_if_unchanged(
project_path: String,
expected_project_id: String,
expected_name: String,
name: String,
) -> Result<ConditionalRenameLocalProjectResult, String> {
let root = validated_local_project_directory_path(project_path.trim())?;
rename_local_game_project_if_unchanged_at(&root, &expected_project_id, &expected_name, &name)
}
#[cfg(test)]
mod conditional_project_rename_tests {
use super::*;
const FALLBACK_NAME: &str = "策划项目 ab12cd34";
fn fixture_root(project_id: &str) -> tempfile::TempDir {
let project = tempfile::tempdir().expect("project tempdir");
init_local_game_project_at(project.path(), project_id, FALLBACK_NAME)
.expect("initialize project");
project
}
fn manifest_name(root: &Path) -> String {
read_existing_manifest_for_project(root)
.expect("read manifest")
.name
}
fn project_revision(root: &Path) -> u64 {
read_game_creator_agent_runtime_project_revision(root)
.expect("read revision")
.revision
}
#[test]
fn conditional_rename_applies_when_fallback_name_still_matches() {
let project = fixture_root("conditional-rename-project");
let root = project.path();
let project_id = read_existing_manifest_for_project(root)
.expect("read manifest before")
.project_id;
let revision_before = project_revision(root);
let result = rename_local_game_project_if_unchanged_at(
root,
"conditional-rename-project",
FALLBACK_NAME,
"星轨夜航",
)
.expect("conditional rename applies");
assert!(result.renamed);
assert_eq!(result.manifest.name, "星轨夜航");
assert_eq!(result.manifest.project_id, project_id);
assert_eq!(manifest_name(root), "星轨夜航");
assert_eq!(project_revision(root), result.revision);
// 与 rename_local_game_project 同语义:改名是簿记写入,刻意不推项目 revision
// (见 projectResourceLiveUpdateModel.ts:推 revision 会让运行时的验证凭证无故漂移),
// 所以这里断言改名前后 revision 一致,而不是变大。
assert_eq!(result.revision, revision_before);
}
#[test]
fn conditional_rename_keeps_user_rename_when_expected_name_is_stale() {
let project = fixture_root("conditional-rename-user-edited");
let root = project.path();
// 用户先手动改名:此刻兜底名仍然成立,这一步会落盘。
let edited = rename_local_game_project_if_unchanged_at(
root,
"conditional-rename-user-edited",
FALLBACK_NAME,
"用户自己起的名字",
)
.expect("user rename applies");
assert!(edited.renamed);
// AI 后台命名这时才回来:它带的兜底名已过期,必须整体跳过。
let result = rename_local_game_project_if_unchanged_at(
root,
"conditional-rename-user-edited",
FALLBACK_NAME,
"星轨夜航",
)
.expect("stale expected name is not an error");
assert!(!result.renamed);
assert_eq!(result.manifest.name, "用户自己起的名字");
assert_eq!(manifest_name(root), "用户自己起的名字");
}
#[test]
fn conditional_rename_skips_when_project_identity_differs() {
let project = fixture_root("conditional-rename-project");
let root = project.path();
let revision_before = project_revision(root);
let result = rename_local_game_project_if_unchanged_at(
root,
"another-project-id",
FALLBACK_NAME,
"星轨夜航",
)
.expect("identity mismatch is not an error");
assert!(!result.renamed);
assert_eq!(result.manifest.project_id, "conditional-rename-project");
assert_eq!(result.manifest.name, FALLBACK_NAME);
assert_eq!(manifest_name(root), FALLBACK_NAME);
assert_eq!(project_revision(root), revision_before);
}
#[test]
fn conditional_rename_rejects_invalid_name_without_touching_the_project() {
let project = fixture_root("conditional-rename-invalid");
let root = project.path();
let revision_before = project_revision(root);
for invalid in ["", " ", "带\u{0}控制字符的名"] {
let error = rename_local_game_project_if_unchanged_at(
root,
"conditional-rename-invalid",
FALLBACK_NAME,
invalid,
)
.expect_err("invalid name must fail closed");
assert!(error.contains("项目名称"), "{error}");
}
assert_eq!(manifest_name(root), FALLBACK_NAME);
assert_eq!(project_revision(root), revision_before);
}
#[test]
fn conditional_rename_reports_false_when_name_already_matches() {
let project = fixture_root("conditional-rename-same-name");
let root = project.path();
let revision_before = project_revision(root);
for same in [FALLBACK_NAME, " 策划项目 ab12cd34 "] {
let result = rename_local_game_project_if_unchanged_at(
root,
"conditional-rename-same-name",
FALLBACK_NAME,
same,
)
.expect("same name is not an error");
assert!(!result.renamed);
assert_eq!(result.manifest.name, FALLBACK_NAME);
}
assert_eq!(manifest_name(root), FALLBACK_NAME);
assert_eq!(project_revision(root), revision_before);
}
}
#[tauri::command]
pub(crate) fn import_local_godot_project(
project_path: String,
@@ -550,6 +550,7 @@ pub(super) fn run() {
inspect_local_project_directory,
pick_local_project_directory,
rename_local_game_project,
rename_local_game_project_if_unchanged,
suggest_automatic_project_name,
polish_local_project_prompt,
pick_client_extension_file,
@@ -177,6 +177,16 @@ export interface RenameLocalProjectResult {
revision: number;
}
/**
* 条件改名结果:`renamed === false` 表示宿主按「项目 ID 或当前名称不匹配」整体跳过,
* 此时 `manifest` / `revision` 仍是跳过时的现状,调用方不要据此覆盖本地上下文。
*/
export interface ConditionalRenameLocalProjectResult {
renamed: boolean;
manifest: GameCreationAppManifest;
revision: number;
}
export interface LocalProjectDirectoryStatus {
projectPath: string;
exists: boolean;
@@ -137,6 +137,7 @@ export function WorkspaceLauncherShell({
showTransientNotice,
setLauncherView,
rememberRecentWorkspace,
refreshRecentWorkspace: recentProjects.refreshRecentWorkspace,
});
const templateLibrary = useTemplateLibrary({
userId: currentUser.id,
@@ -15,6 +15,7 @@ import { createLocalProjectId } from '../../app/constants';
import { useEscapeToClose } from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
ConditionalRenameLocalProjectResult,
InitLocalProjectResult,
LauncherImportedAttachment,
LauncherProjectContext,
@@ -84,11 +85,23 @@ type UseHomeProjectCreationOptions = {
showTransientNotice: ShowTransientNotice;
setLauncherView: Dispatch<SetStateAction<LauncherView>>;
rememberRecentWorkspace: (projectPath: string) => void;
/**
* 最近项目行的名称来自宿主目录重检(`inspect_local_project_directory` 读 manifest),
* 不是内存投影:后台 AI 改名成功后必须显式重检这一行,列表才会同步到新名字。
*/
refreshRecentWorkspace?: (projectPath: string) => Promise<void>;
};
export const DESIGN_ARTIFACTS_BUILD_PROMPT =
'查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。';
/**
* 发起一次 AI 项目命名请求并返回其结果 Promise。
*
* 调用方**不要 await 它再建项目**:命名只是增强能力,工作区创建、附件导入和首轮创作都不依赖它。
* 这里返回的 Promise 由后台改名任务消费(见 `applyAutomaticProjectName`);配置缺失、Provider
* 失败、超时或非法输出都解析为 `null`,由调用方保留兜底名称。
*/
async function suggestAutomaticProjectName(
invoke: TauriInvoke,
draft: HomeDraft,
@@ -185,6 +198,7 @@ export function useHomeProjectCreation({
showTransientNotice,
setLauncherView,
rememberRecentWorkspace,
refreshRecentWorkspace,
}: UseHomeProjectCreationOptions) {
const lifecycleRef = useRef({ mounted: false, generation: 0 });
const analyticsProjectPathRef = useRef<string | null>(null);
@@ -415,6 +429,72 @@ export function useHomeProjectCreation({
resetLauncherHomeDraft();
}
/**
* 后台把 AI 建议名落到已创建的工作区。
*
* 与建项并行发起(`naming` 在建项前就已经打出请求);改名落盘本身不等任何东西,
* 但「项目上下文」与「最近项目行」这两项都建立在进项目之后:AI 比进项目更快时直接写上下文
* 会被随后的 `enterProjectDevelopment` 用兜底名覆盖,而最近项目行也只有进项目登记过才会被
* 重检。所以它们等 `entrySettled`(建项主体收尾,含进项目成功/失败/让位于别的项目)。
* - 宿主侧条件改名要求「项目 ID 相同」且「当前名称仍是这次创建时的兜底名称」;用户已手动改名时
* 返回 `renamed: false`,这里整体跳过,绝不覆盖用户输入。
* - AI 返回空/非法/失败(Promise 解析为 null)或用例里没有可改名空间时,保持兜底名称。
* - 任何失败都只写诊断日志,不改变已经进入的工作区与首轮创作。
*/
async function applyAutomaticProjectName(
invoke: TauriInvoke,
naming: Promise<string | null>,
created: InitLocalProjectResult,
entrySettled: Promise<void>,
) {
let suggested: string | null = null;
try {
suggested = (await naming)?.trim() || null;
} catch {
return;
}
// 建议名就是兜底名(或没有建议):没有可改的,别白跑一次写操作。
if (!suggested || suggested === created.manifest.name) {
return;
}
let outcome: ConditionalRenameLocalProjectResult;
try {
outcome = await invoke<ConditionalRenameLocalProjectResult>(
'rename_local_game_project_if_unchanged',
{
projectPath: created.projectPath,
expectedProjectId: created.manifest.projectId,
expectedName: created.manifest.name,
name: suggested,
},
);
} catch (error) {
appendWebviewLog('warn', [
`[home-project-name] ${error instanceof Error ? error.message : String(error)}`,
]);
return;
}
if (!outcome?.renamed) {
return;
}
await entrySettled;
setCurrentProjectContext((current) =>
current && current.projectPath === created.projectPath
? {
...current,
projectName: outcome.manifest.name,
manifest: outcome.manifest,
projectRevision: outcome.revision,
}
: current,
);
try {
await refreshRecentWorkspace?.(created.projectPath);
} catch {
// 最近项目行是辅助投影:重检失败不影响已经生效的改名与工作台标题。
}
}
/**
* 创建失败的统一收口。
*
@@ -834,8 +914,8 @@ export function useHomeProjectCreation({
startMode: ProjectStartMode,
) {
return createHomeDraftAutomaticallyWithOptions(draft, startMode, {
// 做方案的首轮还要调用一次策划 Provider;项目名称不是策划输入的
// 前置条件,避免在进入工作区前再额外等待一次模型请求。
// 做方案的首轮还要调用一次策划 Provider;项目命名对它没有价值,
// 「做方案」也从不发起自动项目命名请求。
suggestName: startMode !== 'planning',
});
}
@@ -899,6 +979,17 @@ export function useHomeProjectCreation({
resolve(HOME_CREATION_WATCHDOG_MESSAGE);
}, HOME_CREATION_DEADLINE_MS);
});
/**
* 「建项主体收尾」信号(进项目成功、进项目失败、或让位于别的项目都算)。
*
* 后台 AI 改名可能比进项目更快:那时工作区上下文还没建立,直接写上下文会被随后的
* `enterProjectDevelopment` 覆盖回兜底名,最近项目行也还没被登记进列表。改名落盘照旧立即执行,
* 只有这两项依赖进项目结果的更新等这枚信号。
*/
let settleEntry!: () => void;
const entrySettled = new Promise<void>((resolve) => {
settleEntry = resolve;
});
/** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */
const creation = (async () => {
try {
@@ -919,17 +1010,30 @@ export function useHomeProjectCreation({
);
return message;
}
const suggestedName = options.suggestName
? await suggestAutomaticProjectName(invoke, draft)
// 命名与建项并行:先把命名请求打出去,再建工作区,两者互不等待。命名结果由后台任务
// 落定(见 `applyAutomaticProjectName`),用户不必为了一个名字多等一次模型请求。
const automaticName = options.suggestName
? suggestAutomaticProjectName(invoke, draft)
: null;
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project',
{
name: suggestedName,
// 不传名:走宿主兜底名("GameAgent 项目 <id>" / "策划项目 <id>"),
// AI 建议名回来后再按「名称仍是兜底名」的条件改名。
name: null,
planning: startMode === 'planning',
},
);
createdProjectPath = result.projectPath;
if (automaticName) {
// 纯后台任务:进入工作区、导入附件与首轮创作都不等它。
void applyAutomaticProjectName(
invoke,
automaticName,
result,
entrySettled,
).catch(() => undefined);
}
setHomeCreationOperation(
transitionClientOperation(operation, 'project', {
scope: { projectPath: result.projectPath },
@@ -990,6 +1094,8 @@ export function useHomeProjectCreation({
}
throw failHomeCreation(error, Boolean(createdProjectPath));
} finally {
// 进项目(或放弃进项目)已经尘埃落定,后台改名可以安全地写上下文与最近项目。
settleEntry();
if (watchdogId !== undefined) {
window.clearTimeout(watchdogId);
}
@@ -1672,6 +1672,20 @@ export function registerHomeProjectCreationTests() {
});
return '角色参考游戏';
}
if (command === 'rename_local_game_project_if_unchanged') {
// 建项时用的是宿主兜底名;后台命名回来后按「项目 ID + 兜底名」条件改名。
expect(args).toEqual({
projectPath: automaticProjectPath,
expectedProjectId: manifest.projectId,
expectedName: manifest.name,
name: '角色参考游戏',
});
return {
renamed: true,
manifest: { ...manifest, name: '角色参考游戏' },
revision: 2,
};
}
if (command === 'enqueue_direct_codex_turn') {
chatHarness.completeDirectThreadTurn({
turnId: String(args?.clientTurnId ?? ''),
@@ -1711,10 +1725,15 @@ export function registerHomeProjectCreationTests() {
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
// 建项不再等 AI 命名:名称留空由宿主兜底,命名结果在后台条件改名。
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: '角色参考游戏',
name: null,
planning: false,
});
expect(invoke).toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
expect.objectContaining({ name: '角色参考游戏' }),
);
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
projectPath: automaticProjectPath,
fileName: '角色参考.png',
@@ -1784,6 +1803,119 @@ export function registerHomeProjectCreationTests() {
).toEqual([{ type: 'input_text', text: '再补一句玩法' }]);
});
it('syncs the workbench title and recent project list with the AI name after creation', async () => {
const projectPath = '/tmp/home-async-named-project';
const fallbackManifest = createGameCreationAppManifest(
'home-async-named-project',
'GameAgent 项目 1a2b3c4d',
);
const renamedManifest = { ...fallbackManifest, name: '星轨夜航' };
let renamed = false;
// 命名闸门手控:先证明"命名还没回来时已经进了工作区",再放行命名验证同步。
let resolveNaming!: (name: string) => void;
const namingGate = new Promise<string>((resolve) => {
resolveNaming = resolve;
});
const chatHarness = createProjectChatRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest: fallbackManifest,
};
}
// 命名与建项并行:建项拿不到名字,AI 结果只走后台条件改名。
if (command === 'suggest_automatic_project_name') return namingGate;
if (command === 'rename_local_game_project_if_unchanged') {
expect(args).toEqual({
projectPath,
expectedProjectId: fallbackManifest.projectId,
expectedName: fallbackManifest.name,
name: '星轨夜航',
});
renamed = true;
return { renamed: true, manifest: renamedManifest, revision: 2 };
}
if (command === 'get_local_game_manifest') {
return renamed ? renamedManifest : fallbackManifest;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: String(args?.projectPath ?? ''),
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isCocosProject: false,
godotProjectRoot: null,
// 最近项目行的名字就来自这次重检;改名前是兜底名,改名后是 AI 名。
projectName: renamed ? '星轨夜航' : fallbackManifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'enqueue_direct_codex_turn') {
chatHarness.completeDirectThreadTurn({
turnId: String(args?.clientTurnId ?? ''),
prompt: '做一个星轨游戏',
reply: '开工。',
});
return '开工。';
}
return chatHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: chatHarness.listen },
};
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify([projectPath]),
);
renderLauncherAt('/?launcher', 'home', true);
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '做一个星轨游戏';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('做一个星轨游戏');
});
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
// 进工作区时命名还没回来:创建并不等命名结果,兜底名仍然生效。
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: null,
planning: false,
});
expect(invoke).not.toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
expect.anything(),
);
// 放行命名:AI 名称落定后走条件改名(工作台标题取自项目上下文,见 hook 用例)。
await act(async () => {
resolveNaming('星轨夜航');
await namingGate;
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
expect.objectContaining({ name: '星轨夜航' }),
);
});
// 最近项目行按目录重检读 manifest:列表最终也显示 AI 名称。
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
expect(await screen.findByText('星轨夜航')).not.toBeNull();
expect(screen.queryByText('GameAgent 项目 1a2b3c4d')).toBeNull();
});
it('keeps the home composer out of chat mode while automatic project creation is pending', async () => {
let rejectAutomaticProject: ((error: Error) => void) | null = null;
const automaticProject = new Promise<never>((_resolve, reject) => {
@@ -0,0 +1,320 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { HomeProjectCreationController } from '../src/features/app-shell/useHomeProjectCreation';
import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
const PROJECT_PATH = 'C:/test/async-naming-game';
const PROJECT_ID = 'gameagent-1a2b3c4d';
/** 宿主在建项时给的兜底名(`GameAgent 项目 <8 位短 id>`)。 */
const FALLBACK_NAME = 'GameAgent 项目 1a2b3c4d';
const SUGGESTED_NAME = '星河远征';
afterEach(() => {
cleanup();
delete window.__TAURI__;
});
/** 手控闸门:测试自己决定命名请求何时返回,不靠 sleep 赌时序。 */
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function mount(options: {
naming: Promise<string | null> | string;
/** 条件改名命令的返回;缺省表示调用它就是测试失败。 */
conditionalRename?: () => Promise<unknown>;
/** 其它命令的按名处理;用于把某一步(如进项目时的预览核验)卡在手控闸门上。 */
extraCommands?: Record<
string,
(args?: Record<string, unknown>) => Promise<unknown>
>;
}) {
const order: string[] = [];
const invoke = vi.fn(async (command: string) => {
if (command === 'capture_analytics_context') return {};
if (command === 'preflight_web_game_creation') return { status: 'ready' };
if (command === 'suggest_automatic_project_name') {
order.push(command);
return options.naming;
}
if (command === 'create_automatic_local_game_project') {
order.push(command);
return {
projectPath: PROJECT_PATH,
manifestPath: `${PROJECT_PATH}/.agent/manifest.json`,
manifest: createGameCreationAppManifest(PROJECT_ID, FALLBACK_NAME),
};
}
if (command === 'get_local_game_project_revision') return { revision: 1 };
if (command === 'rename_local_game_project_if_unchanged') {
if (!options.conditionalRename) {
throw new Error('未预期的条件改名调用');
}
return options.conditionalRename();
}
const extra = options.extraCommands?.[command];
if (extra) {
return extra();
}
return null;
});
const refreshRecentWorkspace = vi.fn(async () => {});
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
const hook = renderHook(() =>
useHomeProjectCreation({
setStatus: vi.fn(),
showTransientNotice: vi.fn(),
setLauncherView: vi.fn(),
rememberRecentWorkspace: vi.fn(),
refreshRecentWorkspace,
}),
);
return { ...hook, invoke, order, refreshRecentWorkspace };
}
function createAutomatically(
result: { current: HomeProjectCreationController },
startMode: 'direct-build' | 'planning' = 'direct-build',
) {
return result.current.createHomeDraftAutomatically(
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
startMode,
);
}
it('enters the workspace even though the AI naming request never returns', async () => {
const naming = deferred<string | null>();
const { result, invoke, order } = mount({ naming: naming.promise });
let creation!: Promise<string>;
act(() => {
creation = createAutomatically(result);
});
let status = '';
// 命名闸门一直关着。若建项链路还在等命名,这里会一直挂到用例超时——
// 这正是"`create_automatic_local_game_project` 不再等待命名"的判定方式。
await act(async () => {
status = await creation;
});
expect(status).toBe('已创建工作区并进入项目开发');
expect(result.current.projectPath).toBe(PROJECT_PATH);
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
// 命名请求先打出、建项随后并行,两者都不等对方。
expect(order).toEqual([
'suggest_automatic_project_name',
'create_automatic_local_game_project',
]);
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: null,
planning: false,
});
expect(invoke).not.toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
expect.anything(),
);
await act(async () => {
naming.resolve(null);
await naming.promise;
});
});
it('applies a valid AI name after it arrives and refreshes the recent row', async () => {
const naming = deferred<string | null>();
const { result, invoke, refreshRecentWorkspace } = mount({
naming: naming.promise,
conditionalRename: async () => ({
renamed: true,
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
revision: 7,
}),
});
await act(async () => {
await createAutomatically(result);
});
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
await act(async () => {
naming.resolve(` ${SUGGESTED_NAME} `);
await naming.promise;
});
expect(invoke).toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
{
projectPath: PROJECT_PATH,
// 条件改名的判据由前端原样转述:项目 ID + 这次创建的兜底名称。
expectedProjectId: PROJECT_ID,
expectedName: FALLBACK_NAME,
name: SUGGESTED_NAME,
},
);
expect(result.current.currentProjectContext?.projectName).toBe(
SUGGESTED_NAME,
);
expect(result.current.currentProjectContext?.manifest.name).toBe(
SUGGESTED_NAME,
);
expect(result.current.currentProjectContext?.projectRevision).toBe(7);
expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH);
});
it('keeps the AI name when the naming result lands before the project is entered', async () => {
const renamedManifest = createGameCreationAppManifest(
PROJECT_ID,
SUGGESTED_NAME,
);
// 进项目时的会话预览核验卡住:AI 改名先落定,进项目还没写完上下文。
let releasePreview!: () => void;
const previewGate = new Promise<void>((resolve) => {
releasePreview = resolve;
});
let markRenameInvoked!: () => void;
const renameInvoked = new Promise<void>((resolve) => {
markRenameInvoked = resolve;
});
const { result } = mount({
// 命名瞬时返回(最快的模型情况),建项与改名之间没有等待。
naming: SUGGESTED_NAME,
conditionalRename: async () => {
markRenameInvoked();
return { renamed: true, manifest: renamedManifest, revision: 1 };
},
extraCommands: {
get_local_game_preview_status: async () => {
await previewGate;
return null;
},
},
});
let creation!: Promise<string>;
act(() => {
creation = createAutomatically(result);
});
await act(async () => {
await renameInvoked;
});
// 此刻改名已经落定,但工作区上下文还没建立。
expect(result.current.currentProjectContext).toBeNull();
await act(async () => {
releasePreview();
await creation;
});
// 进项目写下的兜底上下文之后,后台改名结果必须仍然生效,不能被覆盖回兜底名。
expect(result.current.currentProjectContext?.projectName).toBe(
SUGGESTED_NAME,
);
expect(result.current.currentProjectContext?.manifest.name).toBe(
SUGGESTED_NAME,
);
});
it.each(['', ' ', null])(
'keeps the fallback name for a non-usable AI response (%s)',
async (response) => {
const naming = deferred<string | null>();
const { result, invoke } = mount({
naming: naming.promise,
conditionalRename: async () => {
throw new Error('空/非法 AI 名称不应触发改名');
},
});
await act(async () => {
await createAutomatically(result);
});
await act(async () => {
naming.resolve(response);
await naming.promise;
});
expect(invoke).not.toHaveBeenCalledWith(
'rename_local_game_project_if_unchanged',
expect.anything(),
);
expect(result.current.currentProjectContext?.projectName).toBe(
FALLBACK_NAME,
);
},
);
it('does not overwrite a name the user already changed', async () => {
const naming = deferred<string | null>();
const { result } = mount({
naming: naming.promise,
// 用户已经手动改过名:宿主按「当前名称不再是兜底名」整体跳过。
conditionalRename: async () => ({
renamed: false,
manifest: createGameCreationAppManifest(PROJECT_ID, '我自己起的名字'),
revision: 9,
}),
});
await act(async () => {
await createAutomatically(result);
});
await act(async () => {
naming.resolve(SUGGESTED_NAME);
await naming.promise;
});
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
});
it('keeps the created workspace when the conditional rename fails', async () => {
const naming = deferred<string | null>();
const { result } = mount({
naming: naming.promise,
conditionalRename: async () => {
throw new Error('项目正在被其他写操作占用');
},
});
let status = '';
await act(async () => {
status = await createAutomatically(result);
});
await act(async () => {
naming.resolve(SUGGESTED_NAME);
await naming.promise;
});
expect(status).toBe('已创建工作区并进入项目开发');
expect(result.current.projectPath).toBe(PROJECT_PATH);
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
});
it('never starts automatic naming for the 做方案 flow', async () => {
const naming = deferred<string | null>();
const { result, invoke } = mount({ naming: naming.promise });
await act(async () => {
await createAutomatically(result, 'planning');
});
expect(invoke).not.toHaveBeenCalledWith(
'suggest_automatic_project_name',
expect.anything(),
);
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: null,
planning: true,
});
await act(async () => {
naming.resolve(null);
await naming.promise;
});
});
@@ -1,5 +1,13 @@
# 决策记录
## 2026-10-03 首页自动建项与 AI 项目命名解耦(Issue 599)
- 背景:首页「开启创作」原先串行执行「Web 预检 → `await suggest_automatic_project_name` → `create_automatic_local_game_project`」。项目名称不是创建工作区、导入附件或发起首轮创作的前置条件,命名请求(`AUTOMATIC_PROJECT_NAME_TIMEOUT_MS = 15s`,正常请求同样占时)却把用户按在「正在创建工作区」上。
- 决策:建项与命名拆成两条并行链路。建项固定传 `name: null`,由宿主既有兜底名(`GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`)落盘并立即进入项目;命名请求在建项前打出、结果交给后台任务。后台拿到合法名称后调用新增的 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:只有「项目 ID 相同」且「当前名称仍是本次创建的兜底名」才改名,返回 `renamed: true/false`(跳过时不写盘、不是错误)。用户已手动改名、项目 ID 不符、名称与现状相同一律跳过;空名 / 控制字符 / 超长由 `normalize_game_creation_project_name` 失败关闭。
- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检。「做方案」与手动选目录建项不发起自动命名。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx}}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,appSurface/home.suite.ts}`。
- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(9 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖」「非法/空响应保留兜底名」「做方案不发起命名」);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1910 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`(5 passed);`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。
## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359)
- 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。
@@ -2,6 +2,16 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## 2026-10-03 后台异步回填被「进项目」覆盖:AI 项目名只在兜底名仍成立时改
- **现象**(Issue 599):首页建项把 AI 命名改成「与建项并行、结果后台回填」后,偶尔工作台标题停在兜底名(`GameAgent 项目 <短 id>`),而磁盘 manifest 已经是 AI 名字。
- **原因**:改名落盘很快,但写「当前项目上下文」的那次 `setCurrentProjectContext` 被 `enterProjectDevelopment` 随后的兜底上下文覆盖——AI 比进项目更快时,回填写入时上下文里还没有这个项目(`current` 为 null 或仍是上一个项目)。同一原因下 `refreshRecentWorkspace` 也会被丢弃:最近项目行只在 `recentWorkspacesRef` 已包含该路径时才更新状态,而路径是进项目时才登记的。
- **处理(现行口径)**:后台改名任务里,「写项目上下文 + 重检最近项目行」这两步必须等「建项主体收尾」(进项目成功、失败或让位于别的项目)的信号再执行;改名本身照旧立即落盘。回归用 promise 闸门卡住 `get_local_game_preview_status` 复现顺序,不用 sleep。
- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx` 的「keeps the AI name when the naming result lands before the project is entered」(去掉等待即复现兜底名覆盖);appSurface 的「syncs the workbench title and recent project list with the AI name after creation」。
- **边界**:条件改名的判据必须由宿主校验(项目 ID + 当前名称仍是兜底名),前端只转述 `expectedProjectId` / `expectedName`;用户已手动改名时宿主返回 `renamed: false`,回填整体跳过、绝不覆盖用户输入。
- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`。
## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目
- **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。
@@ -1,5 +1,16 @@
# AI 游戏创作智能体 App 实施计划
## 2026-10-03 首页建项不等 AI 命名(Issue 599)
本节覆盖下文「2026-08-20 Direct Codex 审核 Skill Pack 与受控工具内核」里「自动项目命名仍是首页建项前置」的隐含口径。
- 首页「开启创作 / 进入立项策划」的提交链路是「Web 环境预检 → 立即用宿主兜底名创建工作区 → 进入项目 → 首轮创作」。`create_automatic_local_game_project` 不再接收前端命名结果(固定 `name: null`,由宿主生成 `GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`):AI 命名请求在建项前并行打出,命名慢、超时、失败或返回非法内容都不影响建项、附件导入与首轮创作。
- AI 返回合法名称后走新增的条件改名命令 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:`expectedProjectId` 取自建项返回的 `manifest.projectId`,`expectedName` 是本次创建的兜底名。只有「项目身份一致」且「当前名称仍是兜底名」才落盘改名;用户已手动改名、项目 ID 不符、名称与现状相同都返回 `renamed: false` 并保持现状,空名 / 控制字符 / 超长失败关闭。改名与既有 `rename_local_game_project` 同口径:簿记写入,不推进项目 revision。
- 改名成功后,当前项目上下文(窗口标题与工作台项目名)与最近项目行的目录重检在「进项目落定」之后写入;AI 结果比进项目更快返回时同样以 AI 名称为准,不被 `enterProjectDevelopment` 的兜底上下文覆盖。
- 「做方案」不发起自动项目命名请求;手动选择目录建项保持原有行为(沿用目录名,不做 AI 命名)。
- 回归入口:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(时序用 promise 闸门,不用 sleep)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`、`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`。
## 2026-10-02 退役自建 Agent Runtime 与 CLI 执行面
自建 Agent Runtime(`agent/runtime_driver`、`runtime_protocol`、`runtime_tools`、`runtime_actions`、`runtime_state`、`runtime_adapter`、`agent/prompt.rs`、`agent_native_tools.rs`、`collaboration.rs`、`delegation.rs`、`goal.rs`、`context_compaction.rs`、`isolated_agent.rs`、`tool_plan_handoff/`、`user_input.rs`)与它的 Tauri 命令、`--agent-*` CLI、真实 e2e harness、CI job 一并退役,历史由 Git 保存,见 `docs/adr/【ADR】退役AGC独立Agent Runtime与CLI执行面-2026-10-02.md`。本文正文里所有关于 `--agent-run` / `--agent-task` / `--agent-runner-status` / `agent-run:smoke` / `agent-runtime-real-e2e.mjs`、后台单 Agent 队列、`read_game_creator_agent_runtime(s)`、Supervisor 自主协作与 Goal 生命周期的描述均已失效,不再作为实现依据。外部 Runner(`--agent-runner`)仅保留编辑器桥 RPC 与 `runner.attach_gui_owner` + GUI owner 参与锁 / watchdog。AGC 现役链路以 DirectProject(`agent/direct_*` + codex app-server)、资源工作台与本文其后的 DirectProject 相关条目为准。