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 abbcb8cbf..0687eb697 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 @@ -8554,6 +8554,207 @@ export function registerProjectSupervisorSurfaceTests() { ).not.toBeNull(); }); + it('renders the running turn tool-call group in a fresh chat that has no anchored assistant message', async () => { + // 空对话首轮:历史为空,消息列表里只有 App 落的默认问候(`想做什么游戏?`,没有 messageId)。 + // 这种回合没有任何「可锚」的 assistant 消息,块必须兜底落在消息列表末尾, + // 而不是等到回合结束、assistant 消息带上 messageId 之后才出现。 + const projectPath = '/tmp/launcher-tool-call-fresh-turn-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-tool-call-fresh-turn-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + let directTurnUpdateHandler: + | ((event: { payload: Record }) => void) + | null = null; + const listen = vi.fn( + async ( + eventName: string, + handler: (event: { payload: Record }) => void, + ) => { + if (eventName === 'game-creator-direct-turn-update') { + directTurnUpdateHandler = handler; + return () => { + if (directTurnUpdateHandler === handler) { + directTurnUpdateHandler = null; + } + }; + } + return supervisorHarness.listen(eventName, handler); + }, + ); + const directReply = createDeferred(); + 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, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-tool-call-fresh-turn-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'chat_with_game_creator_direct_codex') { + return directReply.promise; + } + if (command === 'read_direct_tool_calls') { + return []; + } + if (command === 'read_direct_project_conversation') { + // 真正的空对话:没有任何历史消息,App 会落一条没有 messageId 的默认问候。 + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [], + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + const messageList = supervisorSurface.querySelector( + '.project-supervisor-message-list', + ); + expect(messageList).not.toBeNull(); + expect( + within(supervisorSurface).getByText('想做什么游戏?'), + ).not.toBeNull(); + expect( + within(supervisorSurface).queryAllByTestId('agent-tool-call-group'), + ).toHaveLength(0); + + await setComposerText( + screen.getByLabelText('陶泥儿对话内容'), + '做一个跑酷游戏', + ); + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, + ); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ projectPath }), + ); + }); + const clientTurnId = String( + ( + invoke.mock.calls.find( + ([command]) => command === 'chat_with_game_creator_direct_codex', + )?.[1] as Record | undefined + )?.clientTurnId ?? '', + ); + expect(clientTurnId).not.toBe(''); + + await act(async () => { + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: clientTurnId, + sequence: 1, + status: 'running', + activity: 'command-exec', + updatedAt: 1400, + toolCalls: [ + { + schemaVersion: 'agc-tool-call.v1', + id: 'fresh-turn-command', + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'running', + detail: { command: 'npm run build' }, + startedAt: 1000, + updatedAt: 1400, + }, + ], + }, + }); + }); + + // 回合进行中:块已经可见,且落在消息列表末尾(默认问候之后),不依赖任何带 messageId 的 assistant 消息。 + const runningGroups = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + ); + expect(runningGroups).toHaveLength(1); + const runningGroup = runningGroups[0] as HTMLElement; + expect(runningGroup.getAttribute('data-status')).toBe('running'); + expect(runningGroup.getAttribute('data-duration-ms')).toBe('400'); + const runningHead = within(runningGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(runningHead.getAttribute('aria-expanded')).toBe('false'); + expect(runningHead.textContent).toContain('已执行 1 个命令'); + const runningChildren = Array.from((messageList as HTMLElement).children); + const greetingIndex = runningChildren.findIndex((node) => + node.textContent?.includes('想做什么游戏?'), + ); + expect(greetingIndex).toBeGreaterThanOrEqual(0); + expect( + runningChildren.findIndex((node) => node === runningGroup), + ).toBeGreaterThan(greetingIndex); + fireEvent.click(runningHead); + const runningRows = within(runningGroup).getAllByTestId( + 'agent-tool-call-row', + ); + expect(runningRows).toHaveLength(1); + expect(runningRows[0]?.getAttribute('data-kind')).toBe('command'); + expect(runningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); + expect( + within(runningRows[0] as HTMLElement).getByText('0.4s'), + ).not.toBeNull(); + + // 回合结束:assistant 消息落盘后块回到它之前,且**只有一份**(末尾兜底不留下重复块)。 + await act(async () => { + directReply.resolve('DIRECT_REPLY:空对话首轮'); + }); + await waitFor(() => { + expect( + within(supervisorSurface).getByText('DIRECT_REPLY:空对话首轮'), + ).not.toBeNull(); + }); + await waitFor(() => { + expect( + within(supervisorSurface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); + }); + const settledGroup = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + )[0] as HTMLElement; + const settledChildren = Array.from((messageList as HTMLElement).children); + const settledAssistantIndex = settledChildren.findIndex( + (node) => + node.classList.contains('message--assistant') && + node.textContent?.includes('DIRECT_REPLY:空对话首轮'), + ); + expect(settledAssistantIndex).toBeGreaterThanOrEqual(0); + expect(settledChildren.findIndex((node) => node === settledGroup)).toBe( + settledAssistantIndex - 1, + ); + }); + it('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => { // 空态只在 direct-codex 面板、且没有任何消息/流式回复/待确认时出现。真实项目打开时 // App 总会先放一条默认问候(`createDefaultChatMessages`),所以这里直接挂面板本体,