import { describe, expect, test, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { AgentRuntimeEventRecord, AgentRuntimeResponseStream, AgentRuntimeResult, AgentRuntimeState, ChatMessage, LocalConversationMessageRecord, } from '../src/app/types'; import { canArchiveGameChatStage, gameChatRuntimeClaimsDynamicArtLineage, latestGameChatPlayableRevision, projectCurrentGameChatRuntimeLineage, projectGameChatPrimaryProgress, } from '../src/features/agent-runtime/gameChatRuntimeProjection'; import { formatAgentRuntimeEvent, mergeGameChatRuntimeResponseMessagesIntoHistory, mergeProjectSupervisorConversation, MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, projectRuntimeVisibleCurrentWork, projectRuntimeVisibleError, projectSupervisorChatRuntimeStatus, projectSupervisorCollaboratingAgentRuntimes, projectSupervisorResponseStreamIdentity, projectSupervisorVisibleConversationText, projectWorkspaceStatusForDisplay, submitProjectSupervisorRuntimeTask, } from '../src/features/agent-runtime/model'; import { projectAgentRuntimeSummaries } from '../src/features/project-summary/agentPresentation'; describe('普通用户工作区状态', () => { test('用项目名称替代 Unix 和 Windows 绝对路径', () => { expect( projectWorkspaceStatusForDisplay('已打开:/tmp/authorized-game'), ).toBe('已打开:authorized-game'); expect( projectWorkspaceStatusForDisplay('已打开:C:\\projects\\canvas-game'), ).toBe('已打开:canvas-game'); expect(projectWorkspaceStatusForDisplay('正在读取项目')).toBe( '正在读取项目', ); }); }); describe('Runtime-owned public statuses', () => { test('keeps backend status messages visible without treating them as client-authored conversation', () => { const projectRecords: LocalConversationMessageRecord[] = [ { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: '任务已接收,项目总控 Agent 正在启动处理。', agentId: null, messageId: 'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-accepted', updatedAt: 2, }, { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: '普通项目消息', agentId: null, messageId: 'project-message-1', updatedAt: 3, }, ]; const supervisorRecords: LocalConversationMessageRecord[] = [ { schemaVersion: 'game-creator-conversation.v1', role: 'user', content: '请继续完成当前游戏', agentId: 'project-supervisor', messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', updatedAt: 2, }, ]; const messages = mergeProjectSupervisorConversation( projectRecords, supervisorRecords, ); expect(messages).toEqual([ expect.objectContaining({ text: '请继续完成当前游戏', runtimeOwned: true, }), expect.objectContaining({ text: '任务已接收,项目总控 Agent 正在启动处理。', runtimeOwned: true, }), expect.objectContaining({ text: '普通项目消息', runtimeOwned: false, }), ]); }); test('interleaves rapid same-second tasks with their accepted and terminal statuses', () => { const projectRecords: LocalConversationMessageRecord[] = [ { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: '任务已接收,项目总控 Agent 正在启动处理。', agentId: null, messageId: 'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-accepted', updatedAt: 9, }, { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: '项目总控 Agent 执行失败,请稍后重试', agentId: null, messageId: 'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-failed', updatedAt: 9, }, { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: '任务已接收,项目总控 Agent 正在启动处理。', agentId: null, messageId: 'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-accepted', updatedAt: 9, }, { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: '项目总控 Agent 已达到执行预算,请缩小任务范围后重试', agentId: null, messageId: 'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-budget', updatedAt: 9, }, ]; const supervisorRecords: LocalConversationMessageRecord[] = [ { schemaVersion: 'game-creator-conversation.v1', role: 'user', content: '第一条任务', agentId: 'project-supervisor', messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', updatedAt: 9, }, { schemaVersion: 'game-creator-conversation.v1', role: 'user', content: '第二条任务', agentId: 'project-supervisor', messageId: 'runtime-task-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', updatedAt: 9, }, ]; expect( mergeProjectSupervisorConversation(projectRecords, supervisorRecords).map( (message) => message.text, ), ).toEqual([ '第一条任务', '任务已接收,项目总控 Agent 正在启动处理。', '项目总控 Agent 执行失败,请稍后重试', '第二条任务', '任务已接收,项目总控 Agent 正在启动处理。', '项目总控 Agent 已达到执行预算,请缩小任务范围后重试', ]); }); test('keeps an older sparse terminal before a new same-second task', () => { const projectRecords: LocalConversationMessageRecord[] = [ { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: '项目总控 Agent 执行失败,请稍后重试', agentId: null, messageId: 'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-failed', updatedAt: 9, }, { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: '任务已接收,项目总控 Agent 正在启动处理。', agentId: null, messageId: 'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-accepted', updatedAt: 9, }, ]; const supervisorRecords: LocalConversationMessageRecord[] = [ { schemaVersion: 'game-creator-conversation.v1', role: 'user', content: '旧任务', agentId: 'project-supervisor', messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', updatedAt: 8, }, { schemaVersion: 'game-creator-conversation.v1', role: 'user', content: '新任务', agentId: 'project-supervisor', messageId: 'runtime-task-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', updatedAt: 9, }, ]; expect( mergeProjectSupervisorConversation(projectRecords, supervisorRecords).map( (message) => message.text, ), ).toEqual([ '旧任务', '项目总控 Agent 执行失败,请稍后重试', '新任务', '任务已接收,项目总控 Agent 正在启动处理。', ]); }); test('interleaves same-second steer acknowledgements with their user messages', () => { const acknowledgement = '收到。我正在判断这条消息是否需要调整当前任务;现有任务会继续运行。'; const projectRecords: LocalConversationMessageRecord[] = [ { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: acknowledgement, agentId: null, messageId: 'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-steer-1', updatedAt: 9, }, { schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: acknowledgement, agentId: null, messageId: 'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-steer-2', updatedAt: 9, }, ]; const supervisorRecords: LocalConversationMessageRecord[] = [ { schemaVersion: 'game-creator-conversation.v1', role: 'user', content: '你在做什么?', agentId: 'project-supervisor', messageId: 'agent-steer-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', updatedAt: 9, }, { schemaVersion: 'game-creator-conversation.v1', role: 'user', content: '先把移动端操作做好。', agentId: 'project-supervisor', messageId: 'agent-steer-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', updatedAt: 9, }, ]; expect( mergeProjectSupervisorConversation(projectRecords, supervisorRecords).map( (message) => message.text, ), ).toEqual([ '你在做什么?', acknowledgement, '先把移动端操作做好。', acknowledgement, ]); }); }); describe('Game Chat stream identity and source', () => { test('response stream identity binds run, slot, and revision', () => { const base: AgentRuntimeResponseStream = { schemaVersion: 'game-creator-runtime-response-stream.v1', agentId: 'project-supervisor', taskId: 'project-supervisor', sessionId: 'session-1', runId: 'run-1', requestKind: 'final-reply', requestSlot: 'final-reply-loop-1-revision-0', appliedSteerCursor: 0, responseRevision: 0, sequence: 1, status: 'ready', accumulatedText: 'same text', finishReason: 'stop', startedAt: 1, updatedAt: 2, }; expect(projectSupervisorResponseStreamIdentity(base)).toBe( 'run-1\u001ffinal-reply-loop-1-revision-0\u001f0', ); expect( projectSupervisorResponseStreamIdentity({ ...base, runId: 'run-2' }), ).not.toBe(projectSupervisorResponseStreamIdentity(base)); expect( projectSupervisorResponseStreamIdentity({ ...base, requestSlot: 'final-reply-loop-2-revision-0', }), ).not.toBe(projectSupervisorResponseStreamIdentity(base)); expect( projectSupervisorResponseStreamIdentity({ ...base, responseRevision: 1 }), ).not.toBe(projectSupervisorResponseStreamIdentity(base)); }); test('game-chat source is forwarded for start and steer without changing default source behavior', async () => { const runtimeResult = { state: { schemaVersion: 'game-creator-agent-runtime.v1', agentId: 'project-supervisor', taskId: 'project-supervisor', sessionId: 'session-provider-retry', runId: 'accepted-run', source: 'project-supervisor-chat', status: 'running', phase: 'planning', currentTask: null, currentAction: null, waitingOn: null, nextStep: null, plan: [], planSteps: [], observations: [], allowedTools: [], lastResponse: null, error: null, updatedAt: 1, }, sessionPath: 'session.json', eventPath: 'events.jsonl', } satisfies AgentRuntimeResult; const invoke = vi.fn( async (_command: string, _args?: Record) => runtimeResult, ); await submitProjectSupervisorRuntimeTask({ invoke, projectPath: '/tmp/game-chat', sessionId: 'session-provider-retry', prompt: 'make a game', runtime: null, runProfile: 'autonomous-game-build', source: 'project-supervisor-game-chat', }); expect(invoke).toHaveBeenCalledWith( 'start_game_creator_supervisor_runtime_task', expect.objectContaining({ source: 'project-supervisor-game-chat' }), ); invoke.mockClear(); await submitProjectSupervisorRuntimeTask({ invoke, projectPath: '/tmp/game-chat', sessionId: 'session-provider-retry', prompt: 'chat', runtime: null, runProfile: 'standard', }); expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); invoke.mockClear(); const steerRuntime = { ...runtimeResult.state, runId: 'steer-run', runProfile: 'autonomous-game-build', source: 'project-supervisor-game-chat', status: 'running', phase: 'planning', } satisfies AgentRuntimeState; invoke.mockResolvedValueOnce({ runtime: runtimeResult, steerId: 'steer-1', sequence: 1, status: 'applied', providerInterrupted: false, }); await submitProjectSupervisorRuntimeTask({ invoke, projectPath: '/tmp/game-chat', sessionId: 'session-provider-retry', prompt: 'continue this run', runtime: steerRuntime, runProfile: 'autonomous-game-build', source: 'project-supervisor-game-chat', }); expect(invoke).toHaveBeenCalledWith( 'steer_game_creator_agent_runtime_task', expect.objectContaining({ source: 'project-supervisor-game-chat' }), ); invoke.mockClear(); invoke.mockResolvedValueOnce({ runtime: runtimeResult, steerId: 'steer-2', sequence: 2, status: 'applied', providerInterrupted: false, }); await submitProjectSupervisorRuntimeTask({ invoke, projectPath: '/tmp/game-chat', sessionId: 'session-provider-retry', prompt: 'continue this run', runtime: steerRuntime, runProfile: 'autonomous-game-build', }); expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); invoke.mockClear(); await submitProjectSupervisorRuntimeTask({ invoke, projectPath: '/tmp/game-chat', sessionId: 'session-provider-retry', prompt: 'continue from the app', runtime: { ...steerRuntime, source: 'project-supervisor-cli', }, runProfile: 'autonomous-game-build', source: 'project-supervisor-game-chat', }); expect(invoke).toHaveBeenCalledWith( 'start_game_creator_supervisor_runtime_task', expect.objectContaining({ source: 'project-supervisor-game-chat' }), ); }); test('game-chat hydration keeps identical text from a different runtime response identity', () => { const history = [ { role: 'assistant' as const, text: 'same text', messageId: 'persisted-message-1', updatedAt: 20, }, ]; const pending = [ { role: 'assistant' as const, text: 'same text', messageId: 'runtime-response:run-2\u001fslot-1\u001f0', updatedAt: 10, runtimeOwned: true, }, ]; expect( mergeGameChatRuntimeResponseMessagesIntoHistory(history, pending), ).toEqual([pending[0], history[0]]); expect( mergeGameChatRuntimeResponseMessagesIntoHistory(history, [ { ...pending[0], messageId: 'persisted-message-1' }, ]), ).toEqual(history); }); test('ready response survives either ordering of runtime commit and conversation hydration', () => { const history = [ { role: 'user' as const, text: 'build a game', messageId: 'persisted-message-1', updatedAt: 1, }, ]; const ready = { role: 'assistant' as const, text: 'ready response', messageId: 'runtime-response:run-1\u001fslot-1\u001f0', updatedAt: 2, runtimeOwned: true, }; const commitReady = (current: ChatMessage[]) => current.some((message) => message.messageId === ready.messageId) ? current : [...current, ready]; const hydrateConversation = (current: ChatMessage[]) => mergeGameChatRuntimeResponseMessagesIntoHistory(history, current); expect(hydrateConversation(commitReady([]))).toContainEqual(ready); expect(commitReady(hydrateConversation([]))).toContainEqual(ready); }); }); function providerRetryRuntime(): AgentRuntimeState { return { schemaVersion: 'game-creator-agent-runtime.v1', agentId: 'project-supervisor', taskId: 'task-provider-retry', sessionId: 'session-provider-retry', runId: 'run-provider-retry', source: 'project-supervisor-chat', status: 'running', phase: 'waiting-for-provider-retry', currentTask: '生成项目总控计划', currentAction: 'Provider 上游返回 HTTP 503,准备自动重试 1/3', waitingOn: '预计 30 秒后重试', nextStep: '到期后继续同一 Run', plan: ['生成项目总控计划'], planSteps: [ { step: '这个活跃计划步骤不应覆盖真实 Provider 等待进度', status: 'in_progress', index: 0, }, ], observations: [], allowedTools: [], lastResponse: null, error: null, updatedAt: 1, }; } describe('Agent Runtime Provider 状态投影', () => { test('等待 503 重试时优先显示 Runtime 的真实动作和等待时间', () => { const runtime = providerRetryRuntime(); const expected = 'Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 30 秒后重试'; expect(projectSupervisorChatRuntimeStatus(runtime)).toBe(expected); expect(projectRuntimeVisibleCurrentWork(runtime)).toBe(expected); }); test('旧等待投影不再显示内部 upstream-5xx 分类', () => { const legacyRuntime = { ...providerRetryRuntime(), currentAction: '等待 Provider 瞬态重试 1/3', waitingOn: 'Provider upstream-5xx 瞬态故障退避到期', }; const expected = 'Provider 上游服务暂时不可用,准备自动重试 1/3'; expect(projectSupervisorChatRuntimeStatus(legacyRuntime)).toBe(expected); expect(projectRuntimeVisibleCurrentWork(legacyRuntime)).toBe(expected); expect(projectSupervisorChatRuntimeStatus(legacyRuntime)).not.toContain( 'upstream-5xx', ); }); test('严格解析 503 重试耗尽的稳定字段', () => { const fingerprint = 'a'.repeat(64); const message = `agentLlm.project-supervisor 规划调用 LLM 失败:kind=upstream-503 ` + `httpStatus=503 fingerprint=${fingerprint} chars=248 ` + 'retryAttempt=3 maxRetries=3 retryState=exhausted'; expect(projectRuntimeVisibleError(message, '项目总控 Agent', true)).toBe( '项目总控 Agent 上游服务返回 HTTP 503;自动重试已耗尽(3/3)', ); const failedRuntime = { ...providerRetryRuntime(), status: 'failed', phase: 'failed', error: message, }; expect(projectSupervisorChatRuntimeStatus(failedRuntime)).toBe( '项目总控 Agent 上游服务返回 HTTP 503;自动重试已耗尽(3/3)', ); }); test('明确说明泥点余额不足导致的中断并隐藏附加诊断', () => { for (const message of [ 'agentLlm.project-supervisor 规划调用 LLM 失败:kind=mud-points-insufficient', '平台图片生成任务失败:泥点余额不足;operationId=private-operation-id', ]) { expect(projectRuntimeVisibleError(message, '项目总控 Agent', true)).toBe( MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, ); expect( projectSupervisorVisibleConversationText(`后台任务失败:${message}`), ).toBe(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE); } const failedRuntime = { ...providerRetryRuntime(), status: 'failed', phase: 'failed', error: '平台图片生成任务失败:泥点余额不足;operationId=private-operation-id', }; const status = projectSupervisorChatRuntimeStatus(failedRuntime); expect(status).toBe(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE); expect(status).not.toContain('operationId'); expect(status).not.toContain('private-operation-id'); expect( projectRuntimeVisibleError('上游余额不足', '项目总控 Agent', true), ).toBe('项目总控 Agent 执行失败,请稍后重试'); }); test('即使前缀含恶意上游正文也只展示严格字段派生的摘要', () => { const fingerprint = 'b'.repeat(64); const malicious = 'https://provider.example/private?api_key=sk-secret C:\\Users\\victim\\project'; const message = `${malicious}:kind=upstream-503 httpStatus=503 ` + `fingerprint=${fingerprint} chars=999 retryAttempt=3 ` + 'maxRetries=3 retryState=exhausted'; const visible = projectRuntimeVisibleError(message, '项目总控 Agent', true); expect(visible).toBe( '项目总控 Agent 上游服务返回 HTTP 503;自动重试已耗尽(3/3)', ); expect(visible).not.toContain('provider.example'); expect(visible).not.toContain('sk-secret'); expect(visible).not.toContain('victim'); }); test('拒绝字段不一致或带尾随正文的伪造 Provider 摘要', () => { const fingerprint = 'c'.repeat(64); const inconsistent = `kind=upstream-503 httpStatus=500 fingerprint=${fingerprint} chars=1 ` + 'retryAttempt=3 maxRetries=3 retryState=exhausted'; const trailingBody = `kind=upstream-503 httpStatus=503 fingerprint=${fingerprint} chars=1 ` + 'retryAttempt=3 maxRetries=3 retryState=exhausted provider body'; expect( projectRuntimeVisibleError(inconsistent, '项目总控 Agent', true), ).toBe('项目总控 Agent 执行失败,请稍后重试'); expect( projectRuntimeVisibleError(trailingBody, '项目总控 Agent', true), ).toBe('项目总控 Agent 执行失败,请稍后重试'); }); test('隐藏旧失败对话与事件中的内部诊断标记', () => { const fingerprint = 'd'.repeat(64); const legacyConversation = '后台任务失败: [redacted sensitive context] ' + `kind=upstream-503 httpStatus=503 fingerprint=${fingerprint} chars=99 ` + 'retryAttempt=3 maxRetries=3 retryState=exhausted'; expect(projectSupervisorVisibleConversationText(legacyConversation)).toBe( '项目总控 Agent 上游服务返回 HTTP 503;自动重试已耗尽(3/3)', ); expect( projectSupervisorVisibleConversationText(legacyConversation, 'user'), ).toBe(legacyConversation); const formattedEvent = formatAgentRuntimeEvent({ schemaVersion: 'game-creator-agent-runtime.v1', agentId: 'project-supervisor', taskId: 'task-provider-retry', sessionId: 'session-provider-retry', runId: 'run-provider-retry', source: 'project-supervisor-chat', eventType: 'turn.failed', status: 'failed', phase: 'failed', summary: 'Agent Runtime 本轮处理失败。', detail: ` [redacted sensitive context] ` + `errorSha256=${fingerprint} · errorChars=99`, updatedAt: 1, }); expect(formattedEvent).toBe( 'turn.failed · failed / failed · Agent Runtime 本轮处理失败。', ); expect(formattedEvent).not.toContain('errorSha256'); expect(formattedEvent).not.toContain(fingerprint); const emptySummaryEvent = formatAgentRuntimeEvent({ schemaVersion: 'game-creator-agent-runtime.v1', agentId: 'project-supervisor', taskId: 'task-provider-retry', sessionId: 'session-provider-retry', runId: 'run-provider-retry', source: 'project-supervisor-chat', eventType: 'error', status: 'failed', phase: 'failed', summary: '', detail: ` [redacted sensitive context] ` + `fingerprint=${fingerprint} chars=99`, updatedAt: 1, }); expect(emptySummaryEvent).toBe( 'error · failed / failed · Agent Runtime 本轮处理失败。', ); expect(emptySummaryEvent).not.toContain('absolute-path'); expect(emptySummaryEvent).not.toContain(fingerprint); }); }); function gameChatProjectionRuntime( overrides: Partial = {}, ): AgentRuntimeState { return { schemaVersion: 'game-creator-agent-runtime.v1', agentId: 'project-supervisor', taskId: 'project-supervisor', sessionId: 'game-chat-root-session', runId: 'game-chat-root-run', source: 'project-supervisor-game-chat', status: 'running', phase: 'execution', currentTask: '完成当前游戏创作轮次', currentAction: '等待单主 Agent 完成', plan: [], observations: [], allowedTools: [], lastResponse: null, error: null, updatedAt: 100, ...overrides, }; } function gameChatProjectionMain( root: AgentRuntimeState, overrides: Partial = {}, ) { return gameChatProjectionRuntime({ agentId: 'code-prototype', taskId: 'code-prototype', sessionId: 'game-chat-main-session', runId: 'game-chat-main-run', source: 'agent-ready-task-scheduler', parentAgentId: 'project-supervisor', parentRunId: root.runId, currentTask: '审计、接入并验收当前原型', updatedAt: 110, ...overrides, }); } function gameChatProjectionEvent( runtime: AgentRuntimeState, overrides: Partial, ): AgentRuntimeEventRecord { return { schemaVersion: 'game-creator-runtime-event.v1', agentId: runtime.agentId, taskId: runtime.taskId, sessionId: runtime.sessionId, runId: runtime.runId, source: runtime.source, eventType: 'observation', status: runtime.status, phase: 'observation', summary: 'observation', detail: null, updatedAt: runtime.updatedAt, ...overrides, }; } describe('Game-chat source-aware runtime projection', () => { test('projects only the current scheduler main and its nested art children', () => { const root = gameChatProjectionRuntime(); const main = gameChatProjectionMain(root); const artDirector = gameChatProjectionRuntime({ agentId: 'art-director', taskId: 'art-director', sessionId: 'art-director-session', runId: 'art-director-run', source: 'agent-delegate', parentAgentId: 'code-prototype', parentRunId: main.runId, status: 'completed', phase: 'completed', updatedAt: 120, }); const legacyRetry = gameChatProjectionRuntime({ agentId: 'art-asset-plan', taskId: 'art-asset-plan', sessionId: 'art-asset-plan-retry-session', runId: 'art-asset-plan-retry-run', source: 'agent-delegate-retry', parentAgentId: 'code-prototype', parentRunId: main.runId, status: 'failed', phase: 'failed', updatedAt: 130, }); const oldMain = gameChatProjectionMain(root, { sessionId: 'old-main-session', runId: 'old-main-run', parentRunId: 'old-root-run', updatedAt: 999, }); const oldRootDirectArt = gameChatProjectionRuntime({ agentId: 'art-director', taskId: 'art-director', sessionId: 'old-root-art-session', runId: 'old-root-art-run', source: 'agent-ready-task-scheduler', parentAgentId: 'project-supervisor', parentRunId: root.runId, updatedAt: 998, }); const fullDagPlaytest = gameChatProjectionRuntime({ agentId: 'preview-playtest', taskId: 'preview-playtest', sessionId: 'full-dag-playtest-session', runId: 'full-dag-playtest-run', source: 'agent-ready-task-scheduler', parentAgentId: 'project-supervisor', parentRunId: root.runId, updatedAt: 997, }); const runtimeMap = { main, 'main-task-alias': main, artDirector, legacyRetry, oldMain, oldRootDirectArt, fullDagPlaytest, }; const lineage = projectCurrentGameChatRuntimeLineage(root, runtimeMap); expect(lineage?.main).toBe(main); expect(lineage?.dynamicArtChildren).toEqual([artDirector, legacyRetry]); expect(lineage?.hasConflict).toBe(false); expect(gameChatRuntimeClaimsDynamicArtLineage(artDirector)).toBe(true); expect(gameChatRuntimeClaimsDynamicArtLineage(legacyRetry)).toBe(true); expect(gameChatRuntimeClaimsDynamicArtLineage(oldRootDirectArt)).toBe( false, ); expect( projectSupervisorCollaboratingAgentRuntimes(root, runtimeMap), ).toEqual([main, artDirector, legacyRetry]); const malformedGameChatRoot = { ...root, parentAgentId: 'project-supervisor', parentRunId: 'ancestor-run', }; expect( projectSupervisorCollaboratingAgentRuntimes(malformedGameChatRoot, { directDelegate: { ...artDirector, parentAgentId: 'project-supervisor', parentRunId: malformedGameChatRoot.runId, }, }), ).toEqual([]); }); test('projects current game-chat main and nested art into launcher summaries', () => { const root = gameChatProjectionRuntime(); const main = gameChatProjectionMain(root); const art = gameChatProjectionRuntime({ agentId: 'art-director', taskId: 'art-director', sessionId: 'launcher-art-session', runId: 'launcher-art-run', source: 'agent-delegate', parentAgentId: 'code-prototype', parentRunId: main.runId, status: 'completed', phase: 'completed', }); const manifest = createGameCreationAppManifest( 'launcher-game-chat', 'Launcher 单主摘要', ); expect(projectAgentRuntimeSummaries(manifest, root, { main, art })).toEqual( [ expect.objectContaining({ group: 'art', label: '美术 Agent', status: 'completed', }), expect.objectContaining({ group: 'code', label: '程序 Agent', status: 'running', }), ], ); expect( projectAgentRuntimeSummaries( manifest, { ...root, parentAgentId: 'project-supervisor' }, { main, art }, ), ).toEqual([]); }); test('uses only the current main for the 0/1 progress denominator', () => { const root = gameChatProjectionRuntime(); const activeMain = gameChatProjectionMain(root); const manifest = createGameCreationAppManifest('project-1', '单主进度'); manifest.tasks = manifest.tasks.map((task) => task.id === 'code-prototype' ? { ...task, status: 'completed' as const } : task, ); expect( projectGameChatPrimaryProgress( manifest, projectCurrentGameChatRuntimeLineage(root, {}), ), ).toEqual({ completed: 0, total: 1, status: 'pending' }); expect( projectGameChatPrimaryProgress( manifest, projectCurrentGameChatRuntimeLineage(root, [activeMain]), ), ).toEqual({ completed: 0, total: 1, status: 'running' }); const completedMain = { ...activeMain, status: 'completed', phase: 'completed', }; expect( projectGameChatPrimaryProgress( manifest, projectCurrentGameChatRuntimeLineage(root, [completedMain]), ), ).toEqual({ completed: 1, total: 1, status: 'completed' }); const failedMain = { ...activeMain, status: 'failed', phase: 'failed', }; expect( projectGameChatPrimaryProgress( manifest, projectCurrentGameChatRuntimeLineage(root, [failedMain]), ), ).toEqual({ completed: 0, total: 1, status: 'failed' }); const reconciliationRoot = { ...root, status: 'needs-reconciliation', phase: 'needs-reconciliation', }; expect( projectGameChatPrimaryProgress( manifest, projectCurrentGameChatRuntimeLineage(reconciliationRoot, []), ), ).toEqual({ completed: 0, total: 1, status: 'needs-reconciliation', }); const conflictingMain = gameChatProjectionMain(root, { sessionId: 'conflicting-main-session', runId: 'conflicting-main-run', }); expect( projectGameChatPrimaryProgress( manifest, projectCurrentGameChatRuntimeLineage(root, [ activeMain, conflictingMain, ]), ), ).toEqual({ completed: 0, total: 1, status: 'needs-reconciliation', }); }); test('keeps an unbound manifest out of the verdict until the current main is terminal', () => { const root = gameChatProjectionRuntime(); const activeMain = gameChatProjectionMain(root); // The manifest carries no root/run binding, so a `failed` value cannot be // attributed to any particular round. A leftover from an earlier round must // not decide the verdict while this round's main is still running. const staleFailedManifest = createGameCreationAppManifest( 'project-1', '跨轮残留清单', ); staleFailedManifest.tasks = staleFailedManifest.tasks.map((task) => task.id === 'code-prototype' ? { ...task, status: 'failed' as const } : task, ); expect( projectGameChatPrimaryProgress( staleFailedManifest, projectCurrentGameChatRuntimeLineage(root, [activeMain]), ), ).toEqual({ completed: 0, total: 1, status: 'running' }); expect( projectGameChatPrimaryProgress( staleFailedManifest, projectCurrentGameChatRuntimeLineage(root, [ { ...activeMain, phase: 'waiting-for-delegate-receipts' }, ]), ), ).toEqual({ completed: 0, total: 1, status: 'running' }); // Mirror gate: a terminal manifest alone must not unlock archiving either. const terminalRoot = gameChatProjectionRuntime({ status: 'completed', phase: 'completed', }); const terminalManifest = createGameCreationAppManifest( 'project-1', '归档不得只看清单', ); terminalManifest.tasks = terminalManifest.tasks.map((task) => task.id === 'code-prototype' ? { ...task, status: 'completed' as const } : task, ); expect( canArchiveGameChatStage( projectCurrentGameChatRuntimeLineage(terminalRoot, [ gameChatProjectionMain(terminalRoot), ]), terminalManifest, ), ).toBe(false); // Recorded residual risk (M0B-2 deferred item): once the current main is // terminal the manifest is trusted verbatim, so a cross-round leftover is // still reported as this round's failure. Changing this expectation means // the deferred ruling was revisited — update the decision log with it. expect( projectGameChatPrimaryProgress( staleFailedManifest, projectCurrentGameChatRuntimeLineage(root, [ { ...activeMain, status: 'completed', phase: 'completed' }, ]), ), ).toEqual({ completed: 0, total: 1, status: 'failed' }); }); test('derives a playable revision only from current-main smoke then structured preview evidence', () => { const root = gameChatProjectionRuntime(); const main = gameChatProjectionMain(root, { recentEvents: [] }); const smoke = gameChatProjectionEvent(main, { summary: 'command.run_limited:ok · game.static_smoke 已完成', updatedAt: 200, }); const previewPassed = gameChatProjectionEvent(main, { summary: 'preview.validate:ok · 浏览器验证已通过', detail: JSON.stringify({ passed: true, playtestPassed: true, revision: 7, }), updatedAt: 201, }); main.recentEvents = [smoke, previewPassed]; const lineage = projectCurrentGameChatRuntimeLineage(root, [main]); expect(latestGameChatPlayableRevision(lineage)).toEqual({ runId: root.runId, mainRunId: main.runId, revision: 7, validatedAt: 201, }); const laterFailure = gameChatProjectionEvent(main, { summary: 'preview.validate:failed · 后续回归', detail: JSON.stringify({ passed: false, playtestPassed: false, revision: 7, }), updatedAt: 202, }); main.recentEvents = [smoke, previewPassed, laterFailure]; expect( latestGameChatPlayableRevision( projectCurrentGameChatRuntimeLineage(root, [main]), ), ).toBeNull(); main.recentEvents = [previewPassed]; expect( latestGameChatPlayableRevision( projectCurrentGameChatRuntimeLineage(root, [main]), ), ).toBeNull(); const oldPlaytest = gameChatProjectionRuntime({ agentId: 'preview-playtest', taskId: 'preview-playtest', sessionId: 'old-playtest-session', runId: 'old-playtest-run', source: 'agent-ready-task-scheduler', parentAgentId: 'project-supervisor', parentRunId: root.runId, recentEvents: [previewPassed], }); expect( latestGameChatPlayableRevision( projectCurrentGameChatRuntimeLineage(root, [oldPlaytest]), ), ).toBeNull(); main.recentEvents = [previewPassed, { ...smoke, updatedAt: 202 }]; expect( latestGameChatPlayableRevision( projectCurrentGameChatRuntimeLineage(root, [main]), ), ).toBeNull(); main.recentEvents = [smoke, { ...previewPassed, updatedAt: -1 }]; expect( latestGameChatPlayableRevision( projectCurrentGameChatRuntimeLineage(root, [main]), ), ).toBeNull(); }); test('archives only after root, main, dynamic children and manifest have converged', () => { const root = gameChatProjectionRuntime({ status: 'completed', phase: 'completed', }); const main = gameChatProjectionMain(root, { status: 'completed', phase: 'completed', }); const art = gameChatProjectionRuntime({ agentId: 'art-director', taskId: 'art-director', sessionId: 'archive-art-session', runId: 'archive-art-run', source: 'agent-delegate', parentAgentId: 'code-prototype', parentRunId: main.runId, status: 'completed', phase: 'completed', }); const manifest = createGameCreationAppManifest('project-2', '归档门'); manifest.tasks = manifest.tasks.map((task) => task.id === 'code-prototype' ? { ...task, status: 'completed' as const } : task, ); expect( canArchiveGameChatStage( projectCurrentGameChatRuntimeLineage(root, [main, art]), manifest, ), ).toBe(true); const activeArt = { ...art, status: 'running', phase: 'execution' }; expect( canArchiveGameChatStage( projectCurrentGameChatRuntimeLineage(root, [main, activeArt]), manifest, ), ).toBe(false); const waitingMain = { ...main, status: 'running', phase: 'waiting-for-delegate-receipts', }; expect( canArchiveGameChatStage( projectCurrentGameChatRuntimeLineage(root, [waitingMain, art]), manifest, ), ).toBe(false); const activeRoot = { ...root, status: 'running', phase: 'execution' }; expect( canArchiveGameChatStage( projectCurrentGameChatRuntimeLineage(activeRoot, [ { ...main, parentRunId: activeRoot.runId }, art, ]), manifest, ), ).toBe(false); const reconciliationArt = { ...art, status: 'needs-reconciliation', phase: 'needs-reconciliation', }; expect( canArchiveGameChatStage( projectCurrentGameChatRuntimeLineage(root, [main, reconciliationArt]), manifest, ), ).toBe(false); const pendingManifest = createGameCreationAppManifest( 'project-2', '归档门未收敛', ); expect( canArchiveGameChatStage( projectCurrentGameChatRuntimeLineage(root, [main, art]), pendingManifest, ), ).toBe(false); }); });