From 832f1e8b59a011bd1b9400845fbd2613fa4ee9ed Mon Sep 17 00:00:00 2001 From: Linghong Date: Sat, 12 Sep 2026 06:41:49 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E9=87=8D=E5=BC=80=E8=BF=90=E8=A1=8C=E6=80=81=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 持久化策划项目 design 运行模式并在重开时恢复 design/game 工作台。 补充旧策划会话兼容判断、前端夹具和恢复回归测试。 --- .../src-tauri/src/agent/design_runtime.rs | 51 ++++++++++++ .../src-tauri/src/main.rs | 1 + .../app-shell/useHomeProjectCreation.ts | 13 ++- .../DesignWorkspacePanel.tsx | 15 +++- .../tests/appSurface/harness.ts | 6 ++ .../tests/appSurface/home.suite.ts | 16 ++++ .../appSurface/project-development.suite.ts | 9 ++ .../tests/designProjectRestore.test.tsx | 82 +++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 5 ++ ...】策划Agent生产迁移与工作区浏览-2026-09-10.md | 2 + 10 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 14c1e9ec7..7c6f2d4db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -845,6 +845,26 @@ pub(crate) fn hydrate_design_agent_session( Ok(Some(design_view(&session, active.is_none()))) } +#[tauri::command] +pub(crate) fn get_design_agent_runtime_mode( + project_path: String, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + design_project_id(root)?; + resolve_design_runtime_mode(root) +} + +fn resolve_design_runtime_mode(root: &Path) -> Result, String> { + if let Some(mode) = read_design_runtime_mode(root)? { + return Ok(Some(mode)); + } + // 已有策划会话可能早于运行模式文件;明确的 game 标记始终优先。 + Ok(read_design_session(root)?.map(|_| DesignRuntimeMode { + active_runtime: "design".into(), + })) +} + #[tauri::command] pub(crate) fn set_design_agent_runtime_mode( project_path: String, @@ -1054,6 +1074,37 @@ mod fake_provider { mod tests { use super::*; + #[test] + fn runtime_mode_restore_prefers_explicit_mode_over_existing_design_session() { + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + assert_eq!(resolve_design_runtime_mode(root).unwrap(), None); + write_design_session(root, &new_design_session("project", "")).unwrap(); + assert_eq!( + resolve_design_runtime_mode(root) + .unwrap() + .unwrap() + .active_runtime, + "design" + ); + write_design_runtime_mode(root, "game").unwrap(); + assert_eq!( + resolve_design_runtime_mode(root) + .unwrap() + .unwrap() + .active_runtime, + "game" + ); + write_design_runtime_mode(root, "design").unwrap(); + assert_eq!( + resolve_design_runtime_mode(root) + .unwrap() + .unwrap() + .active_runtime, + "design" + ); + } + #[test] fn design_runtime_rejects_design_execution_in_game_mode() { let temporary = tempfile::tempdir().expect("create runtime mode root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 3c93f0697..5be012475 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2583,6 +2583,7 @@ fn main() { decide_planning_artifact_v2, hydrate_planning_session_v2, hydrate_design_agent_session, + get_design_agent_runtime_mode, set_design_agent_runtime_mode, debug_fast_forward_design_session, continue_design_agent_session, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 1ab395f17..849d56ceb 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -252,6 +252,12 @@ export function useHomeProjectCreation({ attachments: HomeAttachmentDraft[], startMode: ProjectStartMode, ) { + if (startMode === 'planning') { + await invoke('set_design_agent_runtime_mode', { + projectPath: result.projectPath, + activeRuntime: 'design', + }); + } const importedAttachments = await importHomeAttachments( invoke, result.projectPath, @@ -477,6 +483,11 @@ export function useHomeProjectCreation({ setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。'); return; } + const runtimeMode = await invoke<{ + activeRuntime: 'design' | 'game'; + } | null>('get_design_agent_runtime_mode', { + projectPath: trimmedProjectPath, + }); setStatus('已打开项目'); enterProjectDevelopment({ projectPath: trimmedProjectPath, @@ -494,7 +505,7 @@ export function useHomeProjectCreation({ trimmedProjectPath, ), creationType: null, - startMode: null, + startMode: runtimeMode?.activeRuntime === 'design' ? 'planning' : null, initialPrompt: '', attachments: [], recentRunStatus: directoryStatus.recentRunStatus, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx index d8a894b9b..932208819 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx @@ -186,6 +186,7 @@ export function DesignWorkspacePanel({ () => new Set(), ); const refreshTimerRef = useRef(null); + const refreshHydrateSessionRef = useRef(false); const loadWorkspace = useCallback( async ({ @@ -266,20 +267,27 @@ export function DesignWorkspacePanel({ } let disposed = false; let cleanup: (() => void) | null = null; - const scheduleRefresh = () => { + const scheduleRefresh = (hydrateSession: boolean) => { + refreshHydrateSessionRef.current = + refreshHydrateSessionRef.current || hydrateSession; if (refreshTimerRef.current !== null) { window.clearTimeout(refreshTimerRef.current); } refreshTimerRef.current = window.setTimeout(() => { refreshTimerRef.current = null; - void loadWorkspace({ showLoading: false, hydrateSession: false }); + const shouldHydrateSession = refreshHydrateSessionRef.current; + refreshHydrateSessionRef.current = false; + void loadWorkspace({ + showLoading: false, + hydrateSession: shouldHydrateSession, + }); }, 250); }; void listen<{ projectPath: string; kind: string }>( 'design-agent-update', (event) => { if (!disposed && event.payload.projectPath === projectPath) { - scheduleRefresh(); + scheduleRefresh(event.payload.kind === 'state'); } }, ) @@ -297,6 +305,7 @@ export function DesignWorkspacePanel({ window.clearTimeout(refreshTimerRef.current); refreshTimerRef.current = null; } + refreshHydrateSessionRef.current = false; cleanup?.(); }; }, [loadWorkspace, projectPath]); diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index ab4939de2..0d0cf0a10 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -853,6 +853,12 @@ function createProjectSupervisorRuntimeHarness({ if (command === 'hydrate_design_agent_session') { return currentDesignAgentView; } + if (command === 'get_design_agent_runtime_mode') { + return currentDesignAgentView ? { activeRuntime: 'design' } : null; + } + if (command === 'set_design_agent_runtime_mode') { + return { activeRuntime: args?.activeRuntime }; + } if (command === 'continue_design_agent_session') { const next = currentDesignContinueView ?? currentDesignAgentView; if (!next) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 53d3dc5c4..9da80a84d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -222,6 +222,7 @@ export function registerClientHomeTests() { } const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -368,6 +369,7 @@ export function registerClientHomeTests() { let manifestChanged = false; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -537,6 +539,7 @@ export function registerClientHomeTests() { const invoke = vi.fn( async (command: string, args?: Record) => { const requestedPath = String(args?.projectPath ?? ''); + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath: requestedPath, @@ -714,6 +717,7 @@ export function registerClientHomeTests() { }); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath: String(args?.projectPath ?? ''), @@ -1176,6 +1180,7 @@ export function registerHomeProjectCreationTests() { let inspectCount = 0; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { inspectCount += 1; return { @@ -1241,6 +1246,7 @@ export function registerHomeProjectCreationTests() { it('does not open a missing or non-directory path from the open action', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { const projectPath = String(args?.projectPath ?? ''); if (projectPath === '/tmp/broken-status') { @@ -1338,6 +1344,7 @@ export function registerHomeProjectCreationTests() { expect(args).toBeUndefined(); return '/tmp/picked-game'; } + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath: '/tmp/picked-game', @@ -1368,6 +1375,7 @@ export function registerHomeProjectCreationTests() { if (command === 'pick_local_project_directory') { return '/tmp/broken-project'; } + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath: '/tmp/broken-project', @@ -2459,6 +2467,7 @@ export function registerRecentProjectsTests() { ]; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { const projectPath = String(args?.projectPath ?? ''); if (projectPath === paths[0]) { @@ -2605,6 +2614,7 @@ export function registerRecentProjectsTests() { ); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { const renamed = invoke.mock.calls.filter( @@ -2659,6 +2669,7 @@ export function registerRecentProjectsTests() { const projectPath = '/tmp/rename-failed-game'; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -2705,6 +2716,7 @@ export function registerRecentProjectsTests() { it('opens a recent launcher project directory in the system file manager', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath: String(args?.projectPath ?? ''), @@ -2758,6 +2770,7 @@ export function registerRecentProjectsTests() { it('does not remember a launcher project when project inspection fails', async () => { const invoke = vi.fn(async (command: string) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { throw new Error('inspect failed'); } @@ -2775,6 +2788,7 @@ export function registerRecentProjectsTests() { it('marks missing recent launcher projects and does not reopen them', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { const projectPath = String(args?.projectPath ?? ''); if (projectPath === '/tmp/broken-status') { @@ -2919,6 +2933,7 @@ export function registerRecentProjectsTests() { it('checks recent launcher project status automatically without changing the list', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { const projectPath = String(args?.projectPath ?? ''); return { @@ -2977,6 +2992,7 @@ export function registerRecentProjectsTests() { }) => void) | null = null; const invoke = vi.fn(async (command: string) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return await new Promise((resolve) => { finishRefresh = resolve; diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index e92aab6d7..8de960f80 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -4749,6 +4749,7 @@ export function registerProjectSupervisorSurfaceTests() { }); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -4869,6 +4870,7 @@ export function registerProjectSupervisorSurfaceTests() { }); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -4933,6 +4935,7 @@ export function registerProjectSupervisorSurfaceTests() { }); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -5007,6 +5010,7 @@ export function registerProjectSupervisorSurfaceTests() { }); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -5108,6 +5112,7 @@ export function registerProjectSupervisorSurfaceTests() { }); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -5543,6 +5548,7 @@ export function registerProjectSupervisorSurfaceTests() { ); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -6031,6 +6037,7 @@ export function registerProjectSupervisorSurfaceTests() { }); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -6108,6 +6115,7 @@ export function registerProjectSupervisorSurfaceTests() { }); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, @@ -6182,6 +6190,7 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'pick_local_project_directory') { return projectPath; } + if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { return { projectPath, diff --git a/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx b/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx new file mode 100644 index 000000000..11181ccc0 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx @@ -0,0 +1,82 @@ +// @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 { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; + +afterEach(() => { + cleanup(); + delete window.__TAURI__; +}); + +const projectPath = 'C:/test/design-project'; +const manifest = createGameCreationAppManifest('test-project', '策划项目'); + +function setup(invoke: ReturnType) { + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return renderHook(() => + useHomeProjectCreation({ + setStatus: vi.fn(), + setLauncherView: vi.fn(), + setAgentChatProjectPath: vi.fn(), + rememberRecentWorkspace: vi.fn(), + }), + ); +} + +it.each(['design', 'game', null] as const)( + 'restores %s before publishing the project context', + async (mode) => { + let resolveMode!: (value: { activeRuntime: string } | null) => void; + const modePromise = new Promise<{ activeRuntime: string } | null>( + (resolve) => { + resolveMode = resolve; + }, + ); + const invoke = vi.fn(async (command: string) => { + if (command === 'inspect_local_project_directory') + return { exists: true, isDirectory: true, isGameCreatorProject: true }; + if (command === 'get_local_game_manifest') return manifest; + if (command === 'get_local_game_project_revision') return { revision: 1 }; + if (command === 'get_design_agent_runtime_mode') return modePromise; + throw new Error(command); + }); + const { result } = setup(invoke); + let opening!: Promise; + await act(async () => { + opening = result.current.openProject(projectPath, 'open'); + }); + expect(result.current.currentProjectContext).toBeNull(); + await act(async () => { + resolveMode(mode ? { activeRuntime: mode } : null); + await opening; + }); + expect(result.current.currentProjectContext?.startMode).toBe( + mode === 'design' ? 'planning' : null, + ); + }, +); + +it('persists design mode before entering a newly created planning project', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'create_automatic_local_game_project') + return { projectPath, manifest }; + if (command === 'set_design_agent_runtime_mode') + return { activeRuntime: 'design' }; + if (command === 'get_local_game_project_revision') return { revision: 1 }; + throw new Error(command); + }); + const { result } = setup(invoke); + await act(async () => { + await result.current.createHomeDraftAutomatically( + { creationType: 'game', prompt: '测试', attachments: [] }, + 'planning', + ); + }); + expect(invoke).toHaveBeenCalledWith('set_design_agent_runtime_mode', { + projectPath, + activeRuntime: 'design', + }); + expect(result.current.currentProjectContext?.startMode).toBe('planning'); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3004a9902..8ed9581fa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,11 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-12 策划项目重开前必须恢复运行模式 + +- 工作台不能只依赖创建时的内存 `startMode`:重开时丢失该值会挂载游戏资源画布,而对话恢复后又进入策划状态,造成左右区域不一致。 +- 新建策划项目进入工作台前持久化 `.agent/runtime-mode.json`;重开先读取模式再发布项目上下文。无模式文件的旧项目用策划会话恢复为 design,明确的 game 标记优先于旧会话。 + ## 2026-09-10 设计 Agent 需要流式 Responses 的原生 output[],不能只靠 tool_calls - **现象**:新策划报 `Provider 返回工具调用但未提供完整 Responses output`。模型已经在调工具,`.debug/design-agent` 里 `tool_calls` 有值但 `output` 是 `[]`。 diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index b47357753..4d0bc94fe 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -342,6 +342,8 @@ UI 使用“批准”和“继续修改”两个文字按钮,分别配 Lucide ## 14. 开发调试入口 +项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”写入 `game`。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。 + 开发构建的策划工作区页头在“刷新”旁提供“快速准备做成游戏测试”按钮。该入口与策划 Debug 日志共用 `GENARRATIVE_AGC_DESIGN_DEBUG=1` 开关:开关未启用时按钮不显示,命令也不可执行。入口仅进行本地 fixture 和会话状态写入,不调用 Provider;完成后自动刷新文件树与阶段,通过 `design-agent-update` 状态事件同步右侧审批/阶段操作区。随后仍需点击正常的“做成游戏”按钮执行资产登记与运行时切换。 ## 15. 策划 Agent reasoning 展示现状