Merge branch 'master' into opt/design-debug
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m32s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m39s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m56s
Project CI / Repository checks (pull_request) Successful in 4m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m33s
Project CI / Backend tests (pull_request) Successful in 8m44s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / Frontend tests (pull_request) Successful in 4m31s
Project CI / Native shell tests (pull_request) Successful in 6m53s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m42s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m45s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m48s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m1s
Project CI / AI game creator shell Rust crates (push) Successful in 3m22s
Project CI / Frontend tests (push) Successful in 5m23s
Project CI / Backend tests (push) Successful in 7m54s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m41s
Project CI / Repository checks (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 7m50s
Project CI / AI game creator shell web tests (push) Successful in 2m32s

This commit was merged in pull request #371.
This commit is contained in:
2026-09-15 12:29:58 +08:00
15 changed files with 261 additions and 12 deletions
@@ -2159,6 +2159,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
generate_platform_art_asset_with_options_at(&state.root, &prompt, &[], &options),
)
.await?;
emit_game_creator_manifest_invalidated(&state.root, "direct-codex-art");
let resources = bridge_art_resources(
&state.root,
std::slice::from_ref(&generated.asset.local_path),
@@ -1088,6 +1088,90 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
fs::remove_dir_all(ui_config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn direct_image_generation_notifies_after_manifest_commit() {
let root = unique_project_path();
let config_dir = unique_project_path();
let canvas_base_url = spawn_mock_external_canvas_generation_api_server(None);
let _session = crate::platform_session::install_test_platform_session(
"direct-image-refresh-user",
"editor-runtime-key",
&canvas_base_url,
);
fs::create_dir_all(&config_dir).expect("create config directory");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" }
})
.to_string(),
)
.expect("write config");
let _config = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(&root, "direct-image-refresh", "生成图片刷新测试")
.expect("init project");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow generation");
let listener =
TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).expect("bind event receiver");
let sink = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
sink.configure(listener.local_addr().unwrap().port(), &"d".repeat(64))
.expect("configure event receiver");
let bridge = start_direct_tool_bridge(&root, false)
.await
.expect("start tool bridge");
let client = reqwest::Client::new();
let result: Value = client.post(bridge.url()).json(&serde_json::json!({
"tool": "agc_generate_image",
"arguments": { "prompt": "像素月光主角", "kind": "icon-spec", "outputPath": "assets/art-spec.png" }
})).send().await.expect("generate through bridge").json().await.expect("read tool result");
assert_eq!(result["isError"], false, "{result}");
let manifest = read_existing_manifest_for_project(&root).expect("read committed manifest");
assert!(manifest
.assets
.iter()
.any(|asset| asset.local_path == "assets/art-spec.png"));
assert!(root.join("assets/art-spec.png").is_file());
let payload = read_manifest_invalidation_relay_payload_with_deadline(&listener)
.expect("generation must notify the client");
let envelope: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&payload).expect("event envelope");
assert_eq!(
envelope.event.project_path,
fs::canonicalize(&root).unwrap().to_string_lossy()
);
assert_eq!(envelope.event.agent_id, "direct-codex-art");
let rejected: Value = client
.post(bridge.url())
.json(&serde_json::json!({
"tool": "agc_generate_image", "arguments": { "prompt": "", "kind": "icon-spec" }
}))
.send()
.await
.expect("send rejected request")
.json()
.await
.expect("read rejected result");
assert_eq!(rejected["isError"], true);
assert_eq!(
read_manifest_invalidation_relay_payload_with_deadline(&listener)
.expect_err("rejected generation must not emit a commit")
.kind(),
io::ErrorKind::TimedOut
);
drop(bridge);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_manifest() {
let root = unique_project_path();
+10 -2
View File
@@ -190,6 +190,7 @@ import {
missingChatCommandArgumentMessage,
projectFileActionDrafts,
projectPathHasControlCharacter,
projectPathsMatchForInvalidation,
readableArtifactsFromAgentRunTrace,
sortCheckpointManifestFiles,
summarizeAgentRunSupportFileReadDrafts,
@@ -2192,10 +2193,17 @@ export function App({
void subscribeTauriEvent<GameCreatorManifestInvalidatedEvent>(
'game-creator-manifest-invalidated',
(event) => {
if (event.payload.projectPath !== localProjectPathRef.current) {
const activeProjectPath = localProjectPathRef.current;
if (
!activeProjectPath ||
!projectPathsMatchForInvalidation(
event.payload.projectPath,
activeProjectPath,
)
) {
return;
}
void refreshManifest(event.payload.projectPath);
void refreshManifest(activeProjectPath);
},
)
.then((unlisten) => {
@@ -17,6 +17,27 @@ export function projectPathHasControlCharacter(value: string) {
});
}
// 失效事件只是重读提示:匹配 Windows 的普通 / verbatim 路径后,调用方仍用当前项目路径
// 读取权威清单。此比较不解析链接,也不作为文件访问授权依据。
export function projectPathsMatchForInvalidation(
eventPath: string,
activePath: string | null,
) {
if (!eventPath || !activePath) return false;
function normalize(path: string) {
if (/^\\\\\?\\UNC\\/i.test(path)) {
path = `\\\\${path.slice(8)}`;
} else if (/^\\\\\?\\[a-z]:\\/i.test(path)) {
path = path.slice(4);
}
if (/^[a-z]:[\\/]/i.test(path) || /^\\\\[^?.\\][^\\]*\\[^\\]+/.test(path)) {
return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
}
return path;
}
return normalize(eventPath) === normalize(activePath);
}
export function isSafeProjectRelativePath(value: string) {
const path = value.trim();
return (
@@ -80,6 +80,7 @@ export {
isAbsoluteProjectPath,
isSafeProjectRelativePath,
projectPathHasControlCharacter,
projectPathsMatchForInvalidation,
} from './projectPath';
export {
summarizeProjectDependencyMap,
@@ -0,0 +1,12 @@
import type { ProjectStartMode } from '../../app/types';
import type { HomeCreationType } from './useHomeDraftStore';
export function resolveHomeStartMode(
creationType: HomeCreationType,
planningCompletionEnabled: boolean,
): ProjectStartMode {
return creationType === 'doc' ||
(creationType === 'game' && planningCompletionEnabled)
? 'planning'
: 'direct-build';
}
@@ -20,6 +20,7 @@ import {
richTextToAttachments,
richTextToPrompt,
} from './components/RichInputArea/richTextToPrompt';
import { resolveHomeStartMode } from './homeStartMode';
import InspirationGallery from './InspirationGallery';
import {
type HomeCreationType,
@@ -145,14 +146,18 @@ export default function HomeView({
(state) => state.setRichText,
);
const [homeCreationBusy, setHomeCreationBusy] = useState(false);
const [planningCompletionEnabled, setPlanningCompletionEnabled] =
useState(false);
const homeCreationBusyRef = useRef(false);
const activeCreationType =
HOME_CREATION_TYPE_ITEMS.find(
(item) => item.creationType === homeCreationType,
) ?? HOME_CREATION_TYPE_ITEMS[0]!;
// 做方案走立项策划链路做游戏 / 做素材维持既有的直接开建路由
const startMode: ProjectStartMode =
homeCreationType === 'doc' ? 'planning' : 'direct-build';
// 做方案始终走立项策划链路做游戏勾选“策划补全”时复用该链路,做素材保持直接开建。
const startMode = resolveHomeStartMode(
homeCreationType,
planningCompletionEnabled,
);
async function createFromHome() {
if (homeCreationBusyRef.current || creationBusy) {
@@ -241,7 +246,12 @@ export default function HomeView({
type="button"
key={item.creationType}
aria-pressed={isActive}
onClick={() => setHomeCreationType(item.creationType)}
onClick={() => {
setHomeCreationType(item.creationType);
if (item.creationType !== 'game') {
setPlanningCompletionEnabled(false);
}
}}
>
<CreationTypeIcon size={14} aria-hidden="true" />
{item.label}
@@ -262,8 +272,21 @@ export default function HomeView({
}}
>
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
<div className="flex min-w-0 items-center gap-1.5">
<div className="flex min-w-0 items-center gap-3">
<UploadButton />
{homeCreationType === 'game' ? (
<label className="inline-flex cursor-pointer items-center gap-1.5 whitespace-nowrap">
<input
type="checkbox"
checked={planningCompletionEnabled}
onChange={(event) =>
setPlanningCompletionEnabled(event.target.checked)
}
disabled={homeCreationBusy || creationBusy}
/>
<span></span>
</label>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<ConversationModelSelect
@@ -1212,10 +1212,10 @@ function createProjectSupervisorRuntimeHarness({
},
});
},
emitManifestInvalidated(agentId: string) {
emitManifestInvalidated(agentId: string, eventProjectPath = projectPath) {
manifestInvalidatedHandler?.({
payload: {
projectPath,
projectPath: eventProjectPath,
agentId,
},
});
@@ -2257,6 +2257,7 @@ export function registerHomeProjectCreationTests() {
it('refreshes Direct Codex art commits while the turn is still running and after a later failure', async () => {
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\live-direct-art';
const eventProjectPath = `\\\\?\\${projectPath}`;
const manifest = createGameCreationAppManifest(
'live-direct-art',
'直连美术实时刷新',
@@ -2347,7 +2348,7 @@ export function registerHomeProjectCreationTests() {
taskId: 'direct-codex-art-art-spritesheet',
},
},
];
].map((asset) => ({ ...asset, category: 'ui-interaction' as const }));
for (let index = 0; index < committedAssets.length; index += 1) {
const refreshCountBeforeEvent = invoke.mock.calls.filter(
@@ -2357,8 +2358,12 @@ export function registerHomeProjectCreationTests() {
...currentManifest,
assets: committedAssets.slice(0, index + 1),
};
runtimeHarness.setProjectRevision(index + 1);
act(() => {
runtimeHarness.emitManifestInvalidated('direct-codex-art');
runtimeHarness.emitManifestInvalidated(
'direct-codex-art',
eventProjectPath,
);
});
await waitFor(() => {
expect(
@@ -2373,6 +2378,12 @@ export function registerHomeProjectCreationTests() {
),
).toHaveLength(1);
}
await openResourceBookCategory('UI 交互');
expect(getResourceSelectButton('art-spec.png')).not.toBeNull();
expect(
getResourceSelectButton('direct-game-background.png'),
).not.toBeNull();
expect(getResourceSelectButton('art-spritesheet.png')).not.toBeNull();
const refreshCountBeforeFailure = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { resolveHomeStartMode } from '../src/view/home/homeStartMode';
describe('AGC 首页启动模式', () => {
it('做游戏勾选策划补全时进入策划 runtime', () => {
expect(resolveHomeStartMode('game', true)).toBe('planning');
});
it('做游戏未勾选策划补全时保持直接创作', () => {
expect(resolveHomeStartMode('game', false)).toBe('direct-build');
});
it('做方案始终进入策划 runtime,其他入口不受影响', () => {
expect(resolveHomeStartMode('doc', false)).toBe('planning');
expect(resolveHomeStartMode('art', true)).toBe('direct-build');
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { projectPathsMatchForInvalidation } from '../src/features/project-summary/projectPath';
describe('项目刷新事件路径', () => {
it.each([
['C:\\Projects\\game', '\\\\?\\C:\\Projects\\game'],
['\\\\?\\C:\\Projects\\game', 'c:/Projects/game/'],
['\\\\server\\share\\game', '\\\\?\\UNC\\server\\share\\game'],
['/tmp/game', '/tmp/game'],
])('识别同一项目 %s 与 %s', (eventPath, activePath) => {
expect(projectPathsMatchForInvalidation(eventPath, activePath)).toBe(true);
});
it.each([
['\\\\?\\C:\\Projects\\game-other', 'C:\\Projects\\game'],
['\\\\?\\C:\\Projects\\game\\child', 'C:\\Projects\\game'],
['\\\\?\\UNC\\other\\share\\game', '\\\\server\\share\\game'],
['\\\\.\\C:\\Projects\\game', 'C:\\Projects\\game'],
['/tmp/Game', '/tmp/game'],
['', ''],
['C:\\Projects\\game', null],
])('拒绝其它项目或空作用域 %s 与 %s', (eventPath, activePath) => {
expect(projectPathsMatchForInvalidation(eventPath, activePath)).toBe(false);
});
});
@@ -0,0 +1,20 @@
Version: 1
Status: active
Date: 2026-09-15
Parent Spec: 【里程碑】AGC首页策划补全入口-2026-09-15.md
## 修改顺序
1.`view/home/index.tsx` 增加本地复选框状态与入口切换清理。
2. 将游戏勾选状态映射到既有 `ProjectStartMode`
3. 增加启动模式纯函数测试,覆盖模式分流;首页显示边界和切换清理作为后续组件测试补充项。
## 验证
- AGC 首页相关定向测试。
- AGC 前端 typecheck。
- `npm run check:encoding``git diff --check`
## 当前验证边界
本次已交付测试覆盖 `planning` / `direct-build` 模式分流;“策划补全”复选框的显示边界及切换创作类型后的状态清理尚未有组件级自动化测试,需后续补充 `HomeView` 测试时完成。
@@ -0,0 +1,20 @@
Version: 1
Status: active
Date: 2026-09-15
Parent Spec: AGC 首页与 Agent Runtime 入口
## 范围
在首页“做游戏”输入框下增加“策划补全”复选框;勾选后复用现有 `planning` 启动模式进入策划 Agent Runtime。
## 验收标准
- 仅“做游戏”显示复选框。
- 勾选时提交 `planning`,未勾选时提交 `direct-build`
- “做方案”原有 `planning` 行为保持不变。
- 切换到其它创作类型时清除游戏专属勾选状态。
## 不做项
- 不新增 runtime 类型、后端接口或持久化字段。
- 不改变现有策划 runtime 内部流程。
@@ -1,5 +1,9 @@
# 踩坑与排障记录
## Windows 已登记生图资产未刷新
Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。
## 2026-09-14 严格 IPC 桩缺登记新命令时,症状可能是「unhandled rejection + 不相干的提示断言」,而不是同一处报错
- **现象**`ProjectDevelopmentView` 新增「项目打开时读生成任务账本」(`list_local_project_asset_generations`)后,两个**别的关注点**的用例同时红:`resourceCanvasManualLayout.test.tsx``AssertionError: expected [ Array(1) ] to deeply equal []`(严格桩把新命令记进 `unexpectedCommands`),并伴随 7 条 `Unhandled Rejection: TypeError: Cannot read properties of undefined (reading 'map')``appSurface/project-development.suite.ts` 的「布局读时提示」用例则因为新命令被当成 unexpected invoke 抛错、触发了新的提示条,导致 `queryBySelector('.game-resource-live-notice')` 断言失败。
@@ -1167,7 +1167,7 @@ game-project/
- `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。
- 后台 Agent 的 manifest 变化以共用 Runtime 状态投影 / 终态 emitter 作为失效因果点:`game-creator-agent-runtime-update` 的 Rust / TypeScript DTO 固定携带 `manifestInvalidated`,且 App 必须在 Supervisor、selected agent、session 和 run 身份的任何 early return 之前处理失效。GUI 进程内 Runtime 直接发该事件;External Runner 是独立进程、没有 GUI `AppHandle`,因此 Runner 协议 v5 的 `runner.attach_gui_owner` 必须登记 GUI 创建的随机 loopback 端口和 64 位随机令牌,Runner 的同一 emitter 通过受令牌保护的短连接转发 `game-creator-manifest-invalidated`。两条路径都只传项目路径与 Agent 身份,不复制 manifest,也不靠轮询补偿。
- Direct Codex 不伪造普通 Agent Runtime state。每张平台美术在本地文件与 manifest 提交成功后,统一通过 standalone `game-creator-manifest-invalidated` 发送 `projectPath + direct-codex-art`;只读恢复的已付费源图同样在 `register_local_asset_at` 成功后发送,下载、解码、文件写入或登记失败时不得发送成功失效。前端仍把 `game-creator-agent-progress` 仅用于进度文案;Direct Codex 整体命令成功、失败或超时 reject 后都追加一次 manifest 最终对账,只有完整成功才启动本地预览。
- Direct Codex 不伪造普通 Agent Runtime state。每张平台美术在本地文件与 manifest 提交成功后,统一通过 standalone `game-creator-manifest-invalidated` 发送 `projectPath + direct-codex-art`普通 `agc_generate_image` 同样在生成通道成功返回后、工具结果组装前发出通知,不能只覆盖标准美术包。只读恢复的已付费源图同样在 `register_local_asset_at` 成功后发送,下载、解码、文件写入或登记失败时不得发送成功失效。失效事件匹配当前项目时统一 Windows 盘符、UNC 与对应 verbatim 前缀的写法,实际重读始终使用当前项目保存的路径;该比较仅用于刷新提示,不替代后端路径与权限校验。前端仍把 `game-creator-agent-progress` 仅用于进度文案;Direct Codex 整体命令成功、失败或超时 reject 后都追加一次 manifest 最终对账,只有完整成功才启动本地预览。
- App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 对外发布前以“revision 前读 -> manifest -> revision 后读”取得一致快照,再通过 `onManifestChange(projectPath, manifest, metadata)` 携带 `projectId + revision + source`;启动器按 `projectPath + projectId` 只接受更高 revision,同 revision 只接受内容一致的重复,旧轮询和同 revision 分叉都不得覆盖。资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。集成测试记录“事件未重新打开项目”的调用基线前,必须先等待项目写入最近列表后触发的只读目录状态刷新完成,不能把这项合法后台检查误算成失效事件副作用。
- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer、task flow 或对应任务环。前端收到截断 DTO 时只剔除 `producerAssignments``taskFlows` 与对应 `cyclicTaskIds`Rust 根据当前 manifest、精确资源引用和仍可信任务深度下限返回的 `dependencyDepths` 继续保留,前端只校验资源仍存在且深度为非负安全整数,不得自行重算或压平权威深度。精确引用边、reference connection index、`cyclicResourceIds` 与 unresolved references 同样继续保留。
-- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id``idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。