import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { act, cleanup, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as clientApi from '../../src/services/clientApi'; import { resetLlmModelCatalogCacheForTest } from '../../src/services/llmModelCatalog'; import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore'; const nativeClipboardMock = vi.hoisted(() => ({ text: '', })); const TEST_LOCAL_PROJECT_PATH = '/tmp/genarrative-ai-game-draft'; const originalCreateObjectUrl = Object.getOwnPropertyDescriptor( URL, 'createObjectURL', ); const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor( URL, 'revokeObjectURL', ); const originalPointerEvent = Object.getOwnPropertyDescriptor( window, 'PointerEvent', ); const originalClipboardEvent = Object.getOwnPropertyDescriptor( window, 'ClipboardEvent', ); const originalDragEvent = Object.getOwnPropertyDescriptor(window, 'DragEvent'); const originalRangeGetBoundingClientRect = Object.getOwnPropertyDescriptor( Range.prototype, 'getBoundingClientRect', ); const originalIntersectionObserver = Object.getOwnPropertyDescriptor( window, 'IntersectionObserver', ); const originalResizeObserver = Object.getOwnPropertyDescriptor( window, 'ResizeObserver', ); class TestPointerEvent extends MouseEvent { readonly pointerId: number; constructor( type: string, init: MouseEventInit & { pointerId?: number } = {}, ) { super(type, init); this.pointerId = init.pointerId ?? 0; } } // jsdom 没有实现 DragEvent / ClipboardEvent,而 Lexical 在粘贴与拖放处理里用 // `instanceof DragEvent` / `instanceof ClipboardEvent` 判定事件来源,缺失会直接抛 // ReferenceError,粘贴通路根本进不去编辑器。这里只补最小的构造器,让事件能带上 // `clipboardData` 并被 Lexical 认出来,不模拟真实浏览器语义。 class TestClipboardEvent extends Event {} class TestDragEvent extends MouseEvent {} // jsdom 的 Range 也没有 getBoundingClientRect,Lexical 计算选区矩形时会抛 TypeError。 function emptyRangeRect(): DOMRect { return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}), } as DOMRect; } function createMemoryStorage(): Storage { const values = new Map(); return { get length() { return values.size; }, clear() { values.clear(); }, getItem(key) { return values.get(key) ?? null; }, key(index) { return Array.from(values.keys())[index] ?? null; }, removeItem(key) { values.delete(key); }, setItem(key, value) { values.set(key, String(value)); }, }; } Object.defineProperty(window, 'localStorage', { configurable: true, value: createMemoryStorage(), }); vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ readImage: vi.fn(async () => { throw new Error('no native clipboard image'); }), readText: vi.fn(async () => nativeClipboardMock.text), })); import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { createGameCreationAppManifest, createGameCreationAppSeedTasks, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, type GameCreationAgentRunTrace, } from '../../../../packages/shared/src/contracts/gameCreationApp'; import { App, AuthenticatedClient, deriveAgentStatusCards, WorkspaceLauncher, } from '../../src/App'; import ProjectDevelopmentView from '../../src/view/project-development'; const testAuthUser: AuthUser = { id: 'user-test', publicUserCode: 'tn-test', displayName: '测试用户', avatarUrl: null, phoneNumber: null, phoneNumberMasked: '138****0000', loginMethod: 'password', bindingStatus: 'active', wechatBound: false, wechatDisplayName: null, wechatAccount: null, }; function renderAppAt(path: string) { window.history.pushState({}, '', path); render(React.createElement(App)); const params = new URLSearchParams(window.location.search); if (params.has('dev') && !params.has('projectPath')) { const projectPathInput = screen.queryByLabelText('本地项目目录'); if (projectPathInput) { fireEvent.change(projectPathInput, { target: { value: TEST_LOCAL_PROJECT_PATH }, }); } } } function renderLauncherAt( path: string, initialView: 'home' = 'home', strictMode = false, ) { window.history.pushState({}, '', path); const launcher = React.createElement(WorkspaceLauncher, { currentUser: testAuthUser, initialView, onLogout: vi.fn(), }); render( strictMode ? React.createElement(React.StrictMode, null, launcher) : launcher, ); } function renderLauncherProjectsAt(path: string) { renderLauncherAt(path); fireEvent.click(screen.getByRole('button', { name: '项目组' })); } function pickProjectFromLauncher( projectPath: string, action: '打开项目' | '新建项目' = '打开项目', ) { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { throw new Error('Tauri invoke is not configured'); } window.__TAURI__ = { ...window.__TAURI__, core: { ...window.__TAURI__.core, invoke: async (command, args) => command === 'pick_local_project_directory' ? projectPath : invoke(command, args), }, }; fireEvent.click(screen.getByRole('button', { name: action })); } function isNativeTextControl( element: HTMLElement, ): element is HTMLInputElement | HTMLTextAreaElement { return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'; } // Lexical 的编辑器状态提交排在微任务里(外部 value 变化、粘贴、删除都走这条路径), // 读取或提交前先让 React 追平编辑器内容,否则会读到上一帧的文本/禁用状态。 // 这里让出一个宏任务而不是用 act(async),因为 act 在编辑器持续自更新的用例里会长时间不收敛。 async function settleComposer() { await new Promise((resolve) => { setTimeout(resolve, 0); }); } // 读取输入区文本:原生 input/textarea 读 value,Lexical contenteditable 读 textContent。 async function composerValue(element: HTMLElement) { await settleComposer(); return isNativeTextControl(element) ? element.value : (element.textContent ?? ''); } type ComposerScope = { getByLabelText: (label: string) => HTMLElement }; async function composerText(label = '创作想法', scope: ComposerScope = screen) { return composerValue(scope.getByLabelText(label)); } async function composerDisabled( label = '创作想法', scope: ComposerScope = screen, ) { await settleComposer(); const element = scope.getByLabelText(label); if (isNativeTextControl(element)) { return element.disabled; } const wrapper = element.closest('.resource-reference-input'); return ( wrapper?.getAttribute('data-disabled') === 'true' || element.getAttribute('contenteditable') !== 'true' ); } // 把整段内容选进 DOM 选区,并让 Lexical 吸收它:jsdom 不会为程序化选区派发 // selectionchange,编辑器内部 selection 不更新的话,粘贴会插在旧内容旁边而不是覆盖。 function selectComposerContents(element: HTMLElement) { element.focus(); const range = document.createRange(); range.selectNodeContents(element); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); document.dispatchEvent(new Event('selectionchange')); } function clearComposerContents(element: HTMLElement) { fireEvent.keyDown(element, { key: 'Delete', code: 'Delete' }); } // 写完内容后把光标收回到末尾,模拟真实输入后的选区;否则残留的“全选”会改变 // Shift+Enter 等按键的后续语义。 function collapseComposerSelectionToEnd(element: HTMLElement) { const range = document.createRange(); range.selectNodeContents(element); range.collapse(false); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); document.dispatchEvent(new Event('selectionchange')); } function pasteComposerText(element: HTMLElement, value: string) { fireEvent.paste(element, { clipboardData: { getData: (type: string) => (type === 'text/plain' ? value : ''), files: [], items: [], types: ['text/plain'], }, }); } // 向输入区写入文本,保证写入结果等于 value。原生控件用 change 事件;Lexical // contenteditable 先全选、清空,再走产品真实的 paste 通路写入。 async function setComposerText(element: HTMLElement, value: string) { if (isNativeTextControl(element)) { fireEvent.change(element, { target: { value } }); return; } selectComposerContents(element); await settleComposer(); clearComposerContents(element); await settleComposer(); if (value.length > 0) { pasteComposerText(element, value); await settleComposer(); } collapseComposerSelectionToEnd(element); await settleComposer(); } // 同一张资源卡上的按钮变少了:卡片本体只有「选中资源」与媒体播放钮两个(原先右上角 // 那个 @ 引用圆钮已挪进选中工具条)。选中按钮的可访问名模板固定为 // `选中资源:<分类标签> <资源文件名>`;分类标签可能自带空格(例如 `UI 交互`), // 这里按模板做精确匹配。 function escapeRegExpLiteral(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function resourceSelectButtonName(label: string) { return new RegExp(`^选中资源:.+ ${escapeRegExpLiteral(label)}$`); } function getResourceSelectButton(label: string) { return screen.getByRole('button', { name: resourceSelectButtonName(label) }); } function findResourceSelectButton( label: string, options?: { timeout?: number; interval?: number }, ) { return screen.findByRole( 'button', { name: resourceSelectButtonName(label) }, options, ); } function queryResourceSelectButton(label: string) { return screen.queryByRole('button', { name: resourceSelectButtonName(label), }); } /** * 叫出画布唯一的筛选面板(右下角 Dock 的放大镜按钮)并返回关键词输入框。 * * 面板只在打开时存在于 DOM:用例必须走产品入口打开它,`getByLabelText` 在面板没打开时 * 会直接抛错,所以「输入框在真实 UI 里点不到」那类假绿不会再出现。关键词 / 所在区域 / * 自定义标签三个字段都在这一次打开的面板里(`Ctrl/Cmd+F` 叫出的是同一个)。 */ function openResourceFilterPanel() { fireEvent.click(screen.getByRole('button', { name: '搜索资源' })); return screen.getByLabelText('查找素材') as HTMLInputElement; } function emptyProjectPolicy() { return { path: '.agent/policy.json', policy: { deniedCommands: [], confirmCommands: [], }, }; } const roleAgentMockReply = '专业 Agent 已完成本轮判断:先补齐角色规范图、验收口径和后续生成条件。'; function mockRoleAgentReply() { return roleAgentMockReply; } function planningResponseStream({ runId, sequence, accumulatedText, status = 'streaming', appliedSteerCursor = 0, responseRevision = 0, loopIteration = 1, overrides = {}, }: { runId: string; sequence: number; accumulatedText: string; status?: 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed'; appliedSteerCursor?: number; responseRevision?: number; loopIteration?: number; overrides?: Record; }) { return { schemaVersion: 'game-creator-runtime-response-stream.v1', agentId: 'planning-agent-v2', taskId: 'planning-agent-v2', sessionId: 'planning-session-active', runId, requestKind: 'final-reply', requestSlot: `final-reply-loop-${loopIteration}-revision-${responseRevision}`, appliedSteerCursor, responseRevision, sequence, status, accumulatedText, finishReason: status === 'ready' || status === 'committed' ? 'stop' : null, startedAt: 6000, updatedAt: 6000 + sequence, ...overrides, }; } function agentRuntimeUserInputRequest({ agentId, sessionId, runId, requestId = 'request-user-input-1', actionId = 'action-user-input-1', responseId = null, status = 'pending', }: { agentId: string; sessionId: string; runId: string; requestId?: string; actionId?: string; responseId?: string | null; status?: 'pending' | 'answer-prepared'; }) { return { schemaVersion: 'game-creator-runtime-user-input.v1', requestId, agentId, taskId: agentId, sessionId, runId, actionId, status, questions: [ { id: 'visual_direction', header: '美术方向', question: '首版角色规范图采用哪种美术方向?', options: [ { label: '像素风', description: '优先验证轮廓与动作可读性。', }, { label: '手绘风', description: '优先验证角色气质与细节。', }, ], }, ], allowFreeform: true, responseId, requestedAt: 6000, updatedAt: 6000, }; } function createPlanGddStateView( overrides: Partial = {}, ): PlanGddStateViewV1 { const gddRef = { gddId: 'gdd-plan-0001', version: 1, fingerprint: 'sha256-serde-json-v2:1111111111111111', }; return { schemaVersion: 'plan-gdd-state-view.v1', projectId: 'local-project-draft', gddId: gddRef.gddId, state: 'ready_for_approval', session: { sessionId: 'plan-session-0001', sessionRevision: 3, sessionFingerprint: 'sha256-serde-json-v2:2222222222222222', phase: 'awaiting_gdd_approval', clarificationRound: 2, repairDepth: 0, accumulatedAgentMillis: 42_000, activeRunId: null, awaitingAnswerFor: null, decisionStateCounts: { confirmed: 2, defaultPending: 1, prototypePending: 0, }, }, versions: [ { gddRef, status: 'ready_for_approval', approvalRequestId: 'gdd-approval-0001', createdAtUtc: '2026-08-18T00:00:00Z', decision: null, }, ], displayGdd: { schemaVersion: 'plan-gdd.v1', projectId: 'local-project-draft', gddId: gddRef.gddId, version: gddRef.version, submissionId: 'action-0123456789abcdef01234567', approvalRequestId: 'gdd-approval-0001', actionFingerprint: 'a'.repeat(64), agentId: 'project-planning', source: 'agent-delegate', runProfile: 'standard', runProfileBindingFingerprint: 'b'.repeat(64), rootAgentId: 'planning-agent-v2', rootRunId: 'run-plan-root-0001', delegationId: 'delegation-0001', sessionId: 'plan-session-0001', sourceSessionRevision: 2, sourceSessionFingerprint: 'sha256-serde-json-v2:3333333333333333', createdByRunId: 'run-plan-child-0001', createdAtUtc: '2026-08-18T00:00:00Z', fingerprint: gddRef.fingerprint, game: { title: '灯塔守夜人', oneLiner: '在潮汐涨落之间调度光束,护送迷航的船只回港。', genre: { primary: '策略', fusion: null }, artStyle: { visualType: '像素', keywords: ['夜色', '海雾'], moodAndColor: '冷蓝为主,暖黄光束作为唯一高光。', mvpArtBoundary: '只做灯塔与三类船只的静帧。', }, pillars: [ { name: '光束调度', playerFeel: '在有限视野里做取舍。', mechanism: '每回合只能照亮一个扇区。', decisionState: 'confirmed', basis: null, }, ], coreLoop: ['观察潮汐', '分配光束', '结算返港'], targetUsers: { coreUsers: '喜欢短局策略的玩家', preferences: '偏好可预测的规则', sessionLength: '单局 5 分钟', referenceGames: ['灯塔物语'], }, platformFacts: { runtime: 'web', viewports: ['desktop', 'mobile'], inputs: ['pointer'], preview: '本地预览', }, mvpSystems: [ { system: '潮汐时钟', minimalFunction: '固定三段潮汐循环。', whyRequired: '没有它就没有节奏压力。', verifyMethod: '观察一局内三段是否各触发一次。', decisionState: 'confirmed', basis: null, }, ], outOfScope: ['多人对战'], creatorTips: { doFirst: '先做潮汐时钟。', deferForNow: '暂缓天气系统。', howToVerify: '单局跑满三段潮汐。', expandWhen: '核心循环稳定后再加船种。', }, }, decisions: [ { id: 'decision-0001', topic: '光束是否可分裂', state: 'confirmed', answerSource: 'user_option', round: 1, answerSummary: '不可分裂,保持取舍压力。', basis: null, }, ], prototypeValidationItems: [ { id: 'proto-0001', question: '单扇区照明是否足够做出取舍?', microPrototype: '纸面推演三回合。', observation: '玩家是否出现犹豫。', passCriterion: '三回合内至少一次改变计划。', }, ], }, pendingApproval: { gddRef, pendingActionId: 'action-0123456789abcdef01234567', actionFingerprint: 'a'.repeat(64), approvalRequestId: 'gdd-approval-0001', sessionId: 'plan-session-0001', runId: 'run-plan-root-0001', }, approvedGddRef: null, recoveryPending: false, ...overrides, }; } /** * 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。 * * `item.delta` 只带 itemId 不带条目,属于瞬时事件,不参与 bootstrap 回放。 */ function directThreadEventItemId( event: Record, ): string | null { if (event.type !== 'item.started' && event.type !== 'item.completed') { return null; } const item = event.item; if (!item || typeof item !== 'object') return null; const itemId = (item as Record).itemId; return typeof itemId === 'string' && itemId ? itemId : null; } function createProjectChatRuntimeHarness({ projectPath = '/tmp/authorized-game', sessionId = 'planning-session-active', initialSessionExists = true, projectMessages = [], agentMessages = [], initialRuntime, initialResponseStream = null, initialProjectRevision = 0, runtimeMapLoader, designAgentView = null, designAgentContinueView = null, designWorkspaceFiles = [], }: { projectPath?: string; sessionId?: string; initialSessionExists?: boolean; projectMessages?: Array>; agentMessages?: Array>; initialRuntime?: Record; initialResponseStream?: Record | null; initialProjectRevision?: number; runtimeMapLoader?: () => Promise>>; designAgentView?: Record | null; designAgentContinueView?: Record | null; designWorkspaceFiles?: Array>; } = {}) { const manifest = createGameCreationAppManifest( 'local-project-draft', '未命名游戏原型', ); let messageSequence = 0; let selectedModelId = 'quality'; let selectedModelIsDefault = false; let reasoningEffort = 'high'; let steerSequence = 0; let sessionExists = initialSessionExists; let currentProjectRevision = initialProjectRevision; const currentProjectMessages = [...projectMessages]; const currentAgentMessages = [...agentMessages]; const runtimeState = ( overrides: Record = {}, ): Record => ({ schemaVersion: 'game-creator-agent-runtime.v1', agentId: 'planning-agent-v2', taskId: 'planning-agent-v2', sessionId, runId: 'agent-idle', source: 'planning-agent-v2', status: 'idle', phase: 'idle', currentTask: '', currentGoal: '', currentAction: '', waitingOn: '', nextStep: '', plan: [], observations: [], allowedTools: [], pendingToolAction: null, lastResponse: null, error: null, updatedAt: 1000, ...overrides, }); let currentRuntime = runtimeState(initialRuntime); let currentResponseStream = initialResponseStream; let runtimeReader: (() => Promise>) | null = null; let confirmRuntime: Record | null = null; let rejectRuntime: Record | null = null; let answerRuntime: Record | null = null; let answerFailuresRemaining = 0; let currentDesignAgentView = designAgentView; const currentDesignContinueView = designAgentContinueView; const currentDesignWorkspaceFiles = [...designWorkspaceFiles]; let runtimeUpdateHandler: | ((event: { payload: { projectPath: string; agentId: string; runId: string; status: string; phase: string; manifestInvalidated: boolean; runtime: Record; }; }) => void) | null = null; /** * `game-creator-manifest-invalidated` 有不止一个订阅方(工作台壳的清单归并与 * DirectProject 聊天自己的 `@` 引用清单),Tauri 会把事件发给每一个监听者, * 所以夹具按监听器集合广播,不能只留最后一次注册的那个。 */ type ManifestInvalidatedHandler = (event: { payload: { projectPath: string; agentId: string; }; }) => void; const manifestInvalidatedHandlers = new Set(); let progressHandler: | ((event: { payload: { projectPath: string; stage: string; message: string; }; }) => void) | null = null; let designAgentUpdateHandler: | ((event: { payload: Record }) => void) | null = null; let directThreadNotifyHandler: | ((event: { payload: { subscriptionId: string } }) => void) | null = null; let directThreadSubscriptionId: string | null = null; let directThreadSubscriptionSequence = 0; // 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取, // 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。 let pendingDirectThreadEvents: Array> = []; let directThreadHistoryItems: Array> = []; let directThreadLastCompletedItemId: string | null = null; // 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期 // 锚点一致,重复 `turn.started` 在生产里不会出现。 let directThreadTurnRunning = false; // bootstrap 的回放规则与 Rust 侧 `is_bootstrap_event` 对齐:只补"这一刻还没结束的条目" // 与最新一条生命周期锚点;已完成条目、增量正文这类瞬时事件都不回放。 const directThreadActiveItemIds = new Set(); const directThreadPendingEventSeqs = new Map< Record, number >(); let directThreadEventSequence = 0; let directThreadLifecycleAnchor: { seq: number; event: Record; } | null = null; const conversationRecord = ( role: 'user' | 'assistant', content: string, messageId: string, ) => ({ schemaVersion: 'game-creator-conversation.v1', role, content, agentId: 'planning-agent-v2', messageId, updatedAt: 2000 + ++messageSequence, }); const runtimeResult = ( state = currentRuntime, responseStream = state === currentRuntime ? currentResponseStream : null, ) => ({ state, sessionPath: `${projectPath}/.agent/runtime/agents/${String(state.agentId)}.json`, eventPath: `${projectPath}/.agent/runtime/events/${String(state.agentId)}.jsonl`, taskPath: `${projectPath}/.agent/runtime/tasks/${String(state.agentId)}.jsonl`, taskQueue: { total: state.status === 'idle' ? 0 : 1, pending: state.status === 'pending' ? 1 : 0, running: state.status === 'running' ? 1 : 0, waitingForConfirmation: state.status === 'waiting-for-confirmation' ? 1 : 0, waitingForUserInput: state.status === 'waiting-for-user-input' ? 1 : 0, completed: state.status === 'completed' ? 1 : 0, failed: state.status === 'failed' ? 1 : 0, latestRunId: String(state.runId), updatedAt: Number(state.updatedAt), }, recentEvents: state.recentEvents ?? [], recentTasks: [], responseStream, userInputRequest: state.userInputRequest ?? null, }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'preflight_web_game_creation') return { status: 'ready' }; if ( command === 'read_game_creator_app_config' || command === 'select_game_creator_model' || command === 'select_game_creator_reasoning_effort' ) { if (command === 'select_game_creator_model') { selectedModelId = String(args?.modelId); selectedModelIsDefault = args?.isDefault === true; } if (command === 'select_game_creator_reasoning_effort') { reasoningEffort = String(args?.effort); } return { path: '/tmp/test-game-creator-config.json', config: { schemaVersion: 'game-creator-config.v2', agentMode: 'codex_app_server', selectedModelId, selectedModelIsDefault, llm: { apiKey: '', baseUrl: '', model: 'quality', apiKind: 'openai_responses', reasoningEffort, stream: true, webSearchEnabled: true, contextWindowTokens: 128000, autoCompactTokenLimit: 64000, toolOutputTokenLimit: 12000, requestTimeoutMs: 180000, maxRetries: 2, retryBackoffMs: 500, }, agentLlm: {}, editorApi: { baseUrl: 'https://dev.genarrative.world', apiKey: '' }, }, }; } if (command === 'append_local_permission_log') { return {}; } if (command === 'read_project_permission_policy') { return emptyProjectPolicy(); } if (command === 'get_local_game_project_revision') { return { revision: currentProjectRevision }; } if (command === 'init_local_game_project') { const requestedProjectPath = String(args?.projectPath ?? ''); return { projectPath: requestedProjectPath, manifestPath: `${requestedProjectPath}/.agent/manifest.json`, manifest, }; } if (command === 'resume_game_creator_agent_runtime_tasks') { return [runtimeResult()]; } if (command === 'list_game_creator_agent_sessions') { return { path: `${projectPath}/.agent/conversations/agents/planning-agent-v2/sessions.json`, agentId: 'planning-agent-v2', activeSessionId: sessionExists ? sessionId : null, sessions: sessionExists ? [ { sessionId, title: '项目总控', createdAt: 1000, updatedAt: 2000, archivedAt: null, messageCount: currentAgentMessages.length, legacy: false, }, ] : [], }; } if (command === 'create_game_creator_agent_session') { sessionExists = true; return { path: `${projectPath}/.agent/conversations/agents/planning-agent-v2/sessions.json`, agentId: 'planning-agent-v2', activeSessionId: sessionId, sessions: [ { sessionId, title: String(args?.title ?? ''), createdAt: 1000, updatedAt: 2000, archivedAt: null, messageCount: currentAgentMessages.length, legacy: false, }, ], }; } if (command === 'read_local_conversation') { if (args?.agentId === null) { return { path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, sessionId: null, messages: [...currentProjectMessages], }; } if (args?.agentId === 'planning-agent-v2') { if (args?.sessionId !== sessionId) { throw new Error(`unexpected agent session ${args?.sessionId}`); } return { path: `${projectPath}/.agent/conversations/agents/planning-agent-v2/sessions/${sessionId}.jsonl`, agentId: 'planning-agent-v2', sessionId, messages: [...currentAgentMessages], }; } } if (command === 'append_local_conversation_message') { if (args?.agentId !== null) { throw new Error('React must not append agent conversation'); } const message = args?.message as { role: 'user' | 'assistant'; content: string; agentId: null; updatedAt?: number; }; currentProjectMessages.push({ schemaVersion: 'game-creator-conversation.v1', ...message, ...(args?.messageId ? { messageId: String(args.messageId) } : {}), updatedAt: typeof message.updatedAt === 'number' ? message.updatedAt : 1500 + ++messageSequence, }); return { path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, sessionId: null, messages: [...currentProjectMessages], }; } if (command === 'read_game_creator_agent_runtime') { if ( args?.agentId !== 'planning-agent-v2' || args?.sessionId !== sessionId ) { throw new Error('unexpected agent runtime identity'); } if (runtimeReader) { return runtimeReader(); } return runtimeResult(); } if (command === 'read_game_creator_agent_runtimes') { const states = runtimeMapLoader ? await runtimeMapLoader() : []; return states.map((state) => runtimeResult(state)); } if (command === 'subscribe_direct_project_thread') { directThreadSubscriptionSequence += 1; directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`; const pending = pendingDirectThreadEvents; pendingDirectThreadEvents = []; // 游标落在队尾:只有"还没结束的条目"与最新生命周期锚点作为 bootstrap 回放, // 已完成条目与增量正文不再补发(Rust 侧 `is_bootstrap_event` 的同一口径)。 const replay = pending .filter((event) => { const itemId = directThreadEventItemId(event); return Boolean(itemId && directThreadActiveItemIds.has(itemId)); }) .map((event) => ({ seq: directThreadPendingEventSeqs.get(event) ?? 0, event, })); for (const event of pending) { directThreadPendingEventSeqs.delete(event); } if ( directThreadLifecycleAnchor && !replay.some( (entry) => entry.event === directThreadLifecycleAnchor?.event, ) ) { replay.push(directThreadLifecycleAnchor); } replay.sort((left, right) => left.seq - right.seq); return { subscriptionId: directThreadSubscriptionId, lastCompletedItemId: directThreadLastCompletedItemId, events: replay.map((entry) => entry.event), }; } if (command === 'consume_direct_project_thread') { if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) { throw new Error('SUBSCRIPTION_EXPIRED'); } const events = pendingDirectThreadEvents; pendingDirectThreadEvents = []; return { events }; } if (command === 'read_direct_project_history_slice') { // 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条, // 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算 // `hasMore`,`firstItemId` 是本屏最老一条的 itemId。 const requestedLimit = Number(args?.limit ?? 20); const limit = Math.min( Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1), 200, ); const throughItemId = typeof args?.throughItemId === 'string' && args.throughItemId ? args.throughItemId : null; const beforeItemId = typeof args?.beforeItemId === 'string' && args.beforeItemId ? args.beforeItemId : null; let end = directThreadHistoryItems.length; if (throughItemId) { const anchorIndex = directThreadHistoryItems.findIndex( (item) => String(item.itemId ?? '') === throughItemId, ); if (anchorIndex < 0) { throw new Error( `DirectProject 历史中不存在 item:${throughItemId}`, ); } end = anchorIndex + 1; } else if (beforeItemId) { const anchorIndex = directThreadHistoryItems.findIndex( (item) => String(item.itemId ?? '') === beforeItemId, ); end = anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length; } const start = Math.max(0, end - limit); const window = directThreadHistoryItems.slice(start, end); const oldest = window[0]; return { items: [...window], hasMore: start > 0, firstItemId: oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null, }; } if (command === 'steer_game_creator_agent_runtime_task') { steerSequence += 1; currentResponseStream = null; currentAgentMessages.push( conversationRecord( 'user', String(args?.instruction ?? ''), `steer-user-${steerSequence}`, ), ); return { runtime: runtimeResult(), steerId: String(args?.steerId ?? ''), sequence: steerSequence, status: 'queued', providerInterrupted: false, }; } if (command === 'confirm_game_creator_agent_runtime_task') { currentResponseStream = null; currentRuntime = confirmRuntime ?? runtimeState({ runId: String(currentRuntime.runId), status: 'running', phase: 'planning', updatedAt: 4000, }); return runtimeResult(); } if (command === 'reject_game_creator_agent_runtime_task') { currentResponseStream = null; currentRuntime = rejectRuntime ?? runtimeState({ runId: String(currentRuntime.runId), status: 'running', phase: 'planning', updatedAt: 5000, }); return runtimeResult(); } if (command === 'answer_game_creator_agent_runtime_user_input') { if (answerFailuresRemaining > 0) { answerFailuresRemaining -= 1; throw new Error('模拟回答提交中断'); } const answers = args?.answers as Record; currentAgentMessages.push( conversationRecord( 'user', String(answers?.visual_direction ?? ''), `answer-user-${String(args?.responseId ?? '')}`, ), ); currentResponseStream = null; currentRuntime = answerRuntime ?? runtimeState({ runId: String(currentRuntime.runId), status: 'running', phase: 'planning', currentAction: '根据用户回答继续规划', userInputRequest: null, updatedAt: 6100, }); return runtimeResult(); } 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) { throw new Error('DESIGN_AGENT_VIEW_NOT_CONFIGURED'); } currentDesignAgentView = next; return next; } if (command === 'decide_design_phase') { if (!currentDesignAgentView) { throw new Error('DESIGN_AGENT_VIEW_NOT_CONFIGURED'); } return currentDesignAgentView; } if (command === 'list_design_workspace') { return currentDesignWorkspaceFiles; } if (command === 'read_design_workspace_file') { return ''; } throw new Error(`unexpected invoke ${command}`); }, ); const listen = vi.fn( async ( eventName: string, handler: (event: { payload: { projectPath: string; agentId: string; runId: string; status: string; phase: string; runtime: Record; }; }) => void, ) => { if (eventName === 'game-creator-agent-runtime-update') { runtimeUpdateHandler = handler; } if (eventName === 'game-creator-manifest-invalidated') { manifestInvalidatedHandlers.add( handler as unknown as ManifestInvalidatedHandler, ); } if (eventName === 'game-creator-agent-progress') { progressHandler = handler as unknown as typeof progressHandler; } if (eventName === 'design-agent-update') { designAgentUpdateHandler = handler as unknown as typeof designAgentUpdateHandler; } if (eventName === 'game-creator-direct-thread-notify') { directThreadNotifyHandler = handler as unknown as typeof directThreadNotifyHandler; } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; } manifestInvalidatedHandlers.delete( handler as unknown as ManifestInvalidatedHandler, ); if (progressHandler === handler) { progressHandler = null; } if (designAgentUpdateHandler === handler) { designAgentUpdateHandler = null; } if (directThreadNotifyHandler === handler) { directThreadNotifyHandler = null; } }; }, ); /** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */ const emitDirectThreadEvents = ( ...events: Array> ) => { for (const event of events) { directThreadEventSequence += 1; directThreadPendingEventSeqs.set(event, directThreadEventSequence); if (event.type === 'turn.started') directThreadTurnRunning = true; if (event.type === 'turn.completed') directThreadTurnRunning = false; if (event.type === 'turn.started' || event.type === 'turn.completed') { // 生命周期锚点只留最新一条,与 Thread Manager 的 `lifecycle_anchor` 一致。 directThreadLifecycleAnchor = { seq: directThreadEventSequence, event, }; } const itemId = directThreadEventItemId(event); if (itemId && event.type === 'item.started') { directThreadActiveItemIds.add(itemId); } if (itemId && event.type === 'item.completed') { directThreadActiveItemIds.delete(itemId); } } pendingDirectThreadEvents.push(...events); if (directThreadSubscriptionId) { directThreadNotifyHandler?.({ payload: { subscriptionId: directThreadSubscriptionId }, }); } }; return { invoke, listen, projectPath, sessionId, runtimeState, runtimeResult, setConfirmRuntime(state: Record) { confirmRuntime = state; }, setRejectRuntime(state: Record) { rejectRuntime = state; }, setAnswerRuntime(state: Record) { answerRuntime = state; }, failNextAnswers(count = 1) { answerFailuresRemaining = count; }, emitDesignAgentEvent(payload: Record) { designAgentUpdateHandler?.({ payload }); }, appendAgentMessage(message: Record) { currentAgentMessages.push(message); }, setResponseStream(responseStream: Record | null) { currentResponseStream = responseStream; }, setProjectRevision(revision: number) { currentProjectRevision = revision; }, setRuntimeReader(reader: (() => Promise>) | null) { runtimeReader = reader; }, emitRuntime( state: Record, responseStream: Record | null = currentResponseStream, ) { currentRuntime = state; currentResponseStream = responseStream; runtimeUpdateHandler?.({ payload: { projectPath, agentId: 'planning-agent-v2', runId: String(state.runId), status: String(state.status), phase: String(state.phase), manifestInvalidated: true, runtime: runtimeResult(currentRuntime, currentResponseStream), }, }); }, emitAgentRuntime(state: Record) { runtimeUpdateHandler?.({ payload: { projectPath, agentId: String(state.agentId ?? ''), runId: String(state.runId ?? ''), status: String(state.status ?? ''), phase: String(state.phase ?? ''), manifestInvalidated: true, runtime: runtimeResult(state, null), }, }); }, emitManifestInvalidated(agentId: string, eventProjectPath = projectPath) { for (const handler of [...manifestInvalidatedHandlers]) { handler({ payload: { projectPath: eventProjectPath, agentId, }, }); } }, emitProgress(stage: string, message: string) { progressHandler?.({ payload: { projectPath, stage, message, }, }); }, emitDirectThreadEvents, /** * 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。 * * 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。 * 事件级 `at` 与条目 `item.at` 同源:原生在两个阶段事件上都给时间,计时只读事件级那一份。 */ completeDirectThreadTurn({ turnId, reply, prompt = '', at = 9_000, status = 'completed', }: { turnId: string; reply: string; prompt?: string; at?: number; status?: string; }) { // 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。 const events: Array> = directThreadTurnRunning ? [] : [{ type: 'turn.started', at }]; if (prompt.trim()) { events.push({ type: 'item.completed', at, item: { itemType: 'message', itemId: `direct-codex:${turnId}:user`, role: 'user', text: prompt, at, }, }); } events.push({ type: 'item.completed', at: at + 1, item: { itemType: 'message', itemId: `direct-codex:${turnId}:assistant`, role: 'assistant', text: reply, at: at + 1, }, }); directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`; // 终态时间就是调用方给的这一刻:整轮总耗时读它,不读正文条目的展示时间。 events.push({ type: 'turn.completed', status, at }); emitDirectThreadEvents(...events); }, setDirectThreadHistory(items: Array>) { directThreadHistoryItems = [...items]; // 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示 // 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。 const newest = directThreadHistoryItems.at(-1); directThreadLastCompletedItemId = typeof newest?.itemId === 'string' ? newest.itemId : null; }, /** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */ setDirectThreadLastCompletedItemId(itemId: string | null) { directThreadLastCompletedItemId = itemId; }, }; } export function installResizeObserverStub() { let observerCount = 0; let observerDisconnected = false; class TestResizeObserver { constructor(readonly callback: ResizeObserverCallback) { observerCount += 1; } observe() {} unobserve() {} disconnect() { observerDisconnected = true; } } Object.defineProperty(window, 'ResizeObserver', { configurable: true, value: TestResizeObserver, }); return { observerCount: () => observerCount, observerDisconnected: () => observerDisconnected, }; } beforeEach(() => { resetLlmModelCatalogCacheForTest(); vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({ defaultModelId: 'quality', models: [ { id: 'quality', displayName: '高质量' }, { id: 'fast', displayName: '快速' }, ], revision: 1, }); Object.defineProperty(window, 'PointerEvent', { configurable: true, value: TestPointerEvent, }); Object.defineProperty(window, 'ClipboardEvent', { configurable: true, value: TestClipboardEvent, }); Object.defineProperty(window, 'DragEvent', { configurable: true, value: TestDragEvent, }); Object.defineProperty(Range.prototype, 'getBoundingClientRect', { configurable: true, value: emptyRangeRect, }); Object.defineProperties(URL, { createObjectURL: { configurable: true, value: vi.fn(() => 'blob:mock-attachment-preview'), }, revokeObjectURL: { configurable: true, value: vi.fn(), }, }); }); afterEach(() => { cleanup(); // 首页创作类型存在 module 级 zustand store 里,跨用例不会自动回到初始值。 // 建项按钮的文案依赖它(做方案 → 进入立项策划),漏一次就会让下一个用例 // 找不到「开启创作」。 useLauncherHomeDraftStore.getState().reset(); nativeClipboardMock.text = ''; window.history.pushState({}, '', '/'); window.localStorage.clear(); window.sessionStorage.clear(); delete window.__TAURI__; vi.restoreAllMocks(); if (originalCreateObjectUrl) { Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl); } else { Reflect.deleteProperty(URL, 'createObjectURL'); } if (originalRevokeObjectUrl) { Object.defineProperty(URL, 'revokeObjectURL', originalRevokeObjectUrl); } else { Reflect.deleteProperty(URL, 'revokeObjectURL'); } if (originalPointerEvent) { Object.defineProperty(window, 'PointerEvent', originalPointerEvent); } else { Reflect.deleteProperty(window, 'PointerEvent'); } if (originalClipboardEvent) { Object.defineProperty(window, 'ClipboardEvent', originalClipboardEvent); } else { Reflect.deleteProperty(window, 'ClipboardEvent'); } if (originalDragEvent) { Object.defineProperty(window, 'DragEvent', originalDragEvent); } else { Reflect.deleteProperty(window, 'DragEvent'); } if (originalRangeGetBoundingClientRect) { Object.defineProperty( Range.prototype, 'getBoundingClientRect', originalRangeGetBoundingClientRect, ); } else { Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect'); } if (originalIntersectionObserver) { Object.defineProperty( window, 'IntersectionObserver', originalIntersectionObserver, ); } else { Reflect.deleteProperty(window, 'IntersectionObserver'); } if (originalResizeObserver) { Object.defineProperty(window, 'ResizeObserver', originalResizeObserver); } else { Reflect.deleteProperty(window, 'ResizeObserver'); } }); export { act, agentRuntimeUserInputRequest, App, AuthenticatedClient, cleanup, composerDisabled, composerText, composerValue, createGameCreationAppManifest, createGameCreationAppSeedTasks, createPlanGddStateView, createProjectChatRuntimeHarness, deriveAgentStatusCards, describe, emptyProjectPolicy, expect, findResourceSelectButton, fireEvent, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, getResourceSelectButton, it, mockRoleAgentReply, nativeClipboardMock, openResourceFilterPanel, pickProjectFromLauncher, planningResponseStream, ProjectDevelopmentView, queryResourceSelectButton, React, readFileSync, render, renderAppAt, renderLauncherAt, renderLauncherProjectsAt, resolve, roleAgentMockReply, screen, setComposerText, TEST_LOCAL_PROJECT_PATH, testAuthUser, vi, waitFor, within, WorkspaceLauncher, }; export type { GameCreationAgentRunTrace };