修复策划项目重开运行态恢复
Project CI / Repository checks (pull_request) Successful in 2m17s
Project CI / Frontend tests (pull_request) Successful in 3m8s
Project CI / Backend tests (pull_request) Successful in 7m46s
Project CI / Native shell tests (pull_request) Successful in 19m52s

持久化策划项目 design 运行模式并在重开时恢复 design/game 工作台。

补充旧策划会话兼容判断、前端夹具和恢复回归测试。
This commit is contained in:
2026-09-12 06:41:49 +00:00
parent 281e6ccf48
commit 832f1e8b59
10 changed files with 196 additions and 4 deletions
@@ -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<Option<DesignRuntimeMode>, 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<Option<DesignRuntimeMode>, 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");
@@ -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,
@@ -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,
@@ -186,6 +186,7 @@ export function DesignWorkspacePanel({
() => new Set(),
);
const refreshTimerRef = useRef<number | null>(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]);
@@ -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) {
@@ -222,6 +222,7 @@ export function registerClientHomeTests() {
}
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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;
@@ -4749,6 +4749,7 @@ export function registerProjectSupervisorSurfaceTests() {
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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,
@@ -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<typeof vi.fn>) {
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<void>;
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');
});
@@ -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``[]`
@@ -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 展示现状