diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 3ea6048a0..41525a5ec 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -107,6 +107,7 @@ import { ensureProjectSupervisorActiveSessionId, isAgentFinalizationMessageId, isAgentRuntimeTerminalState, + isGameChatSupervisorRoot, isMissingAgentRuntimeResumeCommandError, isRuntimeConfigMissingError, latestGameChatPlayableRevision, @@ -270,6 +271,7 @@ type PendingGameChatStageArchive = { rootRuntime: AgentRuntimeState; runtimeRecords: AgentRuntimeState[]; manifestSnapshot: GameCreationAppManifest | null; + awaitingConversationSync: boolean; }; function mergeGameChatRuntimeSnapshots( @@ -898,6 +900,9 @@ export function App({ incoming, runtime, ); + if (gameChatOnly && !isGameChatSupervisorRoot(runtime)) { + nextStream = null; + } const candidateStream = nextStream; if (candidateStream?.status === 'ready' && gameChatOnly) { const text = candidateStream.accumulatedText.trim(); @@ -978,7 +983,20 @@ export function App({ return; } const syncKey = `${projectPath}\n${runtime.sessionId}\n${runtime.runId}`; - if (projectSupervisorRuntimeSyncingRef.current.has(syncKey)) { + const syncAlreadyStarted = + projectSupervisorRuntimeSyncingRef.current.has(syncKey); + if (gameChatOnly && isGameChatSupervisorRoot(runtime)) { + // Freeze the old root and its currently known descendants before the + // asynchronous conversation refresh. A fast next turn may replace the + // current-by-agent map while that refresh is still in flight. + appendGameChatStageRecord( + projectPath, + runtime, + false, + !syncAlreadyStarted, + ); + } + if (syncAlreadyStarted) { return; } const refreshConversation = projectSupervisorRefreshConversationRef.current; @@ -986,24 +1004,54 @@ export function App({ return; } projectSupervisorRuntimeSyncingRef.current.add(syncKey); + if (gameChatOnly && isGameChatSupervisorRoot(runtime)) { + const archiveKey = `${projectPath}\n${runtime.runId}`; + void invoke('get_local_game_manifest', { + projectPath, + }) + .then((capturedManifest) => { + const pendingArchive = + gameChatPendingStageRuntimesRef.current.get(archiveKey); + if ( + pendingArchive && + gameChatManifestHasTerminalPrimaryTask(capturedManifest) + ) { + pendingArchive.manifestSnapshot = capturedManifest; + flushPendingGameChatStageRecords(); + } + }) + .catch(() => { + // The normal manifest refresh path can still supply the snapshot. + }); + } void refreshConversation( invoke, projectPath, runtime.sessionId, runtime.runId, - ).catch((error) => { - projectSupervisorRuntimeSyncingRef.current.delete(syncKey); - if ( - localProjectPathRef.current === projectPath && - projectSupervisorSessionIdRef.current === runtime.sessionId - ) { - setProjectSupervisorRuntimeError( - `项目总控 Agent 对话刷新失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }); + ) + .then(() => { + const archiveKey = `${projectPath}\n${runtime.runId}`; + const pendingArchive = + gameChatPendingStageRuntimesRef.current.get(archiveKey); + if (pendingArchive) { + pendingArchive.awaitingConversationSync = false; + } + flushPendingGameChatStageRecords(); + }) + .catch((error) => { + projectSupervisorRuntimeSyncingRef.current.delete(syncKey); + if ( + localProjectPathRef.current === projectPath && + projectSupervisorSessionIdRef.current === runtime.sessionId + ) { + setProjectSupervisorRuntimeError( + `项目总控 Agent 对话刷新失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }); } function flushPendingGameChatStageRecords() { @@ -1018,9 +1066,12 @@ export function App({ gameChatPendingStageRuntimesRef.current.delete(archiveKey); continue; } + if (pendingArchive.awaitingConversationSync) { + continue; + } pendingArchive.runtimeRecords = mergeGameChatRuntimeSnapshots( pendingArchive.runtimeRecords, - Object.values(agentRuntimeById), + Object.values(agentRuntimeByIdRef.current), ); if ( projectSupervisorRuntimeRef.current?.runId === @@ -1080,7 +1131,7 @@ export function App({ current.length, ); } - return [ + const nextMessages: ChatMessage[] = [ ...current, { role: 'assistant', @@ -1089,6 +1140,8 @@ export function App({ updatedAt: pendingArchive.rootRuntime.updatedAt, }, ]; + latestMessagesRef.current = nextMessages; + return nextMessages; }); } } @@ -1097,6 +1150,9 @@ export function App({ nextProjectPath: string, runtime: AgentRuntimeState, ) { + if (!isGameChatSupervisorRoot(runtime)) { + return; + } const eventMessages = gameChatRuntimeEventMessages( runtime, agentRuntimeByIdRef.current, @@ -1199,8 +1255,14 @@ export function App({ function appendGameChatStageRecord( nextProjectPath: string, runtime: AgentRuntimeState, + flush = true, + awaitingConversationSync = false, ) { - if (!gameChatOnly || !gameChatRuntimeHasTerminalOutcome(runtime)) { + if ( + !gameChatOnly || + !isGameChatSupervisorRoot(runtime) || + !gameChatRuntimeHasTerminalOutcome(runtime) + ) { return; } const archiveKey = `${nextProjectPath}\n${runtime.runId}`; @@ -1218,15 +1280,19 @@ export function App({ : previous.rootRuntime, runtimeRecords: mergeGameChatRuntimeSnapshots( previous?.runtimeRecords ?? [], - Object.values(agentRuntimeById), + Object.values(agentRuntimeByIdRef.current), ), manifestSnapshot: gameChatManifestHasTerminalPrimaryTask(manifest) && projectSupervisorRuntimeRef.current?.runId === runtime.runId ? manifest : (previous?.manifestSnapshot ?? null), + awaitingConversationSync: + awaitingConversationSync || previous?.awaitingConversationSync === true, }); - flushPendingGameChatStageRecords(); + if (flush) { + flushPendingGameChatStageRecords(); + } } useEffect(() => { @@ -5735,6 +5801,38 @@ export function App({ return sessionId; } + async function capturePendingGameChatStageManifestBeforeNextRun( + invoke: TauriInvoke, + projectPath: string, + ) { + const archivePrefix = `${projectPath}\n`; + const pendingArchives = Array.from( + gameChatPendingStageRuntimesRef.current.entries(), + ).filter( + ([archiveKey, pendingArchive]) => + archiveKey.startsWith(archivePrefix) && + !pendingArchive.manifestSnapshot, + ); + for (const [, pendingArchive] of pendingArchives) { + const currentRoot = projectSupervisorRuntimeRef.current; + if ( + !isGameChatSupervisorRoot(currentRoot) || + currentRoot.runId !== pendingArchive.rootRuntime.runId + ) { + throw new Error('上一轮阶段清单尚未冻结,请稍后重试'); + } + const capturedManifest = await invoke( + 'get_local_game_manifest', + { projectPath }, + ); + if (!gameChatManifestHasTerminalPrimaryTask(capturedManifest)) { + throw new Error('上一轮主阶段仍在收束,请稍后重试'); + } + pendingArchive.manifestSnapshot = capturedManifest; + } + flushPendingGameChatStageRecords(); + } + async function executeChatAgentReply(prompt: string) { if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); @@ -5778,6 +5876,15 @@ export function App({ 'project-supervisor-game-chat', ) : null; + if (gameChatOnly && !steerRuntime) { + await capturePendingGameChatStageManifestBeforeNextRun( + invoke, + nextProjectPath, + ); + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + } let autoPreviewAfterRevision = 0; let autoPreviewAfterValidatedAt = 0; if (steerRuntime) { @@ -10304,9 +10411,13 @@ export function App({ if (!runtime) { return; } - setAgentRuntimeById((current) => - mergeAgentRuntimeStateIntoMap(current, runtime, false), + const next = mergeAgentRuntimeStateIntoMap( + agentRuntimeByIdRef.current, + runtime, + false, ); + agentRuntimeByIdRef.current = next; + setAgentRuntimeById(next); } async function refreshAgentRuntimes( @@ -10921,6 +11032,9 @@ export function App({ if (projectSupervisorOnly && gameChatOnly) { const gameChatProjectPath = localProject?.projectPath ?? ''; + const hasCurrentGameChatRoot = isGameChatSupervisorRoot( + projectSupervisorRuntime, + ); return ( { - const runtimes = taskRowsFromManifest(nextManifest) - .filter((task) => task.group === group) - .map((task) => { - const agentId = agentConversationId(task); - return runtimeByAgentId[agentId] ?? runtimeByAgentId[task.id]; - }) - .filter((runtime): runtime is AgentRuntimeState => - Boolean( - runtime && - ['agent-delegate', 'agent-delegate-retry'].includes( - runtime.source, - ) && - runtime.parentAgentId === PROJECT_SUPERVISOR_AGENT_ID && - runtime.parentRunId === supervisorRuntime.runId, - ), - ) - .sort( - (left, right) => - statusRank(right) - statusRank(left) || - right.updatedAt - left.updatedAt, - ); + const runtimes = ( + gameChatRuntimes + ? gameChatRuntimes.filter((runtime) => + manifestTasks.some( + (task) => task.id === runtime.taskId && task.group === group, + ), + ) + : manifestTasks + .filter((task) => task.group === group) + .map((task) => { + const agentId = agentConversationId(task); + return runtimeByAgentId[agentId] ?? runtimeByAgentId[task.id]; + }) + .filter((runtime): runtime is AgentRuntimeState => + Boolean( + runtime && + ['agent-delegate', 'agent-delegate-retry'].includes( + runtime.source, + ) && + runtime.parentAgentId === PROJECT_SUPERVISOR_AGENT_ID && + runtime.parentRunId === supervisorRuntime.runId, + ), + ) + ).sort( + (left, right) => + statusRank(right) - statusRank(left) || + right.updatedAt - left.updatedAt, + ); const runtime = runtimes[0]; if (!runtime) { return []; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index 28d9095b9..e1c48eeb1 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -27,6 +27,7 @@ import { GAME_CHAT_DYNAMIC_ART_AGENT_IDS, GAME_CHAT_PRIMARY_TASK_ID, isAgentRuntimeTerminalState, + isGameChatSupervisorRoot, isMudPointInsufficientRuntimeError, MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, projectCurrentGameChatRuntimeLineage, @@ -328,6 +329,9 @@ export function gameChatMudPointInterruptionText( runtime: AgentRuntimeState, runtimeByAgentId: Record, ) { + if (!isGameChatSupervisorRoot(runtime)) { + return null; + } const relatedRuntimes = [ runtime, ...projectSupervisorCollaboratingAgentRuntimes(runtime, runtimeByAgentId), @@ -453,7 +457,7 @@ export function buildGameChatProgressEvidence( runtimeByAgentId: Record, manifest: GameCreationAppManifest | null, ): GameChatSupervisorProgress | null { - if (!runtime?.runId) { + if (!isGameChatSupervisorRoot(runtime)) { return null; } const lineage = projectCurrentGameChatRuntimeLineage( @@ -472,21 +476,20 @@ export function buildGameChatProgressEvidence( : '主阶段 0/1', plan.total > 0 ? `计划 ${plan.completed}/${plan.total}` : null, ].filter(Boolean); - const activeAgents = projectSupervisorCollaboratingAgentRuntimes( + const collaboratingRuntimes = projectSupervisorCollaboratingAgentRuntimes( runtime, runtimeByAgentId, - ) - .filter( - (professionalRuntime) => - !isAgentRuntimeTerminalState(professionalRuntime), - ) - .map((professionalRuntime) => { - const parts = [ - projectProfessionalAgentLabel(professionalRuntime.agentId), - projectRuntimeVisibleCurrentWork(professionalRuntime), - ].filter(Boolean); - return compactProgressText(parts.join(' · '), 140); - }); + ); + const activeProfessionalRuntimes = collaboratingRuntimes.filter( + (professionalRuntime) => !isAgentRuntimeTerminalState(professionalRuntime), + ); + const activeAgents = activeProfessionalRuntimes.map((professionalRuntime) => { + const parts = [ + projectProfessionalAgentLabel(professionalRuntime.agentId), + projectRuntimeVisibleCurrentWork(professionalRuntime), + ].filter(Boolean); + return compactProgressText(parts.join(' · '), 140); + }); const events = runtimeEvidenceEvents(runtime, runtimeByAgentId); const mainRuntime = lineage?.main ?? null; const preview = latestEvidenceEvent( @@ -627,7 +630,14 @@ export function buildGameChatProgressEvidence( taskProgress: taskParts.join(' · ') || projectSupervisorChatRuntimeStatus(runtime), currentWork: formatGameChatRuntimeText( - compactProgressText(projectRuntimeVisibleCurrentWork(runtime)), + compactProgressText( + projectRuntimeVisibleCurrentWork( + isAgentRuntimeTerminalState(runtime) && + activeProfessionalRuntimes.length > 0 + ? activeProfessionalRuntimes[0]! + : runtime, + ), + ), ), activeAgents, evidence, @@ -643,7 +653,7 @@ function collectGameChatRuntimeEventsInternal( runtimeByAgentId: Record, limit: number, ) { - const sources = runtime + const sources = isGameChatSupervisorRoot(runtime) ? [ { label: '项目总控 Agent', runtime }, ...projectSupervisorCollaboratingAgentRuntimes( @@ -949,17 +959,36 @@ export function SupervisorChatOnlyView({ >([]); const [selectedResultImage, setSelectedResultImage] = useState(null); + const projectedRuntime = + gameChatMode && !isGameChatSupervisorRoot(runtime) ? null : runtime; const synchronizingAcceptedRun = Boolean( - expectedRunId && runtime?.runId !== expectedRunId, + expectedRunId && projectedRuntime?.runId !== expectedRunId, + ); + const collaboratingRuntimes = useMemo( + () => + projectSupervisorCollaboratingAgentRuntimes( + projectedRuntime, + runtimeByAgentId, + ), + [projectedRuntime, runtimeByAgentId], + ); + const activeCollaboratingRuntimes = collaboratingRuntimes.filter( + (candidate) => !isAgentRuntimeTerminalState(candidate), + ); + const runtimeTerminal = Boolean( + projectedRuntime && isAgentRuntimeTerminalState(projectedRuntime), + ); + const descendantsStillActive = Boolean( + gameChatMode && runtimeTerminal && activeCollaboratingRuntimes.length > 0, ); const running = Boolean( chatAgentBusy || synchronizingAcceptedRun || - (runtime && !isAgentRuntimeTerminalState(runtime)), + (projectedRuntime && (!runtimeTerminal || descendantsStillActive)), ); const gameChatInterruptionText = - gameChatMode && runtime - ? gameChatMudPointInterruptionText(runtime, runtimeByAgentId) + gameChatMode && projectedRuntime + ? gameChatMudPointInterruptionText(projectedRuntime, runtimeByAgentId) : null; const status = gameChatInterruptionText || @@ -967,23 +996,37 @@ export function SupervisorChatOnlyView({ ? projectRuntimeVisibleError(runtimeError, '项目总控 Agent', true) : synchronizingAcceptedRun ? '已投递,正在同步 Agent Runner' - : runtime - ? projectSupervisorChatRuntimeStatus(runtime) - : workspaceStatus); + : descendantsStillActive + ? '专业 Agent 正在收束' + : projectedRuntime + ? projectSupervisorChatRuntimeStatus(projectedRuntime) + : workspaceStatus); const headerStatus = gameChatMode ? status : previewStatus || status; const runtimeEvents = useMemo( - () => collectGameChatRuntimeEvents(runtime, runtimeByAgentId), - [runtime, runtimeByAgentId], + () => collectGameChatRuntimeEvents(projectedRuntime, runtimeByAgentId), + [projectedRuntime, runtimeByAgentId], ); const supervisorProgress = useMemo( () => gameChatMode && projectReady && - runtime && - !isAgentRuntimeTerminalState(runtime) - ? buildGameChatProgressEvidence(runtime, runtimeByAgentId, manifest) + projectedRuntime && + (!runtimeTerminal || descendantsStillActive) + ? buildGameChatProgressEvidence( + projectedRuntime, + runtimeByAgentId, + manifest, + ) : null, - [gameChatMode, manifest, projectReady, runtime, runtimeByAgentId], + [ + descendantsStillActive, + gameChatMode, + manifest, + projectReady, + projectedRuntime, + runtimeByAgentId, + runtimeTerminal, + ], ); const resultImages = useMemo( () => (gameChatMode ? collectGameChatResultImages(manifest) : []), @@ -992,38 +1035,27 @@ export function SupervisorChatOnlyView({ const resultImageKey = resultImages .map((image) => `${image.key}:${image.mediaType}`) .join('\n'); - const collaboratingRuntimes = useMemo( - () => - projectSupervisorCollaboratingAgentRuntimes(runtime, runtimeByAgentId), - [runtime, runtimeByAgentId], - ); const attentionAgentCount = collaboratingRuntimes.filter( (candidate) => ['failed', 'needs-reconciliation'].includes(candidate.status) || ['failed', 'needs-reconciliation'].includes(candidate.phase), ).length; - const runtimeTerminal = Boolean( - runtime && isAgentRuntimeTerminalState(runtime), - ); - const runtimeRunId = runtime?.runId ?? null; - const latestParentActivityAt = runtime - ? gameChatRuntimeActivityTimes(runtime).reduce( + const runtimeRunId = projectedRuntime?.runId ?? null; + const latestParentActivityAt = projectedRuntime + ? gameChatRuntimeActivityTimes(projectedRuntime).reduce( (latest, candidate) => Math.max(latest, candidate), 0, ) : 0; - const latestActivityAt = runtime - ? [runtime, ...collaboratingRuntimes] + const latestActivityAt = projectedRuntime + ? [projectedRuntime, ...collaboratingRuntimes] .flatMap(gameChatRuntimeActivityTimes) .reduce((latest, candidate) => Math.max(latest, candidate), 0) : 0; - const activeCollaboratingRuntimes = collaboratingRuntimes.filter( - (candidate) => !isAgentRuntimeTerminalState(candidate), - ); - const activeRuntimeLanes = runtime + const activeRuntimeLanes = projectedRuntime ? activeCollaboratingRuntimes.length > 0 ? activeCollaboratingRuntimes - : [runtime] + : [projectedRuntime] : []; const nonWaitingRuntimeLaneActivity = activeRuntimeLanes .filter((candidate) => !gameChatRuntimeIsExpectedWait(candidate)) @@ -1035,9 +1067,9 @@ export function SupervisorChatOnlyView({ ) .filter((activityAt) => activityAt > 0); const runStartedAt = - runtime && runtimeRunId - ? (gameChatTimestampMilliseconds(runtime.startedAt) ?? - gameChatRuntimeActivityTimes(runtime).reduce( + projectedRuntime && runtimeRunId + ? (gameChatTimestampMilliseconds(projectedRuntime.startedAt) ?? + gameChatRuntimeActivityTimes(projectedRuntime).reduce( (earliest, candidate) => Math.min(earliest, candidate), Number.POSITIVE_INFINITY, )) @@ -1045,7 +1077,9 @@ export function SupervisorChatOnlyView({ const elapsedRuntimeMs = Number.isFinite(runStartedAt) ? Math.max( 0, - (runtimeTerminal && latestParentActivityAt > 0 + (runtimeTerminal && + !descendantsStillActive && + latestParentActivityAt > 0 ? latestParentActivityAt : runtimeClockNow) - runStartedAt, ) @@ -1067,8 +1101,8 @@ export function SupervisorChatOnlyView({ ); const runtimeAppearsStalled = Boolean( running && - runtime && - !runtimeTerminal && + projectedRuntime && + (!runtimeTerminal || descendantsStillActive) && !expectedRuntimeWait && inactiveRuntimeMs !== null && inactiveRuntimeMs > GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS, @@ -1092,13 +1126,22 @@ export function SupervisorChatOnlyView({ } return attentionAgentCount > 0 ? '运行中 · 有异常' : '运行中'; } - if (runtime?.status === 'completed' || runtime?.phase === 'completed') { + if ( + projectedRuntime?.status === 'completed' || + projectedRuntime?.phase === 'completed' + ) { return '本轮已完成'; } - if (runtime?.status === 'failed' || runtime?.phase === 'failed') { + if ( + projectedRuntime?.status === 'failed' || + projectedRuntime?.phase === 'failed' + ) { return '本轮失败'; } - if (runtime?.status === 'cancelled' || runtime?.phase === 'cancelled') { + if ( + projectedRuntime?.status === 'cancelled' || + projectedRuntime?.phase === 'cancelled' + ) { return '本轮已取消'; } return '未运行'; @@ -1114,7 +1157,8 @@ export function SupervisorChatOnlyView({ ? 'warning' : running || synchronizingAcceptedRun ? 'active' - : runtime?.status === 'completed' || runtime?.phase === 'completed' + : projectedRuntime?.status === 'completed' || + projectedRuntime?.phase === 'completed' ? 'complete' : 'idle'; const embeddedPreviewUrl = preview @@ -1134,17 +1178,21 @@ export function SupervisorChatOnlyView({ })(); useLayoutEffect(() => { setShowRuntimeDetails(false); - }, [projectPath, runtime?.runId]); + }, [projectPath, projectedRuntime?.runId]); useEffect(() => { setRuntimeClockNow(Date.now()); - if (!gameChatMode || !runtimeRunId || runtimeTerminal) { + if ( + !gameChatMode || + !runtimeRunId || + (runtimeTerminal && !descendantsStillActive) + ) { return undefined; } const interval = window.setInterval(() => { setRuntimeClockNow(Date.now()); }, GAME_CHAT_RUNTIME_CLOCK_INTERVAL_MS); return () => window.clearInterval(interval); - }, [gameChatMode, runtimeRunId, runtimeTerminal]); + }, [descendantsStillActive, gameChatMode, runtimeRunId, runtimeTerminal]); useEffect(() => { if (!showRuntimeDetails) { return undefined; @@ -1387,7 +1435,7 @@ export function SupervisorChatOnlyView({ {hasConversationControls ? (
{ test('keeps backend status messages visible without treating them as client-authored conversation', () => { @@ -812,6 +814,11 @@ describe('Game-chat source-aware runtime projection', () => { 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]); @@ -832,6 +839,49 @@ describe('Game-chat source-aware runtime projection', () => { ).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); 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 cb344178a..1d32692f2 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 @@ -10,6 +10,7 @@ import type { import { latestGameChatPlayableRevision as projectLatestGameChatPlayableRevision, MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, + AgentRuntimeStatusPanel, ProjectSupervisorRuntimePanel, projectCurrentGameChatRuntimeLineage, } from '../../src/features/agent-runtime'; @@ -3997,14 +3998,7 @@ export function registerProjectSupervisorSurfaceTests() { gameChatRuntimeEventMessages(supervisorContinuation, {}).map( (message) => message.text, ), - ).toEqual([ - expect.stringContaining( - `Supervisor ${continuation.kind} continuation started`, - ), - expect.stringContaining( - `Supervisor ${continuation.kind} continuation failed`, - ), - ]); + ).toEqual([]); } }); @@ -4157,6 +4151,52 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(2); }); + it('disables generic retry when a selected runtime claims nested game-chat art lineage', () => { + const onRetryRuntimeTask = vi.fn(); + const nestedArt = gameChatRuntimeState({ + agentId: 'art-director', + taskId: 'art-director', + sessionId: 'selected-game-chat-art-session', + runId: 'selected-game-chat-art-run', + source: 'agent-delegate', + parentAgentId: 'code-prototype', + parentRunId: 'selected-game-chat-main-run', + status: 'failed', + phase: 'failed', + }); + const rendered = render( + React.createElement(AgentRuntimeStatusPanel, { + runtime: nestedArt, + onRetryRuntimeTask, + }), + ); + const retryButton = within( + screen.getByLabelText('Agent Runtime 操作'), + ).getByRole('button', { name: '重试' }) as HTMLButtonElement; + expect(retryButton.disabled).toBe(true); + fireEvent.click(retryButton); + expect(onRetryRuntimeTask).not.toHaveBeenCalled(); + + rendered.rerender( + React.createElement(AgentRuntimeStatusPanel, { + runtime: { + ...nestedArt, + parentAgentId: 'project-supervisor', + parentRunId: 'full-dag-root-run', + }, + onRetryRuntimeTask, + }), + ); + expect( + ( + within(screen.getByLabelText('Agent Runtime 操作')).getByRole( + 'button', + { name: '重试' }, + ) as HTMLButtonElement + ).disabled, + ).toBe(false); + }); + it('persists professional final-reply streams with stable ids and does not duplicate them after hydration', async () => { const projectPath = '/tmp/game-chat-final-reply-hydration'; const runId = 'game-chat-final-reply-hydration-run'; @@ -4327,6 +4367,7 @@ export function registerProjectSupervisorSurfaceTests() { initialRuntime: { sessionId: 'supervisor-session-active', runId, + source: 'project-supervisor-game-chat', status: 'running', phase: 'execution', recentEvents: events, @@ -5307,6 +5348,58 @@ export function registerProjectSupervisorSurfaceTests() { expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u); }); + it('fails closed when the game-chat surface hydrates a non-game-chat Supervisor root', () => { + const guiRuntime = gameChatRuntimeState({ + source: 'project-supervisor-gui', + recentEvents: [ + gameChatRuntimeEvent({ + summary: '不应进入 game-chat 当前投影', + updatedAt: 100, + }), + ], + }); + + renderGameChatStatus({ runtime: guiRuntime }); + + expect(screen.getByLabelText('最新状态').textContent).toContain('未运行'); + expect(screen.queryByText('不应进入 game-chat 当前投影')).toBeNull(); + expect(buildGameChatProgressEvidence(guiRuntime, {}, null)).toBeNull(); + expect(collectGameChatRuntimeEvents(guiRuntime, {})).toEqual([]); + }); + + it('keeps game-chat running while an exact descendant is still active after root terminal projection', () => { + const root = gameChatRuntimeState({ + runId: 'terminal-root-active-main', + status: 'completed', + phase: 'completed', + updatedAt: 200, + }); + const main = gameChatRuntimeState({ + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'terminal-root-active-main-session', + runId: 'terminal-root-active-main-run', + source: 'agent-ready-task-scheduler', + parentAgentId: 'project-supervisor', + parentRunId: root.runId, + status: 'running', + phase: 'waiting-for-delegate-receipts', + currentAction: '等待动态美术回执', + updatedAt: 201, + }); + + renderGameChatStatus({ + runtime: root, + runtimeByAgentId: { 'code-prototype': main }, + }); + + const status = screen.getByLabelText('最新状态').textContent ?? ''; + expect(status).toContain('运行中'); + expect(status).toContain('主阶段 0/1'); + expect(status).toContain('正在等待专业 Agent 回执'); + expect(status).not.toContain('本轮已完成'); + }); + it('shows and archives the explicit mud point interruption from a failed art child runtime', () => { const rootRunId = 'game-chat-mud-point-root-run'; const runtime = gameChatRuntimeState({ @@ -5899,6 +5992,155 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(0); }); + it('freezes a terminal game-chat stage before a delayed conversation refresh and fast next turn', async () => { + const projectPath = '/tmp/game-chat-stage-record-fast-next-turn'; + let professionalRuntimes: AgentRuntimeState[] = []; + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + runtimeMapLoader: async () => professionalRuntimes, + }); + const manifest = createGameCreationAppManifest( + 'game-chat-stage-record-fast-next-turn', + 'game-chat-stage-record-fast-next-turn', + ); + manifest.tasks = manifest.tasks.map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ); + const conversationGate = createDeferred(); + const manifestGate = createDeferred(); + let delayConversationRefresh = false; + let delayManifestCapture = false; + let delayedConversationReads = 0; + let delayedManifestReads = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'game-chat-stage-record-fast-next-turn', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_local_game_manifest') { + if (delayManifestCapture) { + delayedManifestReads += 1; + await manifestGate.promise; + } + return manifest; + } + if (command === 'read_local_conversation' && delayConversationRefresh) { + delayedConversationReads += 1; + await conversationGate.promise; + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + + const surface = await screen.findByLabelText('游戏创作聊天'); + const composer = within(surface).getByLabelText( + '项目总控对话内容', + ) as HTMLTextAreaElement; + await waitFor(() => expect(composer.disabled).toBe(false)); + fireEvent.change(composer, { target: { value: '完成旧轮' } }); + fireEvent.click(within(surface).getByRole('button', { name: '发送' })); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'start_game_creator_supervisor_runtime_task', + ), + ).toHaveLength(1); + }); + const firstStart = invoke.mock.calls.find( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ); + const firstRunId = String(firstStart?.[1]?.runId ?? ''); + const main = gameChatPlayableMainRuntime({ + parentRunId: firstRunId, + revision: 17, + updatedAt: 1700, + }); + professionalRuntimes = [main]; + act(() => harness.emitAgentRuntime(main)); + + delayConversationRefresh = true; + delayManifestCapture = true; + act(() => { + harness.emitRuntime( + harness.runtimeState({ + runId: firstRunId, + source: 'project-supervisor-game-chat', + status: 'completed', + phase: 'completed', + updatedAt: 4000, + }), + ); + }); + await waitFor(() => { + expect(delayedConversationReads).toBeGreaterThan(0); + expect(delayedManifestReads).toBeGreaterThan(0); + }); + + fireEvent.change(composer, { target: { value: '立即开始下一轮' } }); + fireEvent.click(within(surface).getByRole('button', { name: '发送' })); + await waitFor(() => expect(delayedManifestReads).toBeGreaterThan(1)); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ), + ).toHaveLength(1); + manifestGate.resolve(undefined); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'start_game_creator_supervisor_runtime_task', + ), + ).toHaveLength(2); + }); + + conversationGate.resolve(undefined); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + String( + (args?.message as { content?: string } | undefined)?.content ?? + '', + ).startsWith('【Supervisor 阶段记录】'), + ), + ).toHaveLength(1); + }); + }); + it('persists one terminal game-chat stage record and keeps it in the next round', async () => { const projectPath = '/tmp/game-chat-stage-record'; let professionalRuntimes: AgentRuntimeState[] = []; diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index e12c20e3c..4a036f93d 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1,9 +1,9 @@ # 立项策划 Agent(Fast GDD)技术方案 - 日期:2026-08-10 -- 状态:M0A-1 非交付阶段设计检查通过;M0A-2 / M0-3 合同对齐实施中,M1~M3 功能尚未实现 +- 状态:M0A-1、M0A-2、M0B-1、M0B-2 均已形成独立提交;合入状态以 Git / PR 为准,M1~M3 功能尚未实现 - 适用范围:AI 游戏创作独立 App、Project Supervisor、Agent Runtime、本地项目策划 sidecar 与后续完整构建准入 -- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;合入本文只代表 M0A-1 设计检查点,不代表立项策划入口、审批 UI、Runtime 持久化或构建绑定已经可用 +- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包只冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,不代表立项策划入口、审批 UI、Runtime 持久化或构建绑定已经可用 ## 1. 背景与目标